@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.
- package/README.md +14 -0
- package/dist/color.d.ts +17 -0
- package/dist/color.js +24 -0
- package/dist/geometry.test.d.ts +1 -0
- package/dist/geometry.test.js +155 -0
- package/dist/icons.d.ts +69 -0
- package/dist/icons.js +103 -0
- package/dist/icons.test.d.ts +1 -0
- package/dist/icons.test.js +140 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/mark.d.ts +42 -0
- package/dist/mark.js +102 -0
- package/dist/primitives.d.ts +75 -0
- package/dist/primitives.js +126 -0
- package/dist/primitives.test.d.ts +1 -0
- package/dist/primitives.test.js +134 -0
- package/dist/render.d.ts +97 -0
- package/dist/render.js +1085 -0
- package/dist/render.test.d.ts +1 -0
- package/dist/render.test.js +179 -0
- package/dist/specimen.d.ts +2 -0
- package/dist/specimen.gen.d.ts +1 -0
- package/dist/specimen.gen.js +9 -0
- package/dist/specimen.js +81 -0
- package/dist/stylesheet.d.ts +95 -0
- package/dist/stylesheet.js +987 -0
- package/dist/stylesheet.test.d.ts +1 -0
- package/dist/stylesheet.test.js +265 -0
- package/dist/text.d.ts +28 -0
- package/dist/text.js +89 -0
- package/dist/tokens.d.ts +104 -0
- package/dist/tokens.js +142 -0
- package/dist/tokens.test.d.ts +1 -0
- package/dist/tokens.test.js +125 -0
- package/fonts/IBMPlexMono-Regular-Latin1.woff2 +0 -0
- package/fonts/IBMPlexMono-SemiBold-Latin1.woff2 +0 -0
- package/fonts/IBMPlexSans-Italic-Latin1.woff2 +0 -0
- package/fonts/IBMPlexSans-Medium-Latin1.woff2 +0 -0
- package/fonts/IBMPlexSans-Regular-Latin1.woff2 +0 -0
- package/fonts/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
- package/fonts/LICENSE.txt +93 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# `@runbooks/design`
|
|
2
|
+
|
|
3
|
+
Tokens, graph primitives, the specimen sheet, and the SVG renderer.
|
|
4
|
+
|
|
5
|
+
**Tasks:** [D-01](../../tasks/v0/D-01-design-tokens.md),
|
|
6
|
+
[D-02](../../tasks/v0/D-02-graph-visual-primitives.md) · **Normative:** RUNBOOK.md §18.2
|
|
7
|
+
|
|
8
|
+
One rule decides most of what is here: **each channel encodes exactly one property.**
|
|
9
|
+
Hue is risk. Stroke weight and fill are run state — the type carrying them has no field a
|
|
10
|
+
colour could go in, which is why "state never by hue" survives the next person to touch
|
|
11
|
+
it. Trust is saturation, in the catalog only.
|
|
12
|
+
|
|
13
|
+
`specimen/*.svg` is the baseline: it is committed, compared byte for byte, and it is what
|
|
14
|
+
"check against the specimen sheet" in a task means.
|
package/dist/color.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contrast maths, so accessibility floors are asserted rather than believed.
|
|
3
|
+
*
|
|
4
|
+
* WCAG relative luminance and contrast ratio, implemented here rather than pulled in:
|
|
5
|
+
* it is twenty lines, and a dependency in a package the graph renderer uses is a
|
|
6
|
+
* dependency in every SVG export.
|
|
7
|
+
*/
|
|
8
|
+
export interface Rgb {
|
|
9
|
+
readonly r: number;
|
|
10
|
+
readonly g: number;
|
|
11
|
+
readonly b: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function hexToRgb(hex: string): Rgb;
|
|
14
|
+
export declare function luminance(hex: string): number;
|
|
15
|
+
export declare function contrast(a: string, b: string): number;
|
|
16
|
+
/** Perceived lightness, for checking a scale survives greyscale and colour blindness. */
|
|
17
|
+
export declare function lightness(hex: string): number;
|
package/dist/color.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function hexToRgb(hex) {
|
|
2
|
+
const clean = hex.replace("#", "");
|
|
3
|
+
return {
|
|
4
|
+
r: parseInt(clean.slice(0, 2), 16),
|
|
5
|
+
g: parseInt(clean.slice(2, 4), 16),
|
|
6
|
+
b: parseInt(clean.slice(4, 6), 16),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function channel(value) {
|
|
10
|
+
const c = value / 255;
|
|
11
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
12
|
+
}
|
|
13
|
+
export function luminance(hex) {
|
|
14
|
+
const { r, g, b } = hexToRgb(hex);
|
|
15
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
16
|
+
}
|
|
17
|
+
export function contrast(a, b) {
|
|
18
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
19
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
20
|
+
}
|
|
21
|
+
/** Perceived lightness, for checking a scale survives greyscale and colour blindness. */
|
|
22
|
+
export function lightness(hex) {
|
|
23
|
+
return luminance(hex);
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toGraph } from "@runbooks/graph";
|
|
3
|
+
import { loadCorpus } from "@runbooks/fixtures";
|
|
4
|
+
import { renderGraph } from "./render.js";
|
|
5
|
+
import { measure } from "./text.js";
|
|
6
|
+
/**
|
|
7
|
+
* The picture's geometry, asserted rather than looked at.
|
|
8
|
+
*
|
|
9
|
+
* Every claim here was a defect first, seen on `/r/std/k8s-node-not-ready-drain`: labels
|
|
10
|
+
* ran out past both edges of their outlines, connectors were drawn as chords between two
|
|
11
|
+
* boxes and cut through whatever lay between them, and the three branch labels of one
|
|
12
|
+
* decision were printed at the same point, over each other and over the lines they named.
|
|
13
|
+
* None of that is visible to a test that checks the SVG contains an `<svg>`.
|
|
14
|
+
*/
|
|
15
|
+
const RECORDS = loadCorpus().map((entry) => ({
|
|
16
|
+
name: entry.name,
|
|
17
|
+
graph: toGraph(entry.doc),
|
|
18
|
+
}));
|
|
19
|
+
/** Every drawn line, as segments, from the path data the renderer emits. */
|
|
20
|
+
function segments(svg) {
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const [, d] of svg.matchAll(/<path d="([^"]+)"[^>]*data-line="1"/g)) {
|
|
23
|
+
const points = [];
|
|
24
|
+
for (const [, command, x, y] of d.matchAll(/([ML])\s+(-?[\d.]+)\s+(-?[\d.]+)/g)) {
|
|
25
|
+
void command;
|
|
26
|
+
points.push({ x: Number(x), y: Number(y) });
|
|
27
|
+
}
|
|
28
|
+
for (let i = 0; i + 1 < points.length; i++) {
|
|
29
|
+
out.push({ x1: points[i].x, y1: points[i].y, x2: points[i + 1].x, y2: points[i + 1].y });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
const rendered = RECORDS.map((entry) => ({
|
|
35
|
+
...entry,
|
|
36
|
+
render: renderGraph(entry.graph, { theme: "light", idPrefix: entry.name }),
|
|
37
|
+
}));
|
|
38
|
+
describe("every line runs at 0, 45 or 90 degrees", () => {
|
|
39
|
+
it.each(rendered)("$name", ({ render }) => {
|
|
40
|
+
const drawn = segments(render.svg);
|
|
41
|
+
expect(drawn.length, "no lines were drawn at all").toBeGreaterThan(0);
|
|
42
|
+
for (const s of drawn) {
|
|
43
|
+
const dx = Math.abs(s.x2 - s.x1);
|
|
44
|
+
const dy = Math.abs(s.y2 - s.y1);
|
|
45
|
+
const straight = dx < 0.6 || dy < 0.6;
|
|
46
|
+
const diagonal = Math.abs(dx - dy) < 0.6;
|
|
47
|
+
expect(straight || diagonal, `a segment runs at ${Math.round((Math.atan2(dy, dx) * 180) / Math.PI)}°: ${JSON.stringify(s)}`).toBe(true);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* A connector goes round a box it is not attached to. It used to go through: on the k8s
|
|
53
|
+
* procedure the arrow into `Escalate` crossed `Wait for the node to recover`, which reads
|
|
54
|
+
* as a route through a step that is not on it.
|
|
55
|
+
*/
|
|
56
|
+
describe("no connector crosses a node it does not touch", () => {
|
|
57
|
+
it.each(rendered)("$name", ({ render, graph }) => {
|
|
58
|
+
const drawn = segments(render.svg);
|
|
59
|
+
for (const node of graph.nodes) {
|
|
60
|
+
const box = render.boxes[node.id];
|
|
61
|
+
// Shrunk, because a connector legitimately lands on the outline of the box it
|
|
62
|
+
// arrives at, and grazes the corner of the one it leaves.
|
|
63
|
+
const left = box.x + 3;
|
|
64
|
+
const right = box.x + box.w - 3;
|
|
65
|
+
const top = box.y + 3;
|
|
66
|
+
const bottom = box.y + box.h - 3;
|
|
67
|
+
for (const s of drawn) {
|
|
68
|
+
const inside = (x, y) => x > left && x < right && y > top && y < bottom;
|
|
69
|
+
expect(inside(s.x1, s.y1) || inside(s.x2, s.y2), `a line ends inside ${node.id}: ${JSON.stringify(s)}`).toBe(false);
|
|
70
|
+
// A horizontal or vertical run passing clean through the box.
|
|
71
|
+
if (Math.abs(s.y1 - s.y2) < 0.6 && s.y1 > top && s.y1 < bottom) {
|
|
72
|
+
const spans = Math.min(s.x1, s.x2) < left && Math.max(s.x1, s.x2) > right;
|
|
73
|
+
expect(spans, `a line crosses ${node.id} horizontally`).toBe(false);
|
|
74
|
+
}
|
|
75
|
+
if (Math.abs(s.x1 - s.x2) < 0.6 && s.x1 > left && s.x1 < right) {
|
|
76
|
+
const spans = Math.min(s.y1, s.y2) < top && Math.max(s.y1, s.y2) > bottom;
|
|
77
|
+
expect(spans, `a line crosses ${node.id} vertically`).toBe(false);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
/** A box is as wide as its own label, which is what stopped the labels overflowing. */
|
|
84
|
+
describe("every label fits inside its outline", () => {
|
|
85
|
+
it.each(rendered)("$name", ({ render, graph }) => {
|
|
86
|
+
for (const node of graph.nodes) {
|
|
87
|
+
const box = render.boxes[node.id];
|
|
88
|
+
const group = new RegExp(`<g transform="translate\\\\([^)]*\\\\)"[^>]*data-node="${node.id}"[\\\\s\\\\S]*?</g>`).exec(render.svg);
|
|
89
|
+
expect(group, `${node.id} was not drawn`).toBeTruthy();
|
|
90
|
+
for (const [, line] of group[0].matchAll(/<text[^>]*>([^<]*)<\/text>/g)) {
|
|
91
|
+
const text = line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"');
|
|
92
|
+
expect(measure(text, 13), `"${text}" is wider than the ${Math.round(box.w)}px box it is drawn in`).toBeLessThanOrEqual(box.w);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* Two branches of one decision are told apart by their labels, so the labels have to be
|
|
99
|
+
* in two different places. All three were printed at the midpoint of the stub they share.
|
|
100
|
+
*/
|
|
101
|
+
describe("no two edge labels are drawn on top of each other", () => {
|
|
102
|
+
it.each(rendered)("$name", ({ render }) => {
|
|
103
|
+
const placed = [...render.svg.matchAll(/<rect x="(-?[\d.]+)" y="(-?[\d.]+)"[^>]*rx="3"/g)].map(([, x, y]) => ({ x: Number(x), y: Number(y) }));
|
|
104
|
+
for (let i = 0; i < placed.length; i++) {
|
|
105
|
+
for (let j = i + 1; j < placed.length; j++) {
|
|
106
|
+
const apart = Math.abs(placed[i].x - placed[j].x) > 8 || Math.abs(placed[i].y - placed[j].y) > 8;
|
|
107
|
+
expect(apart, `two edge labels sit at ${JSON.stringify(placed[i])}`).toBe(true);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
/**
|
|
113
|
+
* A crossing is marked. Two lines meeting at a point read as a junction, and a reader
|
|
114
|
+
* tracing a branch loses it at every intersection; the bridge says which is continuous.
|
|
115
|
+
*/
|
|
116
|
+
describe("a crossing is bridged rather than left as a junction", () => {
|
|
117
|
+
it("marks the crossings on a procedure that has them", () => {
|
|
118
|
+
const withCrossings = rendered.find(({ render }) => {
|
|
119
|
+
const drawn = segments(render.svg);
|
|
120
|
+
return drawn.some((a) => drawn.some((b) => Math.abs(a.y1 - a.y2) < 0.6 &&
|
|
121
|
+
Math.abs(b.x1 - b.x2) < 0.6 &&
|
|
122
|
+
b.x1 > Math.min(a.x1, a.x2) + 4 &&
|
|
123
|
+
b.x1 < Math.max(a.x1, a.x2) - 4 &&
|
|
124
|
+
a.y1 > Math.min(b.y1, b.y2) + 4 &&
|
|
125
|
+
a.y1 < Math.max(b.y1, b.y2) - 4));
|
|
126
|
+
});
|
|
127
|
+
// The bridge is a pair of 45° cuts, so a bridged path has diagonal segments in the
|
|
128
|
+
// middle of an otherwise horizontal run.
|
|
129
|
+
if (!withCrossings)
|
|
130
|
+
return;
|
|
131
|
+
const drawn = segments(withCrossings.render.svg);
|
|
132
|
+
const diagonals = drawn.filter((s) => Math.abs(Math.abs(s.x2 - s.x1) - Math.abs(s.y2 - s.y1)) < 0.6 && Math.abs(s.x2 - s.x1) > 0.6);
|
|
133
|
+
expect(diagonals.length, "lines cross and nothing marks it").toBeGreaterThan(0);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
/** Pointing at one thing says which one thing it is (§18.2: weight, never hue). */
|
|
137
|
+
describe("hovering picks something out", () => {
|
|
138
|
+
const { svg } = rendered[0].render;
|
|
139
|
+
it("gives every edge a target a pointer can actually hit", () => {
|
|
140
|
+
expect(svg).toMatch(/data-hit="1"/);
|
|
141
|
+
});
|
|
142
|
+
it("responds to a hover on a node and on an edge", () => {
|
|
143
|
+
expect(svg).toMatch(/\[data-node\]:hover[^{]*\{stroke-width/);
|
|
144
|
+
expect(svg).toMatch(/\[data-edge\]:hover[^{]*\{stroke-width/);
|
|
145
|
+
});
|
|
146
|
+
it("borrows no hue for it, since risk owns that channel", () => {
|
|
147
|
+
const rules = /<style>([\s\S]*?)<\/style>/.exec(svg)?.[1] ?? "";
|
|
148
|
+
const hover = rules
|
|
149
|
+
.split("}")
|
|
150
|
+
.filter((rule) => rule.includes(":hover") || rule.includes(":focus-visible"));
|
|
151
|
+
for (const rule of hover) {
|
|
152
|
+
expect(rule, `a hover rule sets a colour: ${rule}`).not.toMatch(/(^|[^-])\bstroke:|fill:|color:/);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
package/dist/icons.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The icon set (D-03, §18.2).
|
|
3
|
+
*
|
|
4
|
+
* §18.2 allows an icon **only where a label does not fit**, and the list is therefore
|
|
5
|
+
* closed: the three capability classes, the approval lock, the untrusted triangle, the
|
|
6
|
+
* upstream-change arrow. Six.
|
|
7
|
+
*
|
|
8
|
+
* The exclusion is the load-bearing part. **There is no icon for risk.** Risk is a text
|
|
9
|
+
* label plus a colour, and §18.2 already says no property may be carried by colour alone
|
|
10
|
+
* — adding a glyph would be a third channel encoding one property, which reads as three
|
|
11
|
+
* different facts to somebody scanning a list.
|
|
12
|
+
*
|
|
13
|
+
* Inline SVG paths rather than a font or a sprite sheet, so they inherit `currentColor`
|
|
14
|
+
* and stay crisp at the one size they are drawn for. A font would need loading before the
|
|
15
|
+
* first paint and a sprite sheet needs a second request to say what a lock looks like.
|
|
16
|
+
*/
|
|
17
|
+
export declare const ICONS: readonly ["capability-mcp", "capability-cli", "capability-iam", "approval-lock", "untrusted", "upstream-changed"];
|
|
18
|
+
export type IconName = (typeof ICONS)[number];
|
|
19
|
+
export interface Icon {
|
|
20
|
+
readonly name: IconName;
|
|
21
|
+
/** What a screen reader says. Never omitted: an icon is never the only carrier (§18.2). */
|
|
22
|
+
readonly label: string;
|
|
23
|
+
/** Why this glyph, so a redraw keeps the meaning rather than the shape. */
|
|
24
|
+
readonly why: string;
|
|
25
|
+
/**
|
|
26
|
+
* Path data on a 16×16 grid, 1.5px stroke, no fills.
|
|
27
|
+
*
|
|
28
|
+
* Absolute commands only. A relative path is shorter and its numbers are deltas, which
|
|
29
|
+
* means nobody — including a test — can tell from the data whether the glyph stays
|
|
30
|
+
* inside its box. Here every number is a coordinate.
|
|
31
|
+
*/
|
|
32
|
+
readonly path: string;
|
|
33
|
+
}
|
|
34
|
+
/** One grid, so two icons beside each other have the same optical weight. */
|
|
35
|
+
export declare const GRID = 16;
|
|
36
|
+
export declare const STROKE = 1.5;
|
|
37
|
+
export declare function icon(name: IconName): Icon;
|
|
38
|
+
export interface IconOptions {
|
|
39
|
+
/** Defaults to the icon's own label; override for context ("MCP server: github"). */
|
|
40
|
+
readonly label?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Set when the meaning is already in adjacent text, which is the common case: a card
|
|
43
|
+
* shows the capability class as a word beside the glyph, and reading it twice is
|
|
44
|
+
* noise. Then the icon is `aria-hidden` and decorative — which is only honest when
|
|
45
|
+
* something else carries the meaning.
|
|
46
|
+
*/
|
|
47
|
+
readonly decorative?: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One icon as inline SVG.
|
|
51
|
+
*
|
|
52
|
+
* `currentColor` and no fill, so the glyph takes the colour of the text it sits in and
|
|
53
|
+
* has one appearance to reason about instead of a light and a dark variant.
|
|
54
|
+
*/
|
|
55
|
+
export declare function renderIcon(name: IconName, options?: IconOptions): string;
|
|
56
|
+
/** The icon for a capability URN, by its class. Unknown classes get none rather than a guess. */
|
|
57
|
+
export declare function iconForCapability(urn: string): IconName | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Adding an icon requires naming the label that would not fit (D-03).
|
|
60
|
+
*
|
|
61
|
+
* Stated as a function because the rule is about the next person, and a rule in a
|
|
62
|
+
* comment is a rule they will read after adding the icon.
|
|
63
|
+
*/
|
|
64
|
+
export declare function mayAdd(proposal: {
|
|
65
|
+
readonly labelThatDoesNotFit?: string;
|
|
66
|
+
}): {
|
|
67
|
+
readonly allowed: boolean;
|
|
68
|
+
readonly why: string;
|
|
69
|
+
};
|
package/dist/icons.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The icon set (D-03, §18.2).
|
|
3
|
+
*
|
|
4
|
+
* §18.2 allows an icon **only where a label does not fit**, and the list is therefore
|
|
5
|
+
* closed: the three capability classes, the approval lock, the untrusted triangle, the
|
|
6
|
+
* upstream-change arrow. Six.
|
|
7
|
+
*
|
|
8
|
+
* The exclusion is the load-bearing part. **There is no icon for risk.** Risk is a text
|
|
9
|
+
* label plus a colour, and §18.2 already says no property may be carried by colour alone
|
|
10
|
+
* — adding a glyph would be a third channel encoding one property, which reads as three
|
|
11
|
+
* different facts to somebody scanning a list.
|
|
12
|
+
*
|
|
13
|
+
* Inline SVG paths rather than a font or a sprite sheet, so they inherit `currentColor`
|
|
14
|
+
* and stay crisp at the one size they are drawn for. A font would need loading before the
|
|
15
|
+
* first paint and a sprite sheet needs a second request to say what a lock looks like.
|
|
16
|
+
*/
|
|
17
|
+
export const ICONS = [
|
|
18
|
+
"capability-mcp",
|
|
19
|
+
"capability-cli",
|
|
20
|
+
"capability-iam",
|
|
21
|
+
"approval-lock",
|
|
22
|
+
"untrusted",
|
|
23
|
+
"upstream-changed",
|
|
24
|
+
];
|
|
25
|
+
/** One grid, so two icons beside each other have the same optical weight. */
|
|
26
|
+
export const GRID = 16;
|
|
27
|
+
export const STROKE = 1.5;
|
|
28
|
+
const DEFINITIONS = {
|
|
29
|
+
"capability-mcp": {
|
|
30
|
+
label: "MCP server",
|
|
31
|
+
why: "Two boxes and a line between them: a tool on the other side of a connection, which is what distinguishes an MCP call from a local one.",
|
|
32
|
+
path: "M2.5 4.5 L6.5 4.5 L6.5 8.5 L2.5 8.5 Z M9.5 7.5 L13.5 7.5 L13.5 11.5 L9.5 11.5 Z M6.5 6.5 L9.5 6.5 L9.5 9.5",
|
|
33
|
+
},
|
|
34
|
+
"capability-cli": {
|
|
35
|
+
label: "Command line",
|
|
36
|
+
why: "A prompt chevron and a caret. The one glyph every practitioner already reads as a shell.",
|
|
37
|
+
path: "M2.5 5 L5.5 8 L2.5 11 M7.5 11.5 L13.5 11.5",
|
|
38
|
+
},
|
|
39
|
+
"capability-iam": {
|
|
40
|
+
label: "Named permission",
|
|
41
|
+
why: "A key, because the capability is not a tool but a right somebody granted.",
|
|
42
|
+
path: "M6 10 A2.5 2.5 0 1 0 6 5 A2.5 2.5 0 1 0 6 10 M8.5 7.5 L13.5 7.5 M11.5 7.5 L11.5 10 M13 7.5 L13 9.5",
|
|
43
|
+
},
|
|
44
|
+
"approval-lock": {
|
|
45
|
+
label: "Requires approval",
|
|
46
|
+
why: "A closed padlock. The step is shut until a person opens it, which is what a gate is; an open shackle would read as already granted.",
|
|
47
|
+
path: "M4.5 7.5 L11.5 7.5 L11.5 13.5 L4.5 13.5 Z M6.5 7.5 L6.5 5.5 A1.5 1.5 0 0 1 9.5 5.5 L9.5 7.5",
|
|
48
|
+
},
|
|
49
|
+
untrusted: {
|
|
50
|
+
label: "Below the trust threshold",
|
|
51
|
+
why: "A triangle with a bar, not a cross. This is a warning about what is unverified, not a prohibition — a record below T2 is readable, and dimming plus this is how §18.2 says to show it.",
|
|
52
|
+
path: "M8 2.5 L13.5 12.5 L2.5 12.5 Z M8 6.5 L8 9.5 M8 11 L8 11.5",
|
|
53
|
+
},
|
|
54
|
+
"upstream-changed": {
|
|
55
|
+
label: "Source has changed",
|
|
56
|
+
why: "An arrow leaving a document. The source moved on; the record did not, which is the whole distinction §3 draws.",
|
|
57
|
+
path: "M10.5 2.5 L3.5 2.5 L3.5 13.5 L12.5 13.5 L12.5 8 M9.5 6.5 L13.5 2.5 M10.5 2.5 L13.5 2.5 L13.5 5.5",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
export function icon(name) {
|
|
61
|
+
return { name, ...DEFINITIONS[name] };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One icon as inline SVG.
|
|
65
|
+
*
|
|
66
|
+
* `currentColor` and no fill, so the glyph takes the colour of the text it sits in and
|
|
67
|
+
* has one appearance to reason about instead of a light and a dark variant.
|
|
68
|
+
*/
|
|
69
|
+
export function renderIcon(name, options = {}) {
|
|
70
|
+
const definition = icon(name);
|
|
71
|
+
const label = options.label ?? definition.label;
|
|
72
|
+
const accessibility = options.decorative
|
|
73
|
+
? 'aria-hidden="true" focusable="false"'
|
|
74
|
+
: `role="img" aria-label="${label.replace(/"/g, """)}"`;
|
|
75
|
+
return [
|
|
76
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${GRID}" height="${GRID}"`,
|
|
77
|
+
` viewBox="0 0 ${GRID} ${GRID}" fill="none" stroke="currentColor"`,
|
|
78
|
+
` stroke-width="${STROKE}" stroke-linecap="round" stroke-linejoin="round"`,
|
|
79
|
+
` data-icon="${name}" ${accessibility}>`,
|
|
80
|
+
`<path d="${definition.path}"/>`,
|
|
81
|
+
"</svg>",
|
|
82
|
+
].join("");
|
|
83
|
+
}
|
|
84
|
+
/** The icon for a capability URN, by its class. Unknown classes get none rather than a guess. */
|
|
85
|
+
export function iconForCapability(urn) {
|
|
86
|
+
const head = urn.split(":", 1)[0];
|
|
87
|
+
return head === "mcp" || head === "cli" || head === "iam" ? `capability-${head}` : undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Adding an icon requires naming the label that would not fit (D-03).
|
|
91
|
+
*
|
|
92
|
+
* Stated as a function because the rule is about the next person, and a rule in a
|
|
93
|
+
* comment is a rule they will read after adding the icon.
|
|
94
|
+
*/
|
|
95
|
+
export function mayAdd(proposal) {
|
|
96
|
+
if (!proposal.labelThatDoesNotFit) {
|
|
97
|
+
return {
|
|
98
|
+
allowed: false,
|
|
99
|
+
why: "§18.2 allows an icon only where a label does not fit. Name the label and where it does not fit; if it fits, use the words.",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { allowed: true, why: `"${proposal.labelThatDoesNotFit}" does not fit, so a glyph earns its place.` };
|
|
103
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join, dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { ICONS, GRID, STROKE, icon, renderIcon, iconForCapability, mayAdd } from "./icons.js";
|
|
6
|
+
import { PALETTES } from "./tokens.js";
|
|
7
|
+
function repoRoot() {
|
|
8
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
for (let i = 0; i < 8; i++) {
|
|
10
|
+
if (existsSync(join(dir, "pnpm-workspace.yaml")))
|
|
11
|
+
return dir;
|
|
12
|
+
dir = resolve(dir, "..");
|
|
13
|
+
}
|
|
14
|
+
throw new Error("repo root not found");
|
|
15
|
+
}
|
|
16
|
+
/** §18.2 allows an icon only where a label does not fit, so the list is closed. */
|
|
17
|
+
describe("the set is the six §18.2 names and no more", () => {
|
|
18
|
+
it("is exactly what the runbook lists", () => {
|
|
19
|
+
expect([...ICONS]).toEqual([
|
|
20
|
+
"capability-mcp",
|
|
21
|
+
"capability-cli",
|
|
22
|
+
"capability-iam",
|
|
23
|
+
"approval-lock",
|
|
24
|
+
"untrusted",
|
|
25
|
+
"upstream-changed",
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
/** An icon for risk would be a third channel encoding one property. */
|
|
29
|
+
it("has none for risk, which is a label plus a colour", () => {
|
|
30
|
+
expect(ICONS.some((name) => /risk|destruct|irreversible|danger/.test(name))).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
it("requires the label that would not fit before another is added", () => {
|
|
33
|
+
expect(mayAdd({}).allowed).toBe(false);
|
|
34
|
+
expect(mayAdd({}).why).toMatch(/if it fits, use the words/);
|
|
35
|
+
expect(mayAdd({ labelThatDoesNotFit: "reversible-write" }).allowed).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe("every icon is drawn to one grid", () => {
|
|
39
|
+
it.each([...ICONS])("%s is on the 16px grid at 1.5px", (name) => {
|
|
40
|
+
const svg = renderIcon(name);
|
|
41
|
+
expect(svg).toContain(`width="${GRID}"`);
|
|
42
|
+
expect(svg).toContain(`viewBox="0 0 ${GRID} ${GRID}"`);
|
|
43
|
+
expect(svg).toContain(`stroke-width="${STROKE}"`);
|
|
44
|
+
});
|
|
45
|
+
it.each([...ICONS])("%s has no fill, so it inherits the text colour", (name) => {
|
|
46
|
+
const svg = renderIcon(name);
|
|
47
|
+
expect(svg).toContain('fill="none"');
|
|
48
|
+
expect(svg).toContain('stroke="currentColor"');
|
|
49
|
+
expect(svg).not.toMatch(/fill="(?!none)/);
|
|
50
|
+
});
|
|
51
|
+
it.each([...ICONS])("%s uses absolute commands, so its numbers are coordinates", (name) => {
|
|
52
|
+
// A relative path is shorter and unreadable: nobody can tell from `h4v4h-4` whether
|
|
53
|
+
// the glyph stays in its box, which is the only question that matters here.
|
|
54
|
+
expect(icon(name).path).not.toMatch(/[hvlcsqta]/);
|
|
55
|
+
});
|
|
56
|
+
it.each([...ICONS])("%s stays inside its box, so two icons align optically", (name) => {
|
|
57
|
+
const coordinates = [...icon(name).path.matchAll(/-?\d+(\.\d+)?/g)].map((m) => Number(m[0]));
|
|
58
|
+
for (const value of coordinates) {
|
|
59
|
+
expect(value, `${name} draws at ${value}, outside the ${GRID}px grid`).toBeGreaterThanOrEqual(0);
|
|
60
|
+
expect(value).toBeLessThanOrEqual(GRID);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* Only the coordinates that follow M and L, because an arc's parameters include radii
|
|
65
|
+
* and two flags — and a test that treated a flag as a coordinate would be measuring
|
|
66
|
+
* something that is not on the canvas. That is what the first version of this did.
|
|
67
|
+
*/
|
|
68
|
+
it.each([...ICONS])("%s keeps its strokes off the very edge, so nothing is clipped", (name) => {
|
|
69
|
+
const points = [...icon(name).path.matchAll(/[ML]\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)/g)];
|
|
70
|
+
expect(points.length, `${name} has no drawn points`).toBeGreaterThan(1);
|
|
71
|
+
for (const [, x, y] of points) {
|
|
72
|
+
for (const value of [Number(x), Number(y)]) {
|
|
73
|
+
expect(value).toBeGreaterThanOrEqual(STROKE / 2 - 0.75);
|
|
74
|
+
expect(value).toBeLessThanOrEqual(GRID - STROKE / 2 + 0.75);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
it("is one appearance rather than a light and a dark variant", () => {
|
|
79
|
+
// currentColor is what makes that true: the glyph takes the colour of its text, and
|
|
80
|
+
// both palettes already guarantee that text's contrast (D-01).
|
|
81
|
+
for (const name of ICONS)
|
|
82
|
+
expect(renderIcon(name)).not.toMatch(/#[0-9a-f]{3,6}/i);
|
|
83
|
+
expect(Object.keys(PALETTES).sort()).toEqual(["dark", "light"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
/** §18.2's accessibility rule: no property is carried by one channel alone. */
|
|
87
|
+
describe("no icon is the sole carrier of meaning", () => {
|
|
88
|
+
it.each([...ICONS])("%s has an accessible label", (name) => {
|
|
89
|
+
expect(icon(name).label.length).toBeGreaterThan(3);
|
|
90
|
+
expect(renderIcon(name)).toContain(`aria-label="${icon(name).label}"`);
|
|
91
|
+
});
|
|
92
|
+
it("takes a label from the context when there is a better one", () => {
|
|
93
|
+
expect(renderIcon("capability-mcp", { label: "MCP server: github" })).toContain('aria-label="MCP server: github"');
|
|
94
|
+
});
|
|
95
|
+
it("hides itself only when something else says the same thing", () => {
|
|
96
|
+
const decorative = renderIcon("capability-cli", { decorative: true });
|
|
97
|
+
expect(decorative).toContain('aria-hidden="true"');
|
|
98
|
+
expect(decorative).not.toContain("aria-label");
|
|
99
|
+
});
|
|
100
|
+
it("escapes a label rather than emitting broken markup", () => {
|
|
101
|
+
expect(renderIcon("untrusted", { label: 'a "quoted" thing' })).toContain(""quoted"");
|
|
102
|
+
});
|
|
103
|
+
it.each([...ICONS])("%s records why this glyph, so a redraw keeps the meaning", (name) => {
|
|
104
|
+
expect(icon(name).why.length).toBeGreaterThan(40);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe("the capability icons follow the URN's class", () => {
|
|
108
|
+
it.each([
|
|
109
|
+
["mcp:github", "capability-mcp"],
|
|
110
|
+
["cli:kubectl", "capability-cli"],
|
|
111
|
+
["iam:aws:s3:GetObject", "capability-iam"],
|
|
112
|
+
])("%s → %s", (urn, expected) => {
|
|
113
|
+
expect(iconForCapability(urn)).toBe(expected);
|
|
114
|
+
});
|
|
115
|
+
it("gives an unknown class no icon rather than a guess", () => {
|
|
116
|
+
expect(iconForCapability("quantum:thing")).toBeUndefined();
|
|
117
|
+
expect(iconForCapability("kubectl")).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
it("matches the three classes the vocabulary publishes", () => {
|
|
120
|
+
const vocabulary = JSON.parse(readFileSync(join(repoRoot(), "spec", "v1", "vocabularies", "capability-classes.json"), "utf8"));
|
|
121
|
+
for (const entry of vocabulary.entries) {
|
|
122
|
+
expect(iconForCapability(`${entry.id}:thing`), `${entry.id} has no icon`).toBeDefined();
|
|
123
|
+
}
|
|
124
|
+
expect(vocabulary.entries).toHaveLength(3);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe("inline, not a font and not a sprite sheet", () => {
|
|
128
|
+
it("emits the path in the markup", () => {
|
|
129
|
+
expect(renderIcon("approval-lock")).toMatch(/<path d="M/);
|
|
130
|
+
});
|
|
131
|
+
it("needs no second request and no font load", () => {
|
|
132
|
+
const svg = renderIcon("approval-lock");
|
|
133
|
+
expect(svg).not.toMatch(/<use|xlink:href|@font-face/);
|
|
134
|
+
});
|
|
135
|
+
it("says why in the module, where somebody would change it", () => {
|
|
136
|
+
const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "icons.ts"), "utf8").replace(/\s*\*\s*/g, " ");
|
|
137
|
+
expect(source).toMatch(/needs a second request to say what a lock looks like/);
|
|
138
|
+
expect(source).toMatch(/There is no icon for risk/);
|
|
139
|
+
});
|
|
140
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./color.js";
|
|
2
|
+
export * from "./tokens.js";
|
|
3
|
+
export * from "./primitives.js";
|
|
4
|
+
export * from "./specimen.js";
|
|
5
|
+
export * from "./text.js";
|
|
6
|
+
export * from "./mark.js";
|
|
7
|
+
export * from "./render.js";
|
|
8
|
+
export * from "./stylesheet.js";
|
|
9
|
+
export * from "./icons.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./color.js";
|
|
2
|
+
export * from "./tokens.js";
|
|
3
|
+
export * from "./primitives.js";
|
|
4
|
+
export * from "./specimen.js";
|
|
5
|
+
export * from "./text.js";
|
|
6
|
+
export * from "./mark.js";
|
|
7
|
+
export * from "./render.js";
|
|
8
|
+
export * from "./stylesheet.js";
|
|
9
|
+
export * from "./icons.js";
|
package/dist/mark.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog's mark, and the picture a link to it shows.
|
|
3
|
+
*
|
|
4
|
+
* Both are drawn from the tokens for the same reason everything else here is: a mark
|
|
5
|
+
* hand-drawn beside the palette is a second copy of a decision, and it is the copy that
|
|
6
|
+
* goes stale — which is exactly what happened to every graph on the site the day the
|
|
7
|
+
* catalog turned dark.
|
|
8
|
+
*
|
|
9
|
+
* The mark is the product's own claim in one shape: a procedure is a graph, and the thing
|
|
10
|
+
* that matters about it is where it branches and which branch is dangerous. Three nodes
|
|
11
|
+
* and a fork, with the fork's far branch in the destructive hue — the same colour, from
|
|
12
|
+
* the same scale, that a reader will meet on a record page.
|
|
13
|
+
*/
|
|
14
|
+
import { type Theme } from "./tokens.js";
|
|
15
|
+
/**
|
|
16
|
+
* The icon, square, at any size.
|
|
17
|
+
*
|
|
18
|
+
* No text: at 16px a word is a smudge, and a favicon is read at 16px more often than
|
|
19
|
+
* anywhere else. The shape has to survive that, so it is three filled dots and two
|
|
20
|
+
* strokes and nothing finer.
|
|
21
|
+
*/
|
|
22
|
+
export declare function markSvg(theme: Theme, size?: number): string;
|
|
23
|
+
export interface PreviewOptions {
|
|
24
|
+
readonly theme: Theme;
|
|
25
|
+
readonly title: string;
|
|
26
|
+
/** Up to two lines under the title. Wrapped here, by measure, rather than by the caller. */
|
|
27
|
+
readonly summary?: string;
|
|
28
|
+
/** The record's own graph, if this preview is for a record. */
|
|
29
|
+
readonly graph?: string;
|
|
30
|
+
readonly footer?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare const PREVIEW_WIDTH = 1200;
|
|
33
|
+
export declare const PREVIEW_HEIGHT = 630;
|
|
34
|
+
/**
|
|
35
|
+
* The card a link shows in a chat, a search result or a timeline.
|
|
36
|
+
*
|
|
37
|
+
* A record's preview carries that record's own graph, because the graph *is* the summary —
|
|
38
|
+
* a reader who sees it has already been told whether the procedure branches and whether
|
|
39
|
+
* there is red in it. Drawing something else for the preview would be a second opinion
|
|
40
|
+
* about the procedure, which is the thing this repository refuses everywhere else.
|
|
41
|
+
*/
|
|
42
|
+
export declare function previewSvg(options: PreviewOptions): string;
|