@usableapp/cardds 0.7.70 → 0.7.72

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,140 @@
1
+ // lib/catalog.mjs — what an AI session asks of the catalog (dist/catalog.json, built by scripts/build-catalog.mjs) instead of reading src/.
2
+ // Pure functions over the loaded catalog, no dependency beyond node: `npx cardds list|get|find|scaffold` and `npx cardds mcp` (lib/mcp.mjs) are two doors to these.
3
+ // Shipped in the package. The order of a session: find (has a screen like this been drawn?) → scaffold it, or get the components → check.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { checkSource } from '../lint/rules.mjs';
9
+
10
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
11
+ const CSS = 'node_modules/@usableapp/cardds/dist/cardds.css';
12
+ const OTHERS = 5; // stories of OTHER components that show one in use: how many come with their code (`Btn` is in 117)
13
+
14
+ /** the catalog of the package, or of a live repo at `root` (rebuilt first, so it is never stale) */
15
+ export function load({ root } = {}) {
16
+ if (root && existsSync(join(root, 'scripts', 'build-catalog.mjs'))) {
17
+ execFileSync(process.execPath, [join(root, 'scripts', 'build-catalog.mjs'), root], { stdio: ['ignore', 'ignore', 'inherit'] });
18
+ }
19
+ const file = join(root ?? PKG, 'dist', 'catalog.json');
20
+ if (!existsSync(file)) throw new Error(`no catalog at ${file} — \`npm run build\` in the cardds repo makes it`);
21
+ return JSON.parse(readFileSync(file, 'utf8'));
22
+ }
23
+
24
+ /** the names only — the first call of nearly every session, so it stays small */
25
+ export const list = (cat) => cat.components.map(({ name, group }) => ({ name, group }));
26
+
27
+ const sentence = (text) => {
28
+ const flat = text.replace(/\s+/g, ' ').trim();
29
+ const one = flat.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? flat;
30
+ return one.length > 160 ? `${one.slice(0, 157)}…` : one;
31
+ };
32
+ const isSlot = (type) => /\bReactNode\b/.test(type);
33
+
34
+ function distance(a, b) {
35
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
36
+ for (let i = 1; i <= a.length; i++) {
37
+ let prev = row[0];
38
+ row[0] = i;
39
+ for (let j = 1; j <= b.length; j++) {
40
+ const keep = row[j];
41
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
42
+ prev = keep;
43
+ }
44
+ }
45
+ return row[b.length];
46
+ }
47
+ const closest = (names, wanted) => {
48
+ const w = wanted.toLowerCase();
49
+ const rank = (n) => (n.toLowerCase().includes(w) || w.includes(n.toLowerCase()) ? 0 : 100) + distance(n.toLowerCase(), w);
50
+ return [...names].sort((a, b) => rank(a) - rank(b)).slice(0, 5);
51
+ };
52
+
53
+ /** one component: its props (a ReactNode prop is a slot) and its variations — the stories of its own file first, then a few that use it */
54
+ export function get(cat, name, { html = false, all = false } = {}) {
55
+ const c = cat.components.find((x) => x.name.toLowerCase() === String(name).toLowerCase());
56
+ if (!c) return { error: `no component "${name}"`, closest: closest(cat.components.map((x) => x.name), String(name)) };
57
+ const show = (s) => ({ id: s.id, description: s.description, tsx: s.tsx, ...(html && { html: s.html }) });
58
+ const own = cat.stories.filter((s) => s.file === c.name);
59
+ const elsewhere = cat.stories.filter((s) => s.file !== c.name && s.uses.includes(c.name));
60
+ const shown = all ? elsewhere : [...elsewhere].sort((a, b) => a.tsx.length - b.tsx.length).slice(0, OTHERS);
61
+ return {
62
+ name: c.name,
63
+ group: c.group,
64
+ description: c.description,
65
+ element: c.element,
66
+ props: c.props.map((p) => ({ ...p, ...(isSlot(p.type) && { slot: true }) })),
67
+ variations: [...own, ...elsewhere.filter((s) => shown.includes(s))].map(show),
68
+ ...(elsewhere.length > shown.length && { alsoUsedIn: elsewhere.filter((s) => !shown.includes(s)).map((s) => s.id) }),
69
+ };
70
+ }
71
+
72
+ const words = (text) => text.replace(/([a-z\d])([A-Z])/g, '$1 $2').toLowerCase().match(/[\p{L}\p{M}\p{N}]+/gu) ?? [];
73
+ const STOP = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'into', 'of', 'to', 'in', 'on', 'an', 'a']);
74
+ const tokens = (query) => [...new Set(words(String(query)).filter((t) => t.length > 1 && !STOP.has(t)))];
75
+ const stem = (t) => (t.length > 3 && t.endsWith('s') ? t.slice(0, -1) : t);
76
+ const hits = (text, token) => words(text).some((w) => w.startsWith(token) || w.startsWith(stem(token)));
77
+
78
+ /** short ranked answer to "what is there for this?": screens already drawn first, then components, then what they are used with.
79
+ * A query that matches nothing (another language, say) returns every screen's id with `fallback: true` — ask again in English, or pick the nearest. */
80
+ export function find(cat, query) {
81
+ const toks = tokens(query);
82
+ const rank = (items, fields) => items
83
+ .map((item) => {
84
+ let matched = 0, score = 0;
85
+ for (const t of toks) {
86
+ const w = fields(item).filter(([text]) => hits(text, t)).reduce((sum, [, weight]) => sum + weight, 0);
87
+ if (w) { matched++; score += w; }
88
+ }
89
+ return { item, matched, score };
90
+ })
91
+ .filter((r) => r.matched)
92
+ .sort((a, b) => b.matched - a.matched || b.score - a.score);
93
+ const screenOf = (s) => ({ kind: 'screen', id: s.id, summary: sentence(s.description) });
94
+ const screens = cat.stories.filter((s) => s.screen);
95
+ const ranked = [
96
+ ...rank(screens, (s) => [[s.id, 5], [s.description, 3], [s.uses.join(' '), 1]]).slice(0, 8).map((r) => screenOf(r.item)),
97
+ ...rank(cat.components, (c) => [[c.name, 6], [c.description, 2], [c.props.map((p) => p.name).join(' '), 1]]).slice(0, 8)
98
+ .map((r) => ({ kind: 'component', name: r.item.name, summary: sentence(r.item.description) })),
99
+ ];
100
+ if (!ranked.length) return { query, fallback: true, hint: 'the names and descriptions are English: ask again in English words', results: screens.map((s) => ({ kind: 'screen', id: s.id })) };
101
+
102
+ const matched = new Set(ranked.filter((r) => r.kind === 'component').map((r) => r.name));
103
+ const together = new Map();
104
+ for (const s of cat.stories) if (s.uses.some((u) => matched.has(u))) for (const u of s.uses) if (!matched.has(u)) together.set(u, (together.get(u) ?? 0) + 1);
105
+ const usedTogether = [...together].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 8).map(([name, stories]) => ({ name, stories }));
106
+ return { query, results: ranked, usedTogether };
107
+ }
108
+
109
+ /** no id: the screens already drawn. With one: a file to start from — `tsx` (the code) or `html` (the look, static) */
110
+ export function scaffold(cat, id, { format = 'tsx', css = CSS } = {}) {
111
+ if (!id) return cat.stories.filter((s) => s.screen).map((s) => ({ id: s.id, summary: sentence(s.description) }));
112
+ const s = cat.stories.find((x) => x.id.toLowerCase() === String(id).toLowerCase());
113
+ if (!s) return { error: `no story "${id}"`, closest: closest(cat.stories.map((x) => x.id), String(id)) };
114
+ if (format === 'html') {
115
+ if (!s.html) return { error: `${s.id} has no rendered html: ${s.htmlError ?? 'it did not render'}` };
116
+ return `<!doctype html>
117
+ <html lang="en">
118
+ <head>
119
+ <meta charset="utf-8">
120
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
121
+ <title>${s.id}</title>
122
+ <link rel="stylesheet" href="${css}">
123
+ </head>
124
+ <body>
125
+ <!-- ${s.id}, rendered once: the look only, no behaviour. \`scaffold ${s.id}\` (tsx) is the working code -->
126
+ ${s.html}
127
+ </body>
128
+ </html>
129
+ `;
130
+ }
131
+ const glue = [...s.imports.matchAll(/import (.+?) from '\.\/_[^']*';/g)]
132
+ .flatMap((m) => m[1].replace(/[{}]/g, '').split(',').map((n) => n.trim().replace(/^type /, '').split(/ as /).pop()).filter(Boolean));
133
+ const taken = /\bScreen\b/.test(s.imports) || /^(?:function|const|class|type|interface) Screen\b/m.test(s.tsx);
134
+ const as = taken ? 'MyScreen' : 'Screen';
135
+ const note = glue.length ? `// story glue from ./_*, not part of the package — replace or drop: ${glue.join(', ')}\n` : '';
136
+ return `${note}${s.imports}\n\n${s.tsx.replace(new RegExp(`^function ${s.name}\\b`, 'm'), `export default function ${as}`)}\n`;
137
+ }
138
+
139
+ /** the rules of lint/rules.mjs over a piece of code */
140
+ export const check = (code, filename = 'screen.tsx') => checkSource(code, filename);
package/lib/mcp.mjs ADDED
@@ -0,0 +1,91 @@
1
+ // lib/mcp.mjs — `npx cardds mcp`: the catalog (lib/catalog.mjs) as an MCP server over stdio, so an AI session asks instead of reading src/.
2
+ // Newline-delimited JSON-RPC 2.0 written by hand: the package has no MCP SDK and takes none. stdout carries protocol messages ONLY.
3
+ import { createInterface } from 'node:readline';
4
+ import { readFileSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import * as catalog from './catalog.mjs';
8
+
9
+ const { version } = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
10
+ const str = (description) => ({ type: 'string', description });
11
+ const bool = (description) => ({ type: 'boolean', description });
12
+
13
+ const TOOLS = [
14
+ {
15
+ name: 'list_components',
16
+ description: 'The names of every cardds component and the group it belongs to, nothing else. Call this once, first, in a session that will build a UI with cardds, to learn what exists; then get_component for the ones you use.',
17
+ inputSchema: { type: 'object', properties: {} },
18
+ run: (cat) => catalog.list(cat),
19
+ },
20
+ {
21
+ name: 'get_component',
22
+ description: 'One component in full: what it is, its props (a prop marked slot takes children: ReactNode), and its variations, each a working story with its code. Call it before you use a component you have not used yet, and copy a variation rather than guessing a prop. A component used everywhere (Btn, Text) comes with a few of its stories and the ids of the rest (pass all for their code). Names are case-insensitive; a miss returns the closest names.',
23
+ inputSchema: { type: 'object', properties: { name: str('component name, e.g. Menu'), html: bool('also return each variation\'s rendered HTML — only to see the markup it makes'), all: bool('include the code of every story that uses the component') }, required: ['name'] },
24
+ run: (cat, a) => catalog.get(cat, a.name, { html: a.html, all: a.all }),
25
+ },
26
+ {
27
+ name: 'find_components',
28
+ description: 'Search by words ("stamp", "otp pin", "bottom sheet") when you do not know the component name. Answers short: the screens cardds has already drawn first, then components, then the components most often used together with them. Call it at the start of a task, before composing a screen from parts: if a screen like it exists, scaffold_screen gives its code. Ask in ENGLISH words — the names and descriptions are English; translate the request first. A query that matches nothing returns the id of every screen (fallback: true).',
29
+ inputSchema: { type: 'object', properties: { query: str('English words describing the screen or the part') }, required: ['query'] },
30
+ run: (cat, a) => catalog.find(cat, a.query),
31
+ },
32
+ {
33
+ name: 'scaffold_screen',
34
+ description: 'Start from a screen cardds has already drawn. With no id: the list of such screens, one line each. With an id (from find_components): a complete file to start from — format tsx (default) is the React code with its imports, edit its content for the task; format html is a static document (the look only, no behaviour) that links cardds.css, for a prototype without React. A tsx file may name story glue (from ./_story) that is not in the package: replace or drop it.',
35
+ inputSchema: { type: 'object', properties: { id: str('a screen id such as StampScreens/MakeStamp; omit to list the screens'), format: { type: 'string', enum: ['tsx', 'html'], description: 'tsx (default) or html' }, css: str('html only: the href of cardds.css from where the file will sit; default node_modules/@usableapp/cardds/dist/cardds.css') } },
36
+ run: (cat, a) => catalog.scaffold(cat, a.id, { format: a.format, css: a.css }),
37
+ },
38
+ {
39
+ name: 'check_source',
40
+ description: 'Run cardds\'s rules over code you wrote (a hand-written cardds class, a Sheet that cannot move, a bar with a sheet…). Call it on each screen before you call it done; an empty list means clean, an error must be fixed.',
41
+ inputSchema: { type: 'object', properties: { code: str('the .tsx/.jsx source'), filename: str('its file name, for the report (default screen.tsx)') }, required: ['code'] },
42
+ run: (_cat, a) => catalog.check(a.code, a.filename),
43
+ },
44
+ ];
45
+
46
+ const reply = (id, result) => ({ jsonrpc: '2.0', id, result });
47
+ const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
48
+
49
+ /** serve the catalog until the client closes stdin; `root` serves a live repo (rebuilt first) instead of the package's */
50
+ export function serve({ root, input = process.stdin, output = process.stdout } = {}) {
51
+ let cat;
52
+ const text = (r) => typeof r === 'string' ? r : JSON.stringify(r);
53
+
54
+ function call(params) {
55
+ const tool = TOOLS.find((t) => t.name === params?.name);
56
+ if (!tool) return null;
57
+ const args = params.arguments ?? {};
58
+ const missing = tool.inputSchema.required?.find((k) => typeof args[k] !== 'string');
59
+ if (missing) return { content: [{ type: 'text', text: `${tool.name}: "${missing}" is required (a string)` }], isError: true };
60
+ try {
61
+ cat ??= catalog.load({ root });
62
+ const result = tool.run(cat, args);
63
+ return { content: [{ type: 'text', text: text(result) }], ...(result?.error && { isError: true }) };
64
+ } catch (e) { return { content: [{ type: 'text', text: `${tool.name}: ${e.message}` }], isError: true }; }
65
+ }
66
+
67
+ function handle(msg) {
68
+ const { id, method, params } = msg;
69
+ switch (method) {
70
+ case 'initialize': return reply(id, { protocolVersion: params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'cardds', version } });
71
+ case 'ping': return reply(id, {});
72
+ case 'tools/list': return reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
73
+ case 'tools/call': { const r = call(params); return r ? reply(id, r) : fail(id, -32602, `unknown tool: ${params?.name}`); }
74
+ default: return fail(id, -32601, `method not found: ${method}`);
75
+ }
76
+ }
77
+
78
+ return new Promise((done) => {
79
+ const send = (msg) => output.write(`${JSON.stringify(msg)}\n`);
80
+ const lines = createInterface({ input });
81
+ lines.on('line', (line) => {
82
+ if (!line.trim()) return;
83
+ let msg;
84
+ try { msg = JSON.parse(line); } catch { return send(fail(null, -32700, 'parse error')); }
85
+ if (!msg || typeof msg !== 'object') return send(fail(null, -32600, 'invalid request'));
86
+ if (msg.id === undefined) return; // a notification (notifications/initialized…) is never answered
87
+ send(handle(msg));
88
+ });
89
+ lines.on('close', done);
90
+ });
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.70",
3
+ "version": "0.7.72",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
12
  "dev": "vite --config gallery/vite.config.ts",
13
- "build": "node scripts/build-icons.mjs && tsc -p tsconfig.json && node scripts/flatten-css.mjs",
13
+ "build": "node scripts/build-icons.mjs && tsc -p tsconfig.json && node scripts/flatten-css.mjs && node scripts/build-catalog.mjs",
14
14
  "test": "playwright test",
15
15
  "validate:inbox": "node scripts/validate-inbox.mjs",
16
16
  "prepack": "npm run build",
@@ -52,6 +52,7 @@
52
52
  "files": [
53
53
  "dist",
54
54
  "lint",
55
+ "lib",
55
56
  "bin",
56
57
  "css",
57
58
  "fonts",