@usableapp/cardds 0.7.19 → 0.7.21

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 CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  > For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap.
4
4
 
5
+ ## 0.7.21 — 2026-09-29
6
+
7
+ ### Fixed
8
+ - `installScreenSlide()`: the phone's back slid the WHOLE screen on every history step, also on an entry of the same screen (an open `Home`/`SheetStack` sheet kept in `history.state` so back closes it) — the page slid while the stack closed. Now an entry with the same path + query goes back without a slide; the component moves itself. Nothing to change in an app (Lh 2026-09-29, timebank home).
9
+
10
+ ## 0.7.20 — 2026-09-28
11
+
12
+ Lh, from timebank on a real Android phone (card #153). Nothing to change in a screen.
13
+
14
+ ### Changed
15
+ - A `Sheet`'s title is ONE line, cut with … when too long (was: wrapped to two lines). The thing at the head's end keeps its place; a caption still takes its own row. A wrapped title no longer makes a taller peek strip — there is none.
16
+ - `SheetSteps`: the open step's content (words, `Field`, `Pin` cells…) sits inset to the step title's words — the bar's own inset (`--sheet-step-bar-pad-x`), or its own knob `--sheet-step-inner-pad-x`.
17
+ - `SheetSteps`: room under the LAST step (`--sheet-steps-end`, default sp-6) — scrolled to the end it no longer touches the sheet's foot button.
18
+
19
+ ### Fixed
20
+ - A folded `SheetStep`'s long `summary` ran past the sheet's right edge; it is cut with … inside its row now.
21
+
22
+ ### Added
23
+ - `Bullets` + `Bullet` (lists): each item led by a small square picture one line tall (`1lh`), the words wrap beside it. `pic` = an image URL or any node, on the list (every item) or on an item (its own); none = the placeholder square. Knobs `--bullets-pic-size` · `--bullets-pic-gap` · `--bullets-pic-radius` · `--bullets-gap` · `--bullets-font`. Gallery: `/Bullets`.
24
+
5
25
  ## 0.7.19 — 2026-09-28
6
26
 
7
27
  ### Fixed
package/css/lists.css CHANGED
@@ -204,3 +204,13 @@
204
204
  .link:disabled { color: var(--link-disabled-ink, var(--card-muted)); cursor: default; } /* inactive = muted (a WaitLink counting down) */
205
205
  .link--wait { font-variant-numeric: tabular-nums; } /* the countdown's digits keep their width: the words do not jiggle each second */
206
206
  .card--3 .link--muted { color: var(--card-3-muted); }
207
+
208
+ /* ---- bullets: a list, each item led by a small square picture as tall as a line of its words (Lh 2026-09-28, timebank #153) ----
209
+ <ul class="bullets"><li class="bullets__item"><img class="bullets__pic" alt=""><span class="bullets__text">…</span></li></ul>
210
+ The picture is the app's (an image or any node); none = the wireframe's crossed square. It sits on the item's FIRST line
211
+ (1lh — the line's own height, so it follows the text size), the words wrap beside it, never under it. */
212
+ .bullets { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--bullets-gap, var(--sp-3)); font: var(--bullets-font, var(--type-body)); }
213
+ .bullets__item { display: flex; align-items: start; gap: var(--bullets-pic-gap, var(--sp-3)); min-width: 0; }
214
+ .bullets__pic { flex: none; inline-size: var(--bullets-pic-size, 1lh); block-size: var(--bullets-pic-size, 1lh); min-height: 0; object-fit: contain; border-radius: var(--bullets-pic-radius, 0rem); display: grid; place-items: center; }
215
+ .bullets__pic.ph { border-radius: var(--bullets-pic-radius, 0rem); }
216
+ .bullets__text { flex: 1; min-width: 0; }
package/css/sheet.css CHANGED
@@ -208,6 +208,10 @@
208
208
  whole ask is that switch: peek shows it (a row is --tap tall plus its own
209
209
  padding; the strip cuts in that padding) — card #67's "ไม่อยู่บ้าน" */
210
210
  .sheet > .card__head { min-height: var(--sheet-head-h); }
211
+ /* a sheet's title is ONE line, cut with … when the phone is too narrow (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน"
212
+ wrapped to two lines at 390px) — the thing at its end keeps its place beside it; a caption still takes its own row */
213
+ .sheet > .card__head:not(:has(> :is(.t-caption, .t-body))) { flex-wrap: nowrap; }
214
+ .sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
211
215
  .sheet__head {
212
216
  display: grid;
213
217
  grid-template-columns: var(--tap-sm) 1fr var(--tap-sm);
@@ -381,11 +385,11 @@
381
385
  .sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
382
386
  .sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0rem)); /* the keyboard's height more: the pile of next steps stays under the keyboard, it never rises with it */ display: flex; flex-direction: column; touch-action: pan-y; position: relative; }
383
387
  .sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1rem; height: 1rem; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; font 1rem = the browser's own size, so iOS does not zoom to it */
384
- .sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
388
+ .sheet-steps__track { list-style: none; margin: 0; padding: 0 0 var(--sheet-steps-end, var(--sp-6)); flex: 1; /* room under the LAST step: scrolled to the end it never touches the foot's button (Lh 2026-09-28, #153) */ display: flex; flex-direction: column; }
385
389
 
386
390
  .sheet-step {
387
391
  position: relative; min-width: 0;
388
- display: grid; grid-template-rows: auto 0fr; align-content: start; /* mid-fold the rows sum to less than the box: the spare room stays BELOW, never spread into the title row (Lh: it looked like a spring) */
392
+ display: grid; grid-template-columns: minmax(0, 1fr); /* the column is the step's width, never its words' — a long summary is cut with …, it never pushes the row past the sheet's edge (Lh 2026-09-28, #153) */ grid-template-rows: auto 0fr; align-content: start; /* mid-fold the rows sum to less than the box: the spare room stays BELOW, never spread into the title row (Lh: it looked like a spring) */
389
393
  flex: 0 0 auto;
390
394
  transition: grid-template-rows var(--motion-sheet) ease, flex-grow var(--motion-sheet) ease;
391
395
  --_row-h: var(--sheet-steps-row-h, calc(var(--tap-base) * 2 / 3)); /* the bar's own floor, read once (__head and __go below share it) */
@@ -396,7 +400,7 @@
396
400
  .sheet-step--now { grid-template-rows: auto 1fr; flex-grow: 1; }
397
401
  .sheet-step + .sheet-step { margin-top: var(--sheet-steps-gap, var(--sp-1)); } /* the folded bars read as a pile, a little ground between them (Lh 2026-09-25) */
398
402
  .sheet-step__head { /* every step's title line is a quiet BAR (a tint of the ink, not the brand — a brand bar read as a button, Lh 2026-09-25), the same height open or folded — opening shows the white room under it, never a taller bar (Lh 2026-09-25; was a thin rule on top) */
399
- display: flex; align-items: center; gap: var(--sp-3); min-height: var(--_row-h); /* THIN: 32px, a summary line, not a button (Lh 2026-09-25) — the tap stays 44, see __go */
403
+ display: flex; align-items: center; gap: var(--sp-3); min-height: var(--_row-h); min-width: 0; /* THIN: 32px, a summary line, not a button (Lh 2026-09-25) — the tap stays 44, see __go */
400
404
  padding-inline: var(--sheet-step-bar-pad-x, var(--sp-3)); border-radius: var(--sheet-step-bar-radius, var(--r-chip));
401
405
  background: var(--sheet-step-bar-bg, var(--ink-tint)); color: var(--sheet-step-bar-ink, inherit);
402
406
  border-top: var(--sheet-step-border-w, 0rem) solid var(--sheet-step-border, currentColor); /* a rule instead of the bar: set this, the bar's bg transparent, radius 0 */
@@ -410,7 +414,7 @@
410
414
  .sheet-step__summary { color: inherit; opacity: 0.8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
411
415
  .sheet-step__check { margin-inline-start: auto; }
412
416
  .sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
413
- .sheet-step__inner { display: grid; gap: var(--card-gap); padding-block: var(--card-gap); }
417
+ .sheet-step__inner { display: grid; gap: var(--card-gap); padding-block: var(--card-gap); padding-inline: var(--sheet-step-inner-pad-x, var(--sheet-step-bar-pad-x, var(--sp-3))); } /* the open step's content in the frame of its title's words (the bar's own inset), so it reads as that step's (Lh 2026-09-28, #153) */
414
418
  .sheet-step__inner > * { min-width: 0; }
415
419
  .sheet-step__inner > .link { justify-self: center; } /* a link of its own row in a step (skip, "change number", "set PIN again"): centred under the step, as the skip (Lh 2026-09-25) */
416
420
  .sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
package/dist/cardds.css CHANGED
@@ -1490,6 +1490,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1490
1490
  whole ask is that switch: peek shows it (a row is --tap tall plus its own
1491
1491
  padding; the strip cuts in that padding) — card #67's "ไม่อยู่บ้าน" */
1492
1492
  .sheet > .card__head { min-height: var(--sheet-head-h); }
1493
+ /* a sheet's title is ONE line, cut with … when the phone is too narrow (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน"
1494
+ wrapped to two lines at 390px) — the thing at its end keeps its place beside it; a caption still takes its own row */
1495
+ .sheet > .card__head:not(:has(> :is(.t-caption, .t-body))) { flex-wrap: nowrap; }
1496
+ .sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
1493
1497
  .sheet__head {
1494
1498
  display: grid;
1495
1499
  grid-template-columns: var(--tap-sm) 1fr var(--tap-sm);
@@ -1663,11 +1667,11 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1663
1667
  .sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
1664
1668
  .sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0rem)); /* the keyboard's height more: the pile of next steps stays under the keyboard, it never rises with it */ display: flex; flex-direction: column; touch-action: pan-y; position: relative; }
1665
1669
  .sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1rem; height: 1rem; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; font 1rem = the browser's own size, so iOS does not zoom to it */
1666
- .sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
1670
+ .sheet-steps__track { list-style: none; margin: 0; padding: 0 0 var(--sheet-steps-end, var(--sp-6)); flex: 1; /* room under the LAST step: scrolled to the end it never touches the foot's button (Lh 2026-09-28, #153) */ display: flex; flex-direction: column; }
1667
1671
 
1668
1672
  .sheet-step {
1669
1673
  position: relative; min-width: 0;
1670
- display: grid; grid-template-rows: auto 0fr; align-content: start; /* mid-fold the rows sum to less than the box: the spare room stays BELOW, never spread into the title row (Lh: it looked like a spring) */
1674
+ display: grid; grid-template-columns: minmax(0, 1fr); /* the column is the step's width, never its words' — a long summary is cut with …, it never pushes the row past the sheet's edge (Lh 2026-09-28, #153) */ grid-template-rows: auto 0fr; align-content: start; /* mid-fold the rows sum to less than the box: the spare room stays BELOW, never spread into the title row (Lh: it looked like a spring) */
1671
1675
  flex: 0 0 auto;
1672
1676
  transition: grid-template-rows var(--motion-sheet) ease, flex-grow var(--motion-sheet) ease;
1673
1677
  --_row-h: var(--sheet-steps-row-h, calc(var(--tap-base) * 2 / 3)); /* the bar's own floor, read once (__head and __go below share it) */
@@ -1678,7 +1682,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1678
1682
  .sheet-step--now { grid-template-rows: auto 1fr; flex-grow: 1; }
1679
1683
  .sheet-step + .sheet-step { margin-top: var(--sheet-steps-gap, var(--sp-1)); } /* the folded bars read as a pile, a little ground between them (Lh 2026-09-25) */
1680
1684
  .sheet-step__head { /* every step's title line is a quiet BAR (a tint of the ink, not the brand — a brand bar read as a button, Lh 2026-09-25), the same height open or folded — opening shows the white room under it, never a taller bar (Lh 2026-09-25; was a thin rule on top) */
1681
- display: flex; align-items: center; gap: var(--sp-3); min-height: var(--_row-h); /* THIN: 32px, a summary line, not a button (Lh 2026-09-25) — the tap stays 44, see __go */
1685
+ display: flex; align-items: center; gap: var(--sp-3); min-height: var(--_row-h); min-width: 0; /* THIN: 32px, a summary line, not a button (Lh 2026-09-25) — the tap stays 44, see __go */
1682
1686
  padding-inline: var(--sheet-step-bar-pad-x, var(--sp-3)); border-radius: var(--sheet-step-bar-radius, var(--r-chip));
1683
1687
  background: var(--sheet-step-bar-bg, var(--ink-tint)); color: var(--sheet-step-bar-ink, inherit);
1684
1688
  border-top: var(--sheet-step-border-w, 0rem) solid var(--sheet-step-border, currentColor); /* a rule instead of the bar: set this, the bar's bg transparent, radius 0 */
@@ -1692,7 +1696,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1692
1696
  .sheet-step__summary { color: inherit; opacity: 0.8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1693
1697
  .sheet-step__check { margin-inline-start: auto; }
1694
1698
  .sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
1695
- .sheet-step__inner { display: grid; gap: var(--card-gap); padding-block: var(--card-gap); }
1699
+ .sheet-step__inner { display: grid; gap: var(--card-gap); padding-block: var(--card-gap); padding-inline: var(--sheet-step-inner-pad-x, var(--sheet-step-bar-pad-x, var(--sp-3))); } /* the open step's content in the frame of its title's words (the bar's own inset), so it reads as that step's (Lh 2026-09-28, #153) */
1696
1700
  .sheet-step__inner > * { min-width: 0; }
1697
1701
  .sheet-step__inner > .link { justify-self: center; } /* a link of its own row in a step (skip, "change number", "set PIN again"): centred under the step, as the skip (Lh 2026-09-25) */
1698
1702
  .sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
@@ -3244,6 +3248,16 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3244
3248
  .link--wait { font-variant-numeric: tabular-nums; } /* the countdown's digits keep their width: the words do not jiggle each second */
3245
3249
  .card--3 .link--muted { color: var(--card-3-muted); }
3246
3250
 
3251
+ /* ---- bullets: a list, each item led by a small square picture as tall as a line of its words (Lh 2026-09-28, timebank #153) ----
3252
+ <ul class="bullets"><li class="bullets__item"><img class="bullets__pic" alt=""><span class="bullets__text">…</span></li></ul>
3253
+ The picture is the app's (an image or any node); none = the wireframe's crossed square. It sits on the item's FIRST line
3254
+ (1lh — the line's own height, so it follows the text size), the words wrap beside it, never under it. */
3255
+ .bullets { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--bullets-gap, var(--sp-3)); font: var(--bullets-font, var(--type-body)); }
3256
+ .bullets__item { display: flex; align-items: start; gap: var(--bullets-pic-gap, var(--sp-3)); min-width: 0; }
3257
+ .bullets__pic { flex: none; inline-size: var(--bullets-pic-size, 1lh); block-size: var(--bullets-pic-size, 1lh); min-height: 0; object-fit: contain; border-radius: var(--bullets-pic-radius, 0rem); display: grid; place-items: center; }
3258
+ .bullets__pic.ph { border-radius: var(--bullets-pic-radius, 0rem); }
3259
+ .bullets__text { flex: 1; min-width: 0; }
3260
+
3247
3261
  /* ---- css/media.css — cover card, quote, tile grid, fold, mosaic, wave ---- */
3248
3262
  /* ============================================================
3249
3263
  cardds/media.css — media & display inside cards:
package/dist/index.d.ts CHANGED
@@ -65,6 +65,7 @@ export * from './lists/Row.js';
65
65
  export * from './lists/Kv.js';
66
66
  export * from './lists/Timeline.js';
67
67
  export * from './lists/Legend.js';
68
+ export * from './lists/Bullets.js';
68
69
  export * from './lists/Bubble.js';
69
70
  export * from './media/Quote.js';
70
71
  export * from './media/Postcard.js';
package/dist/index.js CHANGED
@@ -69,6 +69,7 @@ export * from './lists/Row.js';
69
69
  export * from './lists/Kv.js';
70
70
  export * from './lists/Timeline.js';
71
71
  export * from './lists/Legend.js';
72
+ export * from './lists/Bullets.js';
72
73
  export * from './lists/Bubble.js';
73
74
  export * from './media/Quote.js';
74
75
  export * from './media/Postcard.js';
@@ -0,0 +1,16 @@
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ /** a bullet's picture: an image URL, or any node (an Icon, an <img> of the app's own) */
3
+ export type BulletPic = string | ReactNode;
4
+ export interface BulletsProps extends ComponentProps<'ul'> {
5
+ /** the picture every item leads with, unless the item gives its own; none = a placeholder square */
6
+ pic?: BulletPic;
7
+ }
8
+ /** Bullets — a list whose items each lead with a small square PICTURE as tall as a line of their words (Lh 2026-09-28: an agreement's
9
+ * short version, one point per item); children are Bullets. Pictures are decoration (alt=""), the words carry the meaning. */
10
+ export declare function Bullets({ pic, className, ...rest }: BulletsProps): import("react").JSX.Element;
11
+ export interface BulletProps extends ComponentProps<'li'> {
12
+ /** this item's own picture (wins over the list's) */
13
+ pic?: BulletPic;
14
+ }
15
+ /** Bullet — one item of Bullets: its picture, then its words. */
16
+ export declare function Bullet({ pic, className, children, ...rest }: BulletProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext } from 'react';
3
+ import { cx } from '../cx.js';
4
+ const PicOfList = createContext(undefined);
5
+ /** Bullets — a list whose items each lead with a small square PICTURE as tall as a line of their words (Lh 2026-09-28: an agreement's
6
+ * short version, one point per item); children are Bullets. Pictures are decoration (alt=""), the words carry the meaning. */
7
+ export function Bullets({ pic, className, ...rest }) {
8
+ return (_jsx(PicOfList.Provider, { value: pic, children: _jsx("ul", { className: cx('bullets', className), ...rest }) }));
9
+ }
10
+ /** Bullet — one item of Bullets: its picture, then its words. */
11
+ export function Bullet({ pic, className, children, ...rest }) {
12
+ const ofList = useContext(PicOfList);
13
+ const shown = pic ?? ofList;
14
+ return (_jsxs("li", { className: cx('bullets__item', className), ...rest, children: [shown == null ? _jsx("span", { className: "bullets__pic ph", "aria-hidden": "true" })
15
+ : typeof shown === 'string' ? _jsx("img", { className: "bullets__pic", src: shown, alt: "" })
16
+ : _jsx("span", { className: "bullets__pic", "aria-hidden": "true", children: shown }), _jsx("span", { className: "bullets__text", children: children })] }));
17
+ }
@@ -10,6 +10,7 @@ export declare function slideScreen(update: () => unknown, direction?: SlideDire
10
10
  /**
11
11
  * installScreenSlide — call ONCE when the app starts: the phone's / browser's BACK (and forward) buttons slide the screen too,
12
12
  * the way `back` (and `next`) does. It listens to `popstate` before the router renders; the direction is read from the history
13
- * entry's index (`history.state.idx`, which React Router keeps; without it: back). Returns the uninstall.
13
+ * entry's index (`history.state.idx`, which React Router keeps; without it: back). An entry of the SAME screen (same path and
14
+ * query, only its state differs — an open sheet pushed so the phone's back closes it) does not slide. Returns the uninstall.
14
15
  */
15
16
  export declare function installScreenSlide(): () => void;
@@ -47,17 +47,25 @@ export function slideScreen(update, direction = 'next', scope = document) {
47
47
  /**
48
48
  * installScreenSlide — call ONCE when the app starts: the phone's / browser's BACK (and forward) buttons slide the screen too,
49
49
  * the way `back` (and `next`) does. It listens to `popstate` before the router renders; the direction is read from the history
50
- * entry's index (`history.state.idx`, which React Router keeps; without it: back). Returns the uninstall.
50
+ * entry's index (`history.state.idx`, which React Router keeps; without it: back). An entry of the SAME screen (same path and
51
+ * query, only its state differs — an open sheet pushed so the phone's back closes it) does not slide. Returns the uninstall.
51
52
  */
52
53
  export function installScreenSlide() {
53
- let idx = history.state?.idx;
54
+ const where = () => location.pathname + location.search;
55
+ let idx = history.state?.idx, at = where();
54
56
  const push = history.pushState.bind(history), replace = history.replaceState.bind(history);
55
- history.pushState = (...a) => { push(...a); idx = history.state?.idx; };
56
- history.replaceState = (...a) => { replace(...a); idx = history.state?.idx; };
57
+ const seen = () => { idx = history.state?.idx; at = where(); };
58
+ history.pushState = (...a) => { push(...a); seen(); };
59
+ history.replaceState = (...a) => { replace(...a); seen(); };
57
60
  const pop = () => {
58
- const to = history.state?.idx;
61
+ const to = history.state?.idx, same = where() === at;
59
62
  const dir = to != null && idx != null && to > idx ? 'next' : 'back';
60
63
  idx = to;
64
+ at = where();
65
+ /* the SAME screen (path + query unchanged, only the entry's state — an open sheet, a tab): the screen stays, the
66
+ component moves itself (a SheetStack closes as a stack does). Lh 2026-09-29, timebank home. */
67
+ if (same)
68
+ return;
61
69
  slideScreen(() => undefined, dir); // the router renders on its own, right after this: the slide waits for its new Screen
62
70
  };
63
71
  addEventListener('popstate', pop, { capture: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.19",
3
+ "version": "0.7.21",
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.",