@askewly/design 0.2.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
@@ -169,15 +169,27 @@ program
169
169
  console.error(`--typography-threshold must be a positive integer (got "${options.typographyThreshold}")`);
170
170
  process.exit(2);
171
171
  }
172
- const { files, violations } = verifyDir(target, options.ext.split(",").map((e) => e.trim()), { typographyThreshold });
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
+ };
173
183
  if (violations.length === 0) {
174
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));
175
186
  return;
176
187
  }
177
188
  console.error(`verify FAIL — ${violations.length} violation(s) in ${files} file(s):`);
178
189
  for (const violation of violations) {
179
190
  console.error(` ${path.relative(target, violation.file)}:${violation.line} [${violation.rule}] ${violation.excerpt}`);
180
191
  }
192
+ printSkips((line) => console.error(line));
181
193
  console.error("\nFix: replace color literals with semantic token utilities (bg-primary, text-foreground, ...);");
182
194
  console.error(" collapse extra type sizes onto the token scale, or raise --typography-threshold.");
183
195
  process.exit(1);
@@ -61,6 +61,26 @@ function stripVariants(utility) {
61
61
  const parts = utility.split(":");
62
62
  return parts[parts.length - 1];
63
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
+ }
64
84
  function lengthToPx(raw) {
65
85
  const text = raw.trim();
66
86
  let match = /^(-?\d*\.?\d+)px$/.exec(text);
@@ -77,8 +97,10 @@ function lengthToPx(raw) {
77
97
  return Math.round((Number(match[1]) * 96) / 72);
78
98
  return null;
79
99
  }
80
- // `text-lg`, `md:text-lg`, `text-[13px]`, `md:text-[0.9rem]`
81
- const UTILITY_RULE = /(?:^|["'`\s{])((?:[a-z0-9-]+:)*text-(?:\[[^\]\s]+\]|[a-z0-9]+))(?=["'`\s}]|$)/g;
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;
82
104
  // `fontSize: "14px"` / `fontSize: '1rem'` / `font-size: 14px`
83
105
  const INLINE_RULE = /fontSize\s*:\s*["'`]([^"'`]+)["'`]/g;
84
106
  const CSS_RULE = /font-size\s*:\s*([^;}\n]+)/g;
@@ -89,58 +111,109 @@ const CSS_RULE = /font-size\s*:\s*([^;}\n]+)/g;
89
111
  */
90
112
  export function resolveTypographySteps(source, scale = typographyScale()) {
91
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
+ };
92
144
  for (const match of source.matchAll(UTILITY_RULE)) {
145
+ const bucket = breakpointBucket(match[1]);
93
146
  const utility = stripVariants(match[1]);
94
147
  const suffix = utility.slice("text-".length);
95
148
  if (suffix.startsWith("[")) {
96
149
  const px = lengthToPx(suffix.slice(1, -1));
97
150
  if (px !== null)
98
- steps.add(px);
151
+ add(bucket, px);
99
152
  continue;
100
153
  }
101
154
  // `text-center`, `text-primary` and friends share the prefix but are not
102
155
  // sizes — only names present in the scale count.
103
156
  if (suffix in scale)
104
- steps.add(Math.round(scale[suffix]));
157
+ add(bucket, Math.round(scale[suffix]));
105
158
  }
106
159
  for (const match of source.matchAll(INLINE_RULE)) {
107
160
  const px = lengthToPx(match[1]);
108
161
  if (px !== null)
109
- steps.add(px);
162
+ add(BASE_BUCKET, px);
110
163
  }
111
164
  for (const match of source.matchAll(CSS_RULE)) {
112
165
  const px = lengthToPx(match[1]);
113
166
  if (px !== null)
114
- steps.add(px);
167
+ add(BASE_BUCKET, px);
115
168
  }
116
- return steps;
169
+ return buckets;
117
170
  }
118
171
  /**
119
172
  * Default step limit.
120
173
  *
121
- * Kraft (당근) uses 4. We use 5 because our own token scale defines five sizes
122
- * (sm 14 / base 16 / lg 20 / xl 28 / 2xl 40) — at 4, a screen that uses the
123
- * design system's full scale and nothing else would be a violation. Borrowing
124
- * someone else's number without checking it against our tokens would have made
125
- * the rule contradict the system it is supposed to defend.
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.
126
186
  */
127
- export const DEFAULT_TYPOGRAPHY_THRESHOLD = 5;
187
+ export const DEFAULT_TYPOGRAPHY_THRESHOLD = 7;
128
188
  /**
129
- * Returns the violation for one file, or null when it stays within the limit.
130
- * The reported line is where the step that broke the limit first appears —
131
- * pointing at the first line of the file would say nothing about what to fix.
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.
132
199
  */
133
200
  export function typographyViolation(source, threshold = DEFAULT_TYPOGRAPHY_THRESHOLD, scale = typographyScale()) {
134
- const seen = new Set();
135
- let breakingLine = null;
201
+ const seen = new Map();
202
+ let breaking = null;
136
203
  source.split("\n").forEach((text, index) => {
137
- for (const step of resolveTypographySteps(text, scale)) {
138
- seen.add(step);
139
- if (breakingLine === null && seen.size > threshold)
140
- breakingLine = index + 1;
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
+ }
141
212
  }
142
213
  });
143
- if (breakingLine === null)
214
+ if (breaking === null)
144
215
  return null;
145
- return { line: breakingLine, steps: [...seen].sort((a, b) => a - b), threshold };
216
+ const { line, bucket } = breaking;
217
+ const steps = [...(seen.get(bucket) ?? new Set())].sort((a, b) => a - b);
218
+ return { line, steps, threshold, bucket };
146
219
  }
package/dist/verify.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import { readFileSync, readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
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]*))?/;
4
14
  const DEFAULT_EXTENSIONS = ["tsx", "ts", "jsx", "js", "css", "html"];
5
15
  const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git", ".next"]);
6
16
  // Generated token files legitimately contain raw color values.
@@ -131,6 +141,7 @@ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS, options = {}) {
131
141
  const threshold = options.typographyThreshold ?? DEFAULT_TYPOGRAPHY_THRESHOLD;
132
142
  const files = collectFiles(dir, extensions);
133
143
  const violations = [];
144
+ const typographySkips = [];
134
145
  for (const file of files) {
135
146
  const source = readFileSync(file, "utf8");
136
147
  const masked = maskIgnoredRegions(source, path.extname(file).slice(1));
@@ -145,18 +156,37 @@ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS, options = {}) {
145
156
  violations.push({ file, line: index + 1, rule: name, excerpt: original[index].trim().slice(0, 120) });
146
157
  }
147
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
+ }
148
177
  // Typography counts per file, not per line: the question is how many sizes
149
178
  // one screen uses. Comments and SVG are masked out here too — a size
150
179
  // mentioned in a comment is not rendered.
151
180
  const typography = typographyViolation(masked, threshold);
152
181
  if (typography) {
182
+ const where = typography.bucket === "" ? "" : ` at ${typography.bucket}`;
153
183
  violations.push({
154
184
  file,
155
185
  line: typography.line,
156
186
  rule: "typography-scale-exceeded",
157
- excerpt: `font-size steps: ${typography.steps.join(", ")} (${typography.steps.length} > limit ${typography.threshold})`,
187
+ excerpt: `font-size steps${where}: ${typography.steps.join(", ")} (${typography.steps.length} > limit ${typography.threshold})`,
158
188
  });
159
189
  }
160
190
  }
161
- return { files: files.length, violations };
191
+ return { files: files.length, violations, typographySkips };
162
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askewly/design",
3
- "version": "0.2.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",