@runbooks/design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +14 -0
  2. package/dist/color.d.ts +17 -0
  3. package/dist/color.js +24 -0
  4. package/dist/geometry.test.d.ts +1 -0
  5. package/dist/geometry.test.js +155 -0
  6. package/dist/icons.d.ts +69 -0
  7. package/dist/icons.js +103 -0
  8. package/dist/icons.test.d.ts +1 -0
  9. package/dist/icons.test.js +140 -0
  10. package/dist/index.d.ts +9 -0
  11. package/dist/index.js +9 -0
  12. package/dist/mark.d.ts +42 -0
  13. package/dist/mark.js +102 -0
  14. package/dist/primitives.d.ts +75 -0
  15. package/dist/primitives.js +126 -0
  16. package/dist/primitives.test.d.ts +1 -0
  17. package/dist/primitives.test.js +134 -0
  18. package/dist/render.d.ts +97 -0
  19. package/dist/render.js +1085 -0
  20. package/dist/render.test.d.ts +1 -0
  21. package/dist/render.test.js +179 -0
  22. package/dist/specimen.d.ts +2 -0
  23. package/dist/specimen.gen.d.ts +1 -0
  24. package/dist/specimen.gen.js +9 -0
  25. package/dist/specimen.js +81 -0
  26. package/dist/stylesheet.d.ts +95 -0
  27. package/dist/stylesheet.js +987 -0
  28. package/dist/stylesheet.test.d.ts +1 -0
  29. package/dist/stylesheet.test.js +265 -0
  30. package/dist/text.d.ts +28 -0
  31. package/dist/text.js +89 -0
  32. package/dist/tokens.d.ts +104 -0
  33. package/dist/tokens.js +142 -0
  34. package/dist/tokens.test.d.ts +1 -0
  35. package/dist/tokens.test.js +125 -0
  36. package/fonts/IBMPlexMono-Regular-Latin1.woff2 +0 -0
  37. package/fonts/IBMPlexMono-SemiBold-Latin1.woff2 +0 -0
  38. package/fonts/IBMPlexSans-Italic-Latin1.woff2 +0 -0
  39. package/fonts/IBMPlexSans-Medium-Latin1.woff2 +0 -0
  40. package/fonts/IBMPlexSans-Regular-Latin1.woff2 +0 -0
  41. package/fonts/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
  42. package/fonts/LICENSE.txt +93 -0
  43. package/package.json +40 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /** Up from packages/design/src, the way the other tests in this package find the repo. */
6
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
7
+ import { stylesheet, FACES, FONT_DIR, LATIN1_RANGE, PALETTES, RISK_COLORS, TYPE_SCALE, FONT_STACKS, NUMERIC_FEATURES, DEFAULT_THEME, } from "./index.js";
8
+ const css = stylesheet({ defaultTheme: DEFAULT_THEME.catalog });
9
+ /**
10
+ * The tokens existed, their contrast was measured, and no page had a stylesheet — so
11
+ * every value in this package was an agreement the product had made with itself. These
12
+ * assert the agreement reaches a document.
13
+ */
14
+ describe("the stylesheet is the tokens and nothing invented", () => {
15
+ it("uses no colour that is not a token", () => {
16
+ const known = new Set([
17
+ ...Object.values(PALETTES).flatMap((palette) => Object.values(palette)),
18
+ ...Object.values(RISK_COLORS).flatMap((scale) => Object.values(scale)),
19
+ ].map((value) => value.toLowerCase()));
20
+ const used = [...css.matchAll(/#[0-9a-fA-F]{3,8}\b/g)].map((match) => match[0].toLowerCase());
21
+ expect(used.length, "the stylesheet has no colours at all").toBeGreaterThan(0);
22
+ for (const colour of new Set(used)) {
23
+ expect(known.has(colour), `${colour} is in the stylesheet and in no token`).toBe(true);
24
+ }
25
+ });
26
+ it("carries every step of the type scale", () => {
27
+ for (const [name, step] of Object.entries(TYPE_SCALE)) {
28
+ const kebab = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
29
+ expect(css, `${name} has no size`).toContain(`--rb-${kebab}-size:${step.size}px`);
30
+ expect(css, `${name} has no leading`).toContain(`--rb-${kebab}-leading:${step.leading}px`);
31
+ }
32
+ });
33
+ it("caps the measure at the scale's line length rather than a per-page choice", () => {
34
+ expect(css).toContain(`--rb-measure:${TYPE_SCALE.body.maxLineLength}ch`);
35
+ expect(css).toMatch(/p,li\{ ?max-width:var\(--rb-measure\)/);
36
+ });
37
+ it("uses the token font stacks, so the fallback is the one that was chosen", () => {
38
+ expect(css).toContain(`--rb-sans:${FONT_STACKS.sans}`);
39
+ expect(css).toContain(`--rb-mono:${FONT_STACKS.mono}`);
40
+ });
41
+ it("turns tabular figures on where numbers appear in sequence", () => {
42
+ expect(css).toContain(`font-variant-numeric:${NUMERIC_FEATURES}`);
43
+ for (const selector of ["body", "table", "code,kbd,pre,samp"]) {
44
+ const block = new RegExp(`${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\{[^}]*font-variant-numeric`);
45
+ expect(css, `${selector} does not set tabular figures`).toMatch(block);
46
+ }
47
+ });
48
+ });
49
+ /** §18.2's prohibitions, kept by having nothing to reach for. */
50
+ describe("the prohibited list cannot be spelled here", () => {
51
+ it("has no shadow", () => {
52
+ expect(css).not.toMatch(/box-shadow|text-shadow|drop-shadow/);
53
+ });
54
+ it("has no gradient", () => {
55
+ expect(css).not.toMatch(/linear-gradient|radial-gradient|conic-gradient/);
56
+ });
57
+ it("has no uppercase transform", () => {
58
+ expect(css).not.toMatch(/text-transform\s*:\s*uppercase/);
59
+ });
60
+ });
61
+ /**
62
+ * Q10's answer, made operative: each surface has a default, and a viewer's explicit
63
+ * choice wins. A theme that could only be a default would be the lock Q10 refused.
64
+ */
65
+ describe("both themes are reachable and a choice wins", () => {
66
+ it("emits the default palette on :root", () => {
67
+ const light = PALETTES[DEFAULT_THEME.catalog];
68
+ expect(css).toContain(`--rb-background:${light.background}`);
69
+ });
70
+ it("offers the other one, reachable by choosing it", () => {
71
+ const other = DEFAULT_THEME.catalog === "light" ? "dark" : "light";
72
+ expect(css).toContain(PALETTES[other].background);
73
+ });
74
+ it("does not hand the default to an operating-system setting", () => {
75
+ // Following prefers-color-scheme made the default whatever the desktop happened to
76
+ // be, so the site had one look on one machine and another on the next. Q10 refused a
77
+ // lock, not a default: the toggle below still wins, in either direction.
78
+ expect(css).not.toMatch(/@media \(prefers-color-scheme/);
79
+ });
80
+ it("lets an explicit choice override both, in either direction", () => {
81
+ expect(css).toMatch(/:root\[data-theme="light"\]/);
82
+ expect(css).toMatch(/:root\[data-theme="dark"\]/);
83
+ });
84
+ it("declares color-scheme, so form controls follow the page", () => {
85
+ expect(css).toMatch(/color-scheme:/);
86
+ });
87
+ });
88
+ /**
89
+ * What the stylesheet can promise on its own. That the files it names are served is the
90
+ * app's side of the same claim, and is asserted in apps/web/src/fonts.test.ts — this
91
+ * package does not know where a catalog puts its public directory.
92
+ */
93
+ describe("the faces it names are the ones the scale needs", () => {
94
+ it("declares a face for each weight the scale asks for", () => {
95
+ const weights = new Set(Object.values(TYPE_SCALE).map((step) => step.weight));
96
+ for (const weight of weights) {
97
+ expect(FACES.some((face) => face.family === "IBM Plex Sans" && face.weight === weight), `no sans face at weight ${weight}, which the type scale asks for`).toBe(true);
98
+ }
99
+ });
100
+ it("points at each by the path it is served from", () => {
101
+ for (const face of FACES) {
102
+ expect(css).toContain(`url("${FONT_DIR}/${face.file}") format("woff2")`);
103
+ }
104
+ });
105
+ it("subsets by upstream's own range rather than one invented here", () => {
106
+ // The characters this catalog's prose is actually full of.
107
+ for (const codepoint of ["U+2013-2014", "U+201C-201E", "U+2026"]) {
108
+ expect(LATIN1_RANGE, `${codepoint} is outside the subset and will fall back mid-sentence`)
109
+ .toContain(codepoint);
110
+ }
111
+ expect(css.match(/unicode-range:/g)?.length).toBe(FACES.length);
112
+ });
113
+ it("swaps rather than blocking, so text is readable before the face arrives", () => {
114
+ expect(css.match(/font-display:swap/g)?.length).toBe(FACES.length);
115
+ });
116
+ });
117
+ /**
118
+ * The decision record and the code agree about what the default is.
119
+ *
120
+ * `docs/decisions/Q10-theme-per-surface.md` said the catalog defaults light for two days
121
+ * after it started defaulting dark. A decision file is what somebody reads to find out
122
+ * what was decided and why; one that describes the opposite of what runs sends them
123
+ * looking for a bug that is not there, or worse, to "restore" the behaviour it describes.
124
+ */
125
+ describe("the theme decision says what the code does", () => {
126
+ const record = readFileSync(join(ROOT, "docs", "decisions", "Q10-theme-per-surface.md"), "utf8");
127
+ it.each(["canvas", "catalog"])("names %s's actual default", (surface) => {
128
+ const theme = DEFAULT_THEME[surface];
129
+ // The word appears near the surface it belongs to, rather than anywhere in the file.
130
+ expect(record.toLowerCase(), `${surface} defaults ${theme} and the record does not say so`)
131
+ .toMatch(new RegExp(`(both surfaces|${surface})[^.]{0,120}\\b${theme}\\b`, "i"));
132
+ });
133
+ it("still records the rule that outlived the default", () => {
134
+ // The lock is what Q10 refused, and that has not changed.
135
+ expect(record).toMatch(/neither is a lock/i);
136
+ expect(record).toMatch(/toggle/i);
137
+ });
138
+ it("does not describe the operating system as deciding it", () => {
139
+ expect(css, "the stylesheet took the default from the desktop again").not.toMatch(/@media \(prefers-color-scheme/);
140
+ });
141
+ });
142
+ /**
143
+ * §18.4: the hero fits the first screen.
144
+ *
145
+ * A width cap is not enough for a graph laid out top to bottom — scaled to the column it is
146
+ * still a thousand pixels tall, and everything the page says goes under it. The hero had no
147
+ * rule of its own until the layout turned vertical and it stopped being a poster.
148
+ */
149
+ describe("the home page hero", () => {
150
+ it("is bounded by height, not only by width", () => {
151
+ expect(css, "no rule for the hero graph").toMatch(/\.hero-graph svg\{[^}]*max-height/);
152
+ });
153
+ /**
154
+ * Width leads and height follows, with the height still capped so the hero stays above
155
+ * the fold. It was the other way round, and a graph fitted by height left half of its
156
+ * column empty beside a tall procedure.
157
+ */
158
+ it("lets the width lead, with the height still capped", () => {
159
+ const rule = /\.hero-graph svg\{([^}]*)\}/.exec(css)?.[1] ?? "";
160
+ expect(rule).toContain("width:100%");
161
+ expect(rule).toContain("height:auto");
162
+ expect(rule, "nothing keeps the hero above the fold").toContain("max-height:");
163
+ });
164
+ });
165
+ /**
166
+ * §18.4 on a phone: the graph is a card and the full-screen view is where it is read.
167
+ *
168
+ * Seventy per cent of the viewport is most of a phone, so a reader who came for the steps
169
+ * meets a picture to scroll past. The card's job there is the silhouette's — that the
170
+ * procedure branches and that there is red in it — and reading belongs to full screen,
171
+ * which keeps the whole viewport.
172
+ */
173
+ describe("the graph on a narrow screen", () => {
174
+ const narrow = css.slice(css.indexOf("@media (max-width:700px)"));
175
+ it("bounds the card well under the viewport", () => {
176
+ expect(narrow).toMatch(/\[data-slot="frame"\]\{ max-height:34vh/);
177
+ });
178
+ it("gives full screen the whole viewport anyway", () => {
179
+ expect(narrow).toMatch(/:fullscreen \[data-slot="frame"\]\{ max-height:none/);
180
+ });
181
+ it("lets the controls wrap rather than squeezing their targets", () => {
182
+ expect(narrow).toMatch(/\[data-slot="controls"\]\{ flex-wrap:wrap/);
183
+ });
184
+ /** The targets themselves are already 44px under a coarse pointer; this keeps that true. */
185
+ it("keeps the 44px floor for a thumb", () => {
186
+ expect(css).toMatch(/@media \(pointer:coarse\)\{[\s\S]*?min-height:44px/);
187
+ });
188
+ });
189
+ /**
190
+ * The rules that make a narrow screen readable, none of which was asserted.
191
+ *
192
+ * Four media queries carried the whole of the small-screen design and no test named one:
193
+ * the record page's two columns collapsing to one, the catalog's doing the same, the
194
+ * graph viewer giving back height and dropping a hint about a pointer nobody on a phone
195
+ * has, and motion being cut for a reader who asked for that. A stylesheet is text here —
196
+ * the viewport question is still open (D-05) and nothing in this repository can measure a
197
+ * rendered phone — so the text is what can honestly be checked, and it was not.
198
+ */
199
+ describe("the small-screen rules exist and say what they must", () => {
200
+ /** Every block opened by this query, by brace depth: two may share a width. */
201
+ const blocks = (query) => {
202
+ const out = [];
203
+ for (let from = css.indexOf(query); from !== -1; from = css.indexOf(query, from + 1)) {
204
+ let depth = 0;
205
+ for (let i = from; i < css.length; i++) {
206
+ if (css[i] === "{")
207
+ depth++;
208
+ else if (css[i] === "}" && --depth === 0) {
209
+ out.push(css.slice(from, i + 1));
210
+ break;
211
+ }
212
+ }
213
+ }
214
+ return out;
215
+ };
216
+ /** One media block, by brace depth, so an assertion cannot match the next block down. */
217
+ const block = (query) => {
218
+ const start = css.indexOf(query);
219
+ expect(start, `${query} is not in the stylesheet at all`).toBeGreaterThan(-1);
220
+ let depth = 0;
221
+ for (let i = start; i < css.length; i++) {
222
+ if (css[i] === "{")
223
+ depth++;
224
+ else if (css[i] === "}") {
225
+ depth--;
226
+ if (depth === 0)
227
+ return css.slice(start, i + 1);
228
+ }
229
+ }
230
+ throw new Error(`${query} is never closed`);
231
+ };
232
+ it("collapses the record page to one column, and unsticks it with it", () => {
233
+ // By what it names, not by which comes first: a tool page opens a block at this width
234
+ // too, and `block` returning the first one made this assert about the wrong layout the
235
+ // day a second one was added.
236
+ // Every block at this width, not the first one that names the layout: since the phone
237
+ // pass there are two — the one that collapses the columns and the one that releases the
238
+ // sticky column, which has to come after the rule it takes back.
239
+ const narrow = blocks("@media (max-width:1100px)")
240
+ .filter((rule) => rule.includes('data-layout="record"'))
241
+ .join("\n");
242
+ expect(narrow, "no narrow-screen rule mentions the record layout").not.toBe("");
243
+ expect(narrow).toMatch(/\[data-layout="record"\]\{ grid-template-columns:minmax\(0,1fr\)/);
244
+ // Any column that stays stuck once it is full-width covers the one under it. Asked of
245
+ // every child rather than of the graph column: the half that sticks is the prose, and
246
+ // naming the other one was a rule that could not have caught the column it was for.
247
+ expect(narrow).toMatch(/\[data-layout="record"\]>\*\{[^}]*position:static/);
248
+ });
249
+ it("collapses the catalog to one column", () => {
250
+ expect(block("@media (max-width:820px)")).toMatch(/\[data-layout="catalog"\]\{ grid-template-columns:minmax\(0,1fr\)/);
251
+ });
252
+ it("gives the viewer back height on a phone and drops the pointer hint", () => {
253
+ // Two blocks share this width; the one that matters is the one naming the viewer.
254
+ const viewer = blocks("@media (max-width:600px)").find((rule) => rule.includes("graph-viewer"));
255
+ expect(viewer, "no narrow-screen rule mentions the graph viewer").toBeDefined();
256
+ expect(viewer).toMatch(/\[data-slot="frame"\]\{ max-height:60vh/);
257
+ expect(viewer).toMatch(/\[data-slot="hint"\]\{ display:none/);
258
+ });
259
+ it("cuts motion for a reader who asked for that", () => {
260
+ const still = block("@media (prefers-reduced-motion:reduce)");
261
+ expect(still).toMatch(/animation-duration:0\.01ms !important/);
262
+ expect(still).toMatch(/transition-duration:0\.01ms !important/);
263
+ expect(still).toMatch(/scroll-behavior:auto !important/);
264
+ });
265
+ });
package/dist/text.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Measuring a label without a browser.
3
+ *
4
+ * The renderer is a pure function that runs in the build, in the CLI's SVG export and in
5
+ * a test — none of which has a text engine. Labels were therefore not measured at all:
6
+ * every node was 168px wide and every title was cut at 28 characters, which at 13px is
7
+ * about 175px, so the longest labels ran out past both edges of their own box. "Confirm
8
+ * the node is NotReady" was drawn as "onfirm the node is NotRead".
9
+ *
10
+ * An approximation is enough, because it is used to *reserve* space. The advances below
11
+ * are IBM Plex Sans at 1em, rounded, grouped by the classes that actually differ; the
12
+ * error against the real face is a few percent and the padding absorbs it. Being slightly
13
+ * generous is the safe direction: a box wider than its text is tidy, a box narrower than
14
+ * its text is broken.
15
+ */
16
+ export declare function advance(character: string, size: number): number;
17
+ export declare function measure(text: string, size: number): number;
18
+ /**
19
+ * Break a label into at most `maxLines`, at word boundaries, and say how wide it needs.
20
+ *
21
+ * A word longer than the line is broken rather than allowed to overflow — a URL or a
22
+ * `kafka-consumer-groups` has to go somewhere, and running past the outline is the one
23
+ * option that makes the picture wrong rather than merely tight.
24
+ */
25
+ export declare function wrap(text: string, size: number, maxWidth: number, maxLines: number): {
26
+ readonly lines: readonly string[];
27
+ readonly width: number;
28
+ };
package/dist/text.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Measuring a label without a browser.
3
+ *
4
+ * The renderer is a pure function that runs in the build, in the CLI's SVG export and in
5
+ * a test — none of which has a text engine. Labels were therefore not measured at all:
6
+ * every node was 168px wide and every title was cut at 28 characters, which at 13px is
7
+ * about 175px, so the longest labels ran out past both edges of their own box. "Confirm
8
+ * the node is NotReady" was drawn as "onfirm the node is NotRead".
9
+ *
10
+ * An approximation is enough, because it is used to *reserve* space. The advances below
11
+ * are IBM Plex Sans at 1em, rounded, grouped by the classes that actually differ; the
12
+ * error against the real face is a few percent and the padding absorbs it. Being slightly
13
+ * generous is the safe direction: a box wider than its text is tidy, a box narrower than
14
+ * its text is broken.
15
+ */
16
+ /** Advance widths in ems, by character class. */
17
+ const NARROW = new Set("iljtIfr.,:;'\"`!|[](){}/\\-");
18
+ const WIDE = new Set("mwMW@%");
19
+ const CAPS = /[A-Z0-9]/;
20
+ export function advance(character, size) {
21
+ if (character === " ")
22
+ return 0.26 * size;
23
+ if (NARROW.has(character))
24
+ return 0.32 * size;
25
+ if (WIDE.has(character))
26
+ return 0.86 * size;
27
+ if (CAPS.test(character))
28
+ return 0.62 * size;
29
+ return 0.53 * size;
30
+ }
31
+ export function measure(text, size) {
32
+ let total = 0;
33
+ for (const character of text)
34
+ total += advance(character, size);
35
+ return total;
36
+ }
37
+ /**
38
+ * Break a label into at most `maxLines`, at word boundaries, and say how wide it needs.
39
+ *
40
+ * A word longer than the line is broken rather than allowed to overflow — a URL or a
41
+ * `kafka-consumer-groups` has to go somewhere, and running past the outline is the one
42
+ * option that makes the picture wrong rather than merely tight.
43
+ */
44
+ export function wrap(text, size, maxWidth, maxLines) {
45
+ const words = text.split(/\s+/).filter(Boolean);
46
+ const lines = [];
47
+ let current = "";
48
+ const push = () => {
49
+ if (current)
50
+ lines.push(current);
51
+ current = "";
52
+ };
53
+ for (const word of words) {
54
+ const candidate = current ? `${current} ${word}` : word;
55
+ if (measure(candidate, size) <= maxWidth) {
56
+ current = candidate;
57
+ continue;
58
+ }
59
+ push();
60
+ if (measure(word, size) <= maxWidth) {
61
+ current = word;
62
+ continue;
63
+ }
64
+ // A single word too long for the line: break it where it stops fitting.
65
+ let piece = "";
66
+ for (const character of word) {
67
+ if (measure(piece + character, size) > maxWidth) {
68
+ lines.push(piece);
69
+ piece = character;
70
+ }
71
+ else {
72
+ piece += character;
73
+ }
74
+ }
75
+ current = piece;
76
+ }
77
+ push();
78
+ if (lines.length <= maxLines) {
79
+ return { lines, width: Math.max(0, ...lines.map((line) => measure(line, size))) };
80
+ }
81
+ // Past the line budget the last line is elided, never a middle one: a reader who sees
82
+ // an ellipsis knows there is more, and one who sees a clipped word does not.
83
+ const kept = lines.slice(0, maxLines);
84
+ let last = kept[maxLines - 1] ?? "";
85
+ while (last.length > 1 && measure(`${last}…`, size) > maxWidth)
86
+ last = last.slice(0, -1);
87
+ kept[maxLines - 1] = `${last.trimEnd()}…`;
88
+ return { lines: kept, width: Math.max(0, ...kept.map((line) => measure(line, size))) };
89
+ }
@@ -0,0 +1,104 @@
1
+ import type { Risk } from "@runbooks/schema";
2
+ export type Surface = "canvas" | "catalog";
3
+ export type Theme = "light" | "dark";
4
+ export interface Palette {
5
+ readonly background: string;
6
+ readonly surface: string;
7
+ readonly border: string;
8
+ readonly text: string;
9
+ readonly textSecondary: string;
10
+ readonly trust: string;
11
+ }
12
+ export declare const PALETTES: Readonly<Record<Theme, Palette>>;
13
+ /**
14
+ * The risk scale, tuned per theme.
15
+ *
16
+ * The visual specification asked for one palette used everywhere, monotonic in
17
+ * lightness so it survives greyscale, and meeting a 3:1 stroke floor. Measured, its
18
+ * values satisfy none of those three at once, and the three cannot all hold: a single
19
+ * palette cannot clear 3:1 against both a near-black and a near-white ground while
20
+ * spanning enough lightness to be legible in greyscale. See
21
+ * docs/decisions/Q10-theme-per-surface.md.
22
+ *
23
+ * What is preserved is what the scale is *for*: severity always reads as darker, the
24
+ * ordering is identical in both themes, and the hue families are unchanged. What varies
25
+ * is only the rendering, and a card is read in one theme at a time — so a silhouette
26
+ * remains comparable to every other silhouette the same reader sees.
27
+ *
28
+ * Monotonic in lightness within each theme, and always duplicated by a text label: a
29
+ * hue is never the sole carrier of meaning.
30
+ */
31
+ export declare const RISK_COLORS: Readonly<Record<Theme, Readonly<Record<Risk, string>>>>;
32
+ export declare function riskColor(risk: Risk, theme: Theme): string;
33
+ export declare const RISK_LABELS: Readonly<Record<Risk, string>>;
34
+ /**
35
+ * Q10: each surface has a default, and a viewer's explicit choice wins.
36
+ *
37
+ * §18.2's reasoning is about exposure time — hours in the editor, minutes in the catalog
38
+ * — and that is a good reason for a *default*, not for a lock. Forcing a theme on
39
+ * someone who needs the other one is an accessibility failure, and the functional
40
+ * argument does not survive contact with that. Reasoning:
41
+ * docs/decisions/Q10-theme-per-surface.md.
42
+ */
43
+ export declare const DEFAULT_THEME: Readonly<Record<Surface, Theme>>;
44
+ export declare const TYPE_SCALE: {
45
+ readonly pageTitle: {
46
+ readonly size: 32;
47
+ readonly leading: 38;
48
+ readonly weight: 500;
49
+ };
50
+ readonly sectionTitle: {
51
+ readonly size: 20;
52
+ readonly leading: 28;
53
+ readonly weight: 500;
54
+ };
55
+ readonly body: {
56
+ readonly size: 15;
57
+ readonly leading: 24;
58
+ readonly weight: 400;
59
+ readonly maxLineLength: 75;
60
+ };
61
+ readonly nodeLabel: {
62
+ readonly size: 13;
63
+ readonly leading: 16;
64
+ readonly weight: 500;
65
+ readonly maxLines: 2;
66
+ };
67
+ readonly meta: {
68
+ readonly size: 13;
69
+ readonly leading: 18;
70
+ readonly weight: 400;
71
+ };
72
+ readonly code: {
73
+ readonly size: 13;
74
+ readonly leading: 20;
75
+ readonly weight: 400;
76
+ readonly mono: true;
77
+ };
78
+ };
79
+ export declare const FONT_STACKS: {
80
+ readonly sans: "\"IBM Plex Sans\", system-ui, -apple-system, \"Segoe UI\", sans-serif";
81
+ readonly mono: "\"IBM Plex Mono\", ui-monospace, \"SF Mono\", Menlo, monospace";
82
+ };
83
+ /** Numerals are tabular wherever numbers appear in sequence. */
84
+ export declare const NUMERIC_FEATURES: "tabular-nums";
85
+ export declare const NODE_RADIUS = 2;
86
+ /**
87
+ * Contrast floors from §18.2. Asserted in tests against every pairing the product
88
+ * actually uses, rather than checked once by eye.
89
+ */
90
+ export declare const CONTRAST_FLOOR: {
91
+ readonly text: 4.5;
92
+ readonly stroke: 3;
93
+ };
94
+ export declare function meetsTextFloor(foreground: string, background: string): boolean;
95
+ export declare function meetsStrokeFloor(stroke: string, background: string): boolean;
96
+ /** Risks ordered lightest to darkest within a theme. Should always equal RISK_ORDER. */
97
+ export declare function riskLightnessOrder(theme: Theme): Risk[];
98
+ /**
99
+ * Borders are dividers, not node strokes, and are exempt from the 3:1 stroke floor by
100
+ * design: a divider that met it would be a rule, and §18.2 separates by border *and*
101
+ * background rather than by shouting. Stated here so the exemption is a decision rather
102
+ * than an oversight the contrast test quietly skips.
103
+ */
104
+ export declare const EXEMPT_FROM_STROKE_FLOOR: readonly ["border", "surface"];
package/dist/tokens.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Design tokens.
3
+ *
4
+ * The rule that shapes this file: **colour never encodes status and risk at the same
5
+ * time** (RUNBOOK.md §18.2). Risk owns the hue channel. Simulation and run state are
6
+ * expressed by stroke weight and background fill, and there is deliberately no token
7
+ * for a state colour — a rule is better kept by having nothing to reach for.
8
+ */
9
+ import { contrast, lightness } from "./color.js";
10
+ export const PALETTES = {
11
+ /**
12
+ * Dark, and the catalog's default.
13
+ *
14
+ * Measured, not chosen by eye — which is the same rule that caught the visual
15
+ * specification's own palette failing two of its three stated properties. Text reads
16
+ * 17.2:1 on the ground and 15.7:1 on a surface, secondary text 7.9:1 and 7.2:1, and the
17
+ * trust accent 11.4:1 against a floor of 4.5.
18
+ */
19
+ dark: {
20
+ background: "#0B0F14",
21
+ surface: "#131A23",
22
+ border: "#243444",
23
+ text: "#E8F4FF",
24
+ textSecondary: "#93A9BF",
25
+ trust: "#3DE0B0",
26
+ },
27
+ light: {
28
+ background: "#F7F6F3",
29
+ surface: "#FFFFFF",
30
+ border: "#D8D5CE",
31
+ text: "#141A1F",
32
+ textSecondary: "#6B7278",
33
+ trust: "#2F6B57",
34
+ },
35
+ };
36
+ /**
37
+ * The risk scale, tuned per theme.
38
+ *
39
+ * The visual specification asked for one palette used everywhere, monotonic in
40
+ * lightness so it survives greyscale, and meeting a 3:1 stroke floor. Measured, its
41
+ * values satisfy none of those three at once, and the three cannot all hold: a single
42
+ * palette cannot clear 3:1 against both a near-black and a near-white ground while
43
+ * spanning enough lightness to be legible in greyscale. See
44
+ * docs/decisions/Q10-theme-per-surface.md.
45
+ *
46
+ * What is preserved is what the scale is *for*: severity always reads as darker, the
47
+ * ordering is identical in both themes, and the hue families are unchanged. What varies
48
+ * is only the rendering, and a card is read in one theme at a time — so a silhouette
49
+ * remains comparable to every other silhouette the same reader sees.
50
+ *
51
+ * Monotonic in lightness within each theme, and always duplicated by a text label: a
52
+ * hue is never the sole carrier of meaning.
53
+ */
54
+ export const RISK_COLORS = {
55
+ dark: {
56
+ /**
57
+ * Tuned against the node surface, not the page background: a node stroke is drawn on
58
+ * the surface, and the surface is the lighter of the two, so it is the ground that
59
+ * decides.
60
+ *
61
+ * Saturated, because on a near-black ground that is what stays legible at a stroke
62
+ * width — and because a risk scale is the one place in this design where colour is
63
+ * doing work. It is still the same four values in the same order, still monotonic in
64
+ * lightness, and still duplicated by a text label: severity reads as darker even in
65
+ * greyscale, and adjacent steps separate at 1.5:1 against a floor of 1.15.
66
+ */
67
+ "read-only": "#AEF4FF",
68
+ "reversible-write": "#E8B62C",
69
+ destructive: "#FF6A28",
70
+ irreversible: "#E81E57",
71
+ },
72
+ light: {
73
+ "read-only": "#598FA6",
74
+ "reversible-write": "#906F30",
75
+ destructive: "#96452F",
76
+ irreversible: "#702835",
77
+ },
78
+ };
79
+ export function riskColor(risk, theme) {
80
+ return RISK_COLORS[theme][risk];
81
+ }
82
+ export const RISK_LABELS = {
83
+ "read-only": "RO",
84
+ "reversible-write": "RW",
85
+ destructive: "DEL",
86
+ irreversible: "IRR",
87
+ };
88
+ /**
89
+ * Q10: each surface has a default, and a viewer's explicit choice wins.
90
+ *
91
+ * §18.2's reasoning is about exposure time — hours in the editor, minutes in the catalog
92
+ * — and that is a good reason for a *default*, not for a lock. Forcing a theme on
93
+ * someone who needs the other one is an accessibility failure, and the functional
94
+ * argument does not survive contact with that. Reasoning:
95
+ * docs/decisions/Q10-theme-per-surface.md.
96
+ */
97
+ export const DEFAULT_THEME = {
98
+ canvas: "dark",
99
+ // Dark on both surfaces. §18.2 argued the catalog's default from exposure time — minutes
100
+ // rather than hours — and that argument was always about a *default*, never a lock.
101
+ // Q10's answer is unchanged and is what makes this a data change: a viewer's explicit
102
+ // choice still wins, in either direction, and the light palette is still measured and
103
+ // still shipped.
104
+ catalog: "dark",
105
+ };
106
+ export const TYPE_SCALE = {
107
+ pageTitle: { size: 32, leading: 38, weight: 500 },
108
+ sectionTitle: { size: 20, leading: 28, weight: 500 },
109
+ body: { size: 15, leading: 24, weight: 400, maxLineLength: 75 },
110
+ nodeLabel: { size: 13, leading: 16, weight: 500, maxLines: 2 },
111
+ meta: { size: 13, leading: 18, weight: 400 },
112
+ code: { size: 13, leading: 20, weight: 400, mono: true },
113
+ };
114
+ export const FONT_STACKS = {
115
+ sans: '"IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif',
116
+ mono: '"IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace',
117
+ };
118
+ /** Numerals are tabular wherever numbers appear in sequence. */
119
+ export const NUMERIC_FEATURES = "tabular-nums";
120
+ export const NODE_RADIUS = 2;
121
+ /**
122
+ * Contrast floors from §18.2. Asserted in tests against every pairing the product
123
+ * actually uses, rather than checked once by eye.
124
+ */
125
+ export const CONTRAST_FLOOR = { text: 4.5, stroke: 3 };
126
+ export function meetsTextFloor(foreground, background) {
127
+ return contrast(foreground, background) >= CONTRAST_FLOOR.text;
128
+ }
129
+ export function meetsStrokeFloor(stroke, background) {
130
+ return contrast(stroke, background) >= CONTRAST_FLOOR.stroke;
131
+ }
132
+ /** Risks ordered lightest to darkest within a theme. Should always equal RISK_ORDER. */
133
+ export function riskLightnessOrder(theme) {
134
+ return Object.keys(RISK_COLORS[theme]).sort((a, b) => lightness(RISK_COLORS[theme][b]) - lightness(RISK_COLORS[theme][a]));
135
+ }
136
+ /**
137
+ * Borders are dividers, not node strokes, and are exempt from the 3:1 stroke floor by
138
+ * design: a divider that met it would be a rule, and §18.2 separates by border *and*
139
+ * background rather than by shouting. Stated here so the exemption is a decision rather
140
+ * than an oversight the contrast test quietly skips.
141
+ */
142
+ export const EXEMPT_FROM_STROKE_FLOOR = ["border", "surface"];
@@ -0,0 +1 @@
1
+ export {};