trykeyline 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/keyline.mjs +2 -1
  3. package/package.json +1 -1
  4. package/plugins/keyline/.claude-plugin/plugin.json +1 -1
  5. package/plugins/keyline/plugin.json +1 -1
  6. package/plugins/keyline/skills/keyline/domains/motion/guide.md +1 -0
  7. package/plugins/keyline/skills/keyline/domains/patterns/guide.md +1 -1
  8. package/plugins/keyline/skills/keyline/domains/type/guide.md +1 -1
  9. package/plugins/keyline/skills/keyline/domains/type/ladders.md +1 -1
  10. package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +34 -145
  11. package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +10 -70
  12. package/plugins/keyline/skills/keyline/glass/color/guide.md +1 -1
  13. package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +2 -34
  14. package/plugins/keyline/skills/keyline/glass/color/theming.md +4 -65
  15. package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +14 -121
  16. package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +29 -188
  17. package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +1 -16
  18. package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +3 -11
  19. package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +5 -91
  20. package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +3 -6
  21. package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +11 -140
  22. package/plugins/keyline/skills/keyline/glass/layout/guide.md +2 -8
  23. package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +0 -2
  24. package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +1 -1
  25. package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +4 -6
  26. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +10 -184
  27. package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +21 -105
  28. package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +2 -31
  29. package/plugins/keyline/skills/keyline/glass/motion/guide.md +3 -3
  30. package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +1 -7
  31. package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +3 -32
  32. package/plugins/keyline/skills/keyline/glass/motion/sound.md +10 -83
  33. package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +8 -43
  34. package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +46 -85
  35. package/plugins/keyline/skills/keyline/glass/type/guide.md +3 -3
  36. package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +9 -74
  37. package/plugins/keyline/skills/keyline/glass/type/scale.md +2 -6
  38. package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +4 -20
  39. package/plugins/keyline/skills/keyline/scripts/check/css.mjs +5 -14
  40. package/plugins/keyline/skills/keyline/scripts/check/design.mjs +3 -1
  41. package/plugins/keyline/skills/keyline/scripts/check/files.mjs +70 -26
  42. package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +3 -3
  43. package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +9 -1
  44. package/plugins/keyline/skills/keyline/scripts/check.mjs +2 -2
  45. package/plugins/keyline/skills/keyline/scripts/contrast.mjs +2 -0
  46. package/plugins/keyline/skills/keyline/scripts/scan.mjs +29 -45
  47. package/plugins/keyline/skills/keyline/system/guide.md +1 -1
@@ -1,44 +1,88 @@
1
1
  /**
2
- * Which files the detector reads. The same skips as scan.mjs, so the two never
3
- * disagree about what counts as the project's interface.
2
+ * Which files Keyline reads. One walker for the detector (check.mjs) and the
3
+ * inventory (scan.mjs), so the two skip the same folders and match ignores the
4
+ * same way; each passes only the extensions and filters its job needs.
4
5
  */
5
6
  import { readdirSync, statSync } from "node:fs";
6
- import { extname, join, relative, sep } from "node:path";
7
+ import { basename, extname, join, relative, sep } from "node:path";
7
8
 
9
+ /** Dependencies, build output, served assets and reports. Dot-folders are skipped wholesale, bar `.storybook`. */
8
10
  export const SKIP_DIRS = new Set([
9
- "node_modules", ".git", ".next", ".nuxt", ".output", ".svelte-kit", ".turbo",
10
- ".vercel", ".cache", "dist", "build", "out", "coverage", "vendor",
11
- "storybook-static", ".claude", ".agents", "public", "static",
11
+ "node_modules", "dist", "build", "out", "coverage", "vendor", "storybook-static", "public", "static",
12
+ "keyline-report", "smoke-report",
12
13
  ]);
13
14
  export const STYLE = new Set([".css", ".scss", ".sass", ".less", ".pcss"]);
14
15
  export const MARKUP = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs", ".vue", ".svelte", ".astro", ".html", ".mdx"]);
15
- const SKIP_FILES = /\.(min|d|test|spec|stories|config)\.[a-z]+$|^(eslint|postcss|tailwind|next|vite|vitest|jest|playwright)\.config\./;
16
+ /** Minified, declaration, test and story files: generated, or not the interface. */
17
+ export const GENERATED = /\.(min|test|spec|stories)\.|\.d\.[a-z]+$/;
18
+ /** Tool configs. Not the interface, though a Tailwind config holds theme values. */
19
+ export const CONFIG = /\.config\.[a-z]+$|^(eslint|postcss|tailwind|next|vite|vitest|jest|playwright)\.config\./;
16
20
 
17
- /** Every style and markup file under `paths`, relative to `root`, minus ignores. */
18
- export function collect(root, paths, ignores) {
21
+ const clean = (ig) => ig.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
22
+
23
+ /** A test for paths relative to the root: `src/content` drops `src/content/a.tsx`, never `src/contentful`. */
24
+ export function ignoredBy(ignores = []) {
25
+ const list = ignores.map(clean).filter(Boolean);
26
+ return (rel) => list.some((ig) => rel === ig || rel.startsWith(ig + "/"));
27
+ }
28
+
29
+ /**
30
+ * Every file under `paths` whose extension is in `accept` and whose name `skip`
31
+ * doesn't match, up to `maxSize` bytes (bigger is a bundle, not source). Folders
32
+ * in SKIP_DIRS and dot-folders are passed over unless named in `paths`;
33
+ * symlinked folders aren't followed.
34
+ * @returns {{ abs: string, rel: string, ext: string }[]} rel is relative to root, with `/`
35
+ */
36
+ export function walk(root, { paths = [root], accept, skip = GENERATED, maxSize = 400_000, ignores = [] }) {
19
37
  const out = [];
20
- const ignored = (rel) => ignores.some((ig) => ig && (rel === ig || rel.startsWith(ig.endsWith("/") ? ig : ig + "/")));
21
- const visit = (abs) => {
22
- const rel = relative(root, abs).split(sep).join("/");
23
- if (rel && ignored(rel)) return;
24
- let st;
38
+ const ignored = ignoredBy(ignores);
39
+ const rel = (abs) => relative(root, abs).split(sep).join("/");
40
+ const file = (abs, name, size) => {
41
+ const ext = extname(name);
42
+ if (!accept.has(ext) || skip?.test(name)) return;
43
+ if ((size ?? statSync(abs).size) > maxSize) return;
44
+ out.push({ abs, rel: rel(abs) || name, ext });
45
+ };
46
+ const dir = (abs) => {
47
+ let entries;
25
48
  try {
26
- st = statSync(abs);
49
+ entries = readdirSync(abs, { withFileTypes: true });
27
50
  } catch {
28
51
  return;
29
52
  }
30
- if (st.isDirectory()) {
31
- const name = abs.split(sep).pop();
32
- if (rel && (SKIP_DIRS.has(name) || (name.startsWith(".") && name !== ".storybook"))) return;
33
- for (const e of readdirSync(abs)) visit(join(abs, e));
34
- return;
53
+ for (const e of entries) {
54
+ const child = join(abs, e.name);
55
+ if (ignored(rel(child))) continue;
56
+ if (e.isDirectory()) {
57
+ if (!SKIP_DIRS.has(e.name) && (!e.name.startsWith(".") || e.name === ".storybook")) dir(child);
58
+ } else {
59
+ try {
60
+ file(child, e.name);
61
+ } catch {}
62
+ }
35
63
  }
36
- const ext = extname(abs);
37
- if (!(STYLE.has(ext) || MARKUP.has(ext))) return;
38
- if (SKIP_FILES.test(abs.split(sep).pop())) return;
39
- if (st.size > 400_000) return; // generated bundles, not source
40
- out.push({ abs, rel: rel || abs.split(sep).pop(), ext, kind: STYLE.has(ext) ? "style" : "markup" });
41
64
  };
42
- for (const p of paths) visit(p);
65
+ for (const p of paths) {
66
+ const r = rel(p);
67
+ if (r && ignored(r)) continue;
68
+ let st;
69
+ try {
70
+ st = statSync(p);
71
+ } catch {
72
+ continue;
73
+ }
74
+ if (st.isDirectory()) dir(p);
75
+ else file(p, basename(p), st.size);
76
+ }
43
77
  return out;
44
78
  }
79
+
80
+ const DETECTOR_SKIP = new RegExp(`${GENERATED.source}|${CONFIG.source}`);
81
+
82
+ /** The detector's files: style and markup, minus generated files and tool configs. */
83
+ export function collect(root, paths, ignores) {
84
+ return walk(root, { paths, ignores, accept: new Set([...STYLE, ...MARKUP]), skip: DETECTOR_SKIP }).map((f) => ({
85
+ ...f,
86
+ kind: STYLE.has(f.ext) ? "style" : "markup",
87
+ }));
88
+ }
@@ -57,7 +57,7 @@ export function readMarkup(source, ext) {
57
57
  text = text.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
58
58
  const at = lineIndex(text);
59
59
 
60
- const classLists = []; // { value, offset, line, tokens: [{...splitClass, line}] }
60
+ const classLists = []; // { line, tokens: [{...splitClass, line}], tag }
61
61
  const seen = new Set();
62
62
  const addList = (value, offset, origin) => {
63
63
  if (seen.has(offset)) return;
@@ -66,7 +66,7 @@ export function readMarkup(source, ext) {
66
66
  const re = /\S+/g;
67
67
  let m;
68
68
  while ((m = re.exec(value))) tokens.push({ ...splitClass(m[0]), line: at(offset + m.index) });
69
- if (tokens.length) classLists.push({ value, offset, line: at(offset), tokens, origin, tag: origin === "attribute" ? tagAround(offset, value.length) : "" });
69
+ if (tokens.length) classLists.push({ line: at(offset), tokens, tag: origin === "attribute" ? tagAround(offset, value.length) : "" });
70
70
  };
71
71
 
72
72
  /** The opening tag a class attribute sits in, for rules that need its element or attributes. */
@@ -93,7 +93,7 @@ export function readMarkup(source, ext) {
93
93
  addList(s[1] === "`" ? staticParts(s[2]) : s[2], open + 1 + s.index + 1, "helper");
94
94
  }
95
95
  }
96
- if (!isHtmlLike || ext !== ".html") {
96
+ if (ext !== ".html") {
97
97
  for (const m of text.matchAll(EXPR_STRING)) {
98
98
  const value = m[2];
99
99
  if (value.length < 3 || !CLASSY.test(value) || !TAILWINDISH.test(value)) continue;
@@ -226,8 +226,16 @@ const colorRules = [
226
226
  // recipe, not a palette colour; the recipe itself is what depth law 4 wants
227
227
  // kept in one definition. A tinted shadow (a blue glow) still counts.
228
228
  if (/^(box-shadow|text-shadow)$/.test(d.prop) && neutralOnly(d.value)) continue;
229
+ // Gradient stops and filter lights only exist inside SVG artwork, which colour law 1 exempts.
230
+ if (/^(stop-color|flood-color|lighting-color)$/.test(d.prop)) continue;
229
231
  const c = literalColor(d.value);
230
- if (c) out.push({ ...where(d), severity: "warning", message: `\`${c}\` typed into \`${d.prop}\`${d.inline ? " in an inline style" : ""}. Give it a token named for its job (a text tier, a border, a status), so a theme or a contrast fix changes it once.` });
232
+ if (!c) continue;
233
+ // fill and stroke paint SVG: artwork (exempt) or an icon (should be currentColor). A regex can't tell which.
234
+ if (/^(fill|stroke)$/.test(d.prop)) {
235
+ out.push({ ...where(d), severity: "info", message: `\`${c}\` painted into \`${d.prop}\`. Inside artwork marked \`role="img"\` that's fine (colour law 1); on an icon, use \`currentColor\` so it follows its label.` });
236
+ continue;
237
+ }
238
+ out.push({ ...where(d), severity: "warning", message: `\`${c}\` typed into \`${d.prop}\`${d.inline ? " in an inline style" : ""}. Give it a token named for its job (a text tier, a border, a status), so a theme or a contrast fix changes it once.` });
231
239
  }
232
240
  for (const c of tw(facts, (b) => TAILWIND_ARBITRARY_COLOR.test(b))) {
233
241
  out.push({ ...where(c), severity: "warning", message: `\`${c.base}\` puts a raw colour in a class. Map it to a token named for its job.` });
@@ -92,8 +92,8 @@ export function gather(root, files) {
92
92
  const VIEWPORT_VALUE = /<meta\b[^>]*\bviewport\b[^>]*>|["'`][^"'`\n<>]*width\s*=\s*device-width[^"'`\n<>]*["'`]/gi;
93
93
  for (const v of m.text.matchAll(VIEWPORT_VALUE)) for (const z of v[0].matchAll(LOCK)) facts.zoomLocks.push({ file: f.rel, line: at(v.index + z.index) });
94
94
  for (const z of m.text.matchAll(/\bmaximumScale\s*:\s*1\b|\buserScalable\s*:\s*false\b/g)) facts.zoomLocks.push({ file: f.rel, line: at(z.index) });
95
- if (MOTION_IMPORT.test(m.text)) {
96
- const imp = m.text.match(MOTION_IMPORT);
95
+ const imp = m.text.match(MOTION_IMPORT);
96
+ if (imp) {
97
97
  noteAnimation({ file: f.rel, line: at(imp.index) });
98
98
  for (const d of m.text.matchAll(/\bduration\s*:\s*(\d*\.?\d+)\b/g)) {
99
99
  // A spring's duration is a settling time set by its character, not a tier.
@@ -150,4 +150,6 @@ if (ladder) {
150
150
  if (rest.length !== 2) die("give a foreground and a background (or --ladder)");
151
151
  const r = ratio(rest[0], rest[1]);
152
152
  console.log(`${r >= floor ? "✓" : "✗"} ${fmt(r)} (floor ${floor}:1)`);
153
+ // 1:1 means the two colours came out identical — nearly always the arguments, not the design.
154
+ if (r < 1.01) console.error(`contrast: ${rest[0]} and ${rest[1]} come out as the same colour. Check the order: <fg> <bg>, or --over <backdrop> <fg> <bg>, or --ladder <bg> <fg>…`);
153
155
  process.exit(r >= floor ? 0 : 2);
@@ -16,8 +16,9 @@
16
16
  * at runtime or a class built by string concatenation. When the summary and the
17
17
  * code disagree, the code wins — open the file the summary points at.
18
18
  */
19
- import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
20
- import { join, relative, extname, basename } from "node:path";
19
+ import { readFileSync, existsSync } from "node:fs";
20
+ import { join, basename, resolve } from "node:path";
21
+ import { walk, STYLE as CHECKED_STYLE, MARKUP as CHECKED_MARKUP } from "./check/files.mjs";
21
22
 
22
23
  const argv = process.argv.slice(2);
23
24
  const json = argv.includes("--json");
@@ -33,37 +34,14 @@ if (!existsSync(root)) {
33
34
  process.exit(1);
34
35
  }
35
36
 
36
- const SKIP_DIRS = new Set([
37
- "node_modules", ".git", ".next", ".nuxt", ".output", ".svelte-kit", ".turbo",
38
- ".vercel", ".cache", "dist", "build", "out", "coverage", "vendor",
39
- "storybook-static", ".claude", "public", "static",
40
- ]);
41
- const STYLE = new Set([".css", ".scss", ".sass", ".less", ".pcss", ".styl"]);
42
- const MARKUP = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte", ".astro", ".html", ".mdx"]);
43
-
44
- /* ── Walk ─────────────────────────────────────────────────────────────────── */
45
-
46
- const files = [];
47
- (function walk(dir) {
48
- let entries;
49
- try {
50
- entries = readdirSync(dir, { withFileTypes: true });
51
- } catch {
52
- return;
53
- }
54
- for (const e of entries) {
55
- if (e.name.startsWith(".") && e.isDirectory() && e.name !== ".storybook") continue;
56
- const path = join(dir, e.name);
57
- if (ignores.some((ig) => ig && relative(root, path).startsWith(ig))) continue;
58
- if (e.isDirectory()) {
59
- if (!SKIP_DIRS.has(e.name)) walk(path);
60
- } else if (STYLE.has(extname(e.name)) || MARKUP.has(extname(e.name))) {
61
- if (/\.min\.|\.d\.ts$|\.test\.|\.spec\.|\.stories\./.test(e.name)) continue;
62
- if (statSync(path).size > 1_000_000) continue;
63
- files.push(path);
64
- }
65
- }
66
- })(root);
37
+ /*
38
+ * The detector's walker and folder skips, read wider: an inventory wants every
39
+ * hand-written value, so it also reads Stylus and CommonJS, keeps tool configs
40
+ * (a Tailwind config is where a theme is declared) and allows files up to 1 MB.
41
+ */
42
+ const STYLE = new Set([...CHECKED_STYLE, ".styl"]);
43
+ const MARKUP = new Set([...CHECKED_MARKUP, ".cjs"]);
44
+ const files = walk(resolve(root), { accept: new Set([...STYLE, ...MARKUP]), maxSize: 1_000_000, ignores });
67
45
 
68
46
  /* ── Tallies ──────────────────────────────────────────────────────────────── */
69
47
 
@@ -74,7 +52,7 @@ function count(bucket, value, file) {
74
52
  tallies[bucket] ??= new Map();
75
53
  const entry = tallies[bucket].get(v) ?? { n: 0, files: new Set() };
76
54
  entry.n++;
77
- entry.files.add(relative(root, file));
55
+ entry.files.add(file);
78
56
  tallies[bucket].set(v, entry);
79
57
  }
80
58
 
@@ -91,7 +69,7 @@ const classWords = new Map();
91
69
  const signals = {};
92
70
  function signal(name, file) {
93
71
  signals[name] ??= new Set();
94
- signals[name].add(relative(root, file));
72
+ signals[name].add(file);
95
73
  }
96
74
 
97
75
  /** Tailwind's scale names, so `text-sm` counts as a size and `text-red-500` doesn't. */
@@ -156,9 +134,9 @@ const SIGNALS = [
156
134
  ["numerals: tabular-nums", /tabular-nums|font-variant-numeric/],
157
135
  ];
158
136
 
159
- for (const file of files) {
160
- const text = readFileSync(file, "utf8");
161
- const kind = STYLE.has(extname(file)) ? "style" : "markup";
137
+ for (const { abs, rel: file, ext } of files) {
138
+ const text = readFileSync(abs, "utf8");
139
+ const kind = STYLE.has(ext) ? "style" : "markup";
162
140
 
163
141
  for (const [bucket, re, group, only] of RULES) {
164
142
  if (only !== "all" && only !== kind) continue;
@@ -175,7 +153,7 @@ for (const file of files) {
175
153
  if (m[1].startsWith("--tw-")) continue;
176
154
  const entry = props.get(m[1]) ?? { values: [], files: new Set() };
177
155
  if (entry.values.length < 4 && !entry.values.includes(m[2].trim())) entry.values.push(m[2].trim());
178
- entry.files.add(relative(root, file));
156
+ entry.files.add(file);
179
157
  props.set(m[1], entry);
180
158
  }
181
159
  if (kind === "markup") {
@@ -196,14 +174,20 @@ for (const file of files) {
196
174
  * but it's the difference between "unused" and "used in 40 files".
197
175
  */
198
176
  const NAMESPACES = ["color", "font", "text", "radius", "shadow", "ease", "animate", "container", "spacing", "tracking", "leading", "breakpoint", "blur", "inset-shadow", "drop-shadow"];
199
- function utilityUses(prop) {
177
+ /** Class-word uses by every tail after a hyphen: `hover:text-ink-soft` counts for `ink-soft` and `soft`. */
178
+ const bySuffix = new Map();
179
+ for (const [word, c] of classWords) {
180
+ for (let i = word.indexOf("-"); i >= 0; i = word.indexOf("-", i + 1)) {
181
+ const tail = word.slice(i + 1);
182
+ bySuffix.set(tail, (bySuffix.get(tail) ?? 0) + c);
183
+ }
184
+ }
185
+ const utilityCount = new Map();
186
+ for (const prop of props.keys()) {
200
187
  const ns = NAMESPACES.find((n) => prop.startsWith(`--${n}-`));
201
- if (!ns) return 0;
202
- const name = prop.slice(ns.length + 3);
203
- let n = 0;
204
- for (const [word, c] of classWords) if (word.endsWith(`-${name}`) && word !== name) n += c;
205
- return n;
188
+ utilityCount.set(prop, ns ? (bySuffix.get(prop.slice(ns.length + 3)) ?? 0) : 0);
206
189
  }
190
+ const utilityUses = (prop) => utilityCount.get(prop) ?? 0;
207
191
  const uses = (prop) => (propUses.get(prop) ?? 0) + utilityUses(prop);
208
192
 
209
193
  /* ── Stack ────────────────────────────────────────────────────────────────── */
@@ -33,7 +33,7 @@ Five places projects have quietly broken a law:
33
33
  - **One token doing two jobs.** A grey used for borders and text: a border needs 3:1, text 4.5:1, and one value rarely serves both. Record the coupling as an open issue; don't split it yet.
34
34
  - **A job with no token.** Captions at six greys, durations typed inline. Propose a name in the project's style (`design-md.md` says how to detect it) and mark it `proposed`. Where a whole domain has no tokens — no type tokens, no motion tokens — record that as one open issue, not one per role; that domain's refining pass proposes a full set in the project's style, marked `proposed`.
35
35
  - **Roles the list doesn't have.** A `keyline`, a `pull-quote`, a `kicker`: record each as a project role under its nearest canonical parent, with what separates it — size, weight, leading, colour or a rule. If nothing does, that's a finding.
36
- - **Boxes as the default.** Every section, stat and list in a card, cards inside cards, a bento grid of unlike sections, tinted strips behind labels, uppercase eyebrows, several chip shapes, a second hue that calls for attention (a hue family answering what, where or who under `product` is not one), success colour on done steps where completion isn't the news (a sequence the reader passes through, not a board or queue). Count the boxes per main screen and list each that holds more than one object (depth law 2, layout law 3). If the user has a reference interface they've already approved, screenshot each main screen beside it; the gaps are open issues.
36
+ - **Boxes as the default.** Every section, stat and list in a card, cards inside cards, a bento grid of unlike sections, tinted strips behind labels, uppercase eyebrows (outside an `expressive` eyebrow role), several chip shapes, a second hue that calls for attention (a hue family answering what, where or who under `product` is not one), success colour on done steps where completion isn't the news (a sequence the reader passes through, not a board or queue). Count the boxes per main screen and list each that holds more than one object (depth law 2, layout law 3); a panel that layout law 3's table gives a settings or form group is not one. If the user has a reference interface they've already approved, screenshot each main screen beside it; the gaps are open issues.
37
37
  - **Values on no ladder.** Twenty-three font sizes, fourteen radii. Cluster them by usage, propose the ladder, list the strays that could snap to a neighbour unseen.
38
38
 
39
39
  Compute contrast; don't estimate it: