@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
@@ -71,6 +71,67 @@ const armSwallow = (swallow) => {
71
71
  swallow.current = true;
72
72
  requestAnimationFrame(() => { swallow.current = false; });
73
73
  };
74
+ /* the finger let go short of the commit (or was cancelled): the card eases back from the frame just read */
75
+ const releaseCard = (card, live, prop) => {
76
+ live.current = null;
77
+ card.classList.remove('is-dragging');
78
+ readFrame(card);
79
+ card.style.removeProperty(prop);
80
+ };
81
+ /* a finger on a head, the open sheet's (down, `--_pull`, folds it) or a peeked one's (up, `--_lift`, opens it): the card follows it up to
82
+ `cap`; let go past the commit, `settle` asks the app, and a card the app left where it was (`unmoved`) eases back */
83
+ function pullHead(e, { card, head, doc, held, live, swallow, way, prop, cap, settle, unmoved }) {
84
+ const slop = pxFromRem(SLOP_REM);
85
+ const commit = pxFromRem(COMMIT_REM);
86
+ const pull = { pointerId: e.pointerId, startY: e.clientY, last: 0, cap: Math.max(cap, commit), commit, slop, moved: false };
87
+ held.current = pull;
88
+ const onMove = (ev) => {
89
+ if (ev.pointerId !== pull.pointerId || held.current !== pull)
90
+ return;
91
+ const d = along(way, pull.startY, ev.clientY);
92
+ pull.last = d;
93
+ if (d < pull.slop)
94
+ return;
95
+ pull.moved = true;
96
+ ev.preventDefault();
97
+ try {
98
+ if (!head.hasPointerCapture(ev.pointerId))
99
+ head.setPointerCapture(ev.pointerId);
100
+ }
101
+ catch { /* the pointer is already gone */ }
102
+ live.current = { prop, px: `${Math.min(d, pull.cap)}px` };
103
+ card.classList.add('is-dragging');
104
+ card.style.setProperty(prop, live.current.px);
105
+ };
106
+ const onUp = (ev) => {
107
+ if (ev.pointerId !== pull.pointerId || held.current !== pull)
108
+ return;
109
+ const d = Math.max(pull.last, along(way, pull.startY, ev.clientY));
110
+ const moved = pull.moved || d >= pull.slop;
111
+ held.current = null;
112
+ doc.stop();
113
+ if (moved && d >= pull.commit) {
114
+ card.classList.remove('is-dragging');
115
+ readFrame(card);
116
+ live.current = null;
117
+ flushSync(settle);
118
+ if (card.isConnected && unmoved() && card.style.getPropertyValue(prop))
119
+ releaseCard(card, live, prop);
120
+ }
121
+ else
122
+ releaseCard(card, live, prop);
123
+ if (moved)
124
+ armSwallow(swallow);
125
+ };
126
+ const onCancel = (ev) => {
127
+ if (ev.pointerId !== pull.pointerId || held.current !== pull)
128
+ return;
129
+ held.current = null;
130
+ doc.stop();
131
+ releaseCard(card, live, prop);
132
+ };
133
+ doc.listen(onMove, onUp, onCancel);
134
+ }
74
135
  /**
75
136
  * The card's own pull, refresh and focus. Listeners sit on this card's head and body.
76
137
  * `--_pull` / `--_lift` are written while a finger is down, the same way a single Sheet writes `--_drag`.
@@ -113,68 +174,14 @@ export function useStackCard(ref, place, swallow) {
113
174
  const scroller = card.querySelector(':scope > .sheet__body') ?? card;
114
175
  const doc = docPointer();
115
176
  const primary = (e) => e.isPrimary && e.button === 0;
116
- const release = (prop) => {
117
- live.current = null;
118
- card.classList.remove('is-dragging');
119
- readFrame(card);
120
- card.style.removeProperty(prop);
121
- };
122
177
  const onHeadDown = (e) => {
123
178
  e.stopPropagation();
124
179
  if (!head || !stack || !primary(e) || headPull.current || bodyPull.current)
125
180
  return;
126
181
  if (!pullsHead(e.target, head))
127
182
  return;
128
- const slop = pxFromRem(SLOP_REM);
129
- const commit = pxFromRem(COMMIT_REM);
130
- const pull = { pointerId: e.pointerId, startY: e.clientY, last: 0, cap: Math.max(restPullPx(card, stack), commit), commit, slop, moved: false };
131
- headPull.current = pull;
132
- const onMove = (ev) => {
133
- if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
134
- return;
135
- const dy = along('down', pull.startY, ev.clientY);
136
- pull.last = dy;
137
- if (dy < pull.slop)
138
- return;
139
- pull.moved = true;
140
- ev.preventDefault();
141
- try {
142
- if (head && !head.hasPointerCapture(ev.pointerId))
143
- head.setPointerCapture(ev.pointerId);
144
- }
145
- catch { /* the pointer is already gone */ }
146
- live.current = { prop: '--_pull', px: `${Math.min(dy, pull.cap)}px` };
147
- card.classList.add('is-dragging');
148
- card.style.setProperty('--_pull', live.current.px);
149
- };
150
- const onUp = (ev) => {
151
- if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
152
- return;
153
- const dy = Math.max(pull.last, along('down', pull.startY, ev.clientY));
154
- const moved = pull.moved || dy >= pull.slop;
155
- headPull.current = null;
156
- doc.stop();
157
- if (moved && dy >= pull.commit) {
158
- card.classList.remove('is-dragging');
159
- readFrame(card);
160
- live.current = null;
161
- flushSync(() => placeRef.current?.onClose());
162
- if (card.isConnected && card.classList.contains('is-open') && card.style.getPropertyValue('--_pull'))
163
- release('--_pull');
164
- }
165
- else
166
- release('--_pull');
167
- if (moved)
168
- armSwallow(swallow);
169
- };
170
- const onCancel = (ev) => {
171
- if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
172
- return;
173
- headPull.current = null;
174
- doc.stop();
175
- release('--_pull');
176
- };
177
- doc.listen(onMove, onUp, onCancel);
183
+ pullHead(e, { card, head, doc, held: headPull, live, swallow, way: 'down', prop: '--_pull', cap: restPullPx(card, stack),
184
+ settle: () => placeRef.current?.onClose(), unmoved: () => card.classList.contains('is-open') });
178
185
  };
179
186
  const onBodyDown = (e) => {
180
187
  const ask = placeRef.current;
@@ -279,7 +286,7 @@ export function useStackCard(ref, place, swallow) {
279
286
  scroller.style.removeProperty('--_refresh');
280
287
  setPulling(false);
281
288
  if (live.current?.prop === '--_pull')
282
- release('--_pull');
289
+ releaseCard(card, live, '--_pull');
283
290
  head?.removeEventListener('pointerdown', onHeadDown);
284
291
  scroller.removeEventListener('pointerdown', onBodyDown);
285
292
  };
@@ -295,12 +302,6 @@ export function useStackCard(ref, place, swallow) {
295
302
  if (!head || !stack)
296
303
  return;
297
304
  const doc = docPointer();
298
- const release = () => {
299
- live.current = null;
300
- card.classList.remove('is-dragging');
301
- readFrame(card);
302
- card.style.removeProperty('--_lift');
303
- };
304
305
  const onDown = (e) => {
305
306
  if (!e.isPrimary || e.button !== 0 || headPull.current)
306
307
  return;
@@ -329,64 +330,15 @@ export function useStackCard(ref, place, swallow) {
329
330
  return;
330
331
  }
331
332
  e.stopPropagation();
332
- const slop = pxFromRem(SLOP_REM);
333
- const commit = pxFromRem(COMMIT_REM);
334
- const travel = card.getBoundingClientRect().top - stack.getBoundingClientRect().top;
335
- const pull = { pointerId: e.pointerId, startY: e.clientY, last: 0, cap: Math.max(travel, commit), commit, slop, moved: false };
336
- headPull.current = pull;
337
- const onMove = (ev) => {
338
- if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
339
- return;
340
- const upPx = along('up', pull.startY, ev.clientY);
341
- pull.last = upPx;
342
- if (upPx < pull.slop)
343
- return;
344
- pull.moved = true;
345
- ev.preventDefault();
346
- try {
347
- if (!head.hasPointerCapture(ev.pointerId))
348
- head.setPointerCapture(ev.pointerId);
349
- }
350
- catch { /* the pointer is already gone */ }
351
- live.current = { prop: '--_lift', px: `${Math.min(upPx, pull.cap)}px` };
352
- card.classList.add('is-dragging');
353
- card.style.setProperty('--_lift', live.current.px);
354
- };
355
- const onUp = (ev) => {
356
- if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
357
- return;
358
- const upPx = Math.max(pull.last, along('up', pull.startY, ev.clientY));
359
- const moved = pull.moved || upPx >= pull.slop;
360
- headPull.current = null;
361
- doc.stop();
362
- if (moved && upPx >= pull.commit) {
363
- card.classList.remove('is-dragging');
364
- readFrame(card);
365
- live.current = null;
366
- flushSync(() => placeRef.current?.onOpen());
367
- if (card.isConnected && !card.classList.contains('is-open') && card.style.getPropertyValue('--_lift'))
368
- release();
369
- }
370
- else
371
- release();
372
- if (moved)
373
- armSwallow(swallow);
374
- };
375
- const onCancel = (ev) => {
376
- if (ev.pointerId !== pull.pointerId || headPull.current !== pull)
377
- return;
378
- headPull.current = null;
379
- doc.stop();
380
- release();
381
- };
382
- doc.listen(onMove, onUp, onCancel);
333
+ pullHead(e, { card, head, doc, held: headPull, live, swallow, way: 'up', prop: '--_lift', cap: card.getBoundingClientRect().top - stack.getBoundingClientRect().top,
334
+ settle: () => placeRef.current?.onOpen(), unmoved: () => !card.classList.contains('is-open') });
383
335
  };
384
336
  head.addEventListener('pointerdown', onDown);
385
337
  return () => {
386
338
  doc.stop();
387
339
  headPull.current = null;
388
340
  if (live.current?.prop === '--_lift')
389
- release();
341
+ releaseCard(card, live, '--_lift');
390
342
  head.removeEventListener('pointerdown', onDown);
391
343
  };
392
344
  }, [place?.open, place?.before, place?.after, ref, swallow]);
package/dist/state.d.ts CHANGED
@@ -25,6 +25,27 @@ export declare function watchSize(targets: Array<Element | null | undefined>, fn
25
25
  export declare const warnOnce: (key: string, text: string) => void;
26
26
  /** the member asked for less motion (false where there is no window, e.g. a server render) */
27
27
  export declare const reducedMotion: () => boolean;
28
+ /** `--motion-sheet` in ms, read from `cs` (the document's by default); 350 where it reads as none */
29
+ export declare const sheetMs: (cs?: CSSStyleDeclaration) => number;
30
+ /** cancel the moves tagged `id` on `els`, so what is read next is where they rest */
31
+ export declare const stopMoves: (els: HTMLElement[], id: string) => void;
32
+ /** a reordered element glides from `dy` px off to its place, at the `--motion-sheet` and `--ease-settle` of its list (`cs`); tagged `id` for stopMoves */
33
+ export declare function glide(el: HTMLElement, dy: number, cs: CSSStyleDeclaration, id: string): Animation;
34
+ /** how far a press travelled along `axis` from `from` to `to` — 0 when it is no swipe: shorter than `min` px, or not `ratio` times its travel across */
35
+ export declare function swipeOf(from: {
36
+ x: number;
37
+ y: number;
38
+ }, to: {
39
+ clientX: number;
40
+ clientY: number;
41
+ }, axis: 'x' | 'y', min: number, ratio?: number): number;
42
+ /** a rail that comes to rest: `settled()` on each scroll, and `then` runs `ms` after the last one; `cancel` drops the wait, and so does unmounting */
43
+ export declare function useSettled(then: () => void, ms?: number): {
44
+ settled: () => void;
45
+ cancel: () => void;
46
+ };
47
+ /** the bottom of what the member sees — the keyboard's top while it is up (Infinity where the browser has no visualViewport) */
48
+ export declare const visibleBottom: () => number;
28
49
  /** give `box` the cursor once it has settled: after its own `translate` transition (600ms at most), or after `wait` ms. It lands on the field
29
50
  * marked `data-autofocus`, else the first field inside `scope` (a selector under `box`; default: all of it), else `fallback`. A field in
30
51
  * `box` that already has the cursor keeps it. Inside `[data-standby="off"]` no field is looked for: `fallback` only. Nothing moves the page (`preventScroll`). Returns the cancel — `true` when it had already
package/dist/state.js CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  export function useControlled(value, initial, onChange) {
3
3
  const [own, setOwn] = useState(initial);
4
4
  const shown = value !== undefined ? value : own;
@@ -63,6 +63,33 @@ export const warnOnce = (key, text) => { if (!warned.has(key)) {
63
63
  } };
64
64
  /** the member asked for less motion (false where there is no window, e.g. a server render) */
65
65
  export const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
66
+ /** `--motion-sheet` in ms, read from `cs` (the document's by default); 350 where it reads as none */
67
+ export const sheetMs = (cs = getComputedStyle(document.documentElement)) => (parseFloat(cs.getPropertyValue('--motion-sheet')) || 0.35) * 1000;
68
+ /** cancel the moves tagged `id` on `els`, so what is read next is where they rest */
69
+ export const stopMoves = (els, id) => els.forEach((el) => el.getAnimations().forEach((a) => { if (a.id === id)
70
+ a.cancel(); }));
71
+ /** a reordered element glides from `dy` px off to its place, at the `--motion-sheet` and `--ease-settle` of its list (`cs`); tagged `id` for stopMoves */
72
+ export function glide(el, dy, cs, id) {
73
+ const a = el.animate([{ translate: `0 ${dy}px` }, { translate: '0 0' }], { duration: sheetMs(cs), easing: cs.getPropertyValue('--ease-settle').trim() || 'ease' });
74
+ a.id = id;
75
+ return a;
76
+ }
77
+ /** how far a press travelled along `axis` from `from` to `to` — 0 when it is no swipe: shorter than `min` px, or not `ratio` times its travel across */
78
+ export function swipeOf(from, to, axis, min, ratio = 0) {
79
+ const dx = to.clientX - from.x, dy = to.clientY - from.y;
80
+ const [along, across] = axis === 'x' ? [dx, dy] : [dy, dx];
81
+ return Math.abs(along) < min || Math.abs(along) < ratio * Math.abs(across) ? 0 : along;
82
+ }
83
+ /** a rail that comes to rest: `settled()` on each scroll, and `then` runs `ms` after the last one; `cancel` drops the wait, and so does unmounting */
84
+ export function useSettled(then, ms = 120) {
85
+ const wait = useRef(null);
86
+ const cancel = () => { if (wait.current)
87
+ clearTimeout(wait.current); };
88
+ useEffect(() => cancel, []);
89
+ return { settled: () => { cancel(); wait.current = setTimeout(then, ms); }, cancel };
90
+ }
91
+ /** the bottom of what the member sees — the keyboard's top while it is up (Infinity where the browser has no visualViewport) */
92
+ export const visibleBottom = () => { const vv = window.visualViewport; return vv ? vv.offsetTop + vv.height : Infinity; };
66
93
  /* THE RULE (Lh 2026-09-29): a part of the screen that has just become ACTIVE — a sheet come up, a step opened, a stack sheet opened, a
67
94
  modal asking — has its field waiting with the cursor. ONE mechanic for every such part; the component only says WHEN it became active.
68
95
  THE SWITCH (Lh 2026-10-06): under `data-standby="off"` (on any box around the part) no field is given the cursor — the part's
@@ -2,9 +2,9 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { pressable } from '../pressable.js';
5
- import { setRefs, watchSize } from '../state.js';
5
+ import { setRefs } from '../state.js';
6
6
  import { word } from '../words.js';
7
- import { holdRoom } from '../room.js';
7
+ import { holdRoomWhile } from '../room.js';
8
8
  import { fitSticky } from './fit.js';
9
9
  /**
10
10
  * Sticky — a paper note: a flat pastel SQUARE of one size (168, the knob `--sticky-size`), square corners, a soft shadow under it.
@@ -31,11 +31,7 @@ export function Sticky({ tone = 'custard', flat, unreadLabel, head, date, tilt,
31
31
  if (!note || !paper)
32
32
  return;
33
33
  const measure = () => setSmall(fitSticky(note) !== 'full');
34
- room.current = holdRoom(paper, fieldOf, () => fitSticky(note) === 'over', measure);
35
- const body = paper.querySelector('.sticky__body');
36
- const stop = watchSize([body], measure);
37
- document.fonts?.ready.then(measure);
38
- return () => { room.current?.stop(); room.current = null; stop(); };
34
+ return holdRoomWhile(room, [paper.querySelector('.sticky__body')], measure, paper, fieldOf, () => fitSticky(note) === 'over', measure);
39
35
  }, []);
40
36
  const notched = corner != null;
41
37
  const { tap, Box, boxProps } = pressable(notched ? undefined : onClick, 'div', rest);
@@ -9,7 +9,7 @@ export interface HomeSheet {
9
9
  icon?: ReactNode;
10
10
  /** the tone slot — see `Tone` (default: 2, 3, 1, 2, 3 by place). The head shows it */
11
11
  tone?: Tone;
12
- /** white content, the head keeps `tone`. The same look as `Sheet paper` */
12
+ /** content on the theme's paper (`--action-bg`, the ground of its fields), the head keeps `tone`. The same look as `Sheet paper` */
13
13
  paper?: boolean;
14
14
  /** the sheet's pages as icon tabs in its head (a count shows as a small circle over the icon) — see `StackSheet` */
15
15
  tabs?: StackSheetTab[];
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
- import { reducedMotion, useLatest } from '../state.js';
3
+ import { reducedMotion, sheetMs, useLatest } from '../state.js';
4
4
  import { Screen } from '../scaffold/Screen.js';
5
5
  import { cx } from '../cx.js';
6
6
  /**
@@ -24,7 +24,7 @@ export function Splash({ mark, text, done, min = 0, onLeft, className, ...rest }
24
24
  if (!leaving)
25
25
  return;
26
26
  const still = reducedMotion();
27
- const ms = still ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--motion-sheet')) * 1000 || 350;
27
+ const ms = still ? 0 : sheetMs();
28
28
  const t = setTimeout(() => left.current?.(), ms);
29
29
  return () => clearTimeout(t);
30
30
  }, [leaving]);
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useLayoutEffect, useRef } from 'react';
3
- import { useControlled } from '../state.js';
3
+ import { swipeOf, useControlled } from '../state.js';
4
4
  import { Screen } from '../scaffold/Screen.js';
5
5
  import { Step } from '../sheets/Step.js';
6
6
  import { Page } from '../scaffold/Page.js';
@@ -40,10 +40,9 @@ export function Walkthrough({ pages, at: atProp, defaultAt = 0, onAtChange, cta,
40
40
  start.current = null;
41
41
  if (!s0)
42
42
  return;
43
- const dx = e.clientX - s0.x, dy = e.clientY - s0.y;
44
- if (Math.abs(dx) < 48 || Math.abs(dx) < 1.5 * Math.abs(dy))
45
- return;
46
- go(dx < 0 ? at + 1 : at - 1);
43
+ const dx = swipeOf(s0, e, 'x', 48, 1.5);
44
+ if (dx)
45
+ go(dx < 0 ? at + 1 : at - 1);
47
46
  },
48
47
  onPointerCancel: () => { start.current = null; },
49
48
  };
package/docs/reference.md CHANGED
@@ -341,9 +341,9 @@ whose markup has rules, because the sheet's box is the step itself:
341
341
  padding, the same distance from the edge, so the title stays put. `refreshing`
342
342
  (off unless the app sets it) changes the mark's colour while the app reloads
343
343
  what the sheet shows, as a stack sheet's does; the sheet does not fetch. `paper` makes
344
- the content white while the head keeps the sheet's colour (`tone`, as a card).
344
+ the content the theme's paper (`--action-bg`) while the head keeps the sheet's colour (`tone`, as a card).
345
345
  The same prop on a `StackSheet`, or on a `Card` in a stack: the head keeps the
346
- tone, the body and the foot are white, and the gap under the head stays the tone.
346
+ tone, the body and the foot are the paper, and the gap under the head stays the tone.
347
347
  While that sheet is closed, the corners the next sheet does not cover show the
348
348
  card's colour. That colour fades in as the sheet returns, and is done when it stops.
349
349
  A sheet with no head (the dim page sheet) is a body alone.
@@ -497,9 +497,11 @@ between at 40% down the screen (see *Anatomy*, "Where the centre is"):
497
497
  - **A card is head · content · foot.**
498
498
  - **Head** (`.card__head`): the heading on the left; hints, actions or a
499
499
  more-menu — the contextual info — at the far right. With nothing at
500
- the far right it runs all the way across. The heading wraps freely —
501
- no line budget, no squeeze, no clamp (Lh 2026-09-16): a height rule on
502
- words only distorts them. The head is FLAT markup — heading, then an
500
+ the far right it runs all the way across. The heading wraps, no
501
+ squeeze, up to three lines and is cut there with an ellipsis (Lh
502
+ 2026-10-21; `--card-head-title-lines`, default 3). A date
503
+ (`JourneyBadge stretch`) beside it is as tall as the title block up to
504
+ its own three lines, at the head's top. The head is FLAT markup — heading, then an
503
505
  optional caption as a span, then the far-right thing; no
504
506
  wrapper element, no `<p>`: the head wraps, the caption takes the row
505
507
  below (`order`), the gap tokens do the rest (Lh 2026-09-16).
@@ -693,8 +695,8 @@ it up. cardds draws the paper; the pictures, their words and which stamp a membe
693
695
  narrower, so at the standard size only `name` shows, and it is larger.
694
696
  - **`StampPlace`** — an empty place the size of a stamp, its edge a ring of round dots: a word inside, `onClick` for "make
695
697
  yours", `faint` for a gap nobody is asked to fill. The edge is a drawn stroke, so the dots are yours: `--stamp-place-dot`
696
- is how wide each is (default `--rule-w`, 2px), `--stamp-place-gap` the distance from one dot's centre to the next
697
- (default 6px), and a `--stamp-place-dash` above 0 stretches each dot into a round-ended dash. Its corner is
698
+ is how wide each is (default `--rule-w`), `--stamp-place-gap` the distance from one dot's centre to the next
699
+ (default three `--sp-0`), and a `--stamp-place-dash` above 0 stretches each dot into a round-ended dash. Its corner is
698
700
  `--stamp-place-radius`. The dots' outer edge is the box's edge whatever their width.
699
701
  - **`StampBook`** + **`StampCell`** — the album page: cells three across on an album ground, a `label` under each stamp,
700
702
  the free places drawn up to `places`. A cell with `onClick` is a button; `on` marks it pressed. Each stamp stands in
@@ -719,8 +721,8 @@ it up. cardds draws the paper; the pictures, their words and which stamp a membe
719
721
  it lies on. With `onRemove` the stamp can be taken off: when a drag ends or an arrow key moves it, a cross stands on
720
722
  its top right corner for `removeFor` ms (default 3000). A press on the cross, or Delete / Backspace on the focused
721
723
  stamp, asks, and the app takes the `StampOn` away. A stamp with `onRemove` and no `onAtChange` shows the cross on a
722
- press. The cross is a 24 disc (`--stamp-on-remove-size`, `--stamp-on-remove-bg` #141414, `--stamp-on-remove-ink`
723
- #ffffff) in a 44 target, centred on the corner and upright whatever the stamp's `turn`. Its name is cardds's word
724
+ press. The cross is a 24 disc (`--stamp-on-remove-size`, `--stamp-on-remove-bg` `--ink-onlight`, `--stamp-on-remove-ink`
725
+ `--ink-ondark`) in a 44 target, centred on the corner and upright whatever the stamp's `turn`. Its name is cardds's word
724
726
  ("Remove" / "เอาออก"), or `removeLabel`. A press or a drag on a removable stamp never reaches what lies under it.
725
727
  A stamp that is still arriving cannot be taken off yet: its cross waits until it has landed.
726
728
 
@@ -741,7 +743,7 @@ struck over its edge) proves the trip. Three components, one for each thing a me
741
743
  the older `Postcard` (a member's cover card that turns over) is another component and shares nothing with these.
742
744
 
743
745
  - **`PostLetter`**: reading one that arrived. Two upright 2:3 sheets, one in front. The picture side is `picture` (the
744
- app's img or svg) in a white margin with `caption` under it; the written side is the address (`kicker`, `to` lines) over
746
+ app's img or svg) in a margin of its paper (`--post-paper-bg`) with `caption` under it; the written side is the address (`kicker`, `to` lines) over
745
747
  the message (`children`, set large, `sign` at its end), with `stamp` and `postmark` on the corner. The tab over the
746
748
  card, or a tap on the card, turns it: the sheet in front swings to its edge and the other swings in. `face` +
747
749
  `onFaceChange`, or `defaultFace`. `voice` is a voice note's time as the app words it; with it the picture side has a
@@ -910,6 +912,34 @@ question and hunts for its answer. Rules 11–13 serve that:
910
912
  the card's ground, a note is a `Banner` (a light band, a rule above and
911
913
  below, edge to edge), a count is words (`Text caption muted`), never a
912
914
  chip. A chip or tile that looks pressable and is not lies.
915
+ 15. **A component adjusts another only through its knobs.** A component that
916
+ holds another (a sheet holding a card, a stack holding a sheet) sets the
917
+ child's knobs (`.sheet { --card-pad: … }`), never the child's own
918
+ property (`.sheet .card__head { color: … }`). The child's file is the one
919
+ place its look is written, so what a story shows is what a project gets.
920
+ Missing the knob? Add it in the child's file. No `!important` either, but
921
+ `[hidden]`'s. A component belongs to the file that writes its bare class
922
+ (`.card` → `css/card.css`). A knob the holder sets is still the child's
923
+ knob: it is read, with its default, in the child's file and never
924
+ declared there. Where a child SITS in a holder that places it (its
925
+ position, inset, grid cell, layer, margins, how it scrolls or moves in
926
+ each of the holder's states) is not a look the holder retunes: the
927
+ child's own file writes it, as `.screen--fill > .action-bar { … }` in
928
+ `css/actions.css` and the stack's sheets in `css/card.css`. A part or a
929
+ variant of a block (`.card--cover`, `.screen__centre`) is written in the
930
+ block's file too. One kind of rule names many components and is no
931
+ override: a **trait**, one behaviour every control shares, kept as one
932
+ list so a new control joins it there. There are five, each in its own
933
+ file and writing only its own properties: press (cursor, touch-action,
934
+ the transition, scale, opacity, filter) and the focus ring (outline) in
935
+ `css/actions.css`; the button reset (border, background, font, colour,
936
+ padding), the hidden scrollbar and user-select in `css/base.css`. A trait
937
+ is a zero-specificity `:where()` list (the user-select list is a plain
938
+ list of single selectors), so a component's own rule always wins.
939
+ `tests/overrides.spec.js` fails on any override; `tests/overrides.json`,
940
+ which held the 184 there were on 2026-10-20, is empty and stays so.
941
+ One rule that can do neither is named, with why, in `KEPT`
942
+ (`tests/helpers.js`).
913
943
 
914
944
  ## A project's theme
915
945
 
@@ -1093,8 +1123,11 @@ Every length token resolves to rem — a multiplier × one of four rem bases
1093
1123
  `--r-base` × 2, `--fs-h1` = `--fs-base` × 1.2; see "A project's theme") — so the
1094
1124
  whole system scales with the root font size. `base.css` keeps it at `100%` (the browser's setting, 16px by
1095
1125
  default) and steps narrow phones (≤360px) to `93.75%`, which keeps the
1096
- column, its 48px taps and its type in proportion on 320–360 screens. Only
1097
- `--border-w`, `--rule-w` and the focus ring stay in px, so edges stay crisp.
1126
+ column, its 48px taps and its type in proportion on 320–360 screens. No
1127
+ length is written in px, the edges included (Lh 2026-10-20: "units are always rem"): `--border-w`,
1128
+ `--hairline-w`, `--rule-w`, the focus ring and one device pixel, `--px`, are rem. Two px stay, both the
1129
+ platform's: the initial value of a registered length (`@property`), which may not be relative, and the
1130
+ lengths a script measures off the page (`--kb`, a sheet's peek, a pulled sheet).
1098
1131
  An app with its own text-size setting calls `setTextScale(percent)`, which writes
1099
1132
  `--fs-base` on `<html>` from the theme's base (100% = the theme's; 160% = 2.2rem by default): the type and its holders (the count
1100
1133
  badge, the inline icon) grow, the controls grow half as much (`--tap-base`), spacing and radii keep their size.
@@ -1109,6 +1142,37 @@ so no component ever writes `px` to consume it (`.action-bar` rides up by it,
1109
1142
  `.step` grows its room by it). The narrow-phone step is an `em` breakpoint,
1110
1143
  so it follows the text size too: at 131% a 390px phone counts as narrow.
1111
1144
 
1145
+ ## A component brings no value of its own
1146
+
1147
+ The gallery is a catalog of components, not of designs: what a story shows must be what a project gets when
1148
+ it imports the piece (Lh 2026-10-20: "a component that slips out of the tokens or the theme is a problem,
1149
+ because that is how a hard-coded look gets made that cannot be taken out and used"). So a component's css
1150
+ reads every value from a token in `css/tokens.css` (or a palette, a finish, a project's theme), and writes none:
1151
+
1152
+ - **colour**: the roles, the inks of each side, the signals, and the nine pastels of a note or a stamp
1153
+ (`--pastel-*`, `--stamp-paper`). A paper sheet's content is the theme's paper, `--action-bg`, the ground of
1154
+ its fields; never a fixed white. A shadow is mixed from the onlight ink (`--shadow-paper`, `--shadow-lift`,
1155
+ `--shadow-fold`), never from black.
1156
+ - **length**: a multiple of a base, in rem (above).
1157
+ - **time**: `--motion-*`. **easing**: `--ease`, `--ease-in`, `--ease-out`, `--ease-in-out`, `--ease-linear`,
1158
+ `--ease-settle` for a row or card that lands where it moved, and `--ease-step` for on or off with nothing between (a caret).
1159
+ - **angle**: a multiple of `--tilt-base` (1deg): a note, a stamp, a letter, a card sent away. A theme that wants
1160
+ everything straight sets it to 0deg. A quarter or half turn is an orientation, not a tilt (an arrow, a face turned over).
1161
+ - **layer**: `--z-under-2` … `--z-over-3`, the order of things inside one piece; `--z-pile`, a sticky pile's top note.
1162
+ - **scale**: `--press-scale` under the finger, `--scale-lift` (a stamp held), `--scale-pick` (a mood's dot picked), `--scale-arrive` (where a stamp's remove button grows from).
1163
+ - **fade**: `--off` (a disabled control) and `--fade-*`, one name per use.
1164
+ - **type**: the eight kinds (display, h1, h2, title, body, label, caption, stat) each have `--face-<kind>`, `--fw-<kind>`,
1165
+ `--fs-<kind>`, `--lh-<kind>` and `--track-<kind>`, and `--type-<kind>` is made of them. A piece set on a size outside the
1166
+ kinds composes its `font:` from the scales: `--fs-*`, weight `--fw-regular` … `--fw-black`, leading `--lh-mark` … `--lh-ruled`.
1167
+ Gallery `Text · Kinds` shows them resolved.
1168
+
1169
+ A knob's default is one of these too, so a knob read from nowhere still shows the theme. `npm run check` reads
1170
+ every declaration of every component file and every literal of an inline `style` in `src/` and `.design-sync/previews/`
1171
+ (`lint/values.mjs`, before the tests run) and fails on a value written in place, naming the file, the line, the kind and
1172
+ the value: a knob's fallback (`z-index: var(--x, 5)`), a number inside `calc()` in a leading or a tracking, `scale` and
1173
+ `scale()`, `filter: opacity()`, a fade in %, a unit in capitals and every CSS colour name included; a number on a length
1174
+ in a `style` object is px. A sign or an identity (`* -1`, `0`, `1`, `100%`) is not a value. `tests/tokens.spec.js` holds it at none.
1175
+
1112
1176
  ## Icons & touch targets
1113
1177
 
1114
1178
  Icons come from `icons.svg` (Lucide, ISC — monochrome stroke, currentColor):
package/lib/catalog.mjs CHANGED
@@ -83,10 +83,20 @@ export function get(cat, name, { html = false, all = false } = {}) {
83
83
  }
84
84
 
85
85
  const words = (text) => text.replace(/([a-z\d])([A-Z])/g, '$1 $2').toLowerCase().match(/[\p{L}\p{M}\p{N}]+/gu) ?? [];
86
- const STOP = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'into', 'of', 'to', 'in', 'on', 'an', 'a']);
87
- const tokens = (query) => [...new Set(words(String(query)).filter((t) => t.length > 1 && !STOP.has(t)))];
88
- const stem = (t) => (t.length > 3 && t.endsWith('s') ? t.slice(0, -1) : t);
89
- const hits = (text, token) => words(text).some((w) => w.startsWith(token) || w.startsWith(stem(token)));
86
+ // the words of a request that carry no intention: "the user switches something on or off" is asked by "switches" and "off", and "on", "user", "something" matched every row that says them
87
+ const STOP = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'into', 'of', 'to', 'in', 'on', 'an', 'a', 'at', 'by', 'as', 'is', 'are', 'be', 'it', 'its', 'can', 'my', 'your', 'their', 'some', 'user', 'users', 'member', 'someone', 'something']);
88
+ // a request says a value of the roles' closed vocabulary (docs/roles.md) in its own words: "choose" is the job pick, "date" represents time, "read" is show.
89
+ // Such a word also matches every component whose role has the value; "or" ("a or b", "on or off") is a choice, and matches nothing but the role
90
+ const SAYS = {
91
+ pick: ['choose', 'pick', 'select', 'switch', 'tick', 'or'], write: ['fill', 'type', 'write', 'enter'], show: ['read', 'see', 'show'], act: ['press', 'tap', 'send'], navigate: ['go', 'navigate'],
92
+ number: ['number', 'count', 'amount', 'total'], time: ['date', 'day', 'time', 'when'], place: ['place', 'where'], person: ['person', 'who'], glance: ['glance'],
93
+ };
94
+ const ROLE_ONLY = new Set(['or']);
95
+ const tokens = (query) => [...new Set(words(String(query)).filter((t) => (t.length > 1 || ROLE_ONLY.has(t)) && !STOP.has(t)))];
96
+ const stem = (t) => (/(?:ch|sh|x|ss)es$/.test(t) ? t.slice(0, -2) : t.length > 3 && t.endsWith('s') ? t.slice(0, -1) : t);
97
+ const hits = (text, token) => !ROLE_ONLY.has(token) && words(text).some((w) => w.startsWith(token) || w.startsWith(stem(token)));
98
+ /** the role's values (what it represents, its density, its job) said by the token, as the value or in a request's words for it */
99
+ const plays = (role, token) => [...role.represents, ...role.density, ...role.job].some((v) => v === stem(token) || SAYS[v]?.includes(stem(token)));
90
100
  const isName = (name, token) => name.toLowerCase() === token || name.toLowerCase() === stem(token);
91
101
 
92
102
  const TOP = 8; // answers per kind; the components that match every axis are never cut
@@ -135,13 +145,18 @@ export function find(cat, query, axes = {}) {
135
145
  const roles = new Map(cat.components.map((c) => [c.name, roleOf(c)]));
136
146
  const toks = tokens(query);
137
147
  const axisWords = [...(asked.want.represents ?? []), ...(asked.want.interact || [])]; // the axis values as words: a screen's own description may say them
138
- const rank = (items, fields, level, said = () => 0) => { // a field is [text, weight, match = hits]
148
+ // every three words of the request in a row, function words kept: a note that says "a or b" or "at a glance" as asked is the piece the request describes
149
+ const all = words(String(query));
150
+ const phrases = all.slice(2).map((w, i) => ` ${all[i]} ${all[i + 1]} ${w} `);
151
+ const rank = (items, fields, level, said = () => 0, saysIt = () => '') => { // a field is [text, weight, match = hits]; saysIt is the text whose phrases count
139
152
  const scored = items.map((item) => {
140
153
  let matched = 0, score = 0;
141
154
  for (const t of toks) {
142
155
  const w = fields(item).filter(([text, , match = hits]) => match(text, t)).reduce((sum, [, weight]) => sum + weight, 0);
143
156
  if (w) { matched++; score += w; }
144
157
  }
158
+ const own = ` ${words(saysIt(item)).join(' ')} `;
159
+ score += 3 * phrases.filter((p) => own.includes(p)).length;
145
160
  return { item, matched, score, level: active ? level(item) : 0, said: active ? said(item) : 0 };
146
161
  });
147
162
  const byWords = (a, b) => b.matched - a.matched || b.score - a.score;
@@ -159,8 +174,9 @@ export function find(cat, query, axes = {}) {
159
174
  const said = (s) => axisWords.filter((v) => hits(s.description, v)).length;
160
175
  const [screenAxis, screenRest] = tier(rank(screens, (s) => [[s.id, 5], [s.description, 3], [s.uses.join(' '), 1]], level, said), TOP);
161
176
  // a component is found by its name (the name that IS the word counts as much again as one that contains it: "card" is Card before FeedCard, whatever
162
- // their other words say), its description, its role's note (what it is FOR, in the words of a request: "a setting turned on or off") and what it stands in for, then its prop names
163
- const components = rank(cat.components, (c) => [[c.name, 6, isName], [c.name, 6], [c.description, 2], [c.role?.note ?? '', 2], [(c.role?.standsInFor ?? []).map((s) => `${s.name} ${s.condition}`).join(' '), 1], [c.props.map((p) => p.name).join(' '), 1]], (c) => Number(matches(roles.get(c.name), asked.want)));
177
+ // their other words say), its description, its role's note (what it is FOR, in the words of a request: "a setting turned on or off"), the values of its role
178
+ // a word says ("choose" → pick, "number"), what it stands in for, then its prop names
179
+ const components = rank(cat.components, (c) => [[c.name, 6, isName], [c.name, 6], [c.description, 2], [c.role?.note ?? '', 2], ...(c.role ? [[c.role, 2, plays]] : []), [(c.role?.standsInFor ?? []).map((s) => `${s.name} ${s.condition}`).join(' '), 1], [c.props.map((p) => p.name).join(' '), 1]], (c) => Number(matches(roles.get(c.name), asked.want)), undefined, (c) => `${c.description} ${c.role?.note ?? ''}`);
164
180
  const [componentAxis, componentRest] = tier(components, Math.max(TOP, components.axis.length));
165
181
  // a screen drawn comes before the components when it answers the words as well as the best component does; one that answers FEWER of them
166
182
  // comes after (a question by intention, "a setting turned on or off", is answered by Toggle, not by eight screens that say "off" or "or")
package/lint/rules.mjs CHANGED
@@ -15,7 +15,7 @@ export const CONTROL_NAMES = {
15
15
  Calendar: ['prevLabel', 'nextLabel'], SheetStep: ['skipLabel'], Sticky: ['unreadLabel'], StampOn: ['removeLabel'], Card: ['removeLabel', 'toBackLabel', 'toFrontLabel'], PostLetter: ['backLabel', 'pictureLabel', 'playLabel', 'pauseLabel', 'toLabel'],
16
16
  };
17
17
 
18
- const lineOf = (text, idx) => text.slice(0, idx).split('\n').length;
18
+ export const lineOf = (text, idx) => text.slice(0, idx).split('\n').length;
19
19
 
20
20
  /** the classes a selector names; a bare state word (`is-open`) is every project's too — it only matters beside a cardds class, which is caught */
21
21
  export const selectorClasses = (selector) => [...selector.matchAll(/(?<!\\)\.(-?[a-zA-Z_][\w-]*)/g)].map((m) => m[1]).filter((c) => !/^(is|has)-/.test(c));
@@ -72,7 +72,7 @@ function elementAt(text, start) {
72
72
  }
73
73
 
74
74
  /** the expression of a `{…}` attribute value from its opening brace */
75
- function braceAt(text, start) {
75
+ export function braceAt(text, start) {
76
76
  let depth = 0, quote = null;
77
77
  for (let i = start; i < text.length; i++) {
78
78
  const c = text[i];
@@ -98,7 +98,7 @@ function classWords(text, at) {
98
98
  }
99
99
 
100
100
  /** 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 */
101
- function* styleEntries(body, at, sep) {
101
+ export function* styleEntries(body, at, sep) {
102
102
  let depth = 0, quote = null, from = 0;
103
103
  for (let i = 0; i <= body.length; i++) {
104
104
  const c = body[i];
@@ -114,6 +114,9 @@ function* styleEntries(body, at, sep) {
114
114
  }
115
115
  }
116
116
 
117
+ /** comments out, lines kept; a string is stepped over whole, so `accept="image/*"` opens no comment */
118
+ export const blankComments = (source) => source.replace(/"[^"\n]*"|'[^'\n]*'|`[^`]*`|\/\*[\s\S]*?\*\/|(?<![:'"`\\])\/\/[^\n]*/g, (c) => /^["'`]/.test(c) ? c : c.replace(/[^\n]/g, ' '));
119
+
117
120
  /**
118
121
  * @param {string} text a source file
119
122
  * @param {string} file its name, for the findings
@@ -122,8 +125,7 @@ function* styleEntries(body, at, sep) {
122
125
  export function checkSource(source, file, { classes = carddsClasses() } = {}) {
123
126
  const findings = [];
124
127
  const lines = source.split('\n');
125
- // comments out, lines kept; a string is stepped over whole, so `accept="image/*"` opens no comment
126
- let text = source.replace(/"[^"\n]*"|'[^'\n]*'|`[^`]*`|\/\*[\s\S]*?\*\/|(?<![:'"`\\])\/\/[^\n]*/g, (c) => /^["'`]/.test(c) ? c : c.replace(/[^\n]/g, ' '));
128
+ let text = blankComments(source);
127
129
  const add = (rule, level, idx, message) => {
128
130
  const line = lineOf(text, idx);
129
131
  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)