From 3b720e0c61e506e1726fab89fea953568a81e9c4 Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Tue, 1 Sep 2026 13:32:48 +0200 Subject: [PATCH] feat(diagrams): resolve every diagram color from tokens, at paint time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagrams were not ugly because Graphviz is ugly. They were ugly because 63 hand-typed colors across 18 pages had no palette to be consistent with, and their dark mode was a hardcoded list of six strings the plugin string-matched. Four dialects had grown — queue-green, emoji-pastel, nineties-pastel and near-white-blueprint — and `enterprise/architecture` rendered as a stack of white slabs on the dark page, because the plugin deliberately skips a cluster that has a fill and every fill on that page is near-white. Graphviz supports a `class` attribute on graphs, nodes, edges and clusters and copies it verbatim into the SVG. So the plugin stops handling color entirely. It injects shape and spacing defaults, drops the opaque canvas, tags each element with a *role*, strips the inline paint, and one stylesheet resolves surface, border and label at paint time. Dark mode then arrives through the same `:root.theme-dark` block as every other surface on the site — no second render, no flash, no string matching — and the built SVGs now contain no color at all. The 19 fences are not touched. A table in the plugin maps the colors the docs were drawn with onto roles, so the whole corpus is recolored with zero MDX edits and this reverts in one commit. The table maps by hue family, so elements drawn alike still read alike; it is lossy the other way — PostgreSQL and Redis both land on `datastore` — and that is paid back one page at a time as each fence names its own roles. It is a shim with a known end: when no fence spells a color, nothing reaches it. Three things change that are worth knowing before reviewing screenshots: - A graph-level caption moves above the figure. Graphviz's default is below; `labelloc="t"` is injected, so every fence with a `label=` that does not set `labelloc` itself now captions on top. - Diagram text is painted in Inter rather than the browser's default sans. Graphviz still measures in Helvetica, so widths drift by about 3%; the injected node margins absorb it. - Borders lift toward white in dark mode. The product accents themselves are untouched, as DESIGN.md requires — what changes is a value derived from them, because a #347d39 outline on the dark page surface is too dim to read. Two roles deliberately share an accent (`queued` and `mergify` are both Merge Queue teal): the same color under two names, because "waiting in the queue" and "a service we run" are the same idea on two kinds of diagram, and a role name that lies is worse than a duplicated accent. `--color-green-700` is not a new color. It is the green nine diagrams already had hardcoded, promoted so it has a name. Verified by rendering all 35 diagrams on the site in both themes and comparing them against the same page before the change. Change-Id: I4100df168b1a98d79cd2f0eca15697cb2a9db1cb --- DESIGN.md | 15 +- plugins/remark-graphviz.test.ts | 144 +++++++++++++ plugins/remark-graphviz.ts | 352 +++++++++++++++++++++----------- src/styles/index.css | 159 +++++++++++++-- src/styles/theme.css | 40 ++++ src/styles/tokens.css | 7 + 6 files changed, 578 insertions(+), 139 deletions(-) create mode 100644 plugins/remark-graphviz.test.ts diff --git a/DESIGN.md b/DESIGN.md index 57dc3d9d02..91bfd296b3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -127,8 +127,8 @@ can bypass the semantic layer. ## Product Accent Colors -Product colors are verbatim from `mergify.com/DESIGN.md`. The docs site reuses the same palette -without adding new tokens. +Product colors are verbatim from `mergify.com/DESIGN.md`. The docs site reuses the same palette and +adds nothing to it; the two extra tokens below are not product colors. | Product | Dark / Label | Light / Accent | Hex (label) | | --- | --- | --- | --- | @@ -139,9 +139,14 @@ without adding new tokens. | Stacks | `--color-coral-700` | `--color-coral-400` | #E53935 | | Workflow Automation | `--color-rose-700` | `--color-rose-400` | #E61E71 | -One docs-only addition: `--color-blue-800` (#237caf) for link hover. It is a darker shade of -`blue-700` and exists only because docs is text-heavy with many cross-references; the marketing -site uses product colors for links far less often. +Two docs-only additions: + +- `--color-blue-800` (#237caf) for link hover. It is a darker shade of `blue-700` and exists only + because docs is text-heavy with many cross-references; the marketing site uses product colors for + links far less often. +- `--color-green-700` (#347d39) for diagrams. Not a product color and never used as one: it is the + green nine diagrams had hardcoded before the diagram surface had a palette, promoted so it has a + name. It backs the diagram role `merged`. ### Section-accent body classes diff --git a/plugins/remark-graphviz.test.ts b/plugins/remark-graphviz.test.ts new file mode 100644 index 0000000000..10109f461a --- /dev/null +++ b/plugins/remark-graphviz.test.ts @@ -0,0 +1,144 @@ +import type * as mdast from 'mdast'; +import { describe, expect, it } from 'vitest'; +import { remarkGraphvizPlugin } from './remark-graphviz'; + +type Transformer = (tree: mdast.Root) => Promise; + +/** The attacher takes a unified processor as `this`; the transformer needs none. */ +const attach = (): Transformer => + (remarkGraphvizPlugin() as unknown as () => Transformer).call(undefined); + +/** Run the plugin over a single fence and return the SVG it produced. */ +async function render(value: string, meta: string | null = null): Promise { + const node: mdast.Code = { type: 'code', lang: 'dot', meta, value }; + const tree: mdast.Root = { type: 'root', children: [node] }; + await attach()(tree); + // Without this, a fence that failed to render hands every assertion below the + // raw DOT source instead of an SVG — and every negative assertion passes on + // it. The one regression this suite exists to catch would report as working. + expect(node.type).toBe('html'); + return (node as unknown as mdast.Html).value; +} + +/** The class list of the `` whose `` names `id`. */ +function classesOf(svg: string, id: string): string[] { + for (const m of svg.matchAll(/<g[^>]*class="([^"]*)"[^>]*>\s*<title>([^<]*)<\/title>/g)) { + if (m[2].replace(/-/g, '-').replace(/>/g, '>') === id) return m[1].split(/\s+/); + } + throw new Error(`no element titled ${id} in ${svg}`); +} + +describe('remarkGraphvizPlugin', () => { + it('renders a fence to an SVG carrying the dg class plus the fence classes', async () => { + const svg = await render('digraph { A -> B; }', 'class="queue"'); + expect(svg).toMatch(/^<svg[^>]*class="dg queue"/); + }); + + it('leaves no colour in the output at all', async () => { + const svg = await render(`digraph { + node [style=filled, fillcolor="#347D39", fontcolor="white"]; + subgraph cluster_b { fillcolor="#1CB893"; style="rounded,filled"; label="Batch"; A; } + A -> B [color="#374151"]; + }`); + expect(svg).not.toMatch(/#[0-9a-f]{3,6}/i); + expect(svg).not.toMatch(/\bfill="/); + expect(svg).not.toMatch(/\bstroke="/); + }); + + it('maps the colours the docs were drawn with onto roles', async () => { + const svg = await render(`digraph { + node [style=filled]; + A [fillcolor="#347D39"]; + B [fillcolor="#6B7280"]; + C [fillcolor="#FFF4ED"]; + subgraph cluster_b { style="rounded,filled"; fillcolor="#1CB893"; label="Batch"; A; } + A -> B [color="#9CA3AF"]; + }`); + expect(classesOf(svg, 'A')).toContain('queued'); + expect(classesOf(svg, 'B')).toContain('muted'); + expect(classesOf(svg, 'C')).toContain('pending'); + // Teal is a container on a cluster and a value on a node. + expect(classesOf(svg, 'cluster_b')).toContain('batch'); + expect(classesOf(svg, 'A->B')).toContain('muted'); + }); + + it('reads a cluster drawn with a stroke and no fill', async () => { + const svg = await render(`digraph { + subgraph cluster_w { style="rounded"; color="#6B7280"; label="Waiting"; A; } + }`); + expect(classesOf(svg, 'cluster_w')).toContain('muted'); + }); + + it('lets an authored role win over the substitution table', async () => { + const svg = await render(`digraph { + node [style=filled]; + A [fillcolor="#347D39", class="failed"]; + }`); + expect(classesOf(svg, 'A')).toContain('failed'); + expect(classesOf(svg, 'A')).not.toContain('queued'); + }); + + it('marks a borderless node as plain, so it reads as a caption', async () => { + // `style=filled` reaches a plaintext node and puts a box behind the label; + // the class is what lets CSS take it back off. + const svg = await render(`digraph { + node [style=filled]; + A [shape=plaintext, label="main"]; + B; + A -> B; + }`); + expect(classesOf(svg, 'A')).toContain('plain'); + expect(classesOf(svg, 'B')).not.toContain('plain'); + }); + + it('drops the opaque canvas a fence paints behind itself', async () => { + const svg = await render('digraph { bgcolor="#FAFBFC"; A; }'); + expect(svg).not.toMatch(/<g id="graph0"[^>]*>\s*<polygon/); + }); + + it('injects defaults a fence can still override', async () => { + const rounded = await render('digraph { A [label="x"]; }'); + // The injected `shape=box, style="rounded,filled"` draws a path, not a + // polygon; asking for a plain box gets the polygon back. + expect(rounded).toMatch(/<g id="node1"[^>]*>\s*<title>A<\/title>\s*<path/); + const square = await render('digraph { node [style=filled]; A [label="x"]; }'); + expect(square).toMatch(/<g id="node1"[^>]*>\s*<title>A<\/title>\s*<polygon/); + }); + + it('does not let a border speak for the shape it outlines', async () => { + // An unmapped fill must fall back to the element default, not to whatever + // role the node's darker border happens to match. + const svg = await render(`digraph { + node [style=filled]; + A [fillcolor="#ABCDEF", color="#374151"]; + }`); + expect(classesOf(svg, 'A')).toEqual(['node']); + }); + + it('does not guess a colour for an element whose author named a class', async () => { + // Even an unrecognised class means the author said something; appending a + // guessed role would contradict them with no warning. + const svg = await render(`digraph { + node [style=filled]; + A [class="Queued", fillcolor="#DC2626"]; + B [shape=plaintext, class="plain", fillcolor="#347D39"]; + }`); + expect(classesOf(svg, 'A')).toEqual(['node', 'Queued']); + expect(classesOf(svg, 'B')).toEqual(['node', 'plain']); + }); + + it('strips the alpha Graphviz emits alongside a colour', async () => { + const svg = await render(`digraph { + node [style=filled]; + A [fillcolor="#347d3980"]; + }`); + expect(svg).not.toMatch(/opacity="/); + }); + + it('leaves a fence it cannot render as a code block', async () => { + const node: mdast.Code = { type: 'code', lang: 'dot', meta: null, value: 'digraph { --- }' }; + const tree: mdast.Root = { type: 'root', children: [node] }; + await attach()(tree); + expect(node.type).toBe('code'); + }); +}); diff --git a/plugins/remark-graphviz.ts b/plugins/remark-graphviz.ts index 464dceb332..ee877d6f5e 100644 --- a/plugins/remark-graphviz.ts +++ b/plugins/remark-graphviz.ts @@ -4,149 +4,263 @@ import type * as mdast from 'mdast'; import type * as unified from 'unified'; import { CONTINUE, visit } from 'unist-util-visit'; +/** + * Render `dot` / `circo` / `neato` fences to inline SVG, and hand every colour + * decision to CSS. + * + * Graphviz supports a `class` attribute on graphs, nodes, edges and clusters + * and copies it verbatim into the SVG (`class="node queued"`). So this plugin + * never resolves a colour: it injects shape and spacing defaults, drops the + * opaque canvas, tags each element with a *role*, and strips the inline paint + * so the `.dg` rules in `index.css` resolve surface, border and label at paint + * time from the role accents in `theme.css`. Dark mode then arrives through the + * same `:root.theme-dark` block as every other surface on the site, with no + * second render and no string matching. + */ + const viz = await instance(); -const validLanguages = [`dot`, `circo`, `neato`]; - -// Dark neutral colors used for structural chrome (edges, arrowheads, connector -// labels, cluster borders/labels). These vanish on the dark-mode surface, so we -// swap them for `currentColor` and let the page drive the value via -// --theme-diagram-edge. Saturated/product colors are intentional highlights and -// are left untouched. Compared case-insensitively. -const DARK_NEUTRALS = new Set([ - '#374151', // gray-700 - '#4b5563', // gray-600 - '#6b7280', // gray-500 - '#000000', - 'black', - '#999999', -]); - -/** Replace an element's stroke/fill with currentColor when it is a dark neutral. */ -function themeNeutral($el: ReturnType<ReturnType<typeof load>>): void { - for (const attr of ['stroke', 'fill'] as const) { - const value = $el.attr(attr); - if (value === undefined) { - // Graphviz omits `fill` on default-black text (edge/cluster labels), so an - // absent fill on a <text> is an implicit dark neutral that must be themed. - if (attr === 'fill' && $el.is('text')) { - $el.attr('fill', 'currentColor'); - } - continue; - } - if (DARK_NEUTRALS.has(value.toLowerCase())) { - $el.attr(attr, 'currentColor'); - } - } -} +const VALID_LANGUAGES = ['dot', 'circo', 'neato']; /** - * Make the neutral structural chrome of a rendered Graphviz SVG theme-aware so - * it stays legible in dark mode. Content nodes (their fills and labels) and - * saturated edge colors are deliberately left alone. + * Metrics font. Graphviz sizes every box with its own Helvetica tables; the + * page then paints the text in Inter via `.dg text`. The two differ by about + * 3% in width, which the node margins below are generous enough to absorb. + * + * The node defaults assume a shape whose size is derived from its label. A + * shape with fixed geometry — `shape=point` above all — is inflated by them and + * needs its own `width`/`height` back. */ -function themeDiagram($: ReturnType<typeof load>): void { - // Drop the opaque canvas background so the page surface shows through. - $('svg > g.graph > polygon').first().attr('fill', 'none').attr('stroke', 'none'); - - // Edges: lines, arrowheads, and connector labels all sit on the page surface. - $('g.edge').each((_, edge) => { - $(edge) - .find('path, polygon, text') - .each((__, el) => themeNeutral($(el))); - }); - - // Clusters: only re-theme the border and label when the cluster has no fill, - // so labels that sit on a tinted cluster background keep their color. - $('g.cluster').each((_, cluster) => { - const $cluster = $(cluster); - const clusterFill = $cluster.find('> polygon').first().attr('fill'); - if (!clusterFill || clusterFill.toLowerCase() === 'none') { - $cluster.find('polygon, path, text').each((__, el) => themeNeutral($(el))); - } - }); - - // Graph-level captions (text rendered directly under the graph group). - $('svg > g.graph > text').each((_, el) => themeNeutral($(el))); -} +const METRICS_FONT = 'Helvetica'; + +/** + * Injected into every fence, immediately after the opening brace, so anything + * the author writes afterwards overrides it. `labelloc="t"` puts a graph-level + * caption above the figure, where a figure caption belongs; Graphviz's own + * default is below. + */ +const BASE = ` + graph [bgcolor="transparent", fontname="${METRICS_FONT}", fontsize=13, + labelloc="t", pad="0.12", nodesep=0.45, ranksep=0.55]; + node [fontname="${METRICS_FONT}", fontsize=13, shape=box, + style="rounded,filled", penwidth=1.4, margin="0.24,0.15", height=0.42]; + edge [fontname="${METRICS_FONT}", fontsize=10, penwidth=1.3, arrowsize=0.7]; +`; + +/** + * The classes Graphviz puts on an element itself. Anything else on the element + * came from the fence, and means the author named the element's role. + */ +const STRUCTURAL_CLASSES = new Set(['graph', 'node', 'edge', 'cluster']); + +/** + * Transitional: the colours the docs were drawn with, mapped onto roles. + * + * Four independent dialects grew here — queue-green, emoji-pastel, + * nineties-pastel and near-white-blueprint — because there was no palette to be + * consistent with. This table maps by the hue family each dialect used, so two + * elements drawn in the same colour still read alike. It is lossy in the other + * direction: where one dialect used two shades of a hue for two meanings, both + * land on one role — PostgreSQL and Redis both become `datastore`, and + * "tests passed" and "merged to main" both become `merged`. That is the price + * of recolouring the whole corpus without editing a single fence, and it is + * paid back one page at a time as each fence names its own roles. + * + * It is a migration shim with a known end: once every fence names its own role, + * nothing reaches this table and it goes away. Keys are lowercase hex. + */ +const LEGACY_ROLES: Record<string, string> = { + // Queue dialect — batches, performance, stacks, queue-modes, scopes, + // direct-merge, gha, buildkite. + '#347d39': 'queued', // queue green: a pull request in the queue + '#1cb893': 'config', // Merge Queue teal, as a node: a scope or a config value + '#6b7280': 'muted', // skipped, waiting, not selected + '#9ca3af': 'muted', // cascaded out, dashed side-links + '#111827': 'external', // CI, ci-gate, main + '#0b1120': 'external', + '#2563eb': 'pending', // the detect-scopes step, mid-run + '#dc2626': 'failed', + '#374151': 'chrome', // the edge colour the old plugin string-matched + '#4b5563': 'chrome', + '#5b21b6': 'chrome', // stacks: edges and their labels + + // Emoji-pastel dialect — lifecycle, two-step. + '#f3f4f6': 'external', // dequeued: out of the queue + '#fff4ed': 'pending', // queueing, validating, testing + '#ede9fe': 'queued', + '#f3e8ff': 'queued', // the queue command + '#dbeafe': 'config', + '#d1fae5': 'merged', + '#ddd6fe': 'merged', // merged to main + '#fee2e2': 'failed', + '#10b981': 'merged', // the "passed" edge + '#ef4444': 'failed', // the "failed" edge + '#7c3aed': 'chrome', // the default edge colour on both pages + + // Nineties-pastel dialect — flaky-test-detection. + '#c9e7f8': 'config', // the commit under test + '#b7f5c1': 'merged', // tests passed + '#f8c9c9': 'failed', // tests failed + '#d8f0ff': 'external', // "consistent (not flaky)" + '#ffe9b3': 'pending', // "flagged as flaky" + '#999999': 'muted', // the dashed commit clusters -// Layered style defaults injected into dot blocks based on their CSS classes. -// Diagrams can override any of these by redeclaring the same attributes. -const themes: Record<string, string> = { - // Base theme: applied to all class="graph" blocks - graph: ` - fontname="sans-serif"; - node [fontname="sans-serif", style=filled, fontcolor="white"]; - edge [fontname="sans-serif", color="#374151"]; - `, - // Git commit graph: circles on a left-to-right line - 'git-commits': ` - rankdir="LR"; - splines=line; - node [shape=circle, width=0.5, height=0.5, fixedsize=true]; - `, + // Near-white-blueprint dialect — enterprise/architecture. + '#f6f8fb': 'external', // the default node fill + '#ffffff': 'external', // GitHub + '#24292e': 'external', + '#fff3d6': 'config', // the reverse proxy: the entry point + '#e6f0ff': 'mergify', // dashboard and workers + '#f0ecfe': 'mergify', // the subscription API + '#f4fbff': 'mergify', // the on-premise cluster + '#e4f5ed': 'datastore', // PostgreSQL + '#fce3e8': 'datastore', // Redis + '#fdfeff': 'batch', // the customer-infrastructure cluster + '#8892bf': 'chrome', }; -function injectDefaults(source: string, attrString: string | null): string { - if (!attrString?.includes('graph')) return source; +/** A cluster reads its colour differently: teal is a container, not a value. */ +const LEGACY_CLUSTER_ROLES: Record<string, string> = { + ...LEGACY_ROLES, + '#1cb893': 'batch', +}; - const braceIndex = source.indexOf('{'); - if (braceIndex === -1) return source; +/** Inject the base defaults immediately after the opening brace. */ +function injectDefaults(source: string): string { + const brace = source.indexOf('{'); + if (brace === -1) return source; + return `${source.slice(0, brace + 1)}\n${BASE}\n${source.slice(brace + 1)}`; +} - // Build the defaults string by layering matching themes - let defaults = ''; - for (const [key, value] of Object.entries(themes)) { - if (attrString.includes(key)) { - defaults += value; - } - } +/** The classes already on an element, as a list. */ +function classesOf(value: string | undefined): string[] { + return (value ?? '').split(/\s+/).filter(Boolean); +} - return `${source.slice(0, braceIndex + 1)}${defaults}${source.slice(braceIndex + 1)}`; +/** + * Tag each node, edge and cluster with a role, and remove the inline paint so + * `.dg` owns every colour. + * + * An element that names any class of its own is left alone — the author's + * intent always wins over the substitution table, including when the class is + * one this plugin does not recognise, because guessing a colour for an element + * whose author already said something would silently contradict them. + */ +function applyRoles($: ReturnType<typeof load>): void { + const tag = ( + selector: string, + shapeSelector: string, + table: Record<string, string>, + colorAttr: 'fill' | 'stroke', + fallbackAttr?: 'fill' | 'stroke' + ) => { + $(selector).each((_, element) => { + const $group = $(element); + const existing = classesOf($group.attr('class')); + const $shape = $group.children(shapeSelector).first(); + + const extra: string[] = []; + + // Graphviz draws no border for `shape=plaintext` / `shape=none`, so a + // shape with no stroke is a caption rather than a box — even when the + // node inherited `style=filled` and so came out with a fill behind it. + // A node that asks for a fill and `color=none` is read the same way; use + // `penwidth=0` to keep the fill. + if ($shape.attr('stroke') === 'none' && !existing.includes('plain')) extra.push('plain'); + + if (!existing.some((name) => !STRUCTURAL_CLASSES.has(name))) { + const primary = $shape.attr(colorAttr); + // A cluster drawn with `style=rounded` and no fill carries its colour + // on the stroke instead. Only an absent or explicitly-none primary + // falls through: an unmapped fill must not let a border speak for the + // shape it merely outlines. + const color = + !primary || primary === 'none' + ? ((fallbackAttr && $shape.attr(fallbackAttr)) ?? '') + : primary; + const role = table[color.toLowerCase()]; + if (role) extra.push(role); + } + + if (extra.length > 0) $group.attr('class', [...existing, ...extra].join(' ')); + + // Only direct children are painted by `.dg` in index.css; anything deeper + // keeps whatever Graphviz gave it. + $group + .children() + .removeAttr('fill') + .removeAttr('stroke') + .removeAttr('fill-opacity') + .removeAttr('stroke-opacity'); + }); + }; + + tag('g.node', 'path, polygon, ellipse', LEGACY_ROLES, 'fill'); + tag('g.cluster', 'path, polygon', LEGACY_CLUSTER_ROLES, 'fill', 'stroke'); + tag('g.edge', 'path', LEGACY_ROLES, 'stroke'); } -export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { - const codeNodes = []; +/** Post-process one rendered Graphviz SVG into a themeable `.dg` diagram. */ +function themeDiagram($: ReturnType<typeof load>, classes: string[]): void { + // Graphviz paints an opaque canvas as the first child of the graph group + // whenever a fence sets its own `bgcolor`. Drop it so the page shows through. + $('svg > g.graph > polygon').first().remove(); + + // The graph-level caption sits directly under the graph group. + $('svg > g.graph > text').removeAttr('fill'); + applyRoles($); + + $('svg').attr('class', ['dg', ...classes].join(' ')); +} + +export function remarkGraphvizPlugin(): unified.Plugin<[], mdast.Root> { const transformer: unified.Transformer<mdast.Root> = async (tree) => { + const codeNodes: { node: mdast.Code; lang: string; attrString: string | undefined }[] = []; + visit(tree, `code`, (node) => { - // Only act on languages supported by graphviz - if (validLanguages.includes(node.lang) && !node.value?.includes('<svg')) { - codeNodes.push({ node, attrString: node.meta }); + // Only act on languages supported by graphviz. A node that already holds + // an `<svg>` has been transformed on an earlier pass. + const lang = node.lang ?? ''; + if (VALID_LANGUAGES.includes(lang) && !node.value?.includes('<svg')) { + codeNodes.push({ node, lang, attrString: node.meta ?? undefined }); } return CONTINUE; }); await Promise.all( - codeNodes.map(async ({ node, attrString }) => { - const { value, lang } = node; - /** This transformer can try to re-transform nodes which are now SVG element, we need to prevent that */ - if (node.value?.includes('<svg')) return node; + codeNodes.map(async ({ node, lang, attrString }) => { try { - // Inject theme defaults for class="graph" blocks - const source = injectDefaults(value, attrString); - // Perform actual render - const svgString = viz.renderString(source, { format: 'svg', engine: lang }); - // Add default inline styling. `color` drives `currentColor` for the - // neutral chrome re-themed in themeDiagram(). + const attrs = attrString ? load(`<element ${attrString}></element>`)(`element`) : null; + const classes = classesOf(attrs?.attr('class')); + + const svgString = viz.renderString(injectDefaults(node.value), { + format: 'svg', + engine: lang, + }); const $ = load(svgString); - $(`svg`).attr( - `style`, - `max-width: 100%; height: auto; color: var(--theme-diagram-edge);` - ); - // Make structural chrome track the theme so diagrams read in dark mode. - themeDiagram($); - // Merge custom attributes if provided by user (adds and overwrites) - if (attrString) { - const attrElement = load(`<element ${attrString}></element>`); - $(`svg`).attr(attrElement(`element`).attr()); - } - // Mutate the current node. Converting from a code block to - // HTML (with svg content) - node.type = `html`; - node.value = $.html(`svg`); + + // Merge the fence's own attributes first — `class` is then recomputed + // from them, so a fence can add a kind without losing `dg`. + const fenceAttrs = attrs?.attr(); + if (fenceAttrs) $(`svg`).attr(fenceAttrs); + themeDiagram($, classes); + + // Rewrite the fence in place: it stops being a code block and becomes + // the rendered SVG. mdast has no in-place conversion, so the node + // itself is retyped — asserting the string into `Code['type']` would + // claim `'html'` is `'code'` and leave the node lying about itself. + const htmlNode = node as unknown as mdast.Html; + htmlNode.type = `html`; + htmlNode.value = $.html(`svg`); } catch (error) { - console.log(`Error during viz.js execution. Leaving code block unchanged`); - console.log(error); + // The fence survives as a code block rather than taking the build + // down, so name it loudly: a diagram silently becoming a wall of DOT + // is easy to miss in a 391-page build log. + console.error( + `remark-graphviz: leaving a ${lang} fence unrendered — ${node.value.split('\n')[0]}` + ); + console.error(error); } return node; diff --git a/src/styles/index.css b/src/styles/index.css index f3f551a097..93a1670a65 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -803,26 +803,155 @@ html { } } -svg.graph { - margin-left: auto; - margin-right: auto; - margin-bottom: 1em; +/* ── Diagrams ──────────────────────────────────────────────────────────────── + Graphviz copies a `class` attribute straight into the SVG, so the plugin + renders shape and layout and nothing else; every colour is resolved here, at + paint time, from the role tokens in theme.css. That is what makes dark mode + free — the same `:root.theme-dark` block as every other surface, no second + render — and what lets a diagram be written without naming a colour. + + Each role carries one accent. These lines turn it into the surface, the + border and the label, by mixing it against the page background. A role that + resolves to nothing — a typo in a fence — falls back to `external` rather + than to an invalid `var()`, which `fill` would inherit as black. */ +.dg, +.dg * { + --dg-accent: var(--dg-a, var(--dg-a-external)); + --dg-surface: color-mix(in oklab, var(--dg-accent) var(--dg-tint), var(--dg-canvas)); + --dg-border: color-mix( + in oklab, + var(--dg-accent) calc(100% - var(--dg-edge-lift)), + var(--dg-edge-base) + ); + --dg-label: color-mix(in oklab, var(--dg-accent) var(--dg-ink), var(--dg-ink-base)); + /* A container is a wash, not a card: mixed at a fraction of a node's tint so + a node of the same role still reads as sitting on top of it rather than + dissolving into it. */ + --dg-container: color-mix(in oklab, var(--dg-accent) var(--dg-tint-cluster), var(--dg-canvas)); +} + +/* Defaults by element kind. Graphviz puts the role class on the same element as + the kind class, so `:where()` drops these to a lower specificity and a role + always wins — regardless of the order the rules end up in. */ +.dg :where(.node) { + --dg-a: var(--dg-a-external); +} +.dg :where(.edge) { + --dg-a: var(--dg-a-chrome); +} +.dg :where(.cluster) { + --dg-a: var(--dg-a-batch); +} + +.dg .chrome { + --dg-a: var(--dg-a-chrome); +} +.dg .muted { + --dg-a: var(--dg-a-muted); +} +.dg .batch { + --dg-a: var(--dg-a-batch); +} +.dg .external { + --dg-a: var(--dg-a-external); +} +.dg .queued { + --dg-a: var(--dg-a-queued); +} +.dg .pending { + --dg-a: var(--dg-a-pending); +} +.dg .merged { + --dg-a: var(--dg-a-merged); +} +.dg .failed { + --dg-a: var(--dg-a-failed); +} +.dg .config { + --dg-a: var(--dg-a-config); +} +.dg .mergify { + --dg-a: var(--dg-a-mergify); +} +.dg .datastore { + --dg-a: var(--dg-a-datastore); +} + +.dg { + display: block; + margin: 1.75em auto; + /* Graphviz's intrinsic `width="NNNpt"` is usually narrower than the prose + column, so without a width the diagrams would all shrink. 80% matches what + the diagrams shipped at before they were tokenised, and keeps a fence's own + inline `max-width` meaningful. */ width: 80%; - background: none !important; + max-width: 100%; + height: auto; +} + +/* Graphviz measures text in its own Helvetica tables and the page paints it in + Inter. The node margins the plugin injects absorb the ~3% width difference. */ +.dg text { + font-family: var(--font-body); +} + +/* Only direct children are painted: the plugin strips their inline fill and + stroke, and leaves anything deeper alone. `:where()` again, so `plain` below + wins on specificity rather than on source order. */ +.dg :where(.node) > path, +.dg :where(.node) > polygon, +.dg :where(.node) > ellipse { + fill: var(--dg-surface); + stroke: var(--dg-border); +} +/* box3d and note shapes draw their folded edges as polylines. */ +.dg :where(.node) > polyline { + fill: none; + stroke: var(--dg-border); +} +.dg .node > text { + fill: var(--dg-label); } -/* Graphviz draws a background polygon as the first child of g.graph; make it transparent */ -svg.graph > g.graph > polygon { - fill: none !important; - stroke: none !important; +.dg :where(.cluster) > path, +.dg :where(.cluster) > polygon { + fill: var(--dg-container); + stroke: var(--dg-border); +} +.dg .cluster > text { + fill: var(--dg-label); + font-weight: 600; + letter-spacing: 0.02em; +} + +.dg .edge > path { + fill: none; + stroke: var(--dg-border); +} +.dg .edge > polygon, +.dg .edge > ellipse { + fill: var(--dg-border); + stroke: var(--dg-border); +} +.dg .edge > text { + fill: var(--dg-label); +} + +/* The graph-level caption sits directly under g.graph, on the page surface. */ +.dg > .graph > text { + fill: var(--dg-title); + font-weight: 600; + letter-spacing: 0.04em; } -/* The graph-level label (e.g. "Merge Queue") is a <text> directly under g.graph, - sitting on the transparent page background. Graphviz bakes it as black, which is - unreadable in dark mode. Use the theme text color so it adapts. Cluster and node - labels live in their own <g> and keep the colors set in the diagram. */ -svg.graph > g.graph > text { - fill: var(--theme-text); +/* A node marked `plain` is a caption, not a box. Graphviz still emits a filled + shape behind a `shape=plaintext` node when it inherited `style=filled`. */ +.dg .plain > path, +.dg .plain > polygon, +.dg .plain > ellipse, +.dg .plain > polyline { + fill: none; + stroke: none; } hr { diff --git a/src/styles/theme.css b/src/styles/theme.css index d5007f97a0..273283be0b 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -109,6 +109,33 @@ so structural lines and captions stay legible over the dark surface. */ --theme-diagram-edge: var(--color-gray-700); + /* Diagrams — role accents. A diagram names a role, never a colour; this is + the whole palette, one line per role. The paint rules that consume them + are the `.dg` block in index.css. */ + --dg-a-chrome: var(--color-gray-700); /* edges, arrowheads, captions */ + --dg-a-muted: var(--color-gray-400); /* skipped, dashed, de-emphasised */ + --dg-a-batch: var(--color-gray-500); /* a grouping container */ + --dg-a-external: var(--color-gray-500); /* GitHub, CI — not us */ + --dg-a-queued: var(--color-teal-700); /* in the queue, waiting its turn */ + --dg-a-pending: var(--color-orange-700); /* running now, validating */ + --dg-a-merged: var(--color-green-700); /* merged, passed, done */ + --dg-a-failed: var(--color-coral-700); /* failed, dequeued, cascaded out */ + --dg-a-config: var(--color-blue-700); /* configuration and inputs */ + --dg-a-mergify: var(--color-teal-700); /* a component we run */ + --dg-a-datastore: var(--color-purple-700); /* Postgres, Redis, storage */ + + /* Diagrams — how an accent becomes a surface, a border and a label. The + surface mixes toward the page background, which is why dark mode needs no + second palette: only these numbers change below, never the accents. */ + --dg-canvas: var(--theme-bg-content); + --dg-tint: 10%; /* accent -> surface */ + --dg-tint-cluster: 4%; /* accent -> container surface */ + --dg-ink: 38%; /* accent -> label */ + --dg-ink-base: var(--color-gray-900); + --dg-edge-lift: 0%; /* accent -> border */ + --dg-edge-base: var(--color-white); + --dg-title: var(--theme-text-secondary); + /* Links + accents */ --theme-link: var(--color-blue-700); --theme-link-hover: var(--color-blue-800); @@ -181,6 +208,19 @@ /* Diagrams — brighten the neutral chrome so edges and labels read on dark. */ --theme-diagram-edge: var(--color-gray-400); + /* Diagrams — every dark value for a diagram lives here and nowhere else. + The product accents are untouched: what changes is how much of one is + mixed into the surface, and how far the border is lifted off it. A + --color-green-700 outline on the dark page surface is too dim to read. */ + --dg-a-chrome: var(--color-gray-400); + --dg-a-muted: var(--color-gray-600); + --dg-a-batch: var(--color-gray-500); + --dg-tint: 22%; + --dg-tint-cluster: 9%; + --dg-ink: 30%; + --dg-ink-base: var(--color-gray-50); + --dg-edge-lift: 18%; + /* Links + accents */ --theme-link: var(--color-blue-400); --theme-link-hover: var(--color-blue-700); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 0b2e85299c..5aed8b5f4c 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -39,4 +39,11 @@ --color-coral-700: #e53935; /* Stacks label */ --color-rose-400: #f485b3; /* Workflow Automation accent */ --color-rose-700: #e61e71; /* Workflow Automation label */ + + /* Diagram green — the value that was hardcoded in nine diagrams before they + had a palette, promoted so it has a name. It is not a product accent: it + backs the diagram role "merged" (done, passed). That is not what those nine + diagrams used it for — they meant "queued", which remark-graphviz now says + on their behalf, so the value moves roles as it gains a name. */ + --color-green-700: #347d39; }