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.
- package/.claude-plugin/marketplace.json +1 -1
- package/bin/keyline.mjs +2 -1
- package/package.json +1 -1
- package/plugins/keyline/.claude-plugin/plugin.json +1 -1
- package/plugins/keyline/plugin.json +1 -1
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +1 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +1 -1
- package/plugins/keyline/skills/keyline/domains/type/guide.md +1 -1
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +1 -1
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +34 -145
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +10 -70
- package/plugins/keyline/skills/keyline/glass/color/guide.md +1 -1
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +2 -34
- package/plugins/keyline/skills/keyline/glass/color/theming.md +4 -65
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +14 -121
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +29 -188
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +1 -16
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +3 -11
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +5 -91
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +3 -6
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +11 -140
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +2 -8
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +0 -2
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +1 -1
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +4 -6
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +10 -184
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +21 -105
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +2 -31
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +3 -3
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +1 -7
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +3 -32
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +10 -83
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +8 -43
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +46 -85
- package/plugins/keyline/skills/keyline/glass/type/guide.md +3 -3
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +9 -74
- package/plugins/keyline/skills/keyline/glass/type/scale.md +2 -6
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +4 -20
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +5 -14
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +3 -1
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +70 -26
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +3 -3
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +9 -1
- package/plugins/keyline/skills/keyline/scripts/check.mjs +2 -2
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +2 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +29 -45
- package/plugins/keyline/skills/keyline/system/guide.md +1 -1
|
@@ -1,44 +1,88 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Which files
|
|
3
|
-
*
|
|
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", "
|
|
10
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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 = (
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
49
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
27
50
|
} catch {
|
|
28
51
|
return;
|
|
29
52
|
}
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
if (rel
|
|
33
|
-
|
|
34
|
-
|
|
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)
|
|
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 = []; // {
|
|
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({
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
96
|
-
|
|
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 {
|
|
20
|
-
import { join,
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const STYLE = new Set([
|
|
42
|
-
const MARKUP = new Set([
|
|
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(
|
|
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(
|
|
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(
|
|
161
|
-
const kind = STYLE.has(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|