@askewly/design 0.1.0 → 0.2.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 +12 -4
- package/dist/typography.js +146 -0
- package/dist/verify.js +17 -2
- package/package.json +1 -1
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,26 @@ 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
|
-
.
|
|
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
|
|
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 } = verifyDir(target, options.ext.split(",").map((e) => e.trim()), { typographyThreshold });
|
|
166
173
|
if (violations.length === 0) {
|
|
167
|
-
console.log(`verify PASS — ${files} file(s) scanned, no color literals
|
|
174
|
+
console.log(`verify PASS — ${files} file(s) scanned, no color literals and no file over ${typographyThreshold} type sizes`);
|
|
168
175
|
return;
|
|
169
176
|
}
|
|
170
177
|
console.error(`verify FAIL — ${violations.length} violation(s) in ${files} file(s):`);
|
|
171
178
|
for (const violation of violations) {
|
|
172
179
|
console.error(` ${path.relative(target, violation.file)}:${violation.line} [${violation.rule}] ${violation.excerpt}`);
|
|
173
180
|
}
|
|
174
|
-
console.error("\nFix: replace literals with semantic token utilities (bg-primary, text-foreground,
|
|
181
|
+
console.error("\nFix: replace color literals with semantic token utilities (bg-primary, text-foreground, ...);");
|
|
182
|
+
console.error(" collapse extra type sizes onto the token scale, or raise --typography-threshold.");
|
|
175
183
|
process.exit(1);
|
|
176
184
|
});
|
|
177
185
|
const recipes = program.command("recipes").description("component recipes (agent-ready implementation contracts)");
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
function lengthToPx(raw) {
|
|
65
|
+
const text = raw.trim();
|
|
66
|
+
let match = /^(-?\d*\.?\d+)px$/.exec(text);
|
|
67
|
+
if (match)
|
|
68
|
+
return Math.round(Number(match[1]));
|
|
69
|
+
match = /^(-?\d*\.?\d+)rem$/.exec(text);
|
|
70
|
+
if (match)
|
|
71
|
+
return Math.round(Number(match[1]) * REM_PX);
|
|
72
|
+
match = /^(-?\d*\.?\d+)em$/.exec(text);
|
|
73
|
+
if (match)
|
|
74
|
+
return Math.round(Number(match[1]) * REM_PX);
|
|
75
|
+
match = /^(-?\d*\.?\d+)pt$/.exec(text);
|
|
76
|
+
if (match)
|
|
77
|
+
return Math.round((Number(match[1]) * 96) / 72);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
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;
|
|
82
|
+
// `fontSize: "14px"` / `fontSize: '1rem'` / `font-size: 14px`
|
|
83
|
+
const INLINE_RULE = /fontSize\s*:\s*["'`]([^"'`]+)["'`]/g;
|
|
84
|
+
const CSS_RULE = /font-size\s*:\s*([^;}\n]+)/g;
|
|
85
|
+
/**
|
|
86
|
+
* Resolves every font-size expression in one file to px and returns the
|
|
87
|
+
* distinct set. Responsive/state prefixes are stripped: only one breakpoint
|
|
88
|
+
* renders at a time, so `md:text-lg` is not a step of its own.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveTypographySteps(source, scale = typographyScale()) {
|
|
91
|
+
const steps = new Set();
|
|
92
|
+
for (const match of source.matchAll(UTILITY_RULE)) {
|
|
93
|
+
const utility = stripVariants(match[1]);
|
|
94
|
+
const suffix = utility.slice("text-".length);
|
|
95
|
+
if (suffix.startsWith("[")) {
|
|
96
|
+
const px = lengthToPx(suffix.slice(1, -1));
|
|
97
|
+
if (px !== null)
|
|
98
|
+
steps.add(px);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// `text-center`, `text-primary` and friends share the prefix but are not
|
|
102
|
+
// sizes — only names present in the scale count.
|
|
103
|
+
if (suffix in scale)
|
|
104
|
+
steps.add(Math.round(scale[suffix]));
|
|
105
|
+
}
|
|
106
|
+
for (const match of source.matchAll(INLINE_RULE)) {
|
|
107
|
+
const px = lengthToPx(match[1]);
|
|
108
|
+
if (px !== null)
|
|
109
|
+
steps.add(px);
|
|
110
|
+
}
|
|
111
|
+
for (const match of source.matchAll(CSS_RULE)) {
|
|
112
|
+
const px = lengthToPx(match[1]);
|
|
113
|
+
if (px !== null)
|
|
114
|
+
steps.add(px);
|
|
115
|
+
}
|
|
116
|
+
return steps;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Default step limit.
|
|
120
|
+
*
|
|
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.
|
|
126
|
+
*/
|
|
127
|
+
export const DEFAULT_TYPOGRAPHY_THRESHOLD = 5;
|
|
128
|
+
/**
|
|
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.
|
|
132
|
+
*/
|
|
133
|
+
export function typographyViolation(source, threshold = DEFAULT_TYPOGRAPHY_THRESHOLD, scale = typographyScale()) {
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
let breakingLine = null;
|
|
136
|
+
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;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
if (breakingLine === null)
|
|
144
|
+
return null;
|
|
145
|
+
return { line: breakingLine, steps: [...seen].sort((a, b) => a - b), threshold };
|
|
146
|
+
}
|
package/dist/verify.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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";
|
|
3
4
|
const DEFAULT_EXTENSIONS = ["tsx", "ts", "jsx", "js", "css", "html"];
|
|
4
5
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git", ".next"]);
|
|
5
6
|
// Generated token files legitimately contain raw color values.
|
|
@@ -126,12 +127,14 @@ function collectFiles(dir, extensions) {
|
|
|
126
127
|
* hex literals and raw color functions. Token indirection means UI code
|
|
127
128
|
* should only reference semantic utilities (bg-primary, text-foreground, ...).
|
|
128
129
|
*/
|
|
129
|
-
export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS) {
|
|
130
|
+
export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS, options = {}) {
|
|
131
|
+
const threshold = options.typographyThreshold ?? DEFAULT_TYPOGRAPHY_THRESHOLD;
|
|
130
132
|
const files = collectFiles(dir, extensions);
|
|
131
133
|
const violations = [];
|
|
132
134
|
for (const file of files) {
|
|
133
135
|
const source = readFileSync(file, "utf8");
|
|
134
|
-
const
|
|
136
|
+
const masked = maskIgnoredRegions(source, path.extname(file).slice(1));
|
|
137
|
+
const scanned = masked.split("\n");
|
|
135
138
|
const original = source.split("\n");
|
|
136
139
|
scanned.forEach((text, index) => {
|
|
137
140
|
for (const { name, pattern } of RULES) {
|
|
@@ -142,6 +145,18 @@ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS) {
|
|
|
142
145
|
violations.push({ file, line: index + 1, rule: name, excerpt: original[index].trim().slice(0, 120) });
|
|
143
146
|
}
|
|
144
147
|
});
|
|
148
|
+
// Typography counts per file, not per line: the question is how many sizes
|
|
149
|
+
// one screen uses. Comments and SVG are masked out here too — a size
|
|
150
|
+
// mentioned in a comment is not rendered.
|
|
151
|
+
const typography = typographyViolation(masked, threshold);
|
|
152
|
+
if (typography) {
|
|
153
|
+
violations.push({
|
|
154
|
+
file,
|
|
155
|
+
line: typography.line,
|
|
156
|
+
rule: "typography-scale-exceeded",
|
|
157
|
+
excerpt: `font-size steps: ${typography.steps.join(", ")} (${typography.steps.length} > limit ${typography.threshold})`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
145
160
|
}
|
|
146
161
|
return { files: files.length, violations };
|
|
147
162
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askewly/design",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|