@usableapp/cardds 0.7.72 → 0.7.74
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/CHANGELOG.md +37 -0
- package/README.md +9 -4
- package/bin/cardds.mjs +27 -9
- package/css/layover.css +4 -0
- package/css/people.css +6 -0
- package/css/stamp.css +4 -0
- package/dist/actions/Chip.d.ts +7 -0
- package/dist/actions/Dropdown.d.ts +1 -0
- package/dist/actions/Fab.d.ts +3 -0
- package/dist/actions/IconBtnGroup.d.ts +1 -0
- package/dist/cardds.css +14 -0
- package/dist/cards/Card.d.ts +2 -0
- package/dist/cards/CardHead.d.ts +5 -0
- package/dist/cards/CardHead.js +5 -5
- package/dist/catalog.json +1 -1
- package/dist/choice/Calendar.d.ts +4 -2
- package/dist/choice/DayStrip.d.ts +1 -0
- package/dist/choice/Mood.d.ts +1 -0
- package/dist/choice/Slider.d.ts +2 -0
- package/dist/forms/Composer.d.ts +2 -0
- package/dist/forms/Field.d.ts +2 -0
- package/dist/forms/FileBtn.d.ts +1 -0
- package/dist/journey/NoteRow.d.ts +1 -0
- package/dist/journey/Route.d.ts +2 -0
- package/dist/layover/Callout.d.ts +4 -1
- package/dist/lists/Kv.d.ts +3 -0
- package/dist/lists/Row.d.ts +3 -1
- package/dist/media/Mosaic.d.ts +2 -0
- package/dist/media/Postcard.d.ts +4 -0
- package/dist/media/Tile.d.ts +6 -0
- package/dist/numbers/Bars.d.ts +1 -0
- package/dist/numbers/DotGrid.d.ts +1 -0
- package/dist/numbers/Picker.d.ts +1 -0
- package/dist/numbers/Track.d.ts +1 -0
- package/dist/people/Avatar.d.ts +7 -2
- package/dist/people/Avatar.js +4 -4
- package/dist/people/AvatarPick.d.ts +1 -0
- package/dist/post/PostLetter.d.ts +5 -4
- package/dist/scaffold/LayerHistory.d.ts +1 -0
- package/dist/scaffold/Screen.d.ts +1 -0
- package/dist/sheets/Drawer.d.ts +1 -1
- package/dist/sheets/Drawer.js +1 -1
- package/dist/sheets/Sheet.d.ts +2 -1
- package/dist/sheets/Sheet.js +1 -0
- package/dist/sheets/SheetStack.d.ts +1 -0
- package/dist/sheets/SheetStack.js +1 -0
- package/dist/sheets/SheetSteps.d.ts +2 -2
- package/dist/sheets/SheetSteps.js +1 -1
- package/dist/sheets/StackSheet.d.ts +4 -2
- package/dist/sheets/StackSheet.js +1 -1
- package/dist/stamp/StampBook.d.ts +3 -1
- package/dist/stamp/StampBook.js +2 -2
- package/dist/templates/CallSheet.d.ts +4 -2
- package/dist/templates/Home.d.ts +3 -1
- package/dist/templates/Walkthrough.d.ts +5 -1
- package/lib/catalog.mjs +105 -23
- package/lib/guide.mjs +16 -0
- package/lib/mcp.mjs +25 -8
- package/lib/role.mjs +30 -0
- package/lint/eslint.mjs +2 -2
- package/lint/rules.mjs +74 -2
- package/package.json +1 -1
package/lib/catalog.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { checkSource } from '../lint/rules.mjs';
|
|
8
|
+
import { checkSource, tagAt, CONTROL_NAMES } from '../lint/rules.mjs';
|
|
9
|
+
import { SHAPES, KINDS, shapeOf, interactOf } from './role.mjs';
|
|
9
10
|
|
|
10
11
|
const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
12
|
const CSS = 'node_modules/@usableapp/cardds/dist/cardds.css';
|
|
@@ -21,8 +22,13 @@ export function load({ root } = {}) {
|
|
|
21
22
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
export { SHAPES, KINDS };
|
|
26
|
+
|
|
27
|
+
// `represents` is the concept (person · group · time…) or, for a control that stands for no one thing, the KIND of data it carries (words · number · picture · signal); `—` is none, so []
|
|
28
|
+
const representsOf = ({ represents }) => represents.filter((v) => v !== '—');
|
|
29
|
+
|
|
30
|
+
/** the names only — the first call of nearly every session, so it stays small. `group` is the src folder; `represents`, `shape` and `interact` say what it is for */
|
|
31
|
+
export const list = (cat) => cat.components.map(({ name, group, role }) => ({ name, group, represents: representsOf(role), shape: shapeOf(role), interact: interactOf(role) }));
|
|
26
32
|
|
|
27
33
|
const sentence = (text) => {
|
|
28
34
|
const flat = text.replace(/\s+/g, ' ').trim();
|
|
@@ -62,6 +68,7 @@ export function get(cat, name, { html = false, all = false } = {}) {
|
|
|
62
68
|
name: c.name,
|
|
63
69
|
group: c.group,
|
|
64
70
|
description: c.description,
|
|
71
|
+
...(c.role && { role: c.role }),
|
|
65
72
|
element: c.element,
|
|
66
73
|
props: c.props.map((p) => ({ ...p, ...(isSlot(p.type) && { slot: true }) })),
|
|
67
74
|
variations: [...own, ...elsewhere.filter((s) => shown.includes(s))].map(show),
|
|
@@ -75,29 +82,80 @@ const tokens = (query) => [...new Set(words(String(query)).filter((t) => t.lengt
|
|
|
75
82
|
const stem = (t) => (t.length > 3 && t.endsWith('s') ? t.slice(0, -1) : t);
|
|
76
83
|
const hits = (text, token) => words(text).some((w) => w.startsWith(token) || w.startsWith(stem(token)));
|
|
77
84
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
const TOP = 8; // answers per kind; the components that match every axis are never cut
|
|
86
|
+
|
|
87
|
+
/** the closed vocabulary of the axes `find` takes, `represents` read from the catalog's own roles (docs/roles.md does not ship). `group` = either group shape */
|
|
88
|
+
export const vocabulary = (cat) => ({
|
|
89
|
+
represents: [...new Set(cat.components.flatMap((c) => representsOf(c.role)))].sort(),
|
|
90
|
+
shape: ['one', 'group-brief', 'group-detail', 'group'],
|
|
91
|
+
interact: KINDS,
|
|
92
|
+
});
|
|
93
|
+
const roleOf = (c) => ({ represents: representsOf(c.role), shape: shapeOf(c.role), interact: interactOf(c.role) });
|
|
94
|
+
|
|
95
|
+
/** the axes a caller gave, checked against the vocabulary: `{ error }`, or only the ones in force (an empty list is no axis) */
|
|
96
|
+
function wanted(cat, { represents, shape, interact } = {}) {
|
|
97
|
+
const many = (v) => (v === undefined ? [] : [v].flat());
|
|
98
|
+
const vocab = vocabulary(cat);
|
|
99
|
+
const kinds = interact === false ? false : many(interact);
|
|
100
|
+
const unknown = (axis, given, allowed) => given.filter((v) => !allowed.includes(v)).map((v) => `${axis} "${v}" (one of: ${allowed.join(' · ')})`);
|
|
101
|
+
const errors = [...unknown('represents', many(represents), vocab.represents), ...unknown('shape', many(shape), vocab.shape), ...unknown('interact', kinds || [], vocab.interact)];
|
|
102
|
+
if (errors.length) return { error: `no such value — ${errors.join('; ')}` };
|
|
103
|
+
return { want: { ...(many(represents).length && { represents: many(represents) }), ...(shape && { shape }), ...((kinds === false || kinds.length) && { interact: kinds }) } };
|
|
104
|
+
}
|
|
105
|
+
/** a role matches when it matches EVERY axis in force: holds every `represents` asked, is the shape, has every kind of interaction asked (`false` = only shows) */
|
|
106
|
+
const matches = (role, want) => !!role
|
|
107
|
+
&& (!want.represents || want.represents.every((v) => role.represents.includes(v)))
|
|
108
|
+
&& (!want.shape || (want.shape === 'group' ? role.shape.startsWith('group') : role.shape === want.shape))
|
|
109
|
+
&& (want.interact === undefined || (want.interact === false ? role.interact === false : want.interact.every((k) => role.interact && role.interact.includes(k))));
|
|
110
|
+
|
|
111
|
+
/** a screen is a composition, its axes spread over its parts: for each axis SOME used component answers it (represents: holds any value asked, the looser reading
|
|
112
|
+
* — a part that holds every one is the stronger match, which `matches` finds; shape: has it; interact: has every kind asked, or for `none` every part only shows) */
|
|
113
|
+
const matchesUnion = (roles, want) => roles.length > 0
|
|
114
|
+
&& (!want.represents || roles.some((r) => want.represents.some((v) => r.represents.includes(v))))
|
|
115
|
+
&& (!want.shape || roles.some((r) => matches(r, { shape: want.shape })))
|
|
116
|
+
&& (want.interact === undefined || (want.interact === false ? roles.every((r) => r.interact === false) : roles.some((r) => matches(r, { interact: want.interact }))));
|
|
117
|
+
|
|
118
|
+
/** short ranked answer to "what is there for this?". `axes` ({ represents?, shape?, interact? } — what it represents, how many, what the member can do with it) are
|
|
119
|
+
* asked first: whatever matches every one of them (a component, or a screen where one component does, then a screen whose parts do between them) ranks above whatever matches only words;
|
|
120
|
+
* with axes and no words they are the whole answer. Among screens that tie on words, the one whose description says the axes comes first, then the one where a single component matches them all.
|
|
121
|
+
* Then the words: screens already drawn, then components, then what they are used with.
|
|
122
|
+
* A query that matches nothing (another language, say) returns `fallback: true`, a hint and how many screens are drawn — not their ids:
|
|
123
|
+
* they did not help to choose; ask again in English or by the axes, or `scaffold` with no id lists them. */
|
|
124
|
+
export function find(cat, query, axes = {}) {
|
|
125
|
+
const asked = wanted(cat, axes);
|
|
126
|
+
if (asked.error) return asked;
|
|
127
|
+
const active = Object.keys(asked.want).length > 0;
|
|
128
|
+
const roles = new Map(cat.components.map((c) => [c.name, roleOf(c)]));
|
|
81
129
|
const toks = tokens(query);
|
|
82
|
-
const
|
|
83
|
-
|
|
130
|
+
const axisWords = [...(asked.want.represents ?? []), ...(asked.want.interact || [])]; // the axis values as words: a screen's own description may say them
|
|
131
|
+
const rank = (items, fields, level, said = () => 0) => {
|
|
132
|
+
const scored = items.map((item) => {
|
|
84
133
|
let matched = 0, score = 0;
|
|
85
134
|
for (const t of toks) {
|
|
86
135
|
const w = fields(item).filter(([text]) => hits(text, t)).reduce((sum, [, weight]) => sum + weight, 0);
|
|
87
136
|
if (w) { matched++; score += w; }
|
|
88
137
|
}
|
|
89
|
-
return { item, matched, score };
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
138
|
+
return { item, matched, score, level: active ? level(item) : 0, said: active ? said(item) : 0 };
|
|
139
|
+
});
|
|
140
|
+
const byWords = (a, b) => b.matched - a.matched || b.score - a.score;
|
|
141
|
+
const byAxes = (a, b) => b.said - a.said || b.level - a.level; // among equal words: the screen that says the axes itself, then one component over several
|
|
142
|
+
return { axis: scored.filter((r) => r.level).sort((a, b) => byWords(a, b) || byAxes(a, b)), rest: scored.filter((r) => r.matched && !r.level).sort(byWords) };
|
|
143
|
+
};
|
|
144
|
+
const tier = ({ axis, rest }, cap) => [axis.slice(0, cap), rest.slice(0, Math.max(0, cap - axis.length))];
|
|
145
|
+
const screenOf = ({ item: s }) => ({ kind: 'screen', id: s.id, summary: sentence(s.description) });
|
|
146
|
+
const componentOf = ({ item: c }) => ({ kind: 'component', name: c.name, summary: sentence(c.description), role: roles.get(c.name) });
|
|
94
147
|
const screens = cat.stories.filter((s) => s.screen);
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
148
|
+
const level = (s) => { // 2: one component of the screen matches every axis; 1: its parts do between them
|
|
149
|
+
const parts = s.uses.map((u) => roles.get(u)).filter(Boolean);
|
|
150
|
+
return !matchesUnion(parts, asked.want) ? 0 : parts.some((r) => matches(r, asked.want)) ? 2 : 1; // `none` is about the whole screen: one show-only part is not enough
|
|
151
|
+
};
|
|
152
|
+
const said = (s) => axisWords.filter((v) => hits(s.description, v)).length;
|
|
153
|
+
const [screenAxis, screenRest] = tier(rank(screens, (s) => [[s.id, 5], [s.description, 3], [s.uses.join(' '), 1]], level, said), TOP);
|
|
154
|
+
const components = rank(cat.components, (c) => [[c.name, 6], [c.description, 2], [c.props.map((p) => p.name).join(' '), 1]], (c) => Number(matches(roles.get(c.name), asked.want)));
|
|
155
|
+
const [componentAxis, componentRest] = tier(components, Math.max(TOP, components.axis.length));
|
|
156
|
+
const ranked = [...screenAxis.map(screenOf), ...componentAxis.map(componentOf), ...screenRest.map(screenOf), ...componentRest.map(componentOf)];
|
|
157
|
+
if (!ranked.length && active && !toks.length) return { query, results: [], hint: 'no component or screen has that role: drop an axis, or add English words' };
|
|
158
|
+
if (!ranked.length) return { query, fallback: true, hint: 'the names and descriptions are English: ask again in English words, or by the axes (represents · shape · interact); `scaffold` with no id lists the screens', screens: screens.length };
|
|
101
159
|
|
|
102
160
|
const matched = new Set(ranked.filter((r) => r.kind === 'component').map((r) => r.name));
|
|
103
161
|
const together = new Map();
|
|
@@ -106,14 +164,38 @@ export function find(cat, query) {
|
|
|
106
164
|
return { query, results: ranked, usedTogether };
|
|
107
165
|
}
|
|
108
166
|
|
|
109
|
-
|
|
167
|
+
// the parts that put themselves where they belong by JS at runtime — over the content, open, at the edge: the static html shows them in the wrong place
|
|
168
|
+
const PLACED_BY_JS = ['Sheet', 'SheetStack', 'SheetSteps', 'SheetStage', 'Step', 'Modal', 'Menu', 'Dropdown', 'Snackbar', 'FloatBar'];
|
|
169
|
+
|
|
170
|
+
/** a story names cardds's own controls by hand where the gallery's page is not in the story's language (`backLabel="กลับ"` on an
|
|
171
|
+
* lang="en" page). In an app that is noise — and a session copies what it is handed (two prototypes did, 2026-10-08): the starting file
|
|
172
|
+
* leaves those props, and the note above them, out. The tags are read by the name the import gives them (`Home as HomeScreen`). */
|
|
173
|
+
function ownWords(tsx, imports) {
|
|
174
|
+
let out = tsx;
|
|
175
|
+
for (const [name, props] of Object.entries(CONTROL_NAMES)) {
|
|
176
|
+
const tags = [name, ...[...imports.matchAll(new RegExp(`\\b${name}\\s+as\\s+(\\w+)`, 'g'))].map((m) => m[1])];
|
|
177
|
+
for (const tag of tags) {
|
|
178
|
+
for (let at = out.indexOf(`<${tag}`); at >= 0; at = out.indexOf(`<${tag}`, at + 1)) {
|
|
179
|
+
if (/[\w.]/.test(out[at + tag.length + 1] ?? '')) continue;
|
|
180
|
+
const was = tagAt(out, at);
|
|
181
|
+
const now = props.reduce((t, prop) => t.replace(new RegExp(`(\\n[ \\t]*|[ \\t]+)${prop}\\s*=\\s*(?:"[^"]*"|'[^']*'|\\{\\s*(?:"[^"]*"|'[^']*')\\s*\\})`, 'g'), ''), was);
|
|
182
|
+
if (now !== was) out = out.slice(0, at) + now + out.slice(at + was.length);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out.replace(/^[ \t]*(?:\/\/|\/\*|\{\/\*)[^\n]*cardds-ignore-next-line[^\n]*\n/gm, '');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** no id: the screens already drawn. With one: a file to start from — `tsx` (the code, a string) or `html` (the look, static: `{ html, warning? }`, the warning when the picture lies) */
|
|
110
190
|
export function scaffold(cat, id, { format = 'tsx', css = CSS } = {}) {
|
|
111
191
|
if (!id) return cat.stories.filter((s) => s.screen).map((s) => ({ id: s.id, summary: sentence(s.description) }));
|
|
112
192
|
const s = cat.stories.find((x) => x.id.toLowerCase() === String(id).toLowerCase());
|
|
113
193
|
if (!s) return { error: `no story "${id}"`, closest: closest(cat.stories.map((x) => x.id), String(id)) };
|
|
114
194
|
if (format === 'html') {
|
|
115
195
|
if (!s.html) return { error: `${s.id} has no rendered html: ${s.htmlError ?? 'it did not render'}` };
|
|
116
|
-
|
|
196
|
+
const placed = [...new Set(s.uses.filter((u) => PLACED_BY_JS.includes(u)))];
|
|
197
|
+
const warning = placed.length ? `${placed.join(', ')} ${placed.length > 1 ? 'are' : 'is'} placed by JS at runtime, so this static picture is NOT the layout (a sheet over the content, a menu not open): use format tsx to see it live` : undefined;
|
|
198
|
+
return { html: `<!doctype html>${warning ? `\n<!-- ${warning} -->` : ''}
|
|
117
199
|
<html lang="en">
|
|
118
200
|
<head>
|
|
119
201
|
<meta charset="utf-8">
|
|
@@ -126,14 +208,14 @@ export function scaffold(cat, id, { format = 'tsx', css = CSS } = {}) {
|
|
|
126
208
|
${s.html}
|
|
127
209
|
</body>
|
|
128
210
|
</html>
|
|
129
|
-
|
|
211
|
+
`, ...(warning && { warning }) };
|
|
130
212
|
}
|
|
131
213
|
const glue = [...s.imports.matchAll(/import (.+?) from '\.\/_[^']*';/g)]
|
|
132
214
|
.flatMap((m) => m[1].replace(/[{}]/g, '').split(',').map((n) => n.trim().replace(/^type /, '').split(/ as /).pop()).filter(Boolean));
|
|
133
215
|
const taken = /\bScreen\b/.test(s.imports) || /^(?:function|const|class|type|interface) Screen\b/m.test(s.tsx);
|
|
134
216
|
const as = taken ? 'MyScreen' : 'Screen';
|
|
135
217
|
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`;
|
|
218
|
+
return `${note}${s.imports}\n\n${ownWords(s.tsx, s.imports).replace(new RegExp(`^function ${s.name}\\b`, 'm'), `export default function ${as}`)}\n`;
|
|
137
219
|
}
|
|
138
220
|
|
|
139
221
|
/** the rules of lint/rules.mjs over a piece of code */
|
package/lib/guide.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// lib/guide.mjs — the few rules of using cardds that no checker can see, for an AI session: ONE text, two doors —
|
|
2
|
+
// `npx cardds guide` (bin/cardds.mjs) prints it, the MCP server (lib/mcp.mjs) hands it over as its `instructions` at start.
|
|
3
|
+
// Short on purpose: an MCP client may truncate server instructions, so the trigger and the order come first. A component's own rules are
|
|
4
|
+
// not here — they are in its JSDoc (get_component) and in lint/rules.mjs (check).
|
|
5
|
+
export const GUIDE = `cardds — BEFORE drawing any phone-width / mobile screen in a project with @usableapp/cardds, or that Lh asks to build "with cardds": load these tools (ToolSearch "cardds" if they are deferred) and call find_components. This holds when the request only says prototype, poc, mock-up, sketch, "show me a design", หน้า, หน้าจอ, ขึ้นหน้า, ขอดู prototype — a screen, card, bottom sheet, sheet stack, form step, sticky note / post-it, chat of notes, stamp, postcard, home. Never hand-write HTML, CSS or emoji for what it has.
|
|
6
|
+
ORDER: find → scaffold or get → write → check. First question: has cardds drawn this screen already? find BOTH ways: by the axes (represents · shape · interact) and by the English noun of the task ("sticky", "stamp", "chat").
|
|
7
|
+
TOOLS: find_components · scaffold_screen · get_component · list_components · check_source. No MCP: npx cardds find|scaffold|get|list|check (all take --json).
|
|
8
|
+
What no checker sees:
|
|
9
|
+
1. cardds is a framework you take from outside. Never edit, commit to or release cardds from a consumer project, never node_modules. What cannot be set from outside is a framework gap: STOP and tell the user exactly what you needed.
|
|
10
|
+
2. Adjust from outside only, widest first: theme.css bases → knobs (--<component>-<what>) → props/slots. The theme's values are the user's call; never pick them.
|
|
11
|
+
3. Every component is controlled like an input (state/open/value + its on…, or default…) and none knows another: the app owns the state and wires them.
|
|
12
|
+
4. Import components, never copy their markup or classes: a component is the look AND the behaviour.
|
|
13
|
+
5. Mobile only, big targets; never adapt it to desktop.
|
|
14
|
+
A prototype in HTML (scaffold --html) is a still picture to look at, never a screen; when it warns, run the tsx. check is for tsx only.
|
|
15
|
+
Everything else (a component's own rules) is in get_component and enforced by check.
|
|
16
|
+
`;
|
package/lib/mcp.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs';
|
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import * as catalog from './catalog.mjs';
|
|
8
|
+
import { GUIDE } from './guide.mjs';
|
|
8
9
|
|
|
9
10
|
const { version } = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
|
|
10
11
|
const str = (description) => ({ type: 'string', description });
|
|
@@ -13,7 +14,7 @@ const bool = (description) => ({ type: 'boolean', description });
|
|
|
13
14
|
const TOOLS = [
|
|
14
15
|
{
|
|
15
16
|
name: 'list_components',
|
|
16
|
-
description: 'The names of every cardds component and the group it
|
|
17
|
+
description: 'The names of every cardds component and what each is FOR, nothing else. represents is what it stands for (person, group, time…) or, for a control that stands for no one thing, the KIND of data it carries (words, number, picture, signal); the word in parentheses after a name in the text is its represents. Every component shows information; shape says how much: "one" (one thing), "group-brief" (a group read at a glance or in a line), "group-detail" (a group read in detail), "—" (a helper function or structure, not something drawn). interact says whether the member can do something with it: false (it only shows or holds), or the kinds, any of pick, act, write, navigate — alone when shape is one (a Btn acts), as a group when shape is a group (a Segment picks among several). Two kinds mean it does both. group is the src folder, not what it is for. 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
18
|
inputSchema: { type: 'object', properties: {} },
|
|
18
19
|
run: (cat) => catalog.list(cat),
|
|
19
20
|
},
|
|
@@ -25,15 +26,26 @@ const TOOLS = [
|
|
|
25
26
|
},
|
|
26
27
|
{
|
|
27
28
|
name: 'find_components',
|
|
28
|
-
description:
|
|
29
|
-
inputSchema: {
|
|
30
|
-
|
|
29
|
+
description: (cat) => `Find what to build with when you do not know the component name. Ask BOTH ways and read both answers: by the AXES (translated from the request) AND by the English noun of the task ("stamp", "otp", "postcard") — the screen already drawn is often found by the noun. The axes are a closed vocabulary, so ask by them in any language: represents (what it stands for, or for a control the KIND of data it carries${cat ? `: ${catalog.vocabulary(cat).represents.join(' · ')}` : ' — the word in parentheses after a name in list_components'}), shape (one · group-brief · group-detail · group = either group shape), interact (the kinds the member can do with it: ${catalog.KINDS.join(' · ')}, or "none" = it only shows). "Show all the members" is represents [person] + shape group; adding interact [pick] narrows it to the ones the member chooses from. Whatever matches every axis you give comes first, each with its role so you see why; with axes and no words they are the whole answer. Words ("stamp", "otp pin", "bottom sheet") rank what matches only by words, in ENGLISH — the names and descriptions are English, translate first. Answers short: the screens cardds has already drawn (a screen is a composition: it counts when its parts answer the axes between them, one component per axis, and one that answers them all ranks higher), 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. A query that matches nothing returns fallback: true, a hint and the number of screens drawn, not their ids: ask again in English words or by the axes, or call scaffold_screen with no id to list them.`,
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
represents: { type: 'array', items: { type: 'string' }, description: 'what it stands for, e.g. ["person"]; a component must hold every value you give' },
|
|
34
|
+
shape: { type: 'string', enum: ['one', 'group-brief', 'group-detail', 'group'], description: 'how many it shows and how much reading; group = group-brief or group-detail' },
|
|
35
|
+
interact: { anyOf: [{ type: 'array', items: { type: 'string' }, description: 'kinds, e.g. ["pick"]; the component must have every one' }, { type: 'string', enum: ['none'], description: 'only shows, nothing to do with it' }] },
|
|
36
|
+
query: str('English words describing the screen or the part; optional when axes are given'),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
run: (cat, a) => catalog.find(cat, a.query ?? '', { represents: a.represents, shape: a.shape, interact: a.interact === 'none' ? false : a.interact }),
|
|
31
40
|
},
|
|
32
41
|
{
|
|
33
42
|
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.',
|
|
43
|
+
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 — when the screen has a sheet, menu, step or the like, the file begins with a comment saying the static picture is not the layout. A tsx file may name story glue (from ./_story) that is not in the package: replace or drop it.',
|
|
35
44
|
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) =>
|
|
45
|
+
run: (cat, a) => {
|
|
46
|
+
const r = catalog.scaffold(cat, a.id, { format: a.format, css: a.css });
|
|
47
|
+
return typeof r.html === 'string' ? `${r.warning ? `<!-- ${r.warning} -->\n` : ''}${r.html}` : r; // the warning first, where a caller reading the top of the file cannot miss it
|
|
48
|
+
},
|
|
37
49
|
},
|
|
38
50
|
{
|
|
39
51
|
name: 'check_source',
|
|
@@ -51,6 +63,11 @@ export function serve({ root, input = process.stdin, output = process.stdout } =
|
|
|
51
63
|
let cat;
|
|
52
64
|
const text = (r) => typeof r === 'string' ? r : JSON.stringify(r);
|
|
53
65
|
|
|
66
|
+
// the catalog, or nothing: listing the tools must not fail because it cannot be loaded (a call says why)
|
|
67
|
+
function loaded() {
|
|
68
|
+
try { return (cat ??= catalog.load({ root })); } catch { return undefined; }
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
function call(params) {
|
|
55
72
|
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
56
73
|
if (!tool) return null;
|
|
@@ -67,9 +84,9 @@ export function serve({ root, input = process.stdin, output = process.stdout } =
|
|
|
67
84
|
function handle(msg) {
|
|
68
85
|
const { id, method, params } = msg;
|
|
69
86
|
switch (method) {
|
|
70
|
-
case 'initialize': return reply(id, { protocolVersion: params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'cardds', version } });
|
|
87
|
+
case 'initialize': return reply(id, { protocolVersion: params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'cardds', version }, instructions: GUIDE }); // the guide: what no checker sees, handed over before the first question
|
|
71
88
|
case 'ping': return reply(id, {});
|
|
72
|
-
case 'tools/list': return reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
89
|
+
case 'tools/list': return reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description: typeof description === 'function' ? description(loaded()) : description, inputSchema })) });
|
|
73
90
|
case 'tools/call': { const r = call(params); return r ? reply(id, r) : fail(id, -32602, `unknown tool: ${params?.name}`); }
|
|
74
91
|
default: return fail(id, -32601, `method not found: ${method}`);
|
|
75
92
|
}
|
package/lib/role.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// lib/role.mjs — the role table (docs/roles.md) read, and what a role says a component is FOR. Pure, no fs: the catalog (node), scripts/roles.mjs
|
|
2
|
+
// and the gallery's nav (browser) all read the table through these.
|
|
3
|
+
const AXES = ['represents', 'count', 'density', 'job'];
|
|
4
|
+
const values = (cell) => cell.trim().split(' · ').map((v) => v.trim()).filter(Boolean);
|
|
5
|
+
|
|
6
|
+
/** the text of docs/roles.md → `vocabulary` (each axis's closed values) and `rows` (one per component). Hand-written; the vocabulary is the
|
|
7
|
+
* table's own definition lines, so a value outside it is the test's to catch, not this reader's. */
|
|
8
|
+
export function parseRoles(doc) {
|
|
9
|
+
const vocabulary = Object.fromEntries([...doc.matchAll(/^- `([a-z]+)`: (.+)$/gm)].filter((m) => AXES.includes(m[1])).map((m) => [m[1], values(m[2])]));
|
|
10
|
+
const rows = [...doc.matchAll(/^\| (?!component )([A-Za-z]\w*) \| ([a-z]+) \|(.*)\|$/gm)].map((m) => {
|
|
11
|
+
const [represents, count, density, job, stands, note] = m[3].split('|').map((c) => c.trim());
|
|
12
|
+
return {
|
|
13
|
+
component: m[1],
|
|
14
|
+
group: m[2],
|
|
15
|
+
represents: values(represents), count: values(count), density: values(density), job: values(job),
|
|
16
|
+
standsInFor: stands ? stands.split(';').map((s) => s.trim().match(/^(\w+)(?: \((.*)\))?$/)).map((x) => ({ name: x[1], condition: x[2] ?? '' })) : [],
|
|
17
|
+
note: note.replace(/^\?\s*/, ''),
|
|
18
|
+
unsure: note.startsWith('?'),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
return { vocabulary, rows };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// What a component is FOR, read from its role: every component shows information; what differs is how much it shows
|
|
25
|
+
// (`one` thing, a group in brief, a group in detail) and whether the member can do something with it (and what: pick, act, write, navigate).
|
|
26
|
+
export const SHAPES = ['one', 'group-brief', 'group-detail', '—'];
|
|
27
|
+
export const KINDS = ['pick', 'act', 'write', 'navigate'];
|
|
28
|
+
export const shapeOf = ({ count: [count], density: [density] }) =>
|
|
29
|
+
count === 'one' ? 'one' : count === 'few' || count === 'many' ? (density === 'detail' || density === 'full' ? 'group-detail' : 'group-brief') : '—'; // a holder with no density of its own claims no detail
|
|
30
|
+
export const interactOf = ({ job }) => job.some((j) => KINDS.includes(j)) ? job.filter((j) => KINDS.includes(j)) : false;
|
package/lint/eslint.mjs
CHANGED
|
@@ -18,8 +18,8 @@ const rule = (level, description) => ({
|
|
|
18
18
|
};
|
|
19
19
|
},
|
|
20
20
|
});
|
|
21
|
-
const check = rule('error', 'cardds used through its components: no hand-written cardds class
|
|
22
|
-
const advice = rule('warn', 'cardds behaviour kept: a Sheet that can move and slides out; no bar in front of a sheet');
|
|
21
|
+
const check = rule('error', 'cardds used through its components: no hand-written cardds class, no native <select>; the bar is the Screen\'s bottom');
|
|
22
|
+
const advice = rule('warn', 'cardds behaviour kept: a Sheet that can move and slides out; no bar in front of a sheet; the cursor, the control names and the look are cardds\'s and the theme\'s, not the element\'s');
|
|
23
23
|
|
|
24
24
|
const plugin = { meta: { name: '@usableapp/cardds/eslint' }, rules: { check, advice } };
|
|
25
25
|
plugin.configs = {
|
package/lint/rules.mjs
CHANGED
|
@@ -9,6 +9,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
|
|
10
10
|
const CSS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'css');
|
|
11
11
|
|
|
12
|
+
/** the props whose default is cardds's own word (src/words.ts), per component — a `label` that is a control's required accessible name (IconBtn, Fab, Pin…) is not here */
|
|
13
|
+
export const CONTROL_NAMES = {
|
|
14
|
+
Sheet: ['handle'], CallSheet: ['handle'], StackSheet: ['backLabel'], Home: ['backLabel'], Walkthrough: ['backLabel', 'nextLabel'],
|
|
15
|
+
Calendar: ['prevLabel', 'nextLabel'], SheetStep: ['skipLabel'], PostLetter: ['backLabel', 'pictureLabel', 'playLabel', 'pauseLabel', 'toLabel'],
|
|
16
|
+
};
|
|
17
|
+
|
|
12
18
|
const lineOf = (text, idx) => text.slice(0, idx).split('\n').length;
|
|
13
19
|
|
|
14
20
|
/** every class cardds's CSS styles — read from the package's own css/, so the list is never copied by hand */
|
|
@@ -29,7 +35,7 @@ export function carddsClasses(dir = CSS_DIR) {
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** the whole of a JSX opening tag from `<Name` — attributes may hold `=>` and nested braces, so a `[^>]*` regex is not enough */
|
|
32
|
-
function tagAt(text, start) {
|
|
38
|
+
export function tagAt(text, start) {
|
|
33
39
|
let depth = 0, quote = null;
|
|
34
40
|
for (let i = start + 1; i < text.length; i++) {
|
|
35
41
|
const c = text[i];
|
|
@@ -83,6 +89,23 @@ function classWords(text, at) {
|
|
|
83
89
|
return words;
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
/** a style object's or attribute's `key: value` entries from its body (`at` = where the body starts in the text): split on the commas / semicolons that are not inside a string, bracket or call */
|
|
93
|
+
function* styleEntries(body, at, sep) {
|
|
94
|
+
let depth = 0, quote = null, from = 0;
|
|
95
|
+
for (let i = 0; i <= body.length; i++) {
|
|
96
|
+
const c = body[i];
|
|
97
|
+
if (quote) { if (c === quote && body[i - 1] !== '\\') quote = null; continue; }
|
|
98
|
+
if (c === '"' || c === "'" || c === '`') quote = c;
|
|
99
|
+
else if (c === '(' || c === '[' || c === '{') depth++;
|
|
100
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
101
|
+
else if ((c === sep && depth === 0) || i === body.length) {
|
|
102
|
+
const m = body.slice(from, i).match(/^\s*(?:'([^']+)'|"([^"]+)"|([\w$-]+))\s*:\s*([\s\S]*)$/);
|
|
103
|
+
if (m) yield { key: m[1] ?? m[2] ?? m[3], value: m[4], at: at + from + m[0].length - m[4].length };
|
|
104
|
+
from = i + 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
/**
|
|
87
110
|
* @param {string} text a source file
|
|
88
111
|
* @param {string} file its name, for the findings
|
|
@@ -91,7 +114,12 @@ function classWords(text, at) {
|
|
|
91
114
|
export function checkSource(source, file, { classes = carddsClasses() } = {}) {
|
|
92
115
|
const findings = [];
|
|
93
116
|
const lines = source.split('\n');
|
|
94
|
-
|
|
117
|
+
let text = source.replace(/\/\*[\s\S]*?\*\/|(?<![:'"`\\])\/\/[^\n]*/g, (c) => c.replace(/[^\n]/g, ' ')); // comments out, lines kept
|
|
118
|
+
// a component imported under another name (`Home as HomeScreen`) is still that component: its tags read by the name the rules know. Lines kept
|
|
119
|
+
// (seen 2026-10-08: a prototype's `<HomeScreen backLabel="กลับ">` passed control-name-repeat)
|
|
120
|
+
for (const imp of text.matchAll(/import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"][^'"]*cardds[^'"]*['"]/g)) {
|
|
121
|
+
for (const [, name, alias] of imp[1].matchAll(/\b([A-Z]\w*)\s+as\s+([A-Z]\w*)/g)) text = text.replace(new RegExp(`(</?)${alias}\\b`, 'g'), `$1${name}`);
|
|
122
|
+
}
|
|
95
123
|
const add = (rule, level, idx, message) => {
|
|
96
124
|
const line = lineOf(text, idx);
|
|
97
125
|
if (!/cardds-ignore-next-line/.test(lines[line - 2] ?? '')) findings.push({ rule, level, file, line, message }); // `// cardds-ignore-next-line` — a deliberate case (a story that shows the wrong way)
|
|
@@ -131,5 +159,49 @@ export function checkSource(source, file, { classes = carddsClasses() } = {}) {
|
|
|
131
159
|
if (/<Sheet\b/.test(screen) && /<ActionBar\b/.test(screen)) add('screen.bar-with-sheet', 'warn', m.index, 'a Sheet and an ActionBar on one Screen — a screen with a sheet finishes on the sheet\'s CardFoot, no bar.');
|
|
132
160
|
}
|
|
133
161
|
|
|
162
|
+
// 4 · focus: cardds places the cursor when these parts come up — an `autoFocus` or a `.focus(` beside them fights it
|
|
163
|
+
if (/<(?:Sheet|SheetStep|SheetSteps|SheetStack|StackSheet|Home|Modal|CallSheet)\b/.test(text)) {
|
|
164
|
+
const msg = (what) => `${what} in a file that uses a Sheet, SheetStep, SheetStack or Modal: cardds already places the cursor when one comes up (the field marked data-autofocus, else the first field, else the heading) — remove it. To choose the field put data-autofocus on it; focus about something else (the field a server error points at) is the app's: then // cardds-ignore-next-line with the reason.`;
|
|
165
|
+
for (const m of text.matchAll(/(?<![\w)\]])<[A-Za-z][\w.]*/g)) {
|
|
166
|
+
const hit = tagAt(text, m.index).match(/\sautoFocus\b(?!\s*:)/);
|
|
167
|
+
if (hit) add('focus-by-hand', 'warn', m.index + hit.index + 1, msg('autoFocus'));
|
|
168
|
+
}
|
|
169
|
+
for (const m of text.matchAll(/\.focus\s*\(/g)) add('focus-by-hand', 'warn', m.index, msg('.focus()'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 5 · control names: cardds names its controls in the page's language — a literal that only repeats them is noise (and English-only)
|
|
173
|
+
for (const [name, props] of Object.entries(CONTROL_NAMES)) {
|
|
174
|
+
for (const m of text.matchAll(new RegExp(`<${name}\\b`, 'g'))) {
|
|
175
|
+
const tag = tagAt(text, m.index);
|
|
176
|
+
for (const prop of props) {
|
|
177
|
+
const hit = tag.match(new RegExp(`\\s${prop}\\s*=\\s*(?:"[^"]*"|'[^']*'|\\{\\s*(?:"[^"]*"|'[^']*'|\`[^\`$]*\`)\\s*\\})`));
|
|
178
|
+
if (hit) add('control-name-repeat', 'warn', m.index + hit.index + 1, `${prop}="…" on <${name}>: cardds names this control itself, in the page's language (<html lang>) — drop it unless the words differ from cardds's. Another language is setWords(lang, {…}) once at app start, not a string on each component.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 6 · a native <select> opens the platform's picker: not cardds's look, not its words — Dropdown is the one component for it
|
|
184
|
+
for (const m of text.matchAll(/<select\b/g)) add('native-select', 'error', m.index, '<select> is the platform\'s own picker: it draws outside cardds\'s look and takes none of its knobs — use <Dropdown> (one component, value controlled, the pill named by the chosen item).');
|
|
185
|
+
|
|
186
|
+
// 7 · a raw colour or px length in a style prop: the look is the theme's (theme.css · a knob · a prop); a custom property (`'--x': …`) IS a knob, so its value is the app's to set
|
|
187
|
+
if (/\.[jt]sx$/.test(file)) {
|
|
188
|
+
const raw = (value) => {
|
|
189
|
+
const v = value.replace(/var\((?:[^()]|\([^()]*\))*\)/g, '');
|
|
190
|
+
return /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\(/.test(v) ? 'raw colour' : /(?<![\w.])\d*\.?\d+px\b/.test(v) ? 'raw px length' : null;
|
|
191
|
+
};
|
|
192
|
+
const report = (e) => {
|
|
193
|
+
const what = !e.key.startsWith('--') && raw(e.value);
|
|
194
|
+
if (what) add('raw-value', 'warn', e.at, `${what} in style (${e.key}): the look is set in the project's theme.css, by a knob (--<component>-<what>) or by a prop — not on the element. A hairline is var(--border-w), even 1px.`);
|
|
195
|
+
};
|
|
196
|
+
for (const m of text.matchAll(/\bstyle\s*=\s*\{\{/g)) {
|
|
197
|
+
const open = m.index + m[0].length - 2, expr = braceAt(text, open);
|
|
198
|
+
if (expr.endsWith('}}')) for (const e of styleEntries(expr.slice(2, -2), open + 2, ',')) report(e);
|
|
199
|
+
}
|
|
200
|
+
for (const m of text.matchAll(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
201
|
+
const body = m[1] ?? m[2];
|
|
202
|
+
for (const e of styleEntries(body, m.index + m[0].indexOf(body), ';')) report(e);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
134
206
|
return findings.sort((a, b) => a.line - b.line);
|
|
135
207
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usableapp/cardds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.74",
|
|
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.",
|