@usableapp/cardds 0.7.103 → 0.7.105

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/css/actions.css +112 -37
  3. package/css/base.css +139 -15
  4. package/css/card.css +331 -86
  5. package/css/choice.css +25 -33
  6. package/css/finish.css +29 -28
  7. package/css/forms.css +6 -14
  8. package/css/journey.css +8 -6
  9. package/css/layover.css +26 -24
  10. package/css/lists.css +8 -6
  11. package/css/media.css +13 -74
  12. package/css/numbers.css +37 -32
  13. package/css/palette.css +2 -2
  14. package/css/people.css +3 -8
  15. package/css/post.css +23 -19
  16. package/css/sheet.css +84 -56
  17. package/css/stack.css +40 -216
  18. package/css/stamp.css +41 -29
  19. package/css/step.css +1 -107
  20. package/css/sticky.css +43 -41
  21. package/css/theme.template.css +12 -0
  22. package/css/tokens.css +101 -26
  23. package/dist/actions/Chip.d.ts +1 -1
  24. package/dist/actions/Chip.js +3 -2
  25. package/dist/actions/IconBtn.d.ts +12 -3
  26. package/dist/actions/IconBtn.js +9 -3
  27. package/dist/cardds.css +1094 -881
  28. package/dist/cards/Card.d.ts +8 -6
  29. package/dist/cards/Card.js +4 -4
  30. package/dist/cards/CardFeed.js +7 -9
  31. package/dist/cards/CardFoot.d.ts +7 -2
  32. package/dist/cards/CardFoot.js +4 -3
  33. package/dist/catalog.json +1 -1
  34. package/dist/journey/JourneyBadge.d.ts +1 -1
  35. package/dist/journey/JourneyBadge.js +1 -1
  36. package/dist/layover/Snackbar.js +2 -2
  37. package/dist/lists/Bubble.d.ts +1 -1
  38. package/dist/lists/Bubble.js +1 -1
  39. package/dist/lists/Row.js +5 -7
  40. package/dist/numbers/Picker.js +11 -18
  41. package/dist/people/Avatar.d.ts +1 -1
  42. package/dist/people/Avatar.js +3 -2
  43. package/dist/post/PostDesk.js +6 -9
  44. package/dist/post/PostLetter.js +4 -4
  45. package/dist/pressable.d.ts +9 -0
  46. package/dist/pressable.js +6 -0
  47. package/dist/room.d.ts +5 -0
  48. package/dist/room.js +9 -0
  49. package/dist/scaffold/Panes.js +8 -14
  50. package/dist/sheets/Sheet.d.ts +1 -1
  51. package/dist/sheets/Sheet.js +2 -3
  52. package/dist/sheets/SheetSteps.js +3 -3
  53. package/dist/sheets/StackSheet.d.ts +1 -1
  54. package/dist/sheets/sheetStepsPile.js +2 -3
  55. package/dist/sheets/stackCard.js +67 -115
  56. package/dist/state.d.ts +21 -0
  57. package/dist/state.js +28 -1
  58. package/dist/sticky/Sticky.js +3 -7
  59. package/dist/templates/Home.d.ts +1 -1
  60. package/dist/templates/Splash.js +2 -2
  61. package/dist/templates/Walkthrough.js +4 -5
  62. package/docs/reference.md +76 -12
  63. package/lib/catalog.mjs +23 -7
  64. package/lint/rules.mjs +7 -5
  65. package/lint/values.mjs +144 -0
  66. package/package.json +6 -4
@@ -0,0 +1,144 @@
1
+ // lint/values.mjs — a value cardds writes for itself (Lh 2026-10-20: "the gallery is a component catalog: a component that slips out of the tokens or
2
+ // the theme is a problem, because that is how a hard-coded look gets made that cannot be taken out and used"). Every declaration of a component's css
3
+ // and every literal in an inline `style` of the package's own .tsx is read; what it may not hold is named by kind. The foundation files are where the
4
+ // values live, so they are not read. `npm run check` reports these before the tests run (scripts/own-values.mjs); tests/tokens.spec.js holds them at none.
5
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { blankComments, braceAt, lineOf, styleEntries } from './rules.mjs';
8
+
9
+ export const FOUNDATION = ['tokens.css', 'palette.css', 'finish.css', 'theme.template.css', 'fonts.css'];
10
+ // where the package writes looks: its components' css, its components, its stories
11
+ export const OWN = { css: 'css', tsx: ['src', '.design-sync/previews'] };
12
+
13
+ // every named colour of CSS Color 4 (`transparent` and `currentcolor` name no look of their own)
14
+ const COLOUR_NAMES = `aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse
15
+ chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen
16
+ darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray
17
+ dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred
18
+ indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey
19
+ lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon
20
+ mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue
21
+ mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred
22
+ papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna
23
+ silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`.split(/\s+/);
24
+ const COLOUR = new RegExp(`#[0-9a-f]{3,8}\\b|\\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\\(|(?<![\\w-])(?:${COLOUR_NAMES.join('|')})(?![\\w-])`, 'gi');
25
+ const NUM = String.raw`(?<![\w.#-])(-?\d*\.?\d+)`;
26
+ // the names of the tokens out, their fallbacks kept: `var(--x, 5)` reads as `5`
27
+ const fallbacks = (v) => v.replace(/var\(\s*--[\w-]+\s*,?/g, '');
28
+ const numbers = (v) => [...v.matchAll(new RegExp(`${NUM}(%?)(?![\\w.(])`, 'g'))].map((m) => ({ n: parseFloat(m[1]), what: m[0] }));
29
+ // a sign or an identity is not a value: `* -1` turns a token round, 0 and 1 leave it as it is
30
+ const sign = (n) => n === 0 || Math.abs(n) === 1;
31
+
32
+ /** the values one declaration writes in place: [{ kind, what }] */
33
+ export function ownIn(prop, raw) {
34
+ const out = [], say = (kind, what) => out.push({ kind, what });
35
+ prop = prop.toLowerCase();
36
+ if (/^(-webkit-)?mask/.test(prop)) return out; // a mask's colours are its shape
37
+ const v = raw.replace(/url\([^)]*\)/g, 'url()').replace(/"[^"]*"|'[^']*'/g, '""');
38
+ for (const m of v.matchAll(COLOUR)) say('colour', m[0]);
39
+ for (const m of v.matchAll(new RegExp(`${NUM}(px|pt|cm|mm|in|pc|q)\\b`, 'gi'))) if (parseFloat(m[1])) say('px', m[0]);
40
+ for (const m of v.matchAll(new RegExp(`${NUM}rem\\b`, 'gi'))) if (parseFloat(m[1])) say('length', m[0]);
41
+ for (const m of v.matchAll(new RegExp(`${NUM}(ms|s)\\b`, 'gi'))) if (parseFloat(m[1])) say('time', m[0]);
42
+ for (const m of v.matchAll(new RegExp(`${NUM}(deg|grad|rad|turn)\\b`, 'gi'))) {
43
+ const n = parseFloat(m[1]), deg = m[2].toLowerCase() === 'deg';
44
+ if (!n || (deg && n % 45 === 0) || (deg && Math.abs(n) === 1 && /\*\s*-?1deg/i.test(v))) continue; // a quarter or half turn is an orientation; `* 1deg` makes a number an angle
45
+ say('angle', m[0]);
46
+ }
47
+ if (/^(transition|animation)/.test(prop)) for (const m of v.matchAll(/(?<![\w-])(ease-in-out|ease-out|ease-in|ease|linear|cubic-bezier|steps)(?![\w-])/gi)) say('easing', m[0]);
48
+ if (prop === 'z-index') for (const x of numbers(fallbacks(v))) if (x.n) say('layer', x.what);
49
+ if (prop === 'opacity') for (const x of numbers(fallbacks(v))) if (x.what.endsWith('%') ? x.n % 100 : !sign(x.n)) say('fade', x.what);
50
+ if (/filter$/.test(prop)) for (const m of fallbacks(v).matchAll(/opacity\(\s*([^)]*)\)/gi)) for (const x of numbers(m[1])) if (x.what.endsWith('%') ? x.n % 100 : !sign(x.n)) say('fade', x.what);
51
+ if (prop === 'scale') for (const x of numbers(fallbacks(v))) if (!x.what.endsWith('%') && !sign(x.n)) say('scale', x.what);
52
+ if (prop === 'transform') for (const m of fallbacks(v).matchAll(/scale[xyz3d]*\(\s*([^)]*)\)/gi)) for (const x of numbers(m[1])) if (!sign(x.n)) say('scale', x.what);
53
+ if (/^(font|font-weight|line-height|letter-spacing)$/.test(prop)) {
54
+ if (prop === 'font') {
55
+ // a font's size may be made of a token (`calc(var(--fs-h1) * 3)`): calc() out, and what stays is written in place
56
+ const bare = fallbacks(v.replace(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ''));
57
+ for (const m of bare.matchAll(/(?<![\w.-])[1-9]00(?![\w.])/g)) say('type', m[0]);
58
+ for (const m of bare.matchAll(/\/\s*\d*\.?\d+(?![\w.%])/g)) say('type', m[0]);
59
+ } else {
60
+ // a leading or a tracking is read inside calc() too: only a sign turns a token round
61
+ const bare = fallbacks(v).replace(/[*/]\s*-?1(?![\w.%])/g, '');
62
+ if (prop === 'letter-spacing') { for (const m of bare.matchAll(new RegExp(`${NUM}(?:em)?\\b`, 'gi'))) if (parseFloat(m[1])) say('type', m[0]); }
63
+ else for (const x of numbers(bare)) if (!x.what.endsWith('%') || x.n) say('type', x.what);
64
+ }
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** a component's css rules (tests/helpers.js cssRules) → every value written in place, at the line of its declaration */
70
+ export function ownValues(rules) {
71
+ return rules.flatMap((r) => r.decls.flatMap((d, i) => {
72
+ const at = d.indexOf(':'), prop = d.slice(0, at).trim(), raw = d.slice(at + 1).trim();
73
+ return ownIn(prop, raw).map((o) => ({ file: r.file, line: r.lines?.[i] ?? r.line, selector: r.selector, ...o, decl: `${prop}: ${raw}` }));
74
+ }));
75
+ }
76
+
77
+ // React's numbers that are not lengths; any other number on a style key is px
78
+ const UNITLESS = /^(opacity|z-index|line-height|font-weight|flex|flex-grow|flex-shrink|order|scale|zoom|aspect-ratio|column-count|tab-size|widows|orphans|animation-iteration-count|grid-(row|column)(-start|-end)?|--.*)$/;
79
+ const kebab = (key) => key.startsWith('--') ? key : key.replace(/^(Webkit|Moz|ms)(?=[A-Z])/, (p) => `-${p.toLowerCase()}`).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
80
+
81
+ /** a .tsx source → every value an inline `style` writes in place: the literals of each entry (a string, a template's text, a bare number) */
82
+ export function ownStyle(source, file) {
83
+ const text = blankComments(source), out = [];
84
+ const read = (e) => {
85
+ const prop = kebab(e.key);
86
+ const lit = [...e.value.matchAll(/"([^"]*)"|'([^']*)'|`([^`]*)`/g)].map((m) => m[1] ?? m[2] ?? m[3].replace(/\$\{[^}]*\}/g, ' '));
87
+ const num = e.value.trim().match(/^-?\d*\.?\d+$/);
88
+ if (num) lit.push(UNITLESS.test(prop) || !parseFloat(num[0]) ? num[0] : `${num[0]}px`);
89
+ for (const raw of lit) for (const o of ownIn(prop, raw)) out.push({ file, line: lineOf(text, e.at), selector: 'style', ...o, decl: `${e.key}: ${e.value.trim()}` });
90
+ };
91
+ for (const m of text.matchAll(/\bstyle\s*=\s*\{\{/g)) {
92
+ const open = m.index + m[0].length - 2, expr = braceAt(text, open);
93
+ if (expr.endsWith('}}')) for (const e of styleEntries(expr.slice(2, -2), open + 2, ',')) read(e);
94
+ }
95
+ for (const m of text.matchAll(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
96
+ const body = m[1] ?? m[2];
97
+ for (const e of styleEntries(body, m.index + m[0].indexOf(body), ';')) read({ ...e, value: `'${e.value}'` });
98
+ }
99
+ return out.sort((a, b) => a.line - b.line);
100
+ }
101
+
102
+ // ---- css: every block of a file is a rule — selector (or at-rule prelude), its OWN declarations (and the line each starts on), and the block it sits in ----
103
+ // Comments are blanked to newlines so a line number is the file's. A `}` with nothing open, or a block still open at the end, throws naming the file.
104
+ export function cssRules(text, file) {
105
+ const css = text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ''));
106
+ const rules = [], open = [];
107
+ let buf = '', quote = '', line = 1, from = 1;
108
+ const top = () => open[open.length - 1];
109
+ const decl = () => { const d = buf.replace(/\s+/g, ' ').trim(); buf = ''; if (d && top()) { top().decls.push(d.replace(/^([^:]+?)\s*:\s*/, '$1: ')); top().lines.push(from); } };
110
+ for (const c of css) {
111
+ if (c === '\n') line++;
112
+ if (quote) { buf += c; if (c === quote) quote = ''; continue; }
113
+ if (!buf.trim() && /\S/.test(c)) from = line;
114
+ if (c === '"' || c === "'") { quote = c; buf += c; continue; }
115
+ if (c === '{') {
116
+ const selector = buf.replace(/\s+/g, ' ').trim(), parent = top() ?? null;
117
+ const rule = { file, line, selector, decls: [], lines: [], parent, inKeyframes: !!parent?.inKeyframes || selector.startsWith('@keyframes') };
118
+ rules.push(rule); open.push(rule); buf = '';
119
+ } else if (c === ';') decl();
120
+ else if (c === '}') { if (!top()) throw new Error(`twins: ${file}:${line}: a } with no block open`); decl(); open.pop(); }
121
+ else buf += c;
122
+ }
123
+ if (top()) throw new Error(`twins: ${file}: the block opened at line ${top().line} (${top().selector}) never closes`);
124
+ return rules;
125
+ }
126
+
127
+ function* tsxIn(dir) {
128
+ for (const name of readdirSync(dir)) {
129
+ const p = join(dir, name);
130
+ if (statSync(p).isDirectory()) yield* tsxIn(p);
131
+ else if (name.endsWith('.tsx')) yield p;
132
+ }
133
+ }
134
+
135
+ /** every value the package writes for itself, from `root` (the package folder): its components' css and the inline styles of src/ and the stories */
136
+ export function ownValuesIn(root = '.') {
137
+ const css = readdirSync(join(root, OWN.css)).filter((f) => f.endsWith('.css') && !FOUNDATION.includes(f)).sort()
138
+ .flatMap((f) => ownValues(cssRules(readFileSync(join(root, OWN.css, f), 'utf8'), `${OWN.css}/${f}`)));
139
+ const tsx = OWN.tsx.flatMap((d) => [...tsxIn(join(root, d))].sort().map((p) => [p, p.slice(root === '.' ? 0 : root.length + 1)]))
140
+ .flatMap(([p, f]) => ownStyle(readFileSync(p, 'utf8'), f));
141
+ return [...css, ...tsx];
142
+ }
143
+
144
+ export const say = (o) => `${o.file}:${o.line} error [own-value] ${o.kind} ${o.what} in \`${o.decl}\`${o.selector === 'style' ? ' (an inline style)' : ` (${o.selector})`}: read a token (css/tokens.css), or add one there`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.103",
3
+ "version": "0.7.105",
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.",
@@ -21,13 +21,15 @@
21
21
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p gallery/tsconfig.json --noEmit",
22
22
  "snapshot:write": "CARDDS_SNAPSHOT=write playwright test tests/style-snapshot.spec.js",
23
23
  "snapshot:check": "CARDDS_SNAPSHOT=check playwright test tests/style-snapshot.spec.js",
24
- "check": "node bin/cardds.mjs check .design-sync/previews gallery/src"
24
+ "dup": "jscpd",
25
+ "check": "node scripts/own-values.mjs && node bin/cardds.mjs check .design-sync/previews gallery/src"
25
26
  },
26
27
  "devDependencies": {
27
28
  "@playwright/test": "1.58.2",
28
29
  "@types/react": "^19.3.0",
29
30
  "@types/react-dom": "^19.3.0",
30
31
  "@vitejs/plugin-react": "^6.1.1",
32
+ "jscpd": "4.3.0",
31
33
  "react": "^19.3.0",
32
34
  "react-dom": "^19.3.0",
33
35
  "react-router": "^8.3.1",
@@ -67,9 +69,9 @@
67
69
  "CHANGELOG.md"
68
70
  ],
69
71
  "peerDependencies": {
72
+ "eslint": ">=9",
70
73
  "react": ">=19",
71
- "react-dom": ">=19",
72
- "eslint": ">=9"
74
+ "react-dom": ">=19"
73
75
  },
74
76
  "publishConfig": {
75
77
  "access": "public"