@usableapp/cardds 0.7.103 → 0.7.104

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 (60) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/css/actions.css +102 -36
  3. package/css/base.css +139 -15
  4. package/css/card.css +308 -79
  5. package/css/choice.css +25 -33
  6. package/css/finish.css +28 -27
  7. package/css/forms.css +6 -14
  8. package/css/journey.css +4 -4
  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 +1 -1
  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 +39 -215
  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 +100 -26
  23. package/dist/actions/Chip.d.ts +1 -1
  24. package/dist/actions/Chip.js +3 -2
  25. package/dist/cardds.css +1053 -868
  26. package/dist/cards/Card.d.ts +3 -3
  27. package/dist/cards/Card.js +1 -1
  28. package/dist/cards/CardFeed.js +7 -9
  29. package/dist/catalog.json +1 -1
  30. package/dist/layover/Snackbar.js +2 -2
  31. package/dist/lists/Bubble.d.ts +1 -1
  32. package/dist/lists/Bubble.js +1 -1
  33. package/dist/lists/Row.js +5 -7
  34. package/dist/numbers/Picker.js +11 -18
  35. package/dist/people/Avatar.d.ts +1 -1
  36. package/dist/people/Avatar.js +3 -2
  37. package/dist/post/PostDesk.js +6 -9
  38. package/dist/post/PostLetter.js +4 -4
  39. package/dist/pressable.d.ts +9 -0
  40. package/dist/pressable.js +6 -0
  41. package/dist/room.d.ts +5 -0
  42. package/dist/room.js +9 -0
  43. package/dist/scaffold/Panes.js +8 -14
  44. package/dist/sheets/Sheet.d.ts +1 -1
  45. package/dist/sheets/Sheet.js +2 -3
  46. package/dist/sheets/SheetSteps.js +3 -3
  47. package/dist/sheets/StackSheet.d.ts +1 -1
  48. package/dist/sheets/sheetStepsPile.js +2 -3
  49. package/dist/sheets/stackCard.js +67 -115
  50. package/dist/state.d.ts +21 -0
  51. package/dist/state.js +28 -1
  52. package/dist/sticky/Sticky.js +3 -7
  53. package/dist/templates/Home.d.ts +1 -1
  54. package/dist/templates/Splash.js +2 -2
  55. package/dist/templates/Walkthrough.js +4 -5
  56. package/docs/reference.md +71 -9
  57. package/lib/catalog.mjs +23 -7
  58. package/lint/rules.mjs +7 -5
  59. package/lint/values.mjs +144 -0
  60. 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.
@@ -693,8 +693,8 @@ it up. cardds draws the paper; the pictures, their words and which stamp a membe
693
693
  narrower, so at the standard size only `name` shows, and it is larger.
694
694
  - **`StampPlace`** — an empty place the size of a stamp, its edge a ring of round dots: a word inside, `onClick` for "make
695
695
  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
696
+ is how wide each is (default `--rule-w`), `--stamp-place-gap` the distance from one dot's centre to the next
697
+ (default three `--sp-0`), and a `--stamp-place-dash` above 0 stretches each dot into a round-ended dash. Its corner is
698
698
  `--stamp-place-radius`. The dots' outer edge is the box's edge whatever their width.
699
699
  - **`StampBook`** + **`StampCell`** — the album page: cells three across on an album ground, a `label` under each stamp,
700
700
  the free places drawn up to `places`. A cell with `onClick` is a button; `on` marks it pressed. Each stamp stands in
@@ -719,8 +719,8 @@ it up. cardds draws the paper; the pictures, their words and which stamp a membe
719
719
  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
720
  its top right corner for `removeFor` ms (default 3000). A press on the cross, or Delete / Backspace on the focused
721
721
  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
722
+ press. The cross is a 24 disc (`--stamp-on-remove-size`, `--stamp-on-remove-bg` `--ink-onlight`, `--stamp-on-remove-ink`
723
+ `--ink-ondark`) in a 44 target, centred on the corner and upright whatever the stamp's `turn`. Its name is cardds's word
724
724
  ("Remove" / "เอาออก"), or `removeLabel`. A press or a drag on a removable stamp never reaches what lies under it.
725
725
  A stamp that is still arriving cannot be taken off yet: its cross waits until it has landed.
726
726
 
@@ -741,7 +741,7 @@ struck over its edge) proves the trip. Three components, one for each thing a me
741
741
  the older `Postcard` (a member's cover card that turns over) is another component and shares nothing with these.
742
742
 
743
743
  - **`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
744
+ 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
745
  the message (`children`, set large, `sign` at its end), with `stamp` and `postmark` on the corner. The tab over the
746
746
  card, or a tap on the card, turns it: the sheet in front swings to its edge and the other swings in. `face` +
747
747
  `onFaceChange`, or `defaultFace`. `voice` is a voice note's time as the app words it; with it the picture side has a
@@ -910,6 +910,34 @@ question and hunts for its answer. Rules 11–13 serve that:
910
910
  the card's ground, a note is a `Banner` (a light band, a rule above and
911
911
  below, edge to edge), a count is words (`Text caption muted`), never a
912
912
  chip. A chip or tile that looks pressable and is not lies.
913
+ 15. **A component adjusts another only through its knobs.** A component that
914
+ holds another (a sheet holding a card, a stack holding a sheet) sets the
915
+ child's knobs (`.sheet { --card-pad: … }`), never the child's own
916
+ property (`.sheet .card__head { color: … }`). The child's file is the one
917
+ place its look is written, so what a story shows is what a project gets.
918
+ Missing the knob? Add it in the child's file. No `!important` either, but
919
+ `[hidden]`'s. A component belongs to the file that writes its bare class
920
+ (`.card` → `css/card.css`). A knob the holder sets is still the child's
921
+ knob: it is read, with its default, in the child's file and never
922
+ declared there. Where a child SITS in a holder that places it (its
923
+ position, inset, grid cell, layer, margins, how it scrolls or moves in
924
+ each of the holder's states) is not a look the holder retunes: the
925
+ child's own file writes it, as `.screen--fill > .action-bar { … }` in
926
+ `css/actions.css` and the stack's sheets in `css/card.css`. A part or a
927
+ variant of a block (`.card--cover`, `.screen__centre`) is written in the
928
+ block's file too. One kind of rule names many components and is no
929
+ override: a **trait**, one behaviour every control shares, kept as one
930
+ list so a new control joins it there. There are five, each in its own
931
+ file and writing only its own properties: press (cursor, touch-action,
932
+ the transition, scale, opacity, filter) and the focus ring (outline) in
933
+ `css/actions.css`; the button reset (border, background, font, colour,
934
+ padding), the hidden scrollbar and user-select in `css/base.css`. A trait
935
+ is a zero-specificity `:where()` list (the user-select list is a plain
936
+ list of single selectors), so a component's own rule always wins.
937
+ `tests/overrides.spec.js` fails on any override; `tests/overrides.json`,
938
+ which held the 184 there were on 2026-10-20, is empty and stays so.
939
+ One rule that can do neither is named, with why, in `KEPT`
940
+ (`tests/helpers.js`).
913
941
 
914
942
  ## A project's theme
915
943
 
@@ -1093,8 +1121,11 @@ Every length token resolves to rem — a multiplier × one of four rem bases
1093
1121
  `--r-base` × 2, `--fs-h1` = `--fs-base` × 1.2; see "A project's theme") — so the
1094
1122
  whole system scales with the root font size. `base.css` keeps it at `100%` (the browser's setting, 16px by
1095
1123
  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.
1124
+ column, its 48px taps and its type in proportion on 320–360 screens. No
1125
+ length is written in px, the edges included (Lh 2026-10-20: "units are always rem"): `--border-w`,
1126
+ `--hairline-w`, `--rule-w`, the focus ring and one device pixel, `--px`, are rem. Two px stay, both the
1127
+ platform's: the initial value of a registered length (`@property`), which may not be relative, and the
1128
+ lengths a script measures off the page (`--kb`, a sheet's peek, a pulled sheet).
1098
1129
  An app with its own text-size setting calls `setTextScale(percent)`, which writes
1099
1130
  `--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
1131
  badge, the inline icon) grow, the controls grow half as much (`--tap-base`), spacing and radii keep their size.
@@ -1109,6 +1140,37 @@ so no component ever writes `px` to consume it (`.action-bar` rides up by it,
1109
1140
  `.step` grows its room by it). The narrow-phone step is an `em` breakpoint,
1110
1141
  so it follows the text size too: at 131% a 390px phone counts as narrow.
1111
1142
 
1143
+ ## A component brings no value of its own
1144
+
1145
+ The gallery is a catalog of components, not of designs: what a story shows must be what a project gets when
1146
+ it imports the piece (Lh 2026-10-20: "a component that slips out of the tokens or the theme is a problem,
1147
+ because that is how a hard-coded look gets made that cannot be taken out and used"). So a component's css
1148
+ reads every value from a token in `css/tokens.css` (or a palette, a finish, a project's theme), and writes none:
1149
+
1150
+ - **colour**: the roles, the inks of each side, the signals, and the nine pastels of a note or a stamp
1151
+ (`--pastel-*`, `--stamp-paper`). A paper sheet's content is the theme's paper, `--action-bg`, the ground of
1152
+ its fields; never a fixed white. A shadow is mixed from the onlight ink (`--shadow-paper`, `--shadow-lift`,
1153
+ `--shadow-fold`), never from black.
1154
+ - **length**: a multiple of a base, in rem (above).
1155
+ - **time**: `--motion-*`. **easing**: `--ease`, `--ease-in`, `--ease-out`, `--ease-in-out`, `--ease-linear`,
1156
+ `--ease-settle` for a row or card that lands where it moved, and `--ease-step` for on or off with nothing between (a caret).
1157
+ - **angle**: a multiple of `--tilt-base` (1deg): a note, a stamp, a letter, a card sent away. A theme that wants
1158
+ everything straight sets it to 0deg. A quarter or half turn is an orientation, not a tilt (an arrow, a face turned over).
1159
+ - **layer**: `--z-under-2` … `--z-over-3`, the order of things inside one piece; `--z-pile`, a sticky pile's top note.
1160
+ - **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).
1161
+ - **fade**: `--off` (a disabled control) and `--fade-*`, one name per use.
1162
+ - **type**: the eight kinds (display, h1, h2, title, body, label, caption, stat) each have `--face-<kind>`, `--fw-<kind>`,
1163
+ `--fs-<kind>`, `--lh-<kind>` and `--track-<kind>`, and `--type-<kind>` is made of them. A piece set on a size outside the
1164
+ kinds composes its `font:` from the scales: `--fs-*`, weight `--fw-regular` … `--fw-black`, leading `--lh-mark` … `--lh-ruled`.
1165
+ Gallery `Text · Kinds` shows them resolved.
1166
+
1167
+ A knob's default is one of these too, so a knob read from nowhere still shows the theme. `npm run check` reads
1168
+ every declaration of every component file and every literal of an inline `style` in `src/` and `.design-sync/previews/`
1169
+ (`lint/values.mjs`, before the tests run) and fails on a value written in place, naming the file, the line, the kind and
1170
+ the value: a knob's fallback (`z-index: var(--x, 5)`), a number inside `calc()` in a leading or a tracking, `scale` and
1171
+ `scale()`, `filter: opacity()`, a fade in %, a unit in capitals and every CSS colour name included; a number on a length
1172
+ 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.
1173
+
1112
1174
  ## Icons & touch targets
1113
1175
 
1114
1176
  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)