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; }