pageship 0.1.0 → 0.2.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 +18 -107
- package/dist/cli.mjs +1886 -505
- package/dist/skills/LICENSE +21 -0
- package/dist/skills/pageship/SKILL.md +150 -0
- package/dist/skills/pageship/references/download.md +9 -0
- package/dist/skills/pageship/references/list-and-history.md +15 -0
- package/dist/skills/pageship/references/restore.md +9 -0
- package/dist/skills/pageship/references/visibility.md +12 -0
- package/dist/skills/pageship-chart/SKILL.md +110 -0
- package/dist/skills/pageship-code-block/SKILL.md +113 -0
- package/dist/skills/pageship-code-block/references/highlight-lock.json +542 -0
- package/dist/skills/pageship-code-block/references/highlight.mjs +138 -0
- package/dist/skills/pageship-decision-record/SKILL.md +43 -0
- package/dist/skills/pageship-diagram/SKILL.md +92 -0
- package/dist/skills/pageship-diagram/references/check-diagram.js +301 -0
- package/dist/skills/pageship-diagram/references/computed-layout.md +49 -0
- package/dist/skills/pageship-diagram/references/layout.mjs +310 -0
- package/dist/skills/pageship-diagram/references/mermaid.md +89 -0
- package/dist/skills/pageship-diagram/references/svg.md +205 -0
- package/dist/skills/pageship-docs/NOTICES.md +35 -0
- package/dist/skills/pageship-docs/SKILL.md +67 -0
- package/dist/skills/pageship-docs/references/ask.md +18 -0
- package/dist/skills/pageship-docs/references/check.md +39 -0
- package/dist/skills/pageship-docs/references/content.md +29 -0
- package/dist/skills/pageship-docs/references/design.md +60 -0
- package/dist/skills/pageship-docs/references/file.md +52 -0
- package/dist/skills/pageship-docs/references/plain-default.md +331 -0
- package/dist/skills/pageship-explainer/SKILL.md +92 -0
- package/dist/skills/pageship-interaction/SKILL.md +115 -0
- package/dist/skills/pageship-interaction/references/pointer.md +271 -0
- package/dist/skills/pageship-interaction/references/step-through.md +146 -0
- package/dist/skills/pageship-key-figures/SKILL.md +127 -0
- package/dist/skills/pageship-media/SKILL.md +122 -0
- package/dist/skills/pageship-motion/SKILL.md +58 -0
- package/dist/skills/pageship-plan/SKILL.md +55 -0
- package/dist/skills/pageship-report/SKILL.md +29 -0
- package/dist/skills/pageship-review-report/SKILL.md +90 -0
- package/dist/skills/pageship-slide-deck/SKILL.md +53 -0
- package/dist/skills/pageship-slide-deck/references/engine.md +362 -0
- package/dist/skills/pageship-spec/SKILL.md +58 -0
- package/dist/skills/pageship-table/SKILL.md +243 -0
- package/dist/skills/pageship-table/references/sort-and-filter.md +235 -0
- package/package.json +1 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Lays out a graph with ELK and prints the inline SVG figure for it, in the markup of svg.md.
|
|
2
|
+
// Usage: node layout.mjs < spec.json > figure.html. See computed-layout.md for the spec.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
const charWidth = 8;
|
|
10
|
+
const up8 = (n) => Math.ceil(n / 8) * 8;
|
|
11
|
+
const round = (n) => Math.round(n);
|
|
12
|
+
const escape = (text) =>
|
|
13
|
+
String(text).replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """);
|
|
14
|
+
|
|
15
|
+
// ELK is elkjs's single-file build, pinned to one release and checked by its hash. It comes
|
|
16
|
+
// from PAGESHIP_ELK when set, such as an installed node_modules/elkjs/lib/elk.bundled.js, or
|
|
17
|
+
// from a copy downloaded once into the temporary folder.
|
|
18
|
+
const elk = {
|
|
19
|
+
version: "0.12.0",
|
|
20
|
+
url: "https://cdn.jsdelivr.net/npm/elkjs@0.12.0/lib/elk.bundled.js",
|
|
21
|
+
sha384: "ww57TDqx4cGknIavPm0QKO+aygLUR1BLSn2Vhbnt1XdYKWcwLyWTFKX7aZMaKIi2",
|
|
22
|
+
};
|
|
23
|
+
const hashOf = (bytes) => createHash("sha384").update(bytes).digest("base64");
|
|
24
|
+
|
|
25
|
+
const loadElk = async () => {
|
|
26
|
+
let file = process.env.PAGESHIP_ELK && resolve(process.env.PAGESHIP_ELK);
|
|
27
|
+
if (!file) {
|
|
28
|
+
file = join(tmpdir(), "pageship-layout", `elk-${elk.version}.js`);
|
|
29
|
+
if (!existsSync(file)) {
|
|
30
|
+
let bytes;
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(elk.url);
|
|
33
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
34
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
35
|
+
} catch (error) {
|
|
36
|
+
fail(
|
|
37
|
+
`Could not download ELK from ${elk.url} (${error.message}). Without it, draw the graph with Mermaid as mermaid.md says.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (hashOf(bytes) !== elk.sha384)
|
|
41
|
+
fail(`The download from ${elk.url} does not match elkjs ${elk.version}.`);
|
|
42
|
+
mkdirSync(join(tmpdir(), "pageship-layout"), { recursive: true });
|
|
43
|
+
writeFileSync(file, bytes);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (hashOf(readFileSync(file)) !== elk.sha384)
|
|
47
|
+
fail(`${file} does not match elkjs ${elk.version}.`);
|
|
48
|
+
return createRequire(import.meta.url)(file);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const fail = (message) => {
|
|
52
|
+
process.stderr.write(`${message}\n`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// A box fits 8px per character of its longest line plus 32, and 48 high plus 16 per extra line.
|
|
57
|
+
const boxSize = (label) => {
|
|
58
|
+
const lines = label.split("\n");
|
|
59
|
+
return {
|
|
60
|
+
width: up8(Math.max(...lines.map((line) => line.length)) * charWidth + 32),
|
|
61
|
+
height: 48 + 16 * (lines.length - 1),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const toElkGraph = (spec) => ({
|
|
66
|
+
id: "root",
|
|
67
|
+
layoutOptions: {
|
|
68
|
+
"elk.algorithm": "layered",
|
|
69
|
+
"elk.direction": spec.direction === "right" ? "RIGHT" : "DOWN",
|
|
70
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
71
|
+
"elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
|
|
72
|
+
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
|
73
|
+
"elk.spacing.nodeNode": "48",
|
|
74
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "64",
|
|
75
|
+
"elk.spacing.edgeNode": "24",
|
|
76
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": "24",
|
|
77
|
+
"elk.spacing.edgeEdge": "16",
|
|
78
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": "16",
|
|
79
|
+
"elk.spacing.edgeLabel": "8",
|
|
80
|
+
"elk.spacing.portPort": "16",
|
|
81
|
+
"elk.padding": "[top=24,left=24,bottom=24,right=24]",
|
|
82
|
+
},
|
|
83
|
+
// One port per connector end, so each connector gets its own attach point and a busy
|
|
84
|
+
// node grows to keep them 16px apart. Connector and port ids contain a colon, which a
|
|
85
|
+
// node id can't, so they never collide.
|
|
86
|
+
children: spec.nodes.map((node) => {
|
|
87
|
+
const size = boxSize(node.label);
|
|
88
|
+
return {
|
|
89
|
+
id: node.id,
|
|
90
|
+
...size,
|
|
91
|
+
layoutOptions: {
|
|
92
|
+
"elk.portConstraints": "FREE",
|
|
93
|
+
"elk.nodeSize.constraints": "PORTS MINIMUM_SIZE",
|
|
94
|
+
"elk.nodeSize.minimum": `(${size.width},${size.height})`,
|
|
95
|
+
},
|
|
96
|
+
ports: spec.edges.flatMap((edge, i) => [
|
|
97
|
+
...(edge.from === node.id ? [{ id: `edge:${i}:out`, width: 0, height: 0 }] : []),
|
|
98
|
+
...(edge.to === node.id ? [{ id: `edge:${i}:in`, width: 0, height: 0 }] : []),
|
|
99
|
+
]),
|
|
100
|
+
};
|
|
101
|
+
}),
|
|
102
|
+
edges: spec.edges.map((edge, i) => ({
|
|
103
|
+
id: `edge:${i}`,
|
|
104
|
+
sources: [`edge:${i}:out`],
|
|
105
|
+
targets: [`edge:${i}:in`],
|
|
106
|
+
labels: edge.label
|
|
107
|
+
? [
|
|
108
|
+
{
|
|
109
|
+
id: `edge:${i}:label`,
|
|
110
|
+
text: edge.label,
|
|
111
|
+
width: edge.label.length * charWidth,
|
|
112
|
+
height: 16,
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
: [],
|
|
116
|
+
})),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Writes a route as M, H and V commands. Each step moves along the axis it changes most,
|
|
120
|
+
// so a rounding difference never becomes a slanted segment.
|
|
121
|
+
const pathOf = (points) => {
|
|
122
|
+
let [x, y] = [round(points[0].x), round(points[0].y)];
|
|
123
|
+
let d = `M ${x} ${y}`;
|
|
124
|
+
for (const point of points.slice(1)) {
|
|
125
|
+
const [nx, ny] = [round(point.x), round(point.y)];
|
|
126
|
+
if (Math.abs(nx - x) >= Math.abs(ny - y)) {
|
|
127
|
+
if (nx !== x) d += ` H ${(x = nx)}`;
|
|
128
|
+
} else {
|
|
129
|
+
d += ` V ${(y = ny)}`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return d;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ELK reserves 8px per character for a label, but the text is narrower. Anchor the text on
|
|
136
|
+
// the side that faces the nearest vertical segment of its own connector, so it sits 8px off it.
|
|
137
|
+
// The label names its connector's two nodes, as the connector does, for the pointer script.
|
|
138
|
+
const labelOf = (label, points, ends) => {
|
|
139
|
+
const y = label.y + label.height / 2;
|
|
140
|
+
let [anchor, x, best] = ["middle", label.x + label.width / 2, Infinity];
|
|
141
|
+
for (const [a, b] of points.slice(1).map((point, i) => [points[i], point])) {
|
|
142
|
+
if (Math.abs(a.x - b.x) > 0.5) continue;
|
|
143
|
+
if (y < Math.min(a.y, b.y) - 8 || y > Math.max(a.y, b.y) + 8) continue;
|
|
144
|
+
const right = a.x - (label.x + label.width);
|
|
145
|
+
const left = label.x - a.x;
|
|
146
|
+
if (right >= 0 && right < best) [anchor, x, best] = ["end", label.x + label.width, right];
|
|
147
|
+
if (left >= 0 && left < best) [anchor, x, best] = ["start", label.x, left];
|
|
148
|
+
}
|
|
149
|
+
return `<text class="edge-label" ${ends} x="${round(x)}" y="${round(y)}" text-anchor="${anchor}">${escape(label.text)}</text>`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Each kind's border, in the order of the look's series colours. The legend names it, so the
|
|
153
|
+
// kind never shows by colour alone.
|
|
154
|
+
const kindBorders = ["solid", "dashed", "dotted", "dash-dot"];
|
|
155
|
+
const kindClass = (spec, node) =>
|
|
156
|
+
node.kind === undefined ? "" : ` series-${spec.kinds.indexOf(node.kind) + 1}`;
|
|
157
|
+
|
|
158
|
+
const legendOf = (kinds) =>
|
|
159
|
+
kinds?.length
|
|
160
|
+
? [
|
|
161
|
+
' <ul class="diagram-legend">',
|
|
162
|
+
...kinds.map(
|
|
163
|
+
(kind, i) =>
|
|
164
|
+
` <li class="series-${i + 1}">${escape(kind)}: ${kindBorders[i]} border</li>`,
|
|
165
|
+
),
|
|
166
|
+
" </ul>",
|
|
167
|
+
"",
|
|
168
|
+
].join("\n")
|
|
169
|
+
: "";
|
|
170
|
+
|
|
171
|
+
const nodeOf = (slug, node, box, kind) => {
|
|
172
|
+
const lines = node.label.split("\n");
|
|
173
|
+
const x = round(box.x + box.width / 2);
|
|
174
|
+
const y = round(box.y + box.height / 2 - 8 * (lines.length - 1));
|
|
175
|
+
const text =
|
|
176
|
+
lines.length === 1
|
|
177
|
+
? escape(node.label)
|
|
178
|
+
: lines
|
|
179
|
+
.map((line, i) => `<tspan x="${x}"${i ? ' dy="16"' : ""}>${escape(line)}</tspan>`)
|
|
180
|
+
.join("");
|
|
181
|
+
return [
|
|
182
|
+
` <g class="node${kind}${node.accent ? " accent" : ""}" id="${slug}-${node.id}">`,
|
|
183
|
+
` <rect x="${round(box.x)}" y="${round(box.y)}" width="${round(box.width)}" height="${round(box.height)}" rx="6" />`,
|
|
184
|
+
` <text x="${x}" y="${y}">${text}</text>`,
|
|
185
|
+
" </g>",
|
|
186
|
+
].join("\n");
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const figureOf = async (spec, ELK) => {
|
|
190
|
+
const result = await new ELK().layout(toElkGraph(spec));
|
|
191
|
+
const slug = spec.slug;
|
|
192
|
+
const [width, height] = [round(result.width), round(result.height)];
|
|
193
|
+
const edges = [];
|
|
194
|
+
const labels = [];
|
|
195
|
+
result.edges.forEach((routed, i) => {
|
|
196
|
+
const edge = spec.edges[i];
|
|
197
|
+
const points = (routed.sections ?? []).flatMap((section) => [
|
|
198
|
+
section.startPoint,
|
|
199
|
+
...(section.bendPoints ?? []),
|
|
200
|
+
section.endPoint,
|
|
201
|
+
]);
|
|
202
|
+
const start = edge.both ? ` marker-start="url(#${slug}-arrow)"` : "";
|
|
203
|
+
const ends = `data-from="${slug}-${edge.from}" data-to="${slug}-${edge.to}"`;
|
|
204
|
+
edges.push(
|
|
205
|
+
` <path class="edge" ${ends} d="${pathOf(points)}"${start} marker-end="url(#${slug}-arrow)" />`,
|
|
206
|
+
);
|
|
207
|
+
for (const label of routed.labels ?? []) labels.push(` ${labelOf(label, points, ends)}`);
|
|
208
|
+
});
|
|
209
|
+
const nodes = result.children.map((box) => {
|
|
210
|
+
const node = spec.nodes.find((candidate) => candidate.id === box.id);
|
|
211
|
+
return nodeOf(slug, node, box, kindClass(spec, node));
|
|
212
|
+
});
|
|
213
|
+
return `<figure class="diagram" id="${slug}">
|
|
214
|
+
<div class="scroll" tabindex="0" role="region" aria-label="${escape(spec.title)}">
|
|
215
|
+
<svg role="img" aria-labelledby="${slug}-title ${slug}-desc" data-layout="computed" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
216
|
+
<title id="${slug}-title">${escape(spec.title)}</title>
|
|
217
|
+
<desc id="${slug}-desc">${escape(spec.description)}</desc>
|
|
218
|
+
<defs>
|
|
219
|
+
<marker id="${slug}-arrow" viewBox="0 0 10 10" refX="10" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse">
|
|
220
|
+
<path d="M0 0 L10 5 L0 10 Z" />
|
|
221
|
+
</marker>
|
|
222
|
+
</defs>
|
|
223
|
+
${[...edges, ...labels, ...nodes].join("\n")}
|
|
224
|
+
</svg>
|
|
225
|
+
</div>
|
|
226
|
+
${legendOf(spec.kinds)} <figcaption>${escape(spec.caption)}</figcaption>
|
|
227
|
+
</figure>
|
|
228
|
+
`;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Every problem with the spec, in words the author can act on.
|
|
232
|
+
const problemsOf = (spec) => {
|
|
233
|
+
const problems = [];
|
|
234
|
+
const word = /^[a-z][a-z0-9-]*$/u;
|
|
235
|
+
if (typeof spec.slug !== "string" || !word.test(spec.slug)) {
|
|
236
|
+
problems.push("slug must be lowercase letters, digits and hyphens, starting with a letter");
|
|
237
|
+
}
|
|
238
|
+
for (const field of ["title", "description", "caption"]) {
|
|
239
|
+
if (typeof spec[field] !== "string" || !spec[field].trim())
|
|
240
|
+
problems.push(`${field} is missing`);
|
|
241
|
+
}
|
|
242
|
+
if (!["down", "right", undefined].includes(spec.direction)) {
|
|
243
|
+
problems.push('direction must be "down" or "right"');
|
|
244
|
+
}
|
|
245
|
+
const nodes = Array.isArray(spec.nodes) ? spec.nodes : [];
|
|
246
|
+
const edges = Array.isArray(spec.edges) ? spec.edges : [];
|
|
247
|
+
if (!nodes.length) problems.push("nodes is missing");
|
|
248
|
+
if (nodes.length > 30)
|
|
249
|
+
problems.push(`${nodes.length} nodes; the limit is 30, so split the figure by area`);
|
|
250
|
+
if (edges.length > 40)
|
|
251
|
+
problems.push(`${edges.length} edges; the limit is 40, so split the figure by area`);
|
|
252
|
+
const ids = new Set();
|
|
253
|
+
for (const [i, node] of nodes.entries()) {
|
|
254
|
+
if (typeof node.id !== "string" || !word.test(node.id)) {
|
|
255
|
+
problems.push(`node ${i + 1} needs an id of lowercase letters, digits and hyphens`);
|
|
256
|
+
} else if (ids.has(node.id)) {
|
|
257
|
+
problems.push(`node id "${node.id}" is used twice`);
|
|
258
|
+
} else if (["title", "desc", "arrow"].includes(node.id)) {
|
|
259
|
+
problems.push(`node id "${node.id}" is reserved for the figure's own elements`);
|
|
260
|
+
}
|
|
261
|
+
ids.add(node.id);
|
|
262
|
+
if (typeof node.label !== "string" || !node.label.trim())
|
|
263
|
+
problems.push(`node ${i + 1} needs a label`);
|
|
264
|
+
}
|
|
265
|
+
const kinds = spec.kinds ?? [];
|
|
266
|
+
if (!Array.isArray(kinds) || kinds.some((kind) => typeof kind !== "string" || !kind.trim())) {
|
|
267
|
+
problems.push("kinds must be a list of names");
|
|
268
|
+
} else if (kinds.length > 4) {
|
|
269
|
+
problems.push(`${kinds.length} kinds; the limit is 4`);
|
|
270
|
+
}
|
|
271
|
+
for (const [i, node] of nodes.entries()) {
|
|
272
|
+
if (node.kind !== undefined && !(Array.isArray(kinds) && kinds.includes(node.kind)))
|
|
273
|
+
problems.push(`node ${i + 1}'s kind "${node.kind}" is not one of the kinds`);
|
|
274
|
+
}
|
|
275
|
+
const accents = nodes.filter((node) => node.accent).length;
|
|
276
|
+
if (accents > 2) problems.push(`${accents} accent nodes; the limit is 2`);
|
|
277
|
+
for (const [i, edge] of edges.entries()) {
|
|
278
|
+
for (const end of ["from", "to"]) {
|
|
279
|
+
if (!ids.has(edge[end])) {
|
|
280
|
+
problems.push(
|
|
281
|
+
`edge ${i + 1} ${end === "from" ? "comes from" : "goes to"} "${edge[end]}", which is not a node`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (edge.label !== undefined) {
|
|
286
|
+
if (typeof edge.label !== "string" || !edge.label.trim())
|
|
287
|
+
problems.push(`edge ${i + 1}'s label is empty`);
|
|
288
|
+
else if (edge.label.trim().split(/\s+/u).length > 3)
|
|
289
|
+
problems.push(`edge ${i + 1}'s label has more than three words`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return problems;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
let spec;
|
|
296
|
+
try {
|
|
297
|
+
spec = JSON.parse(readFileSync(0, "utf8"));
|
|
298
|
+
} catch (error) {
|
|
299
|
+
fail(`The spec is not valid JSON: ${error.message}`);
|
|
300
|
+
}
|
|
301
|
+
const problems = problemsOf(spec);
|
|
302
|
+
if (problems.length) fail(`The spec needs changes:\n${problems.map((p) => `- ${p}`).join("\n")}`);
|
|
303
|
+
const figure = await figureOf(spec, await loadElk());
|
|
304
|
+
const width = Number(figure.match(/ width="(\d+)"/u)[1]);
|
|
305
|
+
if (width > 1088) {
|
|
306
|
+
process.stderr.write(
|
|
307
|
+
`Note: the figure is ${width}px wide; the limit is 1088px. Try the other direction, or split the figure.\n`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
process.stdout.write(figure);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Mermaid
|
|
2
|
+
|
|
3
|
+
Mermaid lays out a large graph in the reader's browser. Use only these diagram types: `flowchart`, `stateDiagram-v2`, `sequenceDiagram` and `erDiagram`.
|
|
4
|
+
|
|
5
|
+
## Markup
|
|
6
|
+
|
|
7
|
+
Load Mermaid with the exact tag in [the file rules](../../pageship-docs/references/file.md#trusted-libraries-only), once per file, at the end of the `<body>`, then run it:
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<figure class="diagram" id="systems">
|
|
11
|
+
<div class="scroll" tabindex="0" role="region" aria-label="Map of help desk integrations">
|
|
12
|
+
<pre class="mermaid">
|
|
13
|
+
flowchart LR
|
|
14
|
+
accTitle: Help desk integrations
|
|
15
|
+
accDescr: Web chat, email and the phone system create tickets in the help desk. The help desk sends closed tickets to the survey tool.
|
|
16
|
+
chat["Web chat"] -->|"chats become tickets"| desk["Help desk"]
|
|
17
|
+
desk -->|"closed tickets"| survey["Survey tool"]
|
|
18
|
+
classDef accent stroke-width:3px
|
|
19
|
+
class desk accent
|
|
20
|
+
class chat series-1
|
|
21
|
+
class survey series-2
|
|
22
|
+
</pre>
|
|
23
|
+
</div>
|
|
24
|
+
<ul class="diagram-legend">
|
|
25
|
+
<li class="series-1">Channels: solid border</li>
|
|
26
|
+
<li class="series-2">Tools: dashed border</li>
|
|
27
|
+
</ul>
|
|
28
|
+
<figcaption>Every channel customers use ends up as a ticket in the help desk.</figcaption>
|
|
29
|
+
</figure>
|
|
30
|
+
<details>
|
|
31
|
+
<summary>The integrations as a table</summary>
|
|
32
|
+
<table>
|
|
33
|
+
...one row per connection: from, to, what flows...
|
|
34
|
+
</table>
|
|
35
|
+
</details>
|
|
36
|
+
|
|
37
|
+
<!-- the pointer script, then the Mermaid tag from the file rules -->
|
|
38
|
+
<script>
|
|
39
|
+
// Mermaid reads colours as rgb(), so each look variable, which must be opaque, passes
|
|
40
|
+
// through a canvas pixel.
|
|
41
|
+
const look = (name) => {
|
|
42
|
+
const probe = document.createElement("i");
|
|
43
|
+
probe.style.color = `var(${name})`;
|
|
44
|
+
document.body.append(probe);
|
|
45
|
+
const pixel = document.createElement("canvas").getContext("2d");
|
|
46
|
+
pixel.fillStyle = getComputedStyle(probe).color;
|
|
47
|
+
pixel.fillRect(0, 0, 1, 1);
|
|
48
|
+
probe.remove();
|
|
49
|
+
const [r, g, b] = pixel.getImageData(0, 0, 1, 1).data;
|
|
50
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
51
|
+
};
|
|
52
|
+
mermaid.initialize({
|
|
53
|
+
startOnLoad: false,
|
|
54
|
+
securityLevel: "strict",
|
|
55
|
+
theme: "base",
|
|
56
|
+
themeVariables: {
|
|
57
|
+
darkMode: matchMedia("(prefers-color-scheme: dark)").matches,
|
|
58
|
+
background: look("--bg"),
|
|
59
|
+
primaryColor: look("--surface"),
|
|
60
|
+
primaryTextColor: look("--fg"),
|
|
61
|
+
primaryBorderColor: look("--fg"),
|
|
62
|
+
textColor: look("--fg"),
|
|
63
|
+
lineColor: look("--muted"),
|
|
64
|
+
signalColor: look("--muted"),
|
|
65
|
+
edgeLabelBackground: look("--bg"),
|
|
66
|
+
},
|
|
67
|
+
fontFamily: "system-ui, sans-serif",
|
|
68
|
+
flowchart: { useMaxWidth: false },
|
|
69
|
+
state: { useMaxWidth: false },
|
|
70
|
+
sequence: { useMaxWidth: false },
|
|
71
|
+
er: { useMaxWidth: false },
|
|
72
|
+
});
|
|
73
|
+
// Gives the drawn nodes their pointer states.
|
|
74
|
+
mermaid.run({ querySelector: ".mermaid" }).then(preparePointer);
|
|
75
|
+
</script>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Rules
|
|
79
|
+
|
|
80
|
+
- **Accessible title and description.** The first lines of the definition are `accTitle:` and `accDescr:`, which become the SVG's `<title>` and `<desc>`.
|
|
81
|
+
- **The same facts as a table.** After the figure, give every connection as a table by `pageship-table`, one row each, with what flows in the source's words. Put it inside `<details>` when the connectors carry labels, and `<details open>` when they don't.
|
|
82
|
+
- **Its own size.** Keep `useMaxWidth: false`. Never scale the graph down to fit.
|
|
83
|
+
- **Mermaid's own routing.** Keep Mermaid's default curved lines.
|
|
84
|
+
- **One line per pair.** When two nodes send something both ways, draw one line with arrowheads at both ends (`a <--> b`) and give both directions in the table.
|
|
85
|
+
- **Labels only when they fit.** Label connectors when the graph has 12 connectors or fewer. Above that, leave every connector unlabelled and let the table say what flows.
|
|
86
|
+
- **Labels and ids.** Node ids are short ASCII words. Put every node and connector label in double quotes, in the source's words, three words at most on a connector.
|
|
87
|
+
- **Pointer states.** Add [the pointer style and script](../../pageship-interaction/references/pointer.md) where the markup shows. Use `flowchart` for a map whose nodes the reader follows, so hovering a node highlights its connections.
|
|
88
|
+
- **Colours.** The one or two nodes the caption's claim is about take the `accent` class. A node of a [kind](../SKILL.md#colours) takes the kind's class, `series-1` to `series-4`, with a `class` line and no `classDef`. Add [the kinds and accents CSS](svg.md#kinds-and-accents) to the stylesheet; it colours both.
|
|
89
|
+
- **Direction.** `LR` for wide maps, `TB` for deep ones. When lines run together or through nodes, or labels overlap, in the screenshot, change the direction, reorder the lines of the definition so linked nodes sit next to each other, or split the graph by area.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# SVG rules
|
|
2
|
+
|
|
3
|
+
Hand-drawn diagrams are inline SVG that you lay out yourself, coordinate by coordinate. [check-diagram.js](check-diagram.js) checks these rules in a browser, so follow the classes and attributes below exactly.
|
|
4
|
+
|
|
5
|
+
## Markup
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<figure class="diagram" id="path">
|
|
9
|
+
<div class="scroll" tabindex="0" role="region" aria-label="Ticket path diagram">
|
|
10
|
+
<svg
|
|
11
|
+
role="img"
|
|
12
|
+
aria-labelledby="path-title path-desc"
|
|
13
|
+
width="304"
|
|
14
|
+
height="256"
|
|
15
|
+
viewBox="0 0 304 256"
|
|
16
|
+
>
|
|
17
|
+
<title id="path-title">Path of a ticket in the escalation pilot</title>
|
|
18
|
+
<desc id="path-desc">
|
|
19
|
+
A ticket arrives in triage, is tagged billing, bug or other, and moves to its queue.
|
|
20
|
+
</desc>
|
|
21
|
+
<defs>
|
|
22
|
+
<marker
|
|
23
|
+
id="path-arrow"
|
|
24
|
+
viewBox="0 0 10 10"
|
|
25
|
+
refX="10"
|
|
26
|
+
refY="5"
|
|
27
|
+
markerUnits="userSpaceOnUse"
|
|
28
|
+
markerWidth="12"
|
|
29
|
+
markerHeight="12"
|
|
30
|
+
orient="auto-start-reverse"
|
|
31
|
+
>
|
|
32
|
+
<path d="M0 0 L10 5 L0 10 Z" />
|
|
33
|
+
</marker>
|
|
34
|
+
</defs>
|
|
35
|
+
<path
|
|
36
|
+
class="edge"
|
|
37
|
+
data-from="path-triage"
|
|
38
|
+
data-to="path-billing"
|
|
39
|
+
d="M 200 96 V 136 H 104 V 176"
|
|
40
|
+
marker-end="url(#path-arrow)"
|
|
41
|
+
/>
|
|
42
|
+
<text
|
|
43
|
+
class="edge-label"
|
|
44
|
+
data-from="path-triage"
|
|
45
|
+
data-to="path-billing"
|
|
46
|
+
x="96"
|
|
47
|
+
y="156"
|
|
48
|
+
text-anchor="end"
|
|
49
|
+
>
|
|
50
|
+
billing
|
|
51
|
+
</text>
|
|
52
|
+
<g class="node accent" id="path-triage">
|
|
53
|
+
<rect x="128" y="48" width="144" height="48" rx="6" />
|
|
54
|
+
<text x="200" y="72">Triage queue</text>
|
|
55
|
+
</g>
|
|
56
|
+
<g class="node" id="path-billing">
|
|
57
|
+
<rect x="32" y="176" width="144" height="48" rx="6" />
|
|
58
|
+
<text x="104" y="200">Billing queue</text>
|
|
59
|
+
</g>
|
|
60
|
+
</svg>
|
|
61
|
+
</div>
|
|
62
|
+
<figcaption>Every ticket passes through triage before it reaches a queue.</figcaption>
|
|
63
|
+
</figure>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Prefix every `id` with the figure's slug (`path-title`, `path-arrow`, `path-triage`).
|
|
67
|
+
- Each node is a `<g class="node">` with an `id`, holding its shape and its text. Each connector is one `<path class="edge">` with `data-from` and `data-to` naming the nodes it joins. Its label, a `<text class="edge-label">`, carries the same `data-from` and `data-to`. Containers that group nodes use `class="group"`, never `node`.
|
|
68
|
+
- Put the connectors before the nodes in the markup.
|
|
69
|
+
- Give the one or two elements the caption's claim is about `class="accent"`.
|
|
70
|
+
- A node of a [kind](../SKILL.md#colours) adds the kind's class, such as `class="node series-2"`, and a legend after the `div class="scroll"` names every kind:
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<ul class="diagram-legend">
|
|
74
|
+
<li class="series-1">Triage: solid border</li>
|
|
75
|
+
<li class="series-2">Queues: dashed border</li>
|
|
76
|
+
</ul>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- `width` and `height` equal the `viewBox` size. Keep the drawing 1088 wide at most, and 720 or less when the content allows it.
|
|
80
|
+
|
|
81
|
+
Style the figure in the document's `<style>`. Set no colours in the SVG markup.
|
|
82
|
+
|
|
83
|
+
```css
|
|
84
|
+
.diagram svg {
|
|
85
|
+
display: block;
|
|
86
|
+
font:
|
|
87
|
+
14px system-ui,
|
|
88
|
+
sans-serif;
|
|
89
|
+
}
|
|
90
|
+
.diagram .node rect {
|
|
91
|
+
fill: var(--surface);
|
|
92
|
+
stroke: var(--fg);
|
|
93
|
+
stroke-width: 1.5;
|
|
94
|
+
}
|
|
95
|
+
.diagram text {
|
|
96
|
+
fill: var(--fg);
|
|
97
|
+
dominant-baseline: central;
|
|
98
|
+
}
|
|
99
|
+
.diagram .node text {
|
|
100
|
+
text-anchor: middle;
|
|
101
|
+
}
|
|
102
|
+
.diagram .edge-label {
|
|
103
|
+
fill: var(--muted);
|
|
104
|
+
}
|
|
105
|
+
.diagram .edge {
|
|
106
|
+
fill: none;
|
|
107
|
+
stroke: var(--muted);
|
|
108
|
+
stroke-width: 1.5;
|
|
109
|
+
}
|
|
110
|
+
.diagram marker path {
|
|
111
|
+
fill: var(--muted);
|
|
112
|
+
}
|
|
113
|
+
@media print {
|
|
114
|
+
.diagram svg {
|
|
115
|
+
max-width: 100%;
|
|
116
|
+
height: auto;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Text outside a node sets its own `text-anchor` attribute. A label beside a vertical line uses `start` or `end`, so it grows away from the line.
|
|
122
|
+
|
|
123
|
+
### Kinds and accents
|
|
124
|
+
|
|
125
|
+
Add this after it, for hand-drawn, computed and Mermaid figures alike. `!important` wins over Mermaid's own styles.
|
|
126
|
+
|
|
127
|
+
```css
|
|
128
|
+
.diagram .series-1 {
|
|
129
|
+
--kind: var(--series-1);
|
|
130
|
+
}
|
|
131
|
+
.diagram .series-2 {
|
|
132
|
+
--kind: var(--series-2);
|
|
133
|
+
--dash: 6 4;
|
|
134
|
+
}
|
|
135
|
+
.diagram .series-3 {
|
|
136
|
+
--kind: var(--series-3);
|
|
137
|
+
--dash: 2 3;
|
|
138
|
+
}
|
|
139
|
+
.diagram .series-4 {
|
|
140
|
+
--kind: var(--series-4);
|
|
141
|
+
--dash: 12 4 2 4;
|
|
142
|
+
}
|
|
143
|
+
.diagram .node:is(.series-1, .series-2, .series-3, .series-4) > :is(rect, polygon, circle, path) {
|
|
144
|
+
stroke: var(--kind) !important;
|
|
145
|
+
stroke-width: 2px !important;
|
|
146
|
+
stroke-dasharray: var(--dash, none) !important;
|
|
147
|
+
}
|
|
148
|
+
.diagram .node.accent > :is(rect, polygon, circle, path),
|
|
149
|
+
.diagram .edge.accent {
|
|
150
|
+
stroke: var(--accent) !important;
|
|
151
|
+
stroke-width: 3px !important;
|
|
152
|
+
}
|
|
153
|
+
.diagram-legend {
|
|
154
|
+
display: flex;
|
|
155
|
+
flex-wrap: wrap;
|
|
156
|
+
gap: 0.25rem 1.25rem;
|
|
157
|
+
margin: 0.5rem 0 0;
|
|
158
|
+
padding: 0;
|
|
159
|
+
list-style: none;
|
|
160
|
+
}
|
|
161
|
+
.diagram-legend li::before {
|
|
162
|
+
content: "";
|
|
163
|
+
display: inline-block;
|
|
164
|
+
width: 0.75rem;
|
|
165
|
+
height: 0.75rem;
|
|
166
|
+
margin-right: 0.5rem;
|
|
167
|
+
background: var(--kind);
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Plan the layout
|
|
172
|
+
|
|
173
|
+
Before writing the SVG, write the plan in your working notes, not in the file:
|
|
174
|
+
|
|
175
|
+
1. **Grid.** Put nodes in rows and columns. A flowchart runs top to bottom, so it fits a phone. Nodes of the same rank share a row. Every coordinate and size is a multiple of 8.
|
|
176
|
+
2. **Boxes.** For 14px text, a box is at least 8px per character of its longest line plus 32, and 48 high for one line, 16 more per extra line (`<tspan x=".." dy="16">`). Keep 48px or more between boxes, so connectors and labels have room.
|
|
177
|
+
3. **Connectors.** List each connector's points, from the source box edge to the target box edge, turning only at right angles.
|
|
178
|
+
4. **Labels.** Give each label its box: about 8px per character wide and 16 high, beside its segment.
|
|
179
|
+
5. **Arithmetic.** Check each connector rule below against the numbers.
|
|
180
|
+
|
|
181
|
+
## Connector rules
|
|
182
|
+
|
|
183
|
+
1. **Right angles.** A connector is one path of horizontal and vertical segments, written with `M`, `H` and `V`.
|
|
184
|
+
2. **Own attach point.** A connector starts on its source box's edge and ends with its arrowhead on the target's edge. Two connectors on the same edge sit at least 16px apart and 12px from a corner. Connectors never merge into one line.
|
|
185
|
+
3. **Around boxes.** A connector never passes through or behind a box that isn't one of its two ends. Route it around, 16px or more clear of the box.
|
|
186
|
+
4. **No overlap.** No two connectors share a segment. Parallel segments stay at least 12px apart along their whole length.
|
|
187
|
+
5. **Labels beside their line.** A connector label sits beside the middle of its own segment, 6 to 12px from it, never on it, and at least 6px from every other line, label and box. Keep labels to the source's words, three words at most.
|
|
188
|
+
6. **Few crossings.** Order the nodes so connectors don't cross. When one crossing is left, it meets at a right angle, away from any bend, label or arrowhead.
|
|
189
|
+
|
|
190
|
+
Line style carries meaning only with a legend: say what a dashed line means in a line of text under the figure.
|
|
191
|
+
|
|
192
|
+
## Timelines
|
|
193
|
+
|
|
194
|
+
A timeline places events along one time axis, and the distance between two events shows how far apart they were.
|
|
195
|
+
|
|
196
|
+
- **One linear scale.** Pick a scale, such as 40px per hour or 12px per day, and place each event at `x = start + (time − first time) × scale`. Never space events evenly when the times aren't.
|
|
197
|
+
- **A labelled axis.** Draw ticks at a regular interval, such as every hour or every week, and label them with dates or times in the source's time zone. Name the time zone in the axis label when the source gives one.
|
|
198
|
+
- **Breaks only in empty time.** When a long stretch with no events would squeeze the rest, cut it out of the axis. Cut only time that holds no events. Mark the cut on the axis with a visible gap and two short parallel strokes, and label it with the time it removes, such as "20 days not shown". Both sides of the break keep the same scale.
|
|
199
|
+
- **A detail timeline for a dense cluster.** When events crowd into a short span, draw a second timeline below with a finer scale, label it as a close-up of that span ("3 Aug, 09:00 to 17:00"), and mark the span on the main axis. Never give two parts of one axis different scales.
|
|
200
|
+
- **Spans as bars.** Something that lasted from one time to another, such as an outage, is a bar from its start to its end on the same scale.
|
|
201
|
+
- **The source's precision.** An event the source dates only to the day sits on that day's tick and its label gives only the date. A time the source gives as approximate is labelled "about".
|
|
202
|
+
- **Events take pointer states.** The timeline's `<svg>` carries `data-layout="timeline"`. Each event's point is a `<g class="node" id="…">` holding a `<title>` with the event's time and label, the point's shape, such as a `<circle>`, and then `<circle class="hit" r="12">` at the same centre, so a finger can tap it. Its label and leader line sit outside the node. [The pointer script](../../pageship-interaction/references/pointer.md) makes each event focusable and shows its title as the tooltip.
|
|
203
|
+
- **Readable labels.** Each event label sits above or below the axis, joined to its point by a short vertical leader line that stops 6px short of the label. Alternate above and below, or move labels to more rows, until no two labels overlap. No line, leader or tick crosses or touches any text, tick labels included: move a tick label, or leave out a tick, that would collide with an event.
|
|
204
|
+
|
|
205
|
+
Draw the axis, ticks and leader lines as `<line>` elements without the `edge` class.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Notices
|
|
2
|
+
|
|
3
|
+
The Pageship authoring skills (`pageship-docs` and the `pageship-*` kind and pattern skills) are written in our own words. We learned from the skills below and credit each one with its licence. No text or code from them is copied into these skills. If a later change copies or adapts any of their wording or code, that change must add the full licence text for that source here.
|
|
4
|
+
|
|
5
|
+
| Skill | Author | Licence | Copyright notice | Source |
|
|
6
|
+
| -------------------- | ----------------- | ---------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
7
|
+
| effective-html | plannotator | MIT | Copyright (c) 2026 plannotator | https://github.com/plannotator/effective-html |
|
|
8
|
+
| visual-explainer | Nico Bailon | MIT | Copyright (c) 2025 Nico Bailon | https://github.com/nicobailon/visual-explainer |
|
|
9
|
+
| diagram-design | Cathryn Lavery | MIT | Copyright (c) 2025 Cathryn Lavery | https://github.com/cathrynlavery/diagram-design |
|
|
10
|
+
| frontend-design | Anthropic | Apache-2.0 | The licence file carries no copyright line and the repository has no NOTICE file | https://github.com/anthropics/skills/tree/main/skills/frontend-design |
|
|
11
|
+
| Taste Skill stack | Leonxlnx | MIT | Copyright (c) 2026 Leonxlnx | https://github.com/Leonxlnx/taste-skill |
|
|
12
|
+
| html-ppt | lewis (lewislulu) | MIT | Copyright (c) 2026 lewis | https://github.com/lewislulu/html-ppt-skill |
|
|
13
|
+
| Matt Pocock's skills | Matt Pocock | MIT | Copyright (c) 2026 Matt Pocock | https://github.com/mattpocock/skills |
|
|
14
|
+
|
|
15
|
+
Licence texts are in each repository: `LICENSE` at the root, and for frontend-design `skills/frontend-design/LICENSE.txt`.
|
|
16
|
+
|
|
17
|
+
## Versions we read
|
|
18
|
+
|
|
19
|
+
- **effective-html:** commit `2ac1dfecb0f2474e75260cb6d3c9b9d6d9b5062e` (2026-09-14).
|
|
20
|
+
- **visual-explainer:** commit `7163c3e10660912e0b89e1af465db9f387282b88` (2026-08-28, package 0.11.0).
|
|
21
|
+
- **diagram-design:** commit `57148ac6f7cf8f2d0080f23437ab2929bca15f3e` (2026-09-29, skill v2.6).
|
|
22
|
+
- **frontend-design:** anthropics/skills commit `8a1541c4a3ffa5a20a5a91de0dcf3f0bab1d1ef4` (2026-09-29). The last commit to change `skills/frontend-design` before it is `41bbe19d1a1a7eaab5e7bb9050a417e5c6cffc8f` (2026-09-03).
|
|
23
|
+
- **Taste Skill stack:** the copy installed on 2026-09-29. Its install records folder hashes, not a commit, so we can't name the commit we read. When we checked, the repository's latest commit was `ce26fc25c0e5e8cab638f883de62d9a86ee5e45b` (2026-09-26).
|
|
24
|
+
- **html-ppt:** commit `fd1629067909ff55b36b905476de4e5f26062a1f` (2026-09-14).
|
|
25
|
+
- **Matt Pocock's skills:** plugin `mattpocock-skills` 1.2.3, commit `0ab1b63a410a03d3627979a109c8695de27af954`.
|
|
26
|
+
|
|
27
|
+
## What we learned from each
|
|
28
|
+
|
|
29
|
+
- **effective-html.** One router that picks a narrow skill for the job, an authority order that puts the user's instructions and the project's design system first, one self-contained file with motion only where it explains, and choosing a chart from the comparison the reader needs to make, with honest scales and no facts left to tooltips. Its plan skill taught us to keep a plan's commitments recognisable and to add no timeline, progress figure or status the source doesn't support.
|
|
30
|
+
- **visual-explainer.** Picking the representation from the content, a caption that states each figure's one claim, drawing the mechanism instead of naming it, labelling every arrow, and minimum type sizes. Its slide decks taught us to map every source item to a slide before writing, to add slides rather than drop content, and to check each slide for overflow.
|
|
31
|
+
- **diagram-design.** Deleting nodes before drawing, the node and connector limits, right-angle connectors, labels kept off lines, one or two accent elements, accessible titles and descriptions, asking whether a table would do instead, timelines spaced to an honest scale, and motion that starts and ends on the complete figure.
|
|
32
|
+
- **frontend-design.** Grounding the look in the subject, and avoiding the generated-design clichés. Our design rules defer to it for the look, ahead of the Taste Skill stack.
|
|
33
|
+
- **Taste Skill stack.** Reading the brief before choosing a design direction, and treating the look as the design skill's job. Our design rules defer to it for the look when frontend-design is not installed.
|
|
34
|
+
- **html-ppt.** Keyboard-first slide decks in one file, with speaker material kept off the slide, and a fixed slide canvas scaled to fit the screen.
|
|
35
|
+
- **Matt Pocock's skills.** Writing skills for agents with context pointers, steps that end on a checkable condition and one source of truth for shared rules, and testing at agreed seams.
|