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/components/DocsetGrid/Docset.astro b/src/components/DocsetGrid/Docset.astro index aedf5868f4..76af8d5c2d 100644 --- a/src/components/DocsetGrid/Docset.astro +++ b/src/components/DocsetGrid/Docset.astro @@ -9,15 +9,23 @@ interface Props { icon?: string; /** Render a product icon in the neutral text color instead of its brand color. */ neutral?: boolean; + /** + * Heading level for the card title. Grids almost always sit directly under an + * `##`, so `h3` is the default; pass 4 for the handful nested under an `###`. + * Skipping a level breaks the document outline that assistive tech and + * agents read the page structure from. + */ + headingLevel?: 3 | 4; } -const { title, path, icon, neutral } = Astro.props; +const { title, path, icon, neutral, headingLevel = 3 } = Astro.props; +const Heading = `h${headingLevel}` as 'h3' | 'h4'; const productIconName = parseProductIcon(icon); const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ? 'workflow' : null)); --- <a href={path} class="container" data-product={productKey}> - <h4> + <Heading> {productIconName && ( <div class="docset-icon" data-product={productKey}> <ProductIcon name={productIconName} /> @@ -29,7 +37,7 @@ const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ? </div> )} <span>{title}</span> - </h4> + </Heading> <div class="description"> <slot /> </div> @@ -69,6 +77,7 @@ const productKey = neutral ? null : (productIconName ?? (path === '/workflow' ? border-color: var(--color-rose-700); } + h3, h4 { display: flex; align-items: center; diff --git a/src/content/docs/api/usage.mdx b/src/content/docs/api/usage.mdx index e0d9558bec..23e275d797 100644 --- a/src/content/docs/api/usage.mdx +++ b/src/content/docs/api/usage.mdx @@ -17,9 +17,9 @@ The API is entirely documented in the [API Reference](/api). ## Authentication -The Mergify API supports two authentication methods, both using Bearer -tokens: **Application Keys** (generated from your dashboard) -and **GitHub Personal Access Tokens**. +The Mergify API supports three authentication methods, all using Bearer +tokens: **Application Keys** (generated from your dashboard), **GitHub +Personal Access Tokens**, and **Mergify User Tokens**. ### Creating an Application Key @@ -118,13 +118,23 @@ curl -H "Accept: application/json" \ organizations. ::: +### Using a Mergify User Token + +Mergify also issues user tokens of its own, recognizable by their `mut_` +prefix and obtained through the OAuth 2.0 device authorization grant. Send one +as a Bearer token, the same way as the credentials above. + +A user token identifies the person it was issued to. It reaches exactly what +its owner's dashboard session reaches, and nothing on GitHub directly, so +holding one grants no access its owner does not already have. + :::caution - Four endpoints refuse a PAT and need an application key. `GET /application` - describes the key it was called with, so it accepts either scope. The `PUT` on - `/repos/{owner}/{repository}/commits/{sha}/scopes` and the `PUT` and `POST` - on `/repos/{owner}/{repository}/pulls/{number}/scopes` need a `ci` key. - Everything else takes a PAT or an `admin` key; each endpoint in the [API - Reference](/api) lists the keys it accepts. + Four endpoints refuse a PAT and a user token alike, and need an application + key. `GET /application` describes the key it was called with, so it accepts either + scope. The `PUT` on `/repos/{owner}/{repository}/commits/{sha}/scopes` and the + `PUT` and `POST` on `/repos/{owner}/{repository}/pulls/{number}/scopes` need a + `ci` key. Everything else takes a PAT, a user token, or an `admin` key; each + endpoint in the [API Reference](/api) lists the credentials it accepts. ::: ### Revoking an Application Key diff --git a/src/content/docs/ci-insights.mdx b/src/content/docs/ci-insights.mdx index 82f9ce7e94..4eb8d7fd43 100644 --- a/src/content/docs/ci-insights.mdx +++ b/src/content/docs/ci-insights.mdx @@ -22,6 +22,7 @@ GitHub and covers basic configuration steps. <DocsetGrid> <Docset + headingLevel={4} title="Self-hosted runners" path="/ci-insights/runners" icon="lucide:server" @@ -29,6 +30,7 @@ GitHub and covers basic configuration steps. Monitor your self-hosted runners' capacity, performance, cost, and reliability. </Docset> <Docset + headingLevel={4} title="Jobs" path="/ci-insights/jobs" icon="lucide:list-checks" @@ -36,6 +38,7 @@ GitHub and covers basic configuration steps. Monitor job health, duration, cost, and flaky behavior. </Docset> <Docset + headingLevel={4} title="Auto-Retry" path="/ci-insights/auto-retry" icon="lucide:rotate-cw" diff --git a/src/content/docs/enterprise.mdx b/src/content/docs/enterprise.mdx index ac5a874f71..63a5131c74 100644 --- a/src/content/docs/enterprise.mdx +++ b/src/content/docs/enterprise.mdx @@ -12,6 +12,9 @@ Enterprise, from requirements to installation, integrations, maintenance, and tr [Manual installation](/enterprise/manual-installation-legacy) if you create the GitHub App yourself instead of using the installer. +- Read [Trusting a Private CA](/enterprise/custom-ca) if any of your infrastructure presents + certificates that are not publicly trusted. + - Explore [Advanced features](/enterprise/advanced-features) such as Datadog telemetry, CI Insights, and Slack integrations. diff --git a/src/content/docs/enterprise/advanced-features.mdx b/src/content/docs/enterprise/advanced-features.mdx index dca067f7c2..c2798d2dce 100644 --- a/src/content/docs/enterprise/advanced-features.mdx +++ b/src/content/docs/enterprise/advanced-features.mdx @@ -118,6 +118,9 @@ MERGIFYENGINE_CI_TRACES_DONE_BUCKET="<mycompany>-mergify-ci-traces-done" MERGIFYENGINE_AWS_ENDPOINT_URL_S3=https://my-s3-domain.example.com:1234/ ``` +A self-hosted endpoint whose certificate is not publicly trusted needs its certificate authority +too: see [Trusting a Private CA](/enterprise/custom-ca). + #### Option A: IAM role discovery (recommended) Leave `MERGIFYENGINE_AWS_ACCESS_KEY_ID` and `MERGIFYENGINE_AWS_SECRET_ACCESS_KEY` unset and Mergify diff --git a/src/content/docs/enterprise/custom-ca.mdx b/src/content/docs/enterprise/custom-ca.mdx new file mode 100644 index 0000000000..67a62c6938 --- /dev/null +++ b/src/content/docs/enterprise/custom-ca.mdx @@ -0,0 +1,148 @@ +--- +title: 'Trusting a Private CA' +description: 'Make an on-premise deployment trust certificates issued by your own certificate authority.' +--- + +Mergify checks the TLS certificates it is presented against a set of public certificate authorities +(CAs). When a server presents a certificate that traces back to none of them, verification fails and +the connection is refused. + +Point the `MERGIFYENGINE_EXTRA_CA_BUNDLE` environment variable at a PEM file and Mergify trusts what +it holds on top of the public certificate authorities it already ships. It is the trust store for +every outbound connection the engine opens. You need it when: + +- Your GitHub Enterprise Server presents a self-signed certificate, or one issued by your internal + PKI. This covers the API calls as well as the `git` clones and pushes the merge queue makes. + +- Your Redis, PostgreSQL, or object storage endpoints do the same. Object storage means both Amazon + S3 and Google Cloud Storage. Managed providers usually publish their certificate authority as a + downloadable PEM file, and that file is what goes here. + +- An egress proxy intercepts outbound TLS and re-signs it with a corporate root. + +Your certificates are added to the public ones rather than replacing them, so calls to Mergify's own +[subscription endpoints](/enterprise/requirements/#external-network-access) keep working. + +Two dependencies need one more setting before they check anything against the bundle: +[PostgreSQL](#postgresql-needs-sslmode-too), which verifies no certificate at all by default, and +[Redis](#redis-and-certificate-verification). + +## Prepare the Bundle + +The file is a PEM bundle: one or more `-----BEGIN CERTIFICATE-----` blocks in a single file. What +belongs in it depends on how the server certificate was issued: + +- **Issued by a certificate authority**, whether your internal PKI or a managed provider's. Use the + root certificate and any intermediates, obtained from whoever runs that authority rather than + harvested from the connection you are trying to verify. + +- **Self-signed.** The certificate is its own issuer, so there is no separate authority to ask for. + Put the server certificate itself in the bundle. + +If what you were given is DER-encoded, convert it first: + +```sh +openssl x509 -inform der -in mycompany-ca.crt -out mycompany-ca.pem +``` + +## Configure the Engine + +Mount the file into the container and name it in the environment. Add these two options to your +[normal-mode command](/enterprise/installation/#start-mergify-in-normal-mode): + +```sh +-v /etc/pki/mycompany-ca.pem:/etc/mergify/ca.pem:ro \ +-e MERGIFYENGINE_EXTRA_CA_BUNDLE=/etc/mergify/ca.pem \ +``` + +The container runs as an unprivileged user, so the mounted file has to be readable by it: + +```sh +chmod 644 /etc/pki/mycompany-ca.pem +``` + +Mergify loads the bundle while it starts and refuses to boot when the file is missing, unreadable, +or not valid PEM, so a wrong path is a startup error naming the setting rather than a handshake +failure hours later. + +If you split the deployment across several containers, mount the file into each one and set the +variable on all of them. Every process reads the bundle for itself. + +## PostgreSQL Needs `sslmode` Too + +The bundle gives PostgreSQL the certificates to check against, but it does not decide whether they +get checked. That is `sslmode` in the DSN, and its default, `prefer`, encrypts the connection +without verifying the certificate at all. Ask for verification explicitly: + +```ini +MERGIFYENGINE_DATABASE_URL=postgresql://postgres:password@db.mycompany.com:5432/postgres?sslmode=verify-full +``` + +`verify-full` also requires the certificate to name the host you connect by. When it does not, a +container alias for instance, use `sslmode=verify-ca`, which checks the certificate against the +bundle without pinning the hostname. Either way the engine opens the database while it starts, so a +mismatch is a boot failure rather than something you find later. + +A `?sslrootcert=` already in the DSN takes precedence, and PostgreSQL then verifies against that +file alone. + +## Redis and Certificate Verification + +Connect with `rediss://` and Mergify verifies the Redis certificate against the bundle like any +other. A `?ssl_ca_certs=` in `MERGIFYENGINE_REDIS_URL` points Redis at that file instead. + +:::caution + `MERGIFYENGINE_REDIS_SSL_VERIFY_MODE_CERT_NONE=1` turns Redis certificate verification off + entirely and overrides the bundle. The connection stays encrypted, but Mergify can no longer tell + your Redis apart from anything else answering at that address. Keep it only while you cannot + obtain the certificate, and unset it once you can; Mergify logs a line when both are configured. +::: + +## Verify It Took Effect + +Finish the two sections above first. A connectivity check against a PostgreSQL or Redis that is not +verifying anything reports `ok` without a certificate having been checked. + +Restart the container and look for the bundle in the startup logs: + +```sh +docker logs mergify-engine | grep "Extra CA bundle" +``` + +Each service logs one line naming the bundle it loaded. If the grep finds nothing, read the logs +unfiltered before suspecting your `-e` flag: a bundle the engine cannot read or parse stops the +container before that line, with a different error naming the setting. + +Then check that Mergify reaches each dependency: + +```sh +docker exec -u root -it mergify-engine /bin/bash +mergify-admin connectivity-check +``` + +Every configured dependency should report `ok`; `skipped` means it is not configured at all. A check +that fails on certificate verification means that server's issuer is still missing from the bundle. +See [Troubleshooting](/enterprise/troubleshooting/#checking-third-party-connectivity) for the full +output format. + +## Rotating the Certificate Authority + +The bundle is read once, when the process starts. Replacing the file on disk changes nothing until +the containers restart. + +To rotate without downtime, add the incoming certificate to the bundle alongside the outgoing one, +restart Mergify, switch your servers over, and only then drop the old certificate and restart again. + +## Other Trust Store Variables + +`SSL_CERT_FILE` and `SSL_CERT_DIR` replace the public certificate authorities Mergify would +otherwise use, and `MERGIFYENGINE_EXTRA_CA_BUNDLE` is added on top of whichever you set. + +Use `SSL_CERT_FILE` with a PEM bundle. `SSL_CERT_DIR` reaches the GitHub and Redis connections, but +not the ones that take a single bundle file: PostgreSQL, object storage, Sentry and `git` fall back +to the public authorities Mergify ships, plus your bundle. Your replacement trust store is then only +partly in effect, and Mergify logs a line saying so at startup. + +`AWS_CA_BUNDLE`, if you already have one set, keeps object storage on that file and the bundle is +not applied there. `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` do nothing at all: Mergify clears them +before it builds any client. diff --git a/src/content/docs/enterprise/installation.mdx b/src/content/docs/enterprise/installation.mdx index ff33321ff4..1b9367b421 100644 --- a/src/content/docs/enterprise/installation.mdx +++ b/src/content/docs/enterprise/installation.mdx @@ -163,21 +163,32 @@ Redis server provides TLS termination. To connect to such an instance you need t #### Optional: use Redis with self-signed TLS certificate -Some Redis server providers set it up with self-signed certificates. In that case, to connect to -such an instance you need to: +Some Redis server providers set it up with self-signed certificates, or with certificates from their +own certificate authority. In that case, to connect to such an instance you need to: - Use `rediss://` instead of `redis://` in the `MERGIFYENGINE_REDIS_URL` environment variable. -- Set `MERGIFYENGINE_REDIS_SSL_VERIFY_MODE_CERT_NONE=1` in your environment variables. Mergify will - connect to Redis using encryption but will not verify the server certificate. +- Add the certificate that signed it, or the server certificate itself when it is self-signed, to + the bundle described in [Trusting a Private CA](/enterprise/custom-ca). Managed providers usually + publish theirs as a downloadable PEM file. Mergify then verifies the Redis certificate like any + other. + +:::caution + `MERGIFYENGINE_REDIS_SSL_VERIFY_MODE_CERT_NONE=1` is the fallback for when you cannot obtain the + certificate at all. Mergify keeps encrypting the connection but stops verifying the server + certificate, so it can no longer tell your Redis apart from anything else answering at that + address. It also overrides the bundle, so unset it once you hold the certificate. +::: ### PostgreSQL Provision a PostgreSQL instance, create a database/user for Mergify, and ensure the container can reach it over the network. Capture the DSN you will pass through `MERGIFYENGINE_DATABASE_URL` (for example `postgresql://postgres:password@postgres:5432/postgres`). -If your provider enforces TLS, configure the connection options accordingly. The first start also -creates a few extensions in that database, so check the +If your provider enforces TLS, add the matching `sslmode` to the DSN; a certificate issued by your +own certificate authority needs `sslmode=verify-full` and the bundle described in +[Trusting a Private CA](/enterprise/custom-ca). The first start also creates a few extensions in +that database, so check the [PostgreSQL requirements](/enterprise/requirements/#postgresql-requirements) before you lock down its privileges. @@ -206,7 +217,9 @@ each authentication mode. ::: The example below includes the CI Insights and Test Insights environment variables. Omit them if you -skipped the previous step. +skipped the previous step. If any of the services Mergify connects to present certificates that are +not publicly trusted, see [Trusting a Private CA](/enterprise/custom-ca) for the two options to add +to this command. ```sh docker run \ diff --git a/src/content/docs/enterprise/manual-installation-legacy.mdx b/src/content/docs/enterprise/manual-installation-legacy.mdx index c98fdb89e6..bd4ff7e89d 100644 --- a/src/content/docs/enterprise/manual-installation-legacy.mdx +++ b/src/content/docs/enterprise/manual-installation-legacy.mdx @@ -128,7 +128,9 @@ docker pull registry.mergify.com/enterprise:@@ENTERPRISE_VERSION@@ > A subscription token is required. Contact Mergify if you do not have one. > -> Ensure Redis (with persistence/TLS if needed) and Postgres are ready. +> Ensure Redis (with persistence/TLS if needed) and Postgres are ready. If they, or your GitHub +> Enterprise Server, present certificates that are not publicly trusted, see +> [Trusting a Private CA](/enterprise/custom-ca). ```sh docker run \ diff --git a/src/content/docs/enterprise/requirements.mdx b/src/content/docs/enterprise/requirements.mdx index c688f79ac3..0283932831 100644 --- a/src/content/docs/enterprise/requirements.mdx +++ b/src/content/docs/enterprise/requirements.mdx @@ -56,6 +56,9 @@ docker run --name some-redis-with-tls -d \ bitnami/redis ``` +That example makes Redis present a certificate signed by a CA you created. Give that CA to Mergify +as described in [Trusting a Private CA](/enterprise/custom-ca), so it can verify the connection. + ## Mergify Requirements The Mergify container needs at least: @@ -90,4 +93,6 @@ Two outbound HTTPS calls must be allowed so Mergify can validate and report lice - `POST https://subscription.mergify.com/on-premise/report` Both calls carry your subscription token and are made by the Mergify container itself, so no other -component needs egress to that host. +component needs egress to that host. If a proxy intercepts outbound TLS and re-signs it with your +own certificate authority, Mergify has to trust that authority for these calls to succeed: see +[Trusting a Private CA](/enterprise/custom-ca). diff --git a/src/content/docs/enterprise/troubleshooting.mdx b/src/content/docs/enterprise/troubleshooting.mdx index 75639ad177..b126a3b0f5 100644 --- a/src/content/docs/enterprise/troubleshooting.mdx +++ b/src/content/docs/enterprise/troubleshooting.mdx @@ -37,6 +37,9 @@ object_storage: skipped github_server: ok ``` +A check that fails on certificate verification means the server presents a certificate Mergify does +not trust. See [Trusting a Private CA](/enterprise/custom-ca). + ### API healthcheck Add a shared token: diff --git a/src/content/enterpriseNavItems.ts b/src/content/enterpriseNavItems.ts index 5f12a316ee..4ff52376b8 100644 --- a/src/content/enterpriseNavItems.ts +++ b/src/content/enterpriseNavItems.ts @@ -5,6 +5,7 @@ const enterpriseNavItems: NavItem[] = [ { title: 'Architecture', path: '/enterprise/architecture', icon: 'lucide:layout-grid' }, { title: 'Requirements', path: '/enterprise/requirements', icon: 'lucide:clipboard-list' }, { title: 'Installation', path: '/enterprise/installation', icon: 'lucide:wrench' }, + { title: 'Private CA', path: '/enterprise/custom-ca', icon: 'lucide:shield-check' }, { title: 'Advanced Features', path: '/enterprise/advanced-features', 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; }