@askewly/design 0.1.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.
@@ -0,0 +1,67 @@
1
+ /* GENERATED from tokens/askewly.tokens.json — do not edit by hand. Regenerate: node scripts/generate-tokens.mjs */
2
+ :root {
3
+ --radius: 0.5rem;
4
+ --background: oklch(0.985 0 0);
5
+ --foreground: oklch(0.16 0.015 270);
6
+ --card: oklch(1 0 0);
7
+ --card-foreground: oklch(0.16 0.015 270);
8
+ --popover: oklch(1 0 0);
9
+ --popover-foreground: oklch(0.16 0.015 270);
10
+ --primary: #6F2DBD;
11
+ --primary-foreground: oklch(0.985 0 0);
12
+ --secondary: oklch(0.94 0.02 270);
13
+ --secondary-foreground: oklch(0.25 0.03 270);
14
+ --muted: oklch(0.95 0.015 270);
15
+ --muted-foreground: oklch(0.46 0.03 270);
16
+ --accent: #B9FAF8;
17
+ --accent-foreground: oklch(0.16 0.015 270);
18
+ --destructive: oklch(0.58 0.22 27);
19
+ --border: oklch(0.88 0.015 270);
20
+ --input: oklch(0.88 0.015 270);
21
+ --ring: #6F2DBD;
22
+ --askewly-violet: #6F2DBD;
23
+ --askewly-orchid: #A663CC;
24
+ --askewly-lavender: #B298DC;
25
+ --askewly-sky: #B8D0EB;
26
+ --askewly-mint: #B9FAF8;
27
+ --space-1: 4px;
28
+ --space-2: 8px;
29
+ --space-4: 16px;
30
+ --space-8: 32px;
31
+ --space-12: 48px;
32
+ --space-16: 64px;
33
+ --font-size-sm: 14px;
34
+ --font-size-base: 16px;
35
+ --font-size-lg: 20px;
36
+ --font-size-xl: 28px;
37
+ --font-size-2xl: 40px;
38
+ --font-weight-regular: 400;
39
+ --font-weight-medium: 500;
40
+ --font-sans: Geist, "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
41
+ --font-mono: "Geist Mono", ui-monospace, monospace;
42
+ --radius-sm: 4px;
43
+ --radius-md: 6px;
44
+ --radius-lg: 8px;
45
+ --radius-xl: 12px;
46
+ }
47
+
48
+ .dark {
49
+ --background: oklch(0.16 0.015 270);
50
+ --foreground: oklch(0.985 0 0);
51
+ --card: oklch(0.25 0.03 270);
52
+ --card-foreground: oklch(0.985 0 0);
53
+ --popover: oklch(0.25 0.03 270);
54
+ --popover-foreground: oklch(0.985 0 0);
55
+ --primary: oklch(0.985 0 0);
56
+ --primary-foreground: oklch(0.16 0.015 270);
57
+ --secondary: oklch(0.35 0.03 270);
58
+ --secondary-foreground: oklch(0.95 0.015 270);
59
+ --muted: oklch(0.35 0.03 270);
60
+ --muted-foreground: oklch(0.72 0.02 270);
61
+ --accent: oklch(0.35 0.03 270);
62
+ --accent-foreground: oklch(0.985 0 0);
63
+ --destructive: oklch(0.58 0.22 27);
64
+ --border: oklch(0.35 0.03 270);
65
+ --input: oklch(0.35 0.03 270);
66
+ --ring: #B298DC;
67
+ }
@@ -0,0 +1 @@
1
+ {"$description":"Askewly Design token SSOT (DTCG-compatible, stable 2025.10 subset). Tier rule: component -> semantic -> primitive, one direction only. Dark mode lives in $extensions['com.askewly.modes'].dark on semantic tokens. Generated artifacts: DESIGN.md frontmatter, examples/ui-vocabulary-site/src/tokens.css. Do not edit generated files by hand.","color":{"primitive":{"$description":"Raw values only. gray is the site's violet-tinted neutral (hue 270) formalized as a 12-step ramp; usage convention: 1-2 app background, 3-5 interactive component states, 6-8 borders, 9-10 solid/low-contrast foreground, 11-12 text.","white":{"$type":"color","$value":{"colorSpace":"oklch","components":[1,0,0]}},"gray":{"1":{"$value":{"colorSpace":"oklch","components":[0.985,0,0]}},"2":{"$value":{"colorSpace":"oklch","components":[0.97,0.005,270]}},"3":{"$value":{"colorSpace":"oklch","components":[0.95,0.015,270]}},"4":{"$value":{"colorSpace":"oklch","components":[0.94,0.02,270]}},"5":{"$value":{"colorSpace":"oklch","components":[0.91,0.015,270]}},"6":{"$value":{"colorSpace":"oklch","components":[0.88,0.015,270]}},"7":{"$value":{"colorSpace":"oklch","components":[0.72,0.02,270]}},"8":{"$value":{"colorSpace":"oklch","components":[0.6,0.025,270]}},"9":{"$value":{"colorSpace":"oklch","components":[0.46,0.03,270]}},"10":{"$value":{"colorSpace":"oklch","components":[0.35,0.03,270]}},"11":{"$value":{"colorSpace":"oklch","components":[0.25,0.03,270]}},"12":{"$value":{"colorSpace":"oklch","components":[0.16,0.015,270]}},"$type":"color"},"askewly":{"$type":"color","$description":"Brand palette extracted from the landing planning reference image (2026-07-05). Canonical purple: violet.","violet":{"$value":{"colorSpace":"srgb","components":[0.435294,0.176471,0.741176],"hex":"#6F2DBD"}},"orchid":{"$value":{"colorSpace":"srgb","components":[0.65098,0.388235,0.8],"hex":"#A663CC"}},"lavender":{"$value":{"colorSpace":"srgb","components":[0.698039,0.596078,0.862745],"hex":"#B298DC"}},"sky":{"$value":{"colorSpace":"srgb","components":[0.721569,0.815686,0.921569],"hex":"#B8D0EB"}},"mint":{"$value":{"colorSpace":"srgb","components":[0.72549,0.980392,0.972549],"hex":"#B9FAF8"}}},"red":{"9":{"$value":{"colorSpace":"oklch","components":[0.58,0.22,27]}},"$type":"color"}},"semantic":{"$description":"Role tokens. Components and site CSS consume this layer only. Dark values in $extensions['com.askewly.modes'].dark.","surface":{"$type":"color","base":{"$value":"{color.primitive.gray.1}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.12}"}}},"raised":{"$description":"Cards, panels sitting on base.","$value":"{color.primitive.white}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.11}"}}},"overlay":{"$description":"Popovers, dropdowns, floating layers.","$value":"{color.primitive.white}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.11}"}}},"muted":{"$value":"{color.primitive.gray.3}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.10}"}}},"secondary":{"$description":"Secondary button / subtle filled surface.","$value":"{color.primitive.gray.4}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.10}"}}},"tint":{"$description":"Informational wash, agent-ready signal surface.","$value":"{color.primitive.askewly.mint}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.10}"}}}},"text":{"$type":"color","default":{"$value":"{color.primitive.gray.12}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.1}"}}},"muted":{"$value":"{color.primitive.gray.9}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.7}"}}},"secondary":{"$description":"Foreground on surface.secondary.","$value":"{color.primitive.gray.11}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.3}"}}},"on-accent":{"$description":"Text/icon on action.primary solid fills.","$value":"{color.primitive.gray.1}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.12}"}}}},"action":{"$type":"color","primary":{"$value":"{color.primitive.askewly.violet}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.1}"}}},"secondary":{"$value":"{color.primitive.askewly.orchid}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.askewly.mint}"}}},"destructive":{"$value":"{color.primitive.red.9}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.red.9}"}}}},"accent":{"$type":"color","base":{"$description":"Informational accent wash (mint family).","$value":"{color.primitive.askewly.mint}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.10}"}}},"foreground":{"$value":"{color.primitive.gray.12}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.1}"}}}},"border":{"$type":"color","default":{"$value":"{color.primitive.gray.6}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.10}"}}},"input":{"$value":"{color.primitive.gray.6}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.gray.10}"}}},"focus":{"$description":"Focus ring.","$value":"{color.primitive.askewly.violet}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.askewly.lavender}"}}},"accent":{"$description":"Selected/active edges only.","$value":"{color.primitive.askewly.lavender}","$extensions":{"com.askewly.modes":{"dark":"{color.primitive.askewly.orchid}"}}}}},"component":{"$description":"Minimal by policy (survey criterion A-5): only overrides that semantic roles cannot express. Exemplar tier for the lint chain.","button":{"$type":"color","bg":{"$value":"{color.semantic.action.primary}"},"text":{"$value":"{color.semantic.text.on-accent}"}}}},"dimension":{"space":{"1":{"$value":{"value":4,"unit":"px"}},"2":{"$value":{"value":8,"unit":"px"}},"4":{"$value":{"value":16,"unit":"px"}},"8":{"$value":{"value":32,"unit":"px"}},"12":{"$value":{"value":48,"unit":"px"}},"16":{"$value":{"value":64,"unit":"px"}},"$type":"dimension","$description":"4/8 grid. Reference variables only - not bound into the Tailwind theme namespace."},"radius":{"$type":"dimension","$description":"Canonical = shipped site values (PSS1 visually approved), not the older 2/4px draft.","sm":{"$value":{"value":4,"unit":"px"}},"md":{"$value":{"value":6,"unit":"px"}},"lg":{"$value":{"value":8,"unit":"px"}},"xl":{"$value":{"value":12,"unit":"px"}},"2xl":{"$description":"SD gen2 (2026-07-12): large-surface corner radius for sheets/overlays that sit above the base scale — device-frame inner viewport, bottom-sheet-detents. Recurs identically in >=2 mobile components as Tailwind's built-in rounded-2xl (16px), currently outside the tokenized sm-xl range.","$value":{"value":16,"unit":"px"}}},"size":{"$type":"dimension","$description":"Fixed-purpose sizing constants that are not part of the 4/8 spacing grid. SD gen2 (2026-07-12): touch-target minimum recurs across mobile surfaces (large-title-collapsing-header back button, mobile-signup-field-stack inputs/submit, plus pre-existing search-autocomplete/showcase-card/term-visual usage) as Tailwind size-11/h-11/min-h-11 (44px) — the WCAG 2.5.5 / iOS HIG minimum hit target.","touch-target-min":{"$value":{"value":44,"unit":"px"}}},"z-index":{"$type":"number","$description":"SD gen2 (2026-07-12): layering scale distilled from recurring hardcoded z-index values across mobile overlays and dense-data sticky headers. 'sticky' = content pinned within its own scroll container (collapsing header bar, bottom-sheet/action-sheet root within DeviceFrame, table sticky header row). 'elevated' = a second layer above a sticky layer in the same local stacking context (permission-matrix-editor-grid's sticky corner cell, which must sit above both its own header row and column). 'overlay' = page-level modal/scrim/popover stacking context (ui/dialog, ui/sheet, ui/popover, ui/tooltip, full-screen confirm overlays).","sticky":{"$value":10},"elevated":{"$value":20},"overlay":{"$value":50}}},"typography":{"font":{"$type":"fontFamily","sans":{"$value":["Geist","Noto Sans KR","ui-sans-serif","system-ui","sans-serif"]},"mono":{"$value":["Geist Mono","ui-monospace","monospace"]}},"scale":{"$type":"dimension","sm":{"$value":{"value":14,"unit":"px"}},"base":{"$value":{"value":16,"unit":"px"}},"lg":{"$value":{"value":20,"unit":"px"}},"xl":{"$value":{"value":28,"unit":"px"}},"2xl":{"$value":{"value":40,"unit":"px"}}},"weight":{"$type":"fontWeight","regular":{"$value":400},"medium":{"$value":500}}},"motion":{"$description":"SD gen2 (2026-07-12): motion timing constants. Kept minimal by the same policy as color.component — only the value that actually recurs, not a full scale invented ahead of need.","duration":{"$type":"duration","overlay":{"$description":"Open/close transition for modal-style surfaces. Recurs identically (200ms) in bottom-sheet-detents.tsx and ui/dialog.tsx.","$value":{"value":200,"unit":"ms"}}}}}
package/dist/index.js ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { Command } from "commander";
5
+ import { addRecipe, initProject, InjectError } from "./inject.js";
6
+ import { verifyDir } from "./verify.js";
7
+ import { loadRecipes, loadTerms, loadTokens, loadTokensCss, searchTerms, tokensForTier, } from "./load.js";
8
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
9
+ const program = new Command();
10
+ program
11
+ .name("askewly-design")
12
+ .description("Query the Askewly Design system: UI vocabulary terms, design tokens, and component recipes.")
13
+ .version(pkg.version);
14
+ function fail(message) {
15
+ console.error(message);
16
+ process.exit(1);
17
+ }
18
+ function asList(value) {
19
+ if (!value)
20
+ return [];
21
+ return Array.isArray(value) ? value.map(String) : [String(value)];
22
+ }
23
+ function printTermLine(term) {
24
+ const en = term.en?.name ?? term.id;
25
+ const ko = term.ko?.name ? ` (${term.ko.name})` : "";
26
+ console.log(`${term.id} — ${en}${ko}`);
27
+ if (term.one_liner)
28
+ console.log(` ${term.one_liner}`);
29
+ }
30
+ const terms = program.command("terms").description("UI vocabulary (536+ terms)");
31
+ terms
32
+ .command("search")
33
+ .argument("<query>", "text to match against id, names, aliases, prompt phrases")
34
+ .option("--json", "machine-readable output")
35
+ .option("--limit <n>", "max results", "10")
36
+ .description("search terms")
37
+ .action((query, options) => {
38
+ const results = searchTerms(loadTerms(), query).slice(0, Number(options.limit) || 10);
39
+ if (results.length === 0)
40
+ fail(`no terms match "${query}"`);
41
+ if (options.json) {
42
+ console.log(JSON.stringify(results.map((t) => ({ id: t.id, en: t.en?.name, ko: t.ko?.name, one_liner: t.one_liner, category: t.category, group: t.group })), null, 2));
43
+ return;
44
+ }
45
+ for (const term of results)
46
+ printTermLine(term);
47
+ });
48
+ terms
49
+ .command("show")
50
+ .argument("<id>", "term id, e.g. hero")
51
+ .option("--json", "machine-readable output")
52
+ .description("show one term in full")
53
+ .action((id, options) => {
54
+ const term = loadTerms().find((t) => t.id === id);
55
+ if (!term)
56
+ fail(`unknown term "${id}" — try: askewly-design terms search ${id}`);
57
+ if (options.json) {
58
+ console.log(JSON.stringify(term, null, 2));
59
+ return;
60
+ }
61
+ printTermLine(term);
62
+ console.log(` category: ${term.category} / ${term.group}`);
63
+ if (term.description)
64
+ console.log(`\n${term.description}`);
65
+ const sections = [
66
+ ["When to use", asList(term.when_to_use)],
67
+ ["Anti-use", asList(term.anti_use)],
68
+ ["Prompt phrases", term.prompt_phrases ?? []],
69
+ ];
70
+ for (const [title, items] of sections) {
71
+ if (items.length === 0)
72
+ continue;
73
+ console.log(`\n${title}:`);
74
+ for (const item of items)
75
+ console.log(` - ${item}`);
76
+ }
77
+ });
78
+ program
79
+ .command("tokens")
80
+ .option("--tier <tier>", "color tier: primitive | semantic | component")
81
+ .option("--format <format>", "json | css", "json")
82
+ .description("design tokens (DTCG source or generated CSS)")
83
+ .action((options) => {
84
+ if (options.format === "css") {
85
+ if (options.tier)
86
+ fail("--tier only applies to --format json (the CSS output is generated as a whole)");
87
+ process.stdout.write(loadTokensCss());
88
+ return;
89
+ }
90
+ if (options.format !== "json")
91
+ fail(`unknown format "${options.format}" — use json or css`);
92
+ const tokens = loadTokens();
93
+ if (options.tier) {
94
+ const subtree = tokensForTier(tokens, options.tier);
95
+ if (subtree === null)
96
+ fail(`unknown tier "${options.tier}" — use primitive, semantic, or component`);
97
+ console.log(JSON.stringify(subtree, null, 2));
98
+ return;
99
+ }
100
+ console.log(JSON.stringify(tokens, null, 2));
101
+ });
102
+ program
103
+ .command("init")
104
+ .argument("[dir]", "target directory", ".")
105
+ .option("--force", "overwrite existing files")
106
+ .description("write DESIGN.md and tokens.css into a project")
107
+ .action((dir, options) => {
108
+ try {
109
+ const written = initProject(path.resolve(dir), Boolean(options.force));
110
+ for (const file of written)
111
+ console.log(`wrote ${file}`);
112
+ console.log('\nNext: add `@import "./askewly.css";` after `@import "tailwindcss";` in your global stylesheet, then `askewly-design add <recipe>`.');
113
+ }
114
+ catch (error) {
115
+ if (error instanceof InjectError)
116
+ fail(error.message);
117
+ throw error;
118
+ }
119
+ });
120
+ program
121
+ .command("add")
122
+ .argument("<recipe>", "recipe id, e.g. button")
123
+ .option("--out <dir>", "output directory for the component file", ".")
124
+ .option("--tokens <file>", "path to the project's tokens.css", "tokens.css")
125
+ .option("--force", "overwrite an existing file")
126
+ .description("inject a recipe's Code excerpt into the project")
127
+ .action((id, options) => {
128
+ const recipe = loadRecipes().find((r) => r.id === id);
129
+ if (!recipe)
130
+ fail(`unknown recipe "${id}" — try: askewly-design recipes list`);
131
+ try {
132
+ const result = addRecipe(recipe, path.resolve(options.out), Boolean(options.force), path.resolve(options.tokens));
133
+ console.log(`wrote ${result.file}`);
134
+ if (result.missingTokens.length > 0) {
135
+ console.log(`\n⚠ tokens not found in ${options.tokens} — run \`askewly-design init\` first. Required:`);
136
+ for (const token of result.missingTokens)
137
+ console.log(` - ${token}`);
138
+ }
139
+ if (recipe.code_asset)
140
+ console.log(`\nFull implementation reference: ${recipe.code_asset} (ui-dictionary repo)`);
141
+ if (result.checks.length > 0) {
142
+ console.log("\nChecks before you ship:");
143
+ for (const check of result.checks)
144
+ console.log(` - ${check}`);
145
+ }
146
+ if (result.antiPatterns.length > 0) {
147
+ console.log("\nAnti-patterns to avoid:");
148
+ for (const anti of result.antiPatterns)
149
+ console.log(` - ${anti}`);
150
+ }
151
+ }
152
+ catch (error) {
153
+ if (error instanceof InjectError)
154
+ fail(error.message);
155
+ throw error;
156
+ }
157
+ });
158
+ program
159
+ .command("verify")
160
+ .argument("[dir]", "directory to scan", ".")
161
+ .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
+ .action((dir, options) => {
164
+ const target = path.resolve(dir);
165
+ const { files, violations } = verifyDir(target, options.ext.split(",").map((e) => e.trim()));
166
+ 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)`);
168
+ return;
169
+ }
170
+ console.error(`verify FAIL — ${violations.length} violation(s) in ${files} file(s):`);
171
+ for (const violation of violations) {
172
+ console.error(` ${path.relative(target, violation.file)}:${violation.line} [${violation.rule}] ${violation.excerpt}`);
173
+ }
174
+ console.error("\nFix: replace literals with semantic token utilities (bg-primary, text-foreground, border-border, ...).");
175
+ process.exit(1);
176
+ });
177
+ const recipes = program.command("recipes").description("component recipes (agent-ready implementation contracts)");
178
+ recipes
179
+ .command("list")
180
+ .option("--json", "machine-readable output")
181
+ .description("list all recipes")
182
+ .action((options) => {
183
+ const all = loadRecipes();
184
+ if (options.json) {
185
+ console.log(JSON.stringify(all.map((r) => ({ id: r.id, name: r.name, pattern_group: r.pattern_group, source_path: r.source_path })), null, 2));
186
+ return;
187
+ }
188
+ for (const recipe of all) {
189
+ console.log(`${recipe.id} (${recipe.pattern_group ?? "-"}) — ${recipe.name ?? recipe.id}`);
190
+ }
191
+ });
192
+ recipes
193
+ .command("show")
194
+ .argument("<id>", "recipe id, e.g. button")
195
+ .option("--json", "machine-readable output (frontmatter + body)")
196
+ .description("show one recipe in full")
197
+ .action((id, options) => {
198
+ const recipe = loadRecipes().find((r) => r.id === id);
199
+ if (!recipe)
200
+ fail(`unknown recipe "${id}" — try: askewly-design recipes list`);
201
+ if (options.json) {
202
+ console.log(JSON.stringify(recipe, null, 2));
203
+ return;
204
+ }
205
+ console.log(`# ${recipe.name ?? recipe.id} (${recipe.pattern_group ?? "-"})`);
206
+ if (recipe.tokens_used?.length)
207
+ console.log(`tokens_used: ${recipe.tokens_used.join(", ")}`);
208
+ if (recipe.code_asset)
209
+ console.log(`code_asset: ${recipe.code_asset}`);
210
+ console.log("");
211
+ console.log(recipe.body);
212
+ });
213
+ program.parse();
package/dist/inject.js ADDED
@@ -0,0 +1,90 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ export class InjectError extends Error {
4
+ }
5
+ function readBundled(relative) {
6
+ return readFileSync(new URL(`../data/${relative}`, import.meta.url), "utf8");
7
+ }
8
+ /**
9
+ * Writes DESIGN.md and tokens.css into targetDir.
10
+ * Refuses to overwrite existing files unless force is set.
11
+ */
12
+ export function initProject(targetDir, force) {
13
+ const files = [
14
+ ["DESIGN.md", readBundled("DESIGN.md")],
15
+ ["tokens.css", readBundled("tokens.css")],
16
+ ["askewly.css", readBundled("askewly.css")],
17
+ ];
18
+ const conflicts = files
19
+ .map(([name]) => path.join(targetDir, name))
20
+ .filter((file) => existsSync(file));
21
+ if (conflicts.length > 0 && !force) {
22
+ throw new InjectError(`refusing to overwrite: ${conflicts.join(", ")} (use --force to overwrite)`);
23
+ }
24
+ mkdirSync(targetDir, { recursive: true });
25
+ const written = [];
26
+ for (const [name, content] of files) {
27
+ const file = path.join(targetDir, name);
28
+ writeFileSync(file, content);
29
+ written.push(file);
30
+ }
31
+ return written;
32
+ }
33
+ /** Extracts the first fenced code block under the recipe's `## Code` section. */
34
+ export function extractCodeSnippet(recipe) {
35
+ const codeSection = recipe.body.split(/^## Code\s*$/m)[1];
36
+ if (!codeSection) {
37
+ throw new InjectError(`recipe "${recipe.id}" has no ## Code section`);
38
+ }
39
+ const fence = codeSection.match(/```[a-z]*\n([\s\S]*?)```/);
40
+ if (!fence) {
41
+ throw new InjectError(`recipe "${recipe.id}" has no fenced code block under ## Code`);
42
+ }
43
+ return fence[1].trimEnd() + "\n";
44
+ }
45
+ /** Extracts list items of a `## <title>` section (used for Checks / Anti-patterns notes). */
46
+ export function extractSectionNotes(recipe, title) {
47
+ const section = recipe.body.split(new RegExp(`^## ${title}\\s*$`, "m"))[1];
48
+ if (!section)
49
+ return [];
50
+ const untilNext = section.split(/^## /m)[0];
51
+ return untilNext
52
+ .split("\n")
53
+ .filter((line) => line.trim().startsWith("- "))
54
+ .map((line) => line.trim().slice(2));
55
+ }
56
+ /**
57
+ * Writes the recipe's Code excerpt to <outDir>/<id>.tsx and reports which
58
+ * tokens_used entries are not covered by an existing tokens.css in outDir's
59
+ * project root (heuristic: tokens.css next to outDir or in cwd).
60
+ */
61
+ export function addRecipe(recipe, outDir, force, tokensCssPath) {
62
+ const snippet = extractCodeSnippet(recipe);
63
+ const file = path.join(outDir, `${recipe.id}.tsx`);
64
+ if (existsSync(file) && !force) {
65
+ throw new InjectError(`refusing to overwrite: ${file} (use --force to overwrite)`);
66
+ }
67
+ mkdirSync(outDir, { recursive: true });
68
+ writeFileSync(file, snippet);
69
+ let missingTokens = [];
70
+ const tokensUsed = recipe.tokens_used ?? [];
71
+ if (tokensUsed.length > 0) {
72
+ if (tokensCssPath && existsSync(tokensCssPath)) {
73
+ const css = readFileSync(tokensCssPath, "utf8");
74
+ const bundledCss = readBundled("tokens.css");
75
+ // The generated css does not embed DTCG paths; equality with the bundled
76
+ // generation is the practical "tokens are present and current" check.
77
+ if (css !== bundledCss)
78
+ missingTokens = [...tokensUsed];
79
+ }
80
+ else {
81
+ missingTokens = [...tokensUsed];
82
+ }
83
+ }
84
+ return {
85
+ file,
86
+ missingTokens,
87
+ checks: extractSectionNotes(recipe, "Checks"),
88
+ antiPatterns: extractSectionNotes(recipe, "Anti-patterns"),
89
+ };
90
+ }
package/dist/load.js ADDED
@@ -0,0 +1,51 @@
1
+ import { readFileSync } from "node:fs";
2
+ function loadJson(relative) {
3
+ const url = new URL(`../data/${relative}`, import.meta.url);
4
+ return JSON.parse(readFileSync(url, "utf8"));
5
+ }
6
+ export function loadTerms() {
7
+ return loadJson("terms.json");
8
+ }
9
+ export function loadTokens() {
10
+ return loadJson("tokens.json");
11
+ }
12
+ export function loadTokensCss() {
13
+ return readFileSync(new URL("../data/tokens.css", import.meta.url), "utf8");
14
+ }
15
+ export function loadRecipes() {
16
+ return loadJson("recipes.json");
17
+ }
18
+ /** Case-insensitive substring search over id, names, aliases, one-liner, prompt phrases. */
19
+ export function searchTerms(terms, query) {
20
+ const needle = query.trim().toLowerCase();
21
+ if (!needle)
22
+ return [];
23
+ const scored = [];
24
+ for (const term of terms) {
25
+ const id = term.id.toLowerCase();
26
+ const en = term.en?.name?.toLowerCase() ?? "";
27
+ const ko = term.ko?.name ?? "";
28
+ const aliases = (term.aliases ?? []).map((a) => String(a).toLowerCase());
29
+ const oneLiner = term.one_liner?.toLowerCase() ?? "";
30
+ const phrases = (term.prompt_phrases ?? []).map((p) => String(p).toLowerCase());
31
+ let score = 0;
32
+ if (id === needle || en === needle || ko === query.trim())
33
+ score = 100;
34
+ else if (id.includes(needle) || en.includes(needle) || ko.includes(query.trim()))
35
+ score = 60;
36
+ else if (aliases.some((a) => a.includes(needle)))
37
+ score = 50;
38
+ else if (oneLiner.includes(needle) || phrases.some((p) => p.includes(needle)))
39
+ score = 20;
40
+ if (score > 0)
41
+ scored.push({ term, score });
42
+ }
43
+ return scored.sort((a, b) => b.score - a.score || a.term.id.localeCompare(b.term.id)).map((s) => s.term);
44
+ }
45
+ /** Returns the color subtree for a tier, or null when the tier does not exist. */
46
+ export function tokensForTier(tokens, tier) {
47
+ const color = tokens.color;
48
+ if (!color || !(tier in color))
49
+ return null;
50
+ return color[tier];
51
+ }
package/dist/verify.js ADDED
@@ -0,0 +1,147 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ const DEFAULT_EXTENSIONS = ["tsx", "ts", "jsx", "js", "css", "html"];
4
+ const SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git", ".next"]);
5
+ // Generated token files legitimately contain raw color values.
6
+ const SKIP_FILES = new Set(["tokens.css", "askewly.css"]);
7
+ const HEX_RULE = /#[0-9a-fA-F]{3,8}\b/;
8
+ const COLOR_FN_RULE = /\b(?:rgb|rgba|hsl|hsla|oklch)\(/;
9
+ // Every rule is checked independently per line. A single ternary would report
10
+ // only the first match and silently drop the rest — `"#111" + "rgb(1,2,3)"`
11
+ // hid its raw-color-fn violation that way (DOG1 step-1 baseline).
12
+ const RULES = [
13
+ { name: "hex-literal", pattern: HEX_RULE },
14
+ { name: "raw-color-fn", pattern: COLOR_FN_RULE },
15
+ ];
16
+ // `//` starts a comment in JS-family sources only. In CSS it does not, and
17
+ // blanking to end-of-line there would hide real declarations.
18
+ const LINE_COMMENT_EXTENSIONS = new Set(["ts", "tsx", "js", "jsx"]);
19
+ // Two SVG shapes: paired <svg>…</svg> and self-closing <svg … />.
20
+ const SVG_PAIRED = /<svg\b[\s\S]*?<\/svg\s*>/gi;
21
+ const SVG_SELF_CLOSING = /<svg\b[^>]*\/>/gi;
22
+ /**
23
+ * Blanks regions whose color literals are not rendered output — comments and
24
+ * SVG markup — while preserving every character position and newline, so
25
+ * reported line numbers still point at the original source.
26
+ *
27
+ * Masking happens BEFORE rule matching, deliberately. Attaching per-rule
28
+ * exceptions instead would mean re-implementing the same context handling for
29
+ * every future rule, and DOG1 step-1 observed that both existing rules already
30
+ * had the identical false positive.
31
+ */
32
+ export function maskIgnoredRegions(source, ext) {
33
+ const chars = source.split("");
34
+ const blank = (index) => {
35
+ if (chars[index] !== "\n")
36
+ chars[index] = " ";
37
+ };
38
+ // Pass 1 — comments, string-aware so `"https://…"` is not read as a comment.
39
+ const allowLineComment = LINE_COMMENT_EXTENSIONS.has(ext);
40
+ let state = "code";
41
+ let i = 0;
42
+ while (i < source.length) {
43
+ const c = source[i];
44
+ const next = source[i + 1];
45
+ switch (state) {
46
+ case "code":
47
+ if (c === "/" && next === "*") {
48
+ state = "block-comment";
49
+ blank(i);
50
+ blank(i + 1);
51
+ i += 2;
52
+ continue;
53
+ }
54
+ if (allowLineComment && c === "/" && next === "/") {
55
+ state = "line-comment";
56
+ blank(i);
57
+ blank(i + 1);
58
+ i += 2;
59
+ continue;
60
+ }
61
+ if (c === "'")
62
+ state = "single";
63
+ else if (c === '"')
64
+ state = "double";
65
+ else if (c === "`")
66
+ state = "template";
67
+ break;
68
+ case "line-comment":
69
+ if (c === "\n")
70
+ state = "code";
71
+ else
72
+ blank(i);
73
+ break;
74
+ case "block-comment":
75
+ blank(i);
76
+ if (c === "*" && next === "/") {
77
+ blank(i + 1);
78
+ state = "code";
79
+ i += 2;
80
+ continue;
81
+ }
82
+ break;
83
+ case "single":
84
+ case "double":
85
+ case "template": {
86
+ if (c === "\\") {
87
+ i += 2;
88
+ continue;
89
+ }
90
+ const closes = (state === "single" && c === "'") || (state === "double" && c === '"') || (state === "template" && c === "`");
91
+ if (closes)
92
+ state = "code";
93
+ break;
94
+ }
95
+ }
96
+ i += 1;
97
+ }
98
+ // Pass 2 — SVG markup, matched against the comment-masked text so a commented
99
+ // out `</svg>` cannot terminate a block early.
100
+ let masked = chars.join("");
101
+ for (const pattern of [SVG_PAIRED, SVG_SELF_CLOSING]) {
102
+ masked = masked.replace(pattern, (match) => match.replace(/[^\n]/g, " "));
103
+ }
104
+ return masked;
105
+ }
106
+ function collectFiles(dir, extensions) {
107
+ const files = [];
108
+ for (const entry of readdirSync(dir)) {
109
+ const full = path.join(dir, entry);
110
+ const stats = statSync(full);
111
+ if (stats.isDirectory()) {
112
+ if (!SKIP_DIRS.has(entry))
113
+ files.push(...collectFiles(full, extensions));
114
+ continue;
115
+ }
116
+ if (SKIP_FILES.has(entry))
117
+ continue;
118
+ const ext = path.extname(entry).slice(1);
119
+ if (extensions.includes(ext))
120
+ files.push(full);
121
+ }
122
+ return files;
123
+ }
124
+ /**
125
+ * Scans project files for color values that bypass the token system:
126
+ * hex literals and raw color functions. Token indirection means UI code
127
+ * should only reference semantic utilities (bg-primary, text-foreground, ...).
128
+ */
129
+ export function verifyDir(dir, extensions = DEFAULT_EXTENSIONS) {
130
+ const files = collectFiles(dir, extensions);
131
+ const violations = [];
132
+ for (const file of files) {
133
+ const source = readFileSync(file, "utf8");
134
+ const scanned = maskIgnoredRegions(source, path.extname(file).slice(1)).split("\n");
135
+ const original = source.split("\n");
136
+ scanned.forEach((text, index) => {
137
+ for (const { name, pattern } of RULES) {
138
+ if (!pattern.test(text))
139
+ continue;
140
+ // Report the untouched source line — the mask is a matching device,
141
+ // not something the reader should have to decode.
142
+ violations.push({ file, line: index + 1, rule: name, excerpt: original[index].trim().slice(0, 120) });
143
+ }
144
+ });
145
+ }
146
+ return { files: files.length, violations };
147
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@askewly/design",
3
+ "version": "0.1.0",
4
+ "description": "Query, inject, and verify the Askewly Design system (tokens, UI vocabulary, recipes) from your terminal or coding agent.",
5
+ "license": "MIT",
6
+ "homepage": "https://ui.askewly.com",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lumatic2/ui-dictionary.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "keywords": [
13
+ "design-system",
14
+ "design-tokens",
15
+ "linter",
16
+ "ui",
17
+ "coding-agent"
18
+ ],
19
+ "type": "module",
20
+ "bin": {
21
+ "askewly-design": "./dist/index.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "data",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "scripts": {
36
+ "build:data": "node scripts/build-data.mjs",
37
+ "build": "npm run build:data && tsc",
38
+ "test": "vitest run",
39
+ "prepack": "npm run build"
40
+ },
41
+ "dependencies": {
42
+ "commander": "^13.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.10.0",
46
+ "typescript": "^5.7.0",
47
+ "vitest": "^3.0.0",
48
+ "yaml": "^2.6.0"
49
+ }
50
+ }