@askewly/design 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { Command } from "commander";
5
5
  import { addRecipe, initProject, InjectError } from "./inject.js";
6
+ import { DEFAULT_TYPOGRAPHY_THRESHOLD } from "./typography.js";
6
7
  import { verifyDir } from "./verify.js";
7
8
  import { loadRecipes, loadTerms, loadTokens, loadTokensCss, searchTerms, tokensForTier, } from "./load.js";
8
9
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -159,19 +160,38 @@ program
159
160
  .command("verify")
160
161
  .argument("[dir]", "directory to scan", ".")
161
162
  .option("--ext <list>", "comma-separated extensions to scan", "tsx,ts,jsx,js,css,html")
162
- .description("scan for color values that bypass the token system (hex literals, raw rgb/hsl/oklch)")
163
+ .option("--typography-threshold <n>", "max distinct font sizes per file", String(DEFAULT_TYPOGRAPHY_THRESHOLD))
164
+ .description("scan for color values that bypass the token system and screens with too many type sizes")
163
165
  .action((dir, options) => {
164
166
  const target = path.resolve(dir);
165
- const { files, violations } = verifyDir(target, options.ext.split(",").map((e) => e.trim()));
167
+ const typographyThreshold = Number(options.typographyThreshold);
168
+ if (!Number.isInteger(typographyThreshold) || typographyThreshold < 1) {
169
+ console.error(`--typography-threshold must be a positive integer (got "${options.typographyThreshold}")`);
170
+ process.exit(2);
171
+ }
172
+ const { files, violations, typographySkips } = verifyDir(target, options.ext.split(",").map((e) => e.trim()), { typographyThreshold });
173
+ // Exemptions are printed on every run, pass or fail. A file that opted out
174
+ // silently would read as a file that passed.
175
+ const printSkips = (write) => {
176
+ if (typographySkips.length === 0)
177
+ return;
178
+ write(`\n${typographySkips.length} file(s) exempt from the typography rule:`);
179
+ for (const skip of typographySkips) {
180
+ write(` ${path.relative(target, skip.file)}:${skip.line} — ${skip.reason}`);
181
+ }
182
+ };
166
183
  if (violations.length === 0) {
167
- console.log(`verify PASS — ${files} file(s) scanned, no color literals (tokens.css/askewly.css and build dirs are excluded)`);
184
+ console.log(`verify PASS — ${files} file(s) scanned, no color literals and no file over ${typographyThreshold} type sizes`);
185
+ printSkips((line) => console.log(line));
168
186
  return;
169
187
  }
170
188
  console.error(`verify FAIL — ${violations.length} violation(s) in ${files} file(s):`);
171
189
  for (const violation of violations) {
172
190
  console.error(` ${path.relative(target, violation.file)}:${violation.line} [${violation.rule}] ${violation.excerpt}`);
173
191
  }
174
- console.error("\nFix: replace literals with semantic token utilities (bg-primary, text-foreground, border-border, ...).");
192
+ printSkips((line) => console.error(line));
193
+ console.error("\nFix: replace color literals with semantic token utilities (bg-primary, text-foreground, ...);");
194
+ console.error(" collapse extra type sizes onto the token scale, or raise --typography-threshold.");
175
195
  process.exit(1);
176
196
  });
177
197
  const recipes = program.command("recipes").description("component recipes (agent-ready implementation contracts)");
@@ -0,0 +1,219 @@
1
+ import { loadTokens } from "./load.js";
2
+ /**
3
+ * Counts how many distinct type sizes one file uses.
4
+ *
5
+ * The hard part is not counting — it is deciding what counts as one "step".
6
+ * A file can express font size four ways (named Tailwind utility, arbitrary
7
+ * value, inline style, CSS declaration) and they must land in the same space
8
+ * to be comparable. So every expression is resolved to an integer px value and
9
+ * the distinct set is what we report.
10
+ */
11
+ const REM_PX = 16;
12
+ /**
13
+ * Tailwind v4 defaults for names our token scale does not define. Our SSOT owns
14
+ * sm/base/lg/xl/2xl; everything else is Tailwind's, so it is looked up here
15
+ * rather than invented. Values in rem, converted at REM_PX.
16
+ */
17
+ const TAILWIND_DEFAULTS_REM = {
18
+ xs: 0.75,
19
+ sm: 0.875,
20
+ base: 1,
21
+ lg: 1.125,
22
+ xl: 1.25,
23
+ "2xl": 1.5,
24
+ "3xl": 1.875,
25
+ "4xl": 2.25,
26
+ "5xl": 3,
27
+ "6xl": 3.75,
28
+ "7xl": 4.5,
29
+ "8xl": 6,
30
+ "9xl": 8,
31
+ };
32
+ /** Reads our own scale from the token SSOT. Never hardcode these — they move. */
33
+ function scaleFromTokens() {
34
+ const tokens = loadTokens();
35
+ const scale = tokens.typography?.scale ?? {};
36
+ const out = {};
37
+ for (const [name, node] of Object.entries(scale)) {
38
+ const value = node?.$value?.value;
39
+ const unit = node?.$value?.unit;
40
+ if (typeof value !== "number")
41
+ continue;
42
+ out[name] = unit === "rem" ? value * REM_PX : value;
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Our scale wins over Tailwind's defaults for the names it defines — that is
48
+ * the whole point of having a token SSOT. Names it does not define fall back.
49
+ */
50
+ export function typographyScale() {
51
+ return { ...tailwindDefaultsPx(), ...scaleFromTokens() };
52
+ }
53
+ function tailwindDefaultsPx() {
54
+ const out = {};
55
+ for (const [name, rem] of Object.entries(TAILWIND_DEFAULTS_REM))
56
+ out[name] = Math.round(rem * REM_PX);
57
+ return out;
58
+ }
59
+ /** `md:text-lg`, `hover:text-xl`, `dark:md:text-sm` → `text-lg` etc. */
60
+ function stripVariants(utility) {
61
+ const parts = utility.split(":");
62
+ return parts[parts.length - 1];
63
+ }
64
+ /**
65
+ * Named breakpoints plus the `min-*`/`max-*` families. Matching the families by
66
+ * shape rather than listing them keeps arbitrary variants (`max-[900px]`,
67
+ * `min-[40rem]`) in the same space as the named ones.
68
+ */
69
+ const NAMED_BREAKPOINTS = new Set(["sm", "md", "lg", "xl", "2xl"]);
70
+ function isBreakpointVariant(variant) {
71
+ if (NAMED_BREAKPOINTS.has(variant))
72
+ return true;
73
+ return /^(?:min|max)-/.test(variant);
74
+ }
75
+ /**
76
+ * The breakpoint a utility renders at, as a stable key. Non-breakpoint variants
77
+ * (`hover`, `dark`, `group-*`) do not create a bucket: they are states of the
78
+ * same screen, and their sizes are visible alongside the rest.
79
+ */
80
+ function breakpointBucket(utility) {
81
+ const variants = utility.split(":").slice(0, -1).filter(isBreakpointVariant);
82
+ return [...new Set(variants)].sort().join("+");
83
+ }
84
+ function lengthToPx(raw) {
85
+ const text = raw.trim();
86
+ let match = /^(-?\d*\.?\d+)px$/.exec(text);
87
+ if (match)
88
+ return Math.round(Number(match[1]));
89
+ match = /^(-?\d*\.?\d+)rem$/.exec(text);
90
+ if (match)
91
+ return Math.round(Number(match[1]) * REM_PX);
92
+ match = /^(-?\d*\.?\d+)em$/.exec(text);
93
+ if (match)
94
+ return Math.round(Number(match[1]) * REM_PX);
95
+ match = /^(-?\d*\.?\d+)pt$/.exec(text);
96
+ if (match)
97
+ return Math.round((Number(match[1]) * 96) / 72);
98
+ return null;
99
+ }
100
+ // `text-lg`, `md:text-lg`, `text-[13px]`, `md:text-[0.9rem]`, `min-[900px]:text-lg`
101
+ // The bracket in the variant half is load-bearing: without it an arbitrary
102
+ // breakpoint made the whole utility invisible to the scan, not just its prefix.
103
+ const UTILITY_RULE = /(?:^|["'`\s{])((?:[a-z0-9-]+(?:\[[^\]\s]+\])?:)*text-(?:\[[^\]\s]+\]|[a-z0-9]+))(?=["'`\s}]|$)/g;
104
+ // `fontSize: "14px"` / `fontSize: '1rem'` / `font-size: 14px`
105
+ const INLINE_RULE = /fontSize\s*:\s*["'`]([^"'`]+)["'`]/g;
106
+ const CSS_RULE = /font-size\s*:\s*([^;}\n]+)/g;
107
+ /**
108
+ * Resolves every font-size expression in one file to px and returns the
109
+ * distinct set. Responsive/state prefixes are stripped: only one breakpoint
110
+ * renders at a time, so `md:text-lg` is not a step of its own.
111
+ */
112
+ export function resolveTypographySteps(source, scale = typographyScale()) {
113
+ const steps = new Set();
114
+ for (const sizes of resolveTypographyBuckets(source, scale).values()) {
115
+ for (const size of sizes)
116
+ steps.add(size);
117
+ }
118
+ return steps;
119
+ }
120
+ /** The base bucket: sizes that render with no breakpoint variant attached. */
121
+ export const BASE_BUCKET = "";
122
+ /**
123
+ * Same resolution as `resolveTypographySteps`, but keyed by the breakpoint the
124
+ * size renders at.
125
+ *
126
+ * Counting every size in one set punished the ordinary responsive pair:
127
+ * `text-5xl md:text-7xl` is one element at one size on any given screen, yet it
128
+ * read as two steps. Buckets are judged independently because only one
129
+ * breakpoint is live at a time.
130
+ *
131
+ * Known limit: inline styles and CSS declarations land in the base bucket even
132
+ * inside `@media` — attributing those needs block parsing, and the files that
133
+ * drove this change express sizes as utilities.
134
+ */
135
+ export function resolveTypographyBuckets(source, scale = typographyScale()) {
136
+ const buckets = new Map();
137
+ const add = (bucket, px) => {
138
+ const existing = buckets.get(bucket);
139
+ if (existing)
140
+ existing.add(px);
141
+ else
142
+ buckets.set(bucket, new Set([px]));
143
+ };
144
+ for (const match of source.matchAll(UTILITY_RULE)) {
145
+ const bucket = breakpointBucket(match[1]);
146
+ const utility = stripVariants(match[1]);
147
+ const suffix = utility.slice("text-".length);
148
+ if (suffix.startsWith("[")) {
149
+ const px = lengthToPx(suffix.slice(1, -1));
150
+ if (px !== null)
151
+ add(bucket, px);
152
+ continue;
153
+ }
154
+ // `text-center`, `text-primary` and friends share the prefix but are not
155
+ // sizes — only names present in the scale count.
156
+ if (suffix in scale)
157
+ add(bucket, Math.round(scale[suffix]));
158
+ }
159
+ for (const match of source.matchAll(INLINE_RULE)) {
160
+ const px = lengthToPx(match[1]);
161
+ if (px !== null)
162
+ add(BASE_BUCKET, px);
163
+ }
164
+ for (const match of source.matchAll(CSS_RULE)) {
165
+ const px = lengthToPx(match[1]);
166
+ if (px !== null)
167
+ add(BASE_BUCKET, px);
168
+ }
169
+ return buckets;
170
+ }
171
+ /**
172
+ * Default step limit.
173
+ *
174
+ * Kraft (당근) uses 4. Borrowing that number unchecked would have made the rule
175
+ * contradict the system it defends, so DOG3 set it to 5 — the size of our token
176
+ * scale (sm 14 / base 16 / lg 20 / xl 28 / 2xl 40).
177
+ *
178
+ * M1 re-measured it against real screens and found 5 too tight, because the
179
+ * scale is not the whole story: pages draw headings (30/48/72) and micro labels
180
+ * (10/12) from outside it. Three ordinary screens — the colors page, get
181
+ * started, the recipe gallery — landed at 6-7 with textbook hierarchy (one
182
+ * micro label, two or three body sizes, two headings) and nothing to collapse.
183
+ *
184
+ * 7 passes those and still catches the real thing: the files that motivated
185
+ * this rule sit at 14-15 sizes, twice over the limit either way.
186
+ */
187
+ export const DEFAULT_TYPOGRAPHY_THRESHOLD = 7;
188
+ /**
189
+ * Returns the violation for one file, or null when every breakpoint stays
190
+ * within the limit. The reported line is where the step that broke the limit
191
+ * first appears — pointing at the first line of the file would say nothing
192
+ * about what to fix.
193
+ *
194
+ * Buckets are judged separately, which under-counts on purpose: at `md` the
195
+ * screen also shows whatever base sizes `md` did not override, and a text
196
+ * scanner cannot tell which those are. The alternative — adding base into every
197
+ * breakpoint — reinstates the false positive this rule exists to avoid, so the
198
+ * rule stays quiet where it cannot be sure.
199
+ */
200
+ export function typographyViolation(source, threshold = DEFAULT_TYPOGRAPHY_THRESHOLD, scale = typographyScale()) {
201
+ const seen = new Map();
202
+ let breaking = null;
203
+ source.split("\n").forEach((text, index) => {
204
+ for (const [bucket, sizes] of resolveTypographyBuckets(text, scale)) {
205
+ const known = seen.get(bucket) ?? new Set();
206
+ seen.set(bucket, known);
207
+ for (const step of sizes) {
208
+ known.add(step);
209
+ if (breaking === null && known.size > threshold)
210
+ breaking = { line: index + 1, bucket };
211
+ }
212
+ }
213
+ });
214
+ if (breaking === null)
215
+ return null;
216
+ const { line, bucket } = breaking;
217
+ const steps = [...(seen.get(bucket) ?? new Set())].sort((a, b) => a - b);
218
+ return { line, steps, threshold, bucket };
219
+ }
package/dist/verify.js CHANGED
@@ -1,5 +1,16 @@
1
1
  import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { DEFAULT_TYPOGRAPHY_THRESHOLD, typographyViolation } from "./typography.js";
4
+ /**
5
+ * File-level opt-out for the typography rule. Some files are not one screen —
6
+ * a gallery holding a dozen demos, a miniature mockup drawn at fractional
7
+ * sizes — and for those the count answers a question nobody asked.
8
+ *
9
+ * The reason is mandatory: an exemption nobody had to justify is how a gate
10
+ * stops being a gate. Files claiming the marker are also reported by name, so
11
+ * the exemptions stay countable instead of disappearing into a PASS.
12
+ */
13
+ const TYPOGRAPHY_MARKER = /askewly-typography-ok(?::([^\n]*))?/;
3
14
  const DEFAULT_EXTENSIONS = ["tsx", "ts", "jsx", "js", "css", "html"];
4
15
  const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git", ".next"]);
5
16
  // Generated token files legitimately contain raw color values.
@@ -126,12 +137,15 @@ function collectFiles(dir, extensions) {
126
137
  * hex literals and raw color functions. Token indirection means UI code
127
138
  * should only reference semantic utilities (bg-primary, text-foreground, ...).
128
139
  */
129
- export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS) {
140
+ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS, options = {}) {
141
+ const threshold = options.typographyThreshold ?? DEFAULT_TYPOGRAPHY_THRESHOLD;
130
142
  const files = collectFiles(dir, extensions);
131
143
  const violations = [];
144
+ const typographySkips = [];
132
145
  for (const file of files) {
133
146
  const source = readFileSync(file, "utf8");
134
- const scanned = maskIgnoredRegions(source, path.extname(file).slice(1)).split("\n");
147
+ const masked = maskIgnoredRegions(source, path.extname(file).slice(1));
148
+ const scanned = masked.split("\n");
135
149
  const original = source.split("\n");
136
150
  scanned.forEach((text, index) => {
137
151
  for (const { name, pattern } of RULES) {
@@ -142,6 +156,37 @@ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS) {
142
156
  violations.push({ file, line: index + 1, rule: name, excerpt: original[index].trim().slice(0, 120) });
143
157
  }
144
158
  });
159
+ // The marker is read from the original source: it lives in a comment, and
160
+ // the masked copy has blanked those out.
161
+ const markerLine = original.findIndex((text) => TYPOGRAPHY_MARKER.test(text));
162
+ if (markerLine !== -1) {
163
+ const reason = (TYPOGRAPHY_MARKER.exec(original[markerLine])?.[1] ?? "").trim();
164
+ if (reason.length === 0) {
165
+ violations.push({
166
+ file,
167
+ line: markerLine + 1,
168
+ rule: "typography-marker-no-reason",
169
+ excerpt: "askewly-typography-ok needs a reason: askewly-typography-ok: <why this file is not one screen>",
170
+ });
171
+ }
172
+ else {
173
+ typographySkips.push({ file, line: markerLine + 1, reason });
174
+ }
175
+ continue;
176
+ }
177
+ // Typography counts per file, not per line: the question is how many sizes
178
+ // one screen uses. Comments and SVG are masked out here too — a size
179
+ // mentioned in a comment is not rendered.
180
+ const typography = typographyViolation(masked, threshold);
181
+ if (typography) {
182
+ const where = typography.bucket === "" ? "" : ` at ${typography.bucket}`;
183
+ violations.push({
184
+ file,
185
+ line: typography.line,
186
+ rule: "typography-scale-exceeded",
187
+ excerpt: `font-size steps${where}: ${typography.steps.join(", ")} (${typography.steps.length} > limit ${typography.threshold})`,
188
+ });
189
+ }
145
190
  }
146
- return { files: files.length, violations };
191
+ return { files: files.length, violations, typographySkips };
147
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askewly/design",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Query, inject, and verify the Askewly Design system (tokens, UI vocabulary, recipes) from your terminal or coding agent.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.askewly.com",