Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@
## Design System

See **[DESIGN.md](./DESIGN.md)** for the docs site's design system: tokens, typography utilities,
dark-mode rules, and STRICT code rules.
dark-mode rules, diagram roles, and STRICT code rules.

When you write or modify any styling, read `DESIGN.md` first. The rules there override defaults —
no hex literals, no Chakra references, no `--color-mergify-blue` references, and components should
use semantic tokens by default so dark mode works automatically. Product-specific UI elements
(callouts, brand-colored buttons, product pills) may use `var(--color-*)` primitives directly as a
narrow documented exception — see `DESIGN.md` for the policy.

**Diagrams never name a color.** A Graphviz fence, `<GitGraph>` and `<StackMapping>` all name a
*role* — `queued`, `pending`, `merged`, `failed`, `config`, `mergify`, `datastore`, `external`,
`batch`, `muted`, `chrome` — and the page resolves it at paint time, which is what makes them
theme-aware. `pnpm check:diagram-tokens` fails CI on a color in a diagram. See the "Diagrams"
section of `DESIGN.md` before adding or editing one.

## Quick Facts

- **Framework**: Astro 5.x with MDX
Expand Down Expand Up @@ -43,6 +49,7 @@ pnpm test # Run Vitest unit tests

# Safety
pnpm check:internal-leaks # Scan docs for leaked internal information
pnpm check:diagram-tokens # Scan diagrams for hardcoded colors
```

## Key Directories
Expand Down
95 changes: 94 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ source files listed below; the code is the source of truth.
| Typography utility classes | [`src/styles/typography.css`](./src/styles/typography.css) |
| Global content rules + section-accent system | [`src/styles/index.css`](./src/styles/index.css) |
| Product accent colors (canonical source) | [`mergify.com/DESIGN.md`](../mergify.com/DESIGN.md) — "Product Accent Colors" section |
| Diagram roles + paint rules | [`src/styles/index.css`](./src/styles/index.css) — the `.dg` block |
| Diagram SVG post-processing | [`src/util/diagramSvg.ts`](./src/util/diagramSvg.ts) |
| Reusable Astro components | [`src/components/`](./src/components/) |
| Page layout templates | [`src/layouts/`](./src/layouts/) |
| Icons (Phosphor bold + custom SVGs) | Inline `?raw` imports; no icon component abstraction yet |
Expand Down Expand Up @@ -210,6 +212,90 @@ surrounding surface contrast changes instead.
4. Document the new token in this file under the appropriate section.
5. Consume the semantic token — never the primitive — in components.

## Diagrams

Diagrams were the one surface that escaped this document, and it showed: 63 distinct colors across
18 pages, only 10 of which existed anywhere in `tokens.css`, in four unrelated dialects, with a
dark mode that was a hardcoded list of six strings a build-time plugin matched against. This
section exists so it does not happen again, and `pnpm check:diagram-tokens` enforces the one rule
that matters.

**A diagram never names a color.** It names a *role*, and the page resolves the color at paint
time. Graphviz copies a `class` attribute verbatim into the SVG, so a role survives the render and
CSS can do the rest — which is why dark mode needs no second render and no second palette.

### Roles

| Role | What it means |
| --- | --- |
| `queued` | in the queue, waiting its turn |
| `pending` | running now — validating, testing |
| `merged` | merged, passed, done |
| `failed` | failed, dequeued, cascaded out |
| `config` | configuration and inputs |
| `mergify` | a component we run |
| `datastore` | Postgres, Redis, storage |
| `external` | GitHub, CI, third parties — not us |
| `batch` | a grouping container |
| `muted` | skipped, dashed, de-emphasized |
| `chrome` | edges, arrowheads, captions |

`plain` is not a role: it marks an element as a caption rather than a box, and combines with one
(`class="merged plain"`). The plugin infers it for a shape Graphviz drew with no border, so
`shape=plaintext` needs nothing.

`queued` and `mergify` deliberately share the Merge Queue teal. Same color, two names, because
"waiting in the queue" and "a service we run" are the same idea on two different kinds of diagram,
and a role name that lies is worse than a duplicated accent. Adding a role costs three lines: an
accent in `theme.css` (both blocks), a `.dg .<role>` rule in `index.css`, and an entry in
`DIAGRAM_ROLES` in `src/util/diagramSvg.ts`.

### Diagram kinds

A fence opts into a layout with its class — `` ```dot class="queue" `` — which sets direction and
spacing, never color. A fence that names none lays itself out.

| Kind | For |
| --- | --- |
| `queue` | left-to-right pipelines: queues, batches, CI fan-out |
| `flow` | top-to-bottom state machines and decision trees |
| `arch` | boxes and wires, orthogonal routing, wide labels |

### How a role becomes a color

Each role carries one accent. `index.css` mixes it against the page background to produce a
surface, a border and a label, so a diagram tints toward whatever surface it sits on. Dark mode
changes only how much (`--dg-tint`, `--dg-ink`, `--dg-edge-lift`) and the neutrals — never the
product accents, per "Product palette does not flip" above. Borders do lift toward white on dark:
that is a value *derived* from an accent, not a flipped accent, and a `--color-green-700` outline
on the dark page surface is otherwise too dim to read.

A container is mixed at a fraction of a node's tint (`--dg-tint-cluster`) so a node of the same
role reads as sitting on top of it rather than dissolving into it.

### The four surfaces

All four emit the same `node` / `edge` / `cluster` groups and the same role classes, and all four
are painted by the `.dg` block in `index.css`:

- `` ```dot `` fences, rendered by `plugins/remark-graphviz.ts`
- `<GitGraph>` graph mode, also Graphviz
- `<GitGraph>` linear mode, hand-placed SVG
- `<StackMapping>`, hand-placed SVG

`src/util/diagramSvg.ts` holds what the two Graphviz surfaces share. Before this, each had its own
`COLORS` map and its own dark-mode string-replace, which is how they drifted apart.

### Writing one

- Name a role, never a color. `PR1 [class="queued"];`, not `PR1 [fillcolor="#347D39"];`
- Keep cluster labels short. Graphviz sizes a container to fit the label it measured in Helvetica,
and the page paints it in Inter at weight 600 — roughly 4% wider. Node labels have margin to
absorb the drift; cluster labels do not.
- Do not set `fontsize`, `nodesep` or `ranksep` unless the diagram genuinely needs it. Every fence
gets a shared set of defaults, and a fence that overrides them stops matching its neighbours.
- `none` and `transparent` are shape, not color, and the lint knows the difference.

## Typography

Two font families:
Expand Down Expand Up @@ -334,7 +420,8 @@ These rules apply when writing or generating component, style, or page code.
`var(--color-*)` primitives are only allowed inside `tokens.css` and `theme.css`.
- **Inline SVG `fill`/`stroke`**: the only place raw hex is permitted outside `tokens.css`. Use
`currentColor` wherever possible; fall back to a `var(--color-*)` primitive only when the SVG
must carry its own color independent of the theme.
must carry its own color independent of the theme. **Diagrams are not covered by this
exemption** — they name a role and CSS resolves it; see "Diagrams" above.
- **Typography**: use the utility classes (`heading-section`, `text-subtitle`, etc.) on custom
pages. Do not compose ad-hoc `font-size` / `font-weight` / `letter-spacing` combos from scratch.
- **Dark mode**: never add a `:root.theme-dark` block inside a component-scoped `<style>`. Use
Expand All @@ -350,6 +437,10 @@ These rules apply when writing or generating component, style, or page code.
- **Per-component `:root.theme-dark` rules** — all dark remaps live in `theme.css`.
- **Ad-hoc heading CSS** — no `h2 { font-size: 1.5rem; font-weight: 500; }` rules scattered
across component files. Use the typography utilities.
- **A color in a diagram** — no `fillcolor`, `color`, `fontcolor` or `bgcolor` in a Graphviz
fence, and no hex in a diagram component. Name a role. `pnpm check:diagram-tokens` fails CI on
this, because a hardcoded diagram color renders fine in light mode and wrong in dark, which is
how 63 of them accumulated without anyone noticing.

### Self-correction list

Expand All @@ -363,6 +454,8 @@ If you generate any of the following, **fix it immediately**:
- `:root.theme-dark { … }` inside a `<style>` block in a component → move the logic to a semantic
token in `theme.css`.
- `font-size: 1.5rem; font-weight: 500;` heading ad-hoc combo → use `heading-section` class.
- A color attribute inside a `` ```dot `` fence → replace it with the role that says what the
element *is* (see the role table under "Diagrams").

No exceptions unless the user explicitly overrides.

Expand Down
6 changes: 3 additions & 3 deletions plugins/remark-graphviz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('remarkGraphvizPlugin', () => {
expect(svg).toMatch(/^<svg[^>]*class="dg queue"/);
});

it('leaves no colour in the output at all', async () => {
it('leaves no color 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; }
Expand All @@ -47,7 +47,7 @@ describe('remarkGraphvizPlugin', () => {

it('passes the role a fence names straight through to the SVG', async () => {
// The whole system rests on this: Graphviz copies `class` verbatim, so the
// plugin never has to resolve a colour and CSS can do it at paint time.
// plugin never has to resolve a color and CSS can do it at paint time.
const svg = await render(`digraph {
A [class="failed"];
subgraph cluster_b { class="batch"; label="Batch"; A; }
Expand Down Expand Up @@ -108,7 +108,7 @@ describe('remarkGraphvizPlugin', () => {
expect(square).toMatch(/<g id="node1"[^>]*>\s*<title>A<\/title>\s*<polygon/);
});

it('strips the alpha Graphviz emits alongside a colour', async () => {
it('strips the alpha Graphviz emits alongside a color', async () => {
const svg = await render(`digraph {
node [style=filled];
A [fillcolor="#347d3980"];
Expand Down
8 changes: 4 additions & 4 deletions plugins/remark-graphviz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { CONTINUE, visit } from 'unist-util-visit';
import { finishDiagramSvg } from '../src/util/diagramSvg';

/**
* Render `dot` / `circo` / `neato` fences to inline SVG, and hand every colour
* Render `dot` / `circo` / `neato` fences to inline SVG, and hand every color
* 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 and never names a role: the fence names them, and
* never resolves a color and never names a role: the fence names them, and
* the plugin only injects shape and spacing defaults, drops the opaque canvas
* 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
Expand All @@ -21,7 +21,7 @@ import { finishDiagramSvg } from '../src/util/diagramSvg';
*
* The one class the rendering side still adds is `plain`, in
* `finishDiagramSvg` — a shape fact (this element is a caption, not a box),
* not a colour.
* not a color.
*/

const viz = await instance();
Expand Down Expand Up @@ -55,7 +55,7 @@ const BASE = `

/**
* Diagram kinds. A fence opts into one through its class — ```dot class="queue"
* — and it sets layout, never colour. A fence that names none gets BASE alone
* — and it sets layout, never color. A fence that names none gets BASE alone
* and lays itself out.
*/
const KINDS: Record<string, string> = {
Expand Down
24 changes: 12 additions & 12 deletions scripts/check-diagram-tokens.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
#!/usr/bin/env node
/**
* Scan the docs for diagrams that name a colour.
* Scan the docs for diagrams that name a color.
*
* A diagram names a *role* — `queued`, `merged`, `failed`, `external` — and the
* page resolves it from the design tokens at paint time, so the diagram themes
* itself and every page uses one palette. That worked until it didn't: before
* this rule existed, 63 distinct colours had accumulated across 18 pages in four
* this rule existed, 63 distinct colors had accumulated across 18 pages in four
* unrelated dialects, none of which the token system could reach and none of
* which adapted to dark mode.
*
* Nothing about a hardcoded colour fails a build on its own — the diagram
* Nothing about a hardcoded color fails a build on its own — the diagram
* renders, it just renders wrong on half the site — so this is the thing that
* keeps it from happening again. See DESIGN.md "Diagrams" for the roles.
*
Expand Down Expand Up @@ -47,16 +47,16 @@ const SCANNED_EXTENSIONS = ['.mdx', '.md'];
export const RULES = [
{
id: 'graphviz-color-attr',
label: 'Graphviz colour attribute',
label: 'Graphviz color attribute',
// `(?<![\w-])` so `fillcolor` is reported once, as itself, rather than also
// matching the bare `color` inside it. `none` and `transparent` are not
// colours — they are the absence of one, which is shape work, not styling,
// colors — they are the absence of one, which is shape work, not styling,
// and the way a diagram says "draw no canvas" or "draw no border".
re: /(?<![\w-])(?:bg|fill|font|pen|label)?color\s*=\s*(?!"?(?:none|transparent)\b)/gi,
},
{
id: 'diagram-hex',
label: 'hardcoded colour',
label: 'hardcoded color',
re: /#[0-9a-f]{6}\b/gi,
},
];
Expand Down Expand Up @@ -88,7 +88,7 @@ function allowedOnLine(lines, index) {
/**
* The 1-based line numbers that belong to a diagram: everything inside a
* Graphviz fence, plus its attribute line. Everything else in a docs page is
* prose and code samples, where a colour is ordinary content — a CSS example,
* prose and code samples, where a color is ordinary content — a CSS example,
* a screenshot description, a config value.
*/
function diagramLines(text) {
Expand Down Expand Up @@ -169,21 +169,21 @@ function main(argv) {
return findings.length === 0 ? 0 : 1;
}

console.log(`Scanned ${scanned} file(s) for diagrams that name a colour.`);
console.log(`Scanned ${scanned} file(s) for diagrams that name a color.`);
if (findings.length === 0) {
console.log('Every diagram resolves its colours from tokens.');
console.log('Every diagram resolves its colors from tokens.');
return 0;
}
console.error(`\n${findings.length} diagram colour(s) written by hand:\n`);
console.error(`\n${findings.length} diagram color(s) written by hand:\n`);
for (const f of findings) {
console.error(` ${f.file}:${f.line} — ${f.label} — ${f.match}`);
}
console.error(
'\nName a role instead, and let the page resolve the colour:\n' +
'\nName a role instead, and let the page resolve the color:\n' +
' PR1 [class="queued"]; not PR1 [fillcolor="#347D39"];\n' +
'The roles are queued, pending, merged, failed, config, mergify,\n' +
'datastore, external, batch, muted and chrome; `plain` marks a caption\n' +
'rather than a box. See DESIGN.md "Diagrams". If a colour is genuinely\n' +
'rather than a box. See DESIGN.md "Diagrams". If a color is genuinely\n' +
'the subject rather than the styling, allow it on the line above:\n' +
' // diagram-tokens: allow <rule-id>[, <rule-id>...] — why'
);
Expand Down
16 changes: 8 additions & 8 deletions scripts/check-diagram-tokens.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const fence = (body) => ['```dot class="queue"', body, '```'].join('\n');
const rulesOf = (text, opts) => scanText(text, opts).map((f) => f.rule);

describe('scanText', () => {
it('catches a colour written inside a diagram', () => {
it('catches a color written inside a diagram', () => {
expect(rulesOf(fence('A [fillcolor="#347D39"];'))).toEqual([
'graphviz-color-attr',
'diagram-hex',
Expand All @@ -18,7 +18,7 @@ describe('scanText', () => {
'graphviz-color-attr',
'diagram-hex',
]);
// A named colour carries no hex, and is just as unreachable by the tokens.
// A named color carries no hex, and is just as unreachable by the tokens.
expect(rulesOf(fence('A [fontcolor=white];'))).toEqual(['graphviz-color-attr']);
});

Expand All @@ -39,7 +39,7 @@ describe('scanText', () => {
).toEqual([]);
});

it('treats none and transparent as shape, not colour', () => {
it('treats none and transparent as shape, not color', () => {
// These say "draw nothing", which is the one thing a role cannot express.
expect(rulesOf(fence('graph [bgcolor="transparent"];'))).toEqual([]);
expect(rulesOf(fence('A [color=none];'))).toEqual([]);
Expand All @@ -53,10 +53,10 @@ describe('scanText', () => {
expect(rulesOf(fence('PR1 [label="PR #101\\nScopes: frontend"];'))).toEqual([]);
});

it('ignores colours outside a diagram', () => {
// A docs page is mostly prose and code samples, where a colour is content.
it('ignores colors outside a diagram', () => {
// A docs page is mostly prose and code samples, where a color is content.
const page = [
'Set the badge colour with `color="#347D39"` in your config.',
'Set the badge color with `color="#347D39"` in your config.',
'',
'```css',
'.badge { color: #347d39; }',
Expand All @@ -78,15 +78,15 @@ describe('scanText', () => {
it('honours an allow directive on the line above', () => {
expect(
rulesOf(
fence(`// diagram-tokens: allow diagram-hex — this diagram is about the colour
fence(`// diagram-tokens: allow diagram-hex — this diagram is about the color
A [label="#347D39"];`)
)
).toEqual([]);
});
});

describe('the docs themselves', () => {
it('has no diagram that names a colour', () => {
it('has no diagram that names a color', () => {
const findings = [...iterFiles(['src/content/docs', 'src/components'])].flatMap(scanFile);
expect(findings).toEqual([]);
});
Expand Down
2 changes: 1 addition & 1 deletion src/components/GitGraph.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type DiagramRole, finishDiagramSvg } from '~/util/diagramSvg';
/**
* Commit graphs, in two modes.
*
* Neither mode names a colour. A call site names a *role* — the same vocabulary
* Neither mode names a color. A call site names a *role* — the same vocabulary
* the `dot` fences use — and `.dg` in index.css resolves it at paint time, so
* this component and the fences share one palette and one dark mode. See
* DESIGN.md for the roles.
Expand Down
4 changes: 2 additions & 2 deletions src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -805,10 +805,10 @@ html {

/* ── 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
renders shape and layout and nothing else; every color 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.
render — and what lets a diagram be written without naming a color.

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
Expand Down
Loading
Loading