stylegallery 0.1.8 → 0.1.10

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.
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+
3
+ // The Registrar keeps the gallery's claim register. Markdown claim records under
4
+ // quality/claim-records/ are the source of truth; the Registrar checks the machine-readable fields of
5
+ // each "## Claim Record" section against the template in quality/claims.md, the evidence-family
6
+ // glossary, the referenced files, the other records, and the Conservator's exhibit list. It cannot
7
+ // prove the semantics of a claim, only that its record is complete, well-formed, internally
8
+ // consistent, and points at evidence that exists.
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { exhibitSlugs } from "./conservator-exhibits.mjs";
13
+ import { structuralMarkdown } from "./markdown-structure.mjs";
14
+
15
+ const galleryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
16
+ const RECORD_DIR = "quality/claim-records";
17
+ const TEMPLATE_DOC = "quality/claims.md";
18
+ const FAMILY_DOC = "quality/evidence/families.md";
19
+ const RECORD_INDEX = `${RECORD_DIR}/index.md`;
20
+ const VERDICTS = new Set(["auto", "human", "mixed"]);
21
+ const DECISIONS = new Set(["pass", "review_required", "blocked"]);
22
+ const NA = "not applicable";
23
+
24
+ // Every field of the claims.md template, in template order. The template-sync rule fails when the
25
+ // template and this list drift apart.
26
+ export const RECORD_FIELDS = Object.freeze([
27
+ "Principle", "Claim", "Context", "Use case", "Warrant", "Evidence family", "Verification protocol",
28
+ "Implementation handoff", "Consumer reference", "Consumer reference reason", "Boundary or limitation",
29
+ "Debt or escalation", "Decision", "Evidence refs", "Verified by", "Reviewed at", "Review by",
30
+ "Source revision", "Conflicts with",
31
+ ]);
32
+ // Fields that must state something; the rest may say "not applicable".
33
+ const STATEMENT_FIELDS = ["Principle", "Claim", "Context", "Warrant", "Evidence family", "Verification protocol", "Boundary or limitation", "Debt or escalation"];
34
+ // Human-outcome terms from quality/claims.md that automation alone cannot certify.
35
+ const HUMAN_OUTCOME_TERMS = ["good", "better", "best", "harmonious", "harmony", "usable", "usability", "accessible", "readable", "credible", "clear"];
36
+
37
+ export const REGISTRAR_RULES = Object.freeze([
38
+ "records-present", "record-title-format", "record-number-unique", "record-title-unique",
39
+ "template-sync", "field-present", "field-not-repeated", "statement-not-empty",
40
+ "decision-value", "verified-by-value", "reviewed-at-date", "reviewed-at-not-future",
41
+ "review-by-value", "review-by-after-reviewed-at", "review-by-overdue",
42
+ "source-revision-format", "external-source-review-by",
43
+ "conflicts-with-format", "conflicts-with-resolves", "conflicts-with-reciprocal",
44
+ "evidence-refs-present", "evidence-refs-backticked", "evidence-ref-kind", "evidence-ref-exists", "conservator-ref-resolves",
45
+ "evidence-family-known", "consumer-reference-format", "consumer-reference-reason",
46
+ "auto-pass-only", "auto-executable-refs", "auto-validator-family", "auto-no-human-outcome-terms",
47
+ "human-review-artifact", "record-file-indexed", "conservator-exhibit-recorded",
48
+ ]);
49
+
50
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ const isNa = (value) => value?.toLowerCase() === NA;
52
+ export const validDate = (value) => /^20\d\d-\d{2}-\d{2}$/.test(value)
53
+ && Number.isFinite(Date.parse(`${value}T00:00:00Z`))
54
+ && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
55
+ const ticked = (value) => [...(value ?? "").matchAll(/`([^`]+)`/g)].map((match) => match[1].trim());
56
+
57
+ function listRecordFiles(root) {
58
+ const dir = path.join(root, RECORD_DIR);
59
+ if (!fs.existsSync(dir)) return [];
60
+ return fs.readdirSync(dir, { withFileTypes: true })
61
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
62
+ .map((entry) => `${RECORD_DIR}/${entry.name}`)
63
+ .sort();
64
+ }
65
+
66
+ function splitRecords(content) {
67
+ const records = [];
68
+ let current = null;
69
+ for (const line of content.split("\n")) {
70
+ const heading = line.match(/^## (Claim Record\b.*?)\s*$/);
71
+ if (heading) {
72
+ if (current) records.push(current);
73
+ current = { title: heading[1], body: [] };
74
+ } else if (line.startsWith("## ")) {
75
+ if (current) records.push(current);
76
+ current = null;
77
+ } else if (current) current.body.push(line);
78
+ }
79
+ if (current) records.push(current);
80
+ return records.map((record) => ({ title: record.title, body: record.body.join("\n") }));
81
+ }
82
+
83
+ function fieldValues(body, key) {
84
+ return [...body.matchAll(new RegExp(`^${escape(key)}:[ \\t]*(.*)$`, "gm"))].map((match) => match[1].trim());
85
+ }
86
+
87
+ export function templateFields(root) {
88
+ const file = path.join(root, TEMPLATE_DOC);
89
+ if (!fs.existsSync(file)) return null;
90
+ const section = fs.readFileSync(file, "utf8").split(/^## Claim Record Template\s*$/m)[1];
91
+ const block = section?.match(/^```txt\n([\s\S]*?)^```/m)?.[1];
92
+ return block ? [...block.matchAll(/^([A-Z][^:\n]*):/gm)].map((match) => match[1]) : null;
93
+ }
94
+
95
+ export function evidenceFamilies(root) {
96
+ const file = path.join(root, FAMILY_DOC);
97
+ if (!fs.existsSync(file)) return new Set();
98
+ return new Set([...structuralMarkdown(fs.readFileSync(file, "utf8")).matchAll(/^## `([a-z-]+)`\s*$/gm)].map((match) => match[1]));
99
+ }
100
+
101
+ function classifyRef(ref) {
102
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(ref)) return "id";
103
+ if (ref.includes("/")) return "path";
104
+ if (/^[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)+$/.test(ref)) return "id";
105
+ return "invalid";
106
+ }
107
+
108
+ function resolvesInside(root, ref) {
109
+ if (/[\\]/.test(ref) || ref.startsWith("/")) return null;
110
+ const target = path.resolve(root, ref.split(/[?#]/)[0]);
111
+ return target.startsWith(`${path.resolve(root)}${path.sep}`) ? target : null;
112
+ }
113
+
114
+ const isFile = (target) => Boolean(target) && fs.existsSync(target) && fs.statSync(target).isFile();
115
+
116
+ // An installed package ships only its declared files; a repository checkout ships everything. A ref
117
+ // to a repository-only file is a warning in an installed package and a failure in a checkout.
118
+ function packagedFiles(root) {
119
+ if (fs.existsSync(path.join(root, ".git"))) return null;
120
+ try {
121
+ const files = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).files;
122
+ return Array.isArray(files) ? new Set(files) : null;
123
+ } catch { return null; }
124
+ }
125
+
126
+ export function recordAnchor(file, number) {
127
+ return `${file}#claim-record-${number}`;
128
+ }
129
+
130
+ export function runRegistrar({ root = galleryRoot, now = new Date(), exhibitList = exhibitSlugs } = {}) {
131
+ const failures = [];
132
+ const warnings = [];
133
+ const fail = (rule, where, message) => failures.push(`${where}: ${message} [${rule}]`);
134
+ const warn = (rule, where, message) => warnings.push(`${where}: ${message} [${rule}]`);
135
+ const today = now.toISOString().slice(0, 10);
136
+ const files = listRecordFiles(root);
137
+ const families = evidenceFamilies(root);
138
+ const packaged = packagedFiles(root);
139
+ const exhibits = new Set(exhibitList);
140
+ const register = [];
141
+
142
+ const template = templateFields(root);
143
+ if (!template) fail("template-sync", TEMPLATE_DOC, "has no ```txt block under ## Claim Record Template");
144
+ else if (template.join("|") !== RECORD_FIELDS.join("|")) {
145
+ fail("template-sync", TEMPLATE_DOC, `template fields [${template.join(", ")}] differ from the Registrar's [${RECORD_FIELDS.join(", ")}]`);
146
+ }
147
+
148
+ const indexText = fs.existsSync(path.join(root, RECORD_INDEX)) ? structuralMarkdown(fs.readFileSync(path.join(root, RECORD_INDEX), "utf8")) : "";
149
+ const titles = new Map();
150
+
151
+ for (const relative of files) {
152
+ const records = splitRecords(structuralMarkdown(fs.readFileSync(path.join(root, relative), "utf8")));
153
+ if (records.length > 0 && !new RegExp(`\\]\\(${escape(path.posix.basename(relative))}\\)`).test(indexText)) {
154
+ fail("record-file-indexed", relative, `holds claim records but is not linked from ${RECORD_INDEX}`);
155
+ }
156
+ const numbers = new Set();
157
+ for (const record of records) {
158
+ const where = `${relative}: ${record.title}`;
159
+ const heading = record.title.match(/^Claim Record (\d+) - (\S.*)$/);
160
+ if (!heading) fail("record-title-format", where, 'title must read "Claim Record <number> - <title>"');
161
+ const number = heading ? Number(heading[1]) : null;
162
+ if (number !== null) {
163
+ if (numbers.has(number)) fail("record-number-unique", where, `record number ${number} is used twice in ${relative}`);
164
+ numbers.add(number);
165
+ const key = heading[2].toLowerCase();
166
+ if (titles.has(key)) fail("record-title-unique", where, `title duplicates ${titles.get(key)}`);
167
+ else titles.set(key, where);
168
+ }
169
+
170
+ const values = {};
171
+ for (const key of RECORD_FIELDS) {
172
+ const found = fieldValues(record.body, key);
173
+ if (found.length > 1) fail("field-not-repeated", where, `${key} appears ${found.length} times`);
174
+ values[key] = found[0] ?? null;
175
+ if (values[key] === null) fail("field-present", where, `${key} field is missing`);
176
+ else if (values[key] === "") fail("field-present", where, `${key} must be a value or "${NA}"`);
177
+ }
178
+ for (const key of STATEMENT_FIELDS) {
179
+ if (values[key] && isNa(values[key])) fail("statement-not-empty", where, `${key.toLowerCase()} must be a non-empty statement`);
180
+ }
181
+
182
+ const decision = values.Decision?.match(/^([a-z_]+)\b/)?.[1] ?? null;
183
+ if (values.Decision !== null && (!decision || !DECISIONS.has(decision))) fail("decision-value", where, "decision must begin with pass, review_required, or blocked");
184
+ const verifiedBy = values["Verified by"];
185
+ if (verifiedBy !== null && !VERDICTS.has(verifiedBy)) fail("verified-by-value", where, "verified by must be auto, human, or mixed");
186
+
187
+ const reviewedAt = values["Reviewed at"];
188
+ const reviewedOk = Boolean(reviewedAt) && validDate(reviewedAt);
189
+ if (reviewedAt !== null && !reviewedOk) fail("reviewed-at-date", where, "reviewed at must be a real YYYY-MM-DD date");
190
+ if (reviewedOk && reviewedAt > today) fail("reviewed-at-not-future", where, `reviewed at ${reviewedAt} is after today ${today}`);
191
+
192
+ const reviewBy = values["Review by"];
193
+ const reviewByDate = reviewBy && !isNa(reviewBy) && validDate(reviewBy) ? reviewBy : null;
194
+ if (reviewBy && !isNa(reviewBy) && !reviewByDate) fail("review-by-value", where, `review by must be a real YYYY-MM-DD date or "${NA}"`);
195
+ if (reviewByDate && reviewedOk && reviewByDate < reviewedAt) fail("review-by-after-reviewed-at", where, `review by ${reviewByDate} is before reviewed at ${reviewedAt}`);
196
+ const overdue = Boolean(reviewByDate && reviewByDate < today);
197
+ if (overdue) warn("review-by-overdue", where, `review by ${reviewByDate} has passed; re-review the claim`);
198
+
199
+ const revision = values["Source revision"];
200
+ if (revision && !isNa(revision) && !/^[0-9a-f]{7,40}$/.test(revision) && !/^https:\/\/\S+$/.test(revision)) {
201
+ fail("source-revision-format", where, `source revision must be a 7-40 character lowercase commit SHA, an https URL, or "${NA}"`);
202
+ }
203
+ if (revision && !isNa(revision) && !reviewByDate) fail("external-source-review-by", where, "a claim bound to an external source revision needs a review by date");
204
+
205
+ const conflicts = values["Conflicts with"];
206
+ const conflictRefs = conflicts && !isNa(conflicts) ? ticked(conflicts) : [];
207
+ if (conflicts && !isNa(conflicts) && conflictRefs.length === 0) fail("conflicts-with-format", where, `conflicts with must be backticked record refs or "${NA}"`);
208
+ for (const ref of conflictRefs) {
209
+ if (!/^quality\/claim-records\/[a-z0-9-]+\.md#claim-record-\d+$/.test(ref)) fail("conflicts-with-format", where, `conflict ref ${ref} must read quality/claim-records/<file>.md#claim-record-<number>`);
210
+ }
211
+
212
+ const evidence = values["Evidence refs"];
213
+ let evidenceRefs = [];
214
+ if (evidence !== null && (!evidence || isNa(evidence))) fail("evidence-refs-present", where, "evidence refs must name at least one backticked ref");
215
+ else if (evidence) {
216
+ evidenceRefs = ticked(evidence);
217
+ if (evidenceRefs.length === 0) fail("evidence-refs-backticked", where, "evidence refs must be wrapped in backticks");
218
+ for (const ref of evidenceRefs) {
219
+ const kind = classifyRef(ref);
220
+ if (kind === "invalid") fail("evidence-ref-kind", where, `evidence ref ${ref} is neither a repository path nor a StableRef id`);
221
+ else if (kind === "path" && !isFile(resolvesInside(root, ref))) {
222
+ const inside = resolvesInside(root, ref);
223
+ if (inside && packaged && !packaged.has(ref.split(/[?#]/)[0])) warn("evidence-ref-exists", where, `evidence ref ${ref} is repository-only and not shipped in this package`);
224
+ else fail("evidence-ref-exists", where, `evidence ref ${ref} does not resolve to an existing repository file`);
225
+ }
226
+ const exhibit = ref.match(/^sg:\/\/conservator\/(.+)$/)?.[1];
227
+ if (exhibit !== undefined && !exhibits.has(exhibit)) fail("conservator-ref-resolves", where, `evidence ref ${ref} names no Conservator exhibit`);
228
+ }
229
+ }
230
+
231
+ const familyValues = values["Evidence family"] && !isNa(values["Evidence family"]) ? ticked(values["Evidence family"]) : [];
232
+ if (values["Evidence family"] && !isNa(values["Evidence family"]) && familyValues.length === 0) fail("evidence-family-known", where, "evidence family must list backticked family names");
233
+ for (const family of familyValues) {
234
+ if (!families.has(family)) fail("evidence-family-known", where, `evidence family ${family} is not defined in ${FAMILY_DOC}`);
235
+ }
236
+
237
+ const consumer = values["Consumer reference"];
238
+ if (consumer && consumer !== "not_applicable") {
239
+ const target = ticked(consumer)[0] ?? consumer;
240
+ if (!/^[a-z0-9][a-z0-9._/-]*\.json$/.test(target) || target.includes("..") || !isFile(resolvesInside(root, target))) {
241
+ fail("consumer-reference-format", where, "consumer reference must be not_applicable or one existing repository-relative JSON record");
242
+ }
243
+ }
244
+ const reason = values["Consumer reference reason"];
245
+ if (consumer === "not_applicable" && reason !== null && !/^[A-Z].*\S[.!?]$/.test(reason)) {
246
+ fail("consumer-reference-reason", where, "a not_applicable consumer reference needs a one-sentence reason");
247
+ }
248
+
249
+ if (verifiedBy === "auto") {
250
+ if (decision && decision !== "pass") fail("auto-pass-only", where, `auto verification is not allowed for a ${decision} decision`);
251
+ for (const ref of evidenceRefs) {
252
+ const executable = classifyRef(ref) === "id" || ref.startsWith("scripts/") || ref.startsWith("tests/");
253
+ if (!executable) fail("auto-executable-refs", where, `auto verification requires executable evidence refs (scripts/, tests/, or a StableRef id), not ${ref}`);
254
+ }
255
+ if (values["Evidence family"] && !familyValues.includes("validator")) fail("auto-validator-family", where, "auto verification requires the validator evidence family");
256
+ const term = HUMAN_OUTCOME_TERMS.find((word) => new RegExp(`\\b${word}\\b`, "i").test(values.Claim ?? ""));
257
+ if (term) fail("auto-no-human-outcome-terms", where, `auto verification cannot certify the human-outcome term "${term}" in the claim`);
258
+ } else if (verifiedBy === "human" || verifiedBy === "mixed") {
259
+ if (!evidenceRefs.some((ref) => ref.startsWith("quality/evidence/") || ref.startsWith("quality/gates/"))) {
260
+ fail("human-review-artifact", where, `${verifiedBy} verification requires a human-review evidence ref under quality/evidence/ or quality/gates/`);
261
+ }
262
+ }
263
+
264
+ register.push({
265
+ anchor: number === null ? null : recordAnchor(relative, number), file: relative, title: record.title,
266
+ decision, verified_by: verifiedBy, reviewed_at: reviewedAt, review_by: reviewBy, overdue, conflicts: conflictRefs,
267
+ exhibits: evidenceRefs.map((ref) => ref.match(/^sg:\/\/conservator\/(.+)$/)?.[1]).filter(Boolean),
268
+ });
269
+ }
270
+ }
271
+
272
+ const anchors = new Map(register.filter(({ anchor }) => anchor).map((entry) => [entry.anchor, entry]));
273
+ for (const entry of register) {
274
+ const where = `${entry.file}: ${entry.title}`;
275
+ for (const ref of entry.conflicts) {
276
+ const other = anchors.get(ref);
277
+ if (!other || other === entry) fail("conflicts-with-resolves", where, `conflict ref ${ref} names no other claim record`);
278
+ else if (!other.conflicts.includes(entry.anchor)) fail("conflicts-with-reciprocal", where, `conflict with ${ref} is not recorded on that record`);
279
+ }
280
+ }
281
+ for (const slug of exhibitList) {
282
+ const owners = register.filter((entry) => entry.exhibits.includes(slug));
283
+ if (owners.length !== 1) fail("conservator-exhibit-recorded", `sg://conservator/${slug}`, `Conservator exhibit needs exactly one claim record citing it, found ${owners.length}`);
284
+ }
285
+ if (register.length === 0) fail("records-present", RECORD_DIR, "no claim records found");
286
+
287
+ return {
288
+ ok: failures.length === 0, files: files.length, records: register.length, rules: REGISTRAR_RULES.length,
289
+ failures: [...new Set(failures)], warnings: [...new Set(warnings)], register,
290
+ };
291
+ }
292
+
293
+ // Query views of the register for agents and reviewers (R6): records past their review date, and
294
+ // records whose verdict is not fully automated.
295
+ export function registrarView(result, { stale = false, unverified = false } = {}) {
296
+ let register = result.register;
297
+ if (stale) register = register.filter((entry) => entry.overdue);
298
+ if (unverified) register = register.filter((entry) => entry.verified_by !== "auto");
299
+ return { ...result, register };
300
+ }
301
+
302
+ export function parseRegistrarArgs(argv) {
303
+ const options = { stale: false, unverified: false, root: undefined };
304
+ for (let index = 0; index < argv.length; index += 1) {
305
+ const flag = argv[index];
306
+ if (flag === "--stale" || flag === "--unverified") options[flag.slice(2)] = true;
307
+ else if (flag === "--root" && argv[index + 1] && !argv[index + 1].startsWith("--")) options.root = path.resolve(argv[++index]);
308
+ else if (flag === "--json") continue;
309
+ else throw Object.assign(new Error(`Unknown or incomplete registrar argument: ${flag}`), { code: "argument_invalid" });
310
+ }
311
+ return options;
312
+ }
313
+
314
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
315
+ if (isMain) {
316
+ const argv = process.argv.slice(2);
317
+ const options = parseRegistrarArgs(argv);
318
+ const result = registrarView(runRegistrar({ root: options.root }), options);
319
+ if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
320
+ else if (result.ok) console.log(`ok: ${result.records} claim records in ${result.files} files pass ${result.rules} Registrar rules${result.warnings.length ? `\n${result.warnings.join("\n")}` : ""}`);
321
+ else console.error(result.failures.join("\n"));
322
+ process.exitCode = result.ok ? 0 : 1;
323
+ }
@@ -32,6 +32,12 @@ A brand study (`brand_study: true`) reinterprets a named real brand. It addition
32
32
 
33
33
  See [Brand Studies](../expression/brand-studies.md) for the reasoning and limits.
34
34
 
35
+ ## Asset Selection
36
+
37
+ Reuse existing assets first: reference-site media, supplied files, and project assets. Record the source and selected file for each image/video slot in the brief. Cropping, sizing, compression, masks, and grading adapt an existing asset to the page.
38
+
39
+ Create new assets only for intentionally graphical/vector work: icons, diagrams, patterns, abstract graphics, and vector illustrations. Do not generate photography, video, or realistic product renders, or replace them with CSS/SVG/canvas stand-ins. If the right asset is missing, find an existing one or record the gap rather than drawing a substitute to pass the richness check. Working product UI can still be implemented as behavior; it is not a replacement for photographic media. See [Image Weight](../expression/techniques/image-weight.md).
40
+
35
41
  ## Brief Template
36
42
 
37
43
  ```md
@@ -53,6 +59,9 @@ Type pairing, palette, texture, and motion density, with actual values or a link
53
59
  ## Techniques
54
60
  Expression and Motion technique pages the work uses.
55
61
 
62
+ ## Assets
63
+ Existing source URL/path and selected local file per slot; list any intentionally graphical/vector assets to create and any missing media.
64
+
56
65
  ## Layout Floor
57
66
  Scroll owner, pinned or sticky regions, and how the page collapses at 320px.
58
67
  ```
@@ -94,6 +103,11 @@ Once any work exists, `showcase/index.html` is the hub that lists every work: th
94
103
  | Offline | With every non-local request blocked: page errors, hidden text, or overflow at 390 wide. |
95
104
  | No script | With every script blocked: hidden text or overflow at 390 wide. |
96
105
  | Hub | Works exist but `showcase/index.html` is missing, or a work directory it does not link. With no works, the check passes and reports that there is nothing to check. |
106
+ | Richness: empty screen | At 1440x900, a screen where images, video, canvas, SVG of at least 120x80, background images, and elements marked `data-surface` cover under 8% and text covers under 10%. |
107
+ | Richness: response | Fewer than 4 elements with transitions per screen of page length (at least 12), or link and button transitions with a median above 350ms. |
108
+ | Richness: surface detail | Fewer than 2 elements with a mask or clip-path, or fewer than 3 with 1px hairline borders. |
109
+
110
+ Richness findings are warnings by default so older works keep passing; run with `--richness error` to make them failures, which new works must pass. Mark a drawn scene or a live product mock built from HTML with `data-surface` so the check counts it; never mark a gradient or an empty panel.
97
111
 
98
112
  Fictional actions (subscribe, book, get a key) must be `<button type="button" data-demo="...">` elements that show an honest message in a `role="status"` region, never links that jump to the top.
99
113
 
@@ -105,7 +119,7 @@ The repository ships the contract, the checker, and the guidance, but no committ
105
119
 
106
120
  ## Starting A Work Fast
107
121
 
108
- Read this page, one [Expression direction](../expression/index.md), and the [Motion techniques](../motion/techniques/scroll-choreography.md) you need. Skip governance, provenance, and consumer-reference documents: a showcase work creates no governed record. Build, add a hub card, run `bun run showcase:serve` and `node scripts/check-showcase.mjs --work <slug>`, look at the screenshots, and iterate.
122
+ Read this page, the [Expression Direction Brief](../expression/direction-brief.md), one [Expression direction](../expression/index.md), and the [Motion techniques](../motion/techniques/scroll-choreography.md) you need. Skip governance, provenance, and consumer-reference documents: a showcase work creates no governed record. Plan one image, drawn scene, or product surface per screen before writing CSS ([Image Weight](../expression/techniques/image-weight.md)). Build, add a hub card, run `bun run showcase:serve` and `node scripts/check-showcase.mjs --work <slug> --richness error`, look at the screenshots, and iterate.
109
123
 
110
124
  ## IA Navigation
111
125