@usableapp/cardds 0.7.85 → 0.7.87

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 (46) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +44 -1
  3. package/bin/cardds.mjs +8 -3
  4. package/css/actions.css +12 -7
  5. package/css/card.css +1 -2
  6. package/css/choice.css +2 -2
  7. package/css/finish.css +97 -11
  8. package/css/forms.css +1 -2
  9. package/css/numbers.css +1 -2
  10. package/css/post.css +1 -2
  11. package/css/stack.css +1 -1
  12. package/css/stamp.css +44 -4
  13. package/css/step.css +6 -0
  14. package/css/sticky.css +45 -2
  15. package/css/tokens.css +6 -0
  16. package/dist/cardds.css +217 -35
  17. package/dist/cards/CardFeed.js +2 -9
  18. package/dist/catalog.json +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +1 -0
  21. package/dist/lists/Row.js +2 -9
  22. package/dist/pressable.d.ts +8 -0
  23. package/dist/pressable.js +6 -0
  24. package/dist/scaffold/Screen.js +2 -9
  25. package/dist/sheets/StackSheet.js +3 -7
  26. package/dist/sheets/sheetMeasure.d.ts +1 -0
  27. package/dist/sheets/sheetMeasure.js +14 -9
  28. package/dist/sheets/stackCard.js +41 -48
  29. package/dist/stamp/Stamp.js +3 -6
  30. package/dist/stamp/StampBook.js +2 -3
  31. package/dist/stamp/StampOn.d.ts +21 -3
  32. package/dist/stamp/StampOn.js +75 -18
  33. package/dist/stamp/underNotes.d.ts +3 -0
  34. package/dist/stamp/underNotes.js +4 -0
  35. package/dist/state.d.ts +5 -1
  36. package/dist/state.js +8 -0
  37. package/dist/sticky/Sticky.d.ts +2 -2
  38. package/dist/sticky/Sticky.js +2 -3
  39. package/dist/sticky/StickyPile.d.ts +20 -0
  40. package/dist/sticky/StickyPile.js +31 -0
  41. package/dist/words.d.ts +1 -1
  42. package/dist/words.js +2 -2
  43. package/lib/catalog.mjs +24 -7
  44. package/lib/mcp.mjs +3 -3
  45. package/lint/rules.mjs +1 -1
  46. package/package.json +1 -1
package/dist/cardds.css CHANGED
@@ -307,6 +307,12 @@
307
307
  @property --sheet-half { syntax: "<number>"; inherits: true; initial-value: 0.5; }
308
308
  @property --sheet-3q { syntax: "<number>"; inherits: true; initial-value: 0.25; }
309
309
 
310
+ /* the ring that turns while waiting: Spinner, FormNote busy */
311
+ @keyframes turn { to { rotate: 1turn; } }
312
+ /* a mark struck onto paper — shown large and faint, pressed a little past its size, then at rest: StampOn `arrive`, the post desk's
313
+ postmark (Lh 2026-10-12: one strike for both; the postmark used to slide in from up-left, turning). Both read --stamp-on-arrive-time */
314
+ @keyframes strike { from { scale: 2; opacity: 0; } 60% { scale: 0.92; opacity: 1; } }
315
+
310
316
  /* ---- css/palette.css — color settings — the file you tune ---- */
311
317
  /* ============================================================
312
318
  cardds/palette.css — COLOR SETTINGS. The only file you tune.
@@ -898,12 +904,11 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
898
904
  actions.css) and scrolls up like any page — nothing is capped, nothing
899
905
  shrinks, no scroll box inside a box.
900
906
  So on a .screen that is NOT --fill the group is in flow: the column is at
901
- least one phone tall, the group takes the room after the top bar and
907
+ least one phone tall (step.css), the group takes the room after the top bar and
902
908
  centres its children in it. The screen's own inset stays the inset.
903
909
  <div class="screen"><header class="topbar">…</header>
904
910
  <div class="screen__centre"><article class="card">…</article><div class="pager__at">…</div></div>
905
911
  </div><div class="action-bar">…</div> */
906
- .screen:not(.screen--fill):has(> .screen__centre) { min-height: 100dvh; display: flex; flex-direction: column; }
907
912
  .screen:not(.screen--fill) > .screen__centre { flex: 1; padding-inline: 0; align-content: center; }
908
913
 
909
914
  /* ---- the card that changes slides: out one side, the next in from the other ----
@@ -1387,7 +1392,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1387
1392
  }
1388
1393
  /* a card standing in this room is FLAT (Lh 2026-10-09): the room clips at its own edges, and a card's shadow cut there drew a straight
1389
1394
  line under the bar and along the first sheet. Its ground and its radius are what say "card" here. */
1390
- :is([data-palette], [data-finish="lit"]) .sheet-stack__base .card { box-shadow: none; }
1395
+ :is([data-palette], [data-finish]) .sheet-stack__base .card { box-shadow: none; }
1391
1396
 
1392
1397
  /* ============================================================
1393
1398
  .sheet-stack--flood — a closed stack whose open sheet FLOODS the screen: it rises over the top bar and fills the whole screen
@@ -2207,6 +2212,12 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2207
2212
  padding-bottom: var(--bottom-h, 0rem); /* exactly the bottom layer (measured by Screen): a page scrolls ONLY when the layer would cover its words, and then just far enough to clear them — at 100% nothing scrolls (Lh 2026-09-23) */
2208
2213
  }
2209
2214
 
2215
+ /* a SCROLLING (not fill) screen becomes a column at least one phone tall when it holds a bottom or a centre:
2216
+ an .action-bar as its last child (Lh 2026-09-24, 0.7.0: the bar is the column's last row — at the foot when the words are short,
2217
+ sticky to the viewport's foot while they scroll; actions.css) or a .screen__centre (Lh 2026-09-12: the group is in flow, takes the
2218
+ room after the top bar and centres its children in it; a long one makes the page longer and runs under the bar; card.css) */
2219
+ .screen:not(.screen--fill):has(> .action-bar:last-child, > .screen__centre) { display: flex; flex-direction: column; min-height: 100dvh; }
2220
+
2210
2221
  /* ============================================================
2211
2222
  TOP + CONTENT in one flex column; BOTTOM is a LAYER over them (Lh 2026-09-23, second call — replaces "the bar is a row"):
2212
2223
  in a .screen--fill the content runs to the screen's bottom edge, and the bar lies over it at the foot — it covers what is
@@ -2362,8 +2373,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2362
2373
  .form-note { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); margin: 0; font: var(--form-note-font, var(--type-caption)); color: var(--form-note-ink, var(--card-muted)); text-align: center; }
2363
2374
  .form-note > .icon { flex: none; }
2364
2375
  .form-note--error { color: var(--form-note-error-ink, var(--signal-error)); }
2365
- @media (prefers-reduced-motion: no-preference) { .form-note--busy > .icon { animation: form-note-turn 0.9s linear infinite; } }
2366
- @keyframes form-note-turn { to { rotate: 1turn; } }
2376
+ @media (prefers-reduced-motion: no-preference) { .form-note--busy > .icon { animation: turn 0.9s linear infinite; } } /* `turn`: tokens.css */
2367
2377
 
2368
2378
  /* ---- css/journey.css — route, tile badge, note row ---- */
2369
2379
  /* ============================================================
@@ -2702,10 +2712,9 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2702
2712
  display: flex;
2703
2713
  gap: var(--sp-2);
2704
2714
  }
2705
- /* a bar INSIDE a scrolling (not fill) screen (Lh 2026-09-24, 0.7.0): the screen is a column at least one phone tall, the bar its
2715
+ /* a bar INSIDE a scrolling (not fill) screen (Lh 2026-09-24, 0.7.0): the screen is a column at least one phone tall (step.css), the bar its
2706
2716
  last row — at the foot when the words are short, and sticky to the viewport's foot (above the keyboard) while they scroll;
2707
2717
  it takes the room it needs in the column, so nothing is added to the content for it */
2708
- .screen:not(.screen--fill):has(> .action-bar:last-child) { display: flex; flex-direction: column; min-height: 100dvh; }
2709
2718
  .screen:not(.screen--fill) > .action-bar:last-child {
2710
2719
  position: sticky; bottom: var(--kb, 0rem);
2711
2720
  max-width: none;
@@ -2811,7 +2820,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2811
2820
  button's, the card itself is never focused or disabled. */
2812
2821
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2813
2822
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2814
- a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach) {
2823
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach, .stamp-on__remove) {
2815
2824
  cursor: pointer;
2816
2825
  touch-action: manipulation;
2817
2826
  transition: scale var(--motion-press) ease-out;
@@ -2830,9 +2839,15 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2830
2839
  }
2831
2840
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2832
2841
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2833
- a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach):active:not(:disabled, [aria-disabled="true"], :has(:disabled)) { scale: var(--press-scale); }
2842
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach, .stamp-on__remove):active:not(:disabled, [aria-disabled="true"], :has(:disabled)) { scale: var(--press-scale); }
2834
2843
  :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .card--go, .feed-card--small, .toggle-field, .post-box__edge):active:not([aria-disabled="true"], :has(:disabled)) { opacity: var(--press-dim); }
2835
2844
  .sheet-step:has(> .sheet-step__go:active) { opacity: var(--press-dim); } /* the step bar's button is invisible over it: the bar answers */
2845
+ /* a pile of notes pressed whole (Lh 2026-10-11): the button lies invisible UNDER the notes, so it is the notes that answer — DIM, as a card does (the group dims as one, the
2846
+ notes do not show through one another); the ring is the button's own, round the whole pile */
2847
+ .sticky-pile__press { cursor: pointer; touch-action: manipulation; }
2848
+ .sticky-pile__notes:has(> .sticky-pile__press) { transition: opacity var(--motion-press) ease-out; }
2849
+ .sticky-pile__notes:has(> .sticky-pile__press:active) { opacity: var(--press-dim); }
2850
+ .sticky-pile__press:focus-visible { outline: var(--focus); outline-offset: 0; }
2836
2851
  /* SHADE — what has something BEHIND it (a peeked sheet over the pile, a postcard's face over its back, a post letter's sheet over the other) must not turn see-through:
2837
2852
  it darkens a touch instead (--press-shade) */
2838
2853
  :where(.sheet-stack--tap > .card:not(.is-open), .postcard__turn, .post-letter__sheet:not(:has(.icon-btn:active))) { cursor: pointer; touch-action: manipulation; transition: filter var(--motion-press) ease-out; }
@@ -2840,17 +2855,17 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2840
2855
  /* the focus ring: on the control, or on the label that wraps a hidden input (chip, tile, file button, a row that is a label) */
2841
2856
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2842
2857
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2843
- a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach):focus-visible,
2858
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach, .stamp-on__remove):focus-visible,
2844
2859
  :where(.chip--pick, .tile--pick, label.btn, label.row, .toggle-field):has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
2845
2860
  :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .feed-card--small, .toggle-field, .post-box__edge):not(label, .toggle-field):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
2846
2861
  /* disabled: no pointer anywhere; a control fades (a link and a row keep their own muted look) */
2847
2862
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2848
2863
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2849
- a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach, a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field, .post-box__edge):is(:disabled, [aria-disabled="true"]),
2864
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach, .stamp-on__remove, a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field, .post-box__edge):is(:disabled, [aria-disabled="true"]),
2850
2865
  :where(.chip--pick, .tile--pick, .mood__opt, .toggle-field, label.btn, label.row):has(:disabled) { cursor: default; }
2851
2866
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2852
2867
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2853
- a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach):not(.link):is(:disabled, [aria-disabled="true"]),
2868
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, .stamp--tap, button.stamp-cell, button.stamp-place, .post-letter__turn, .post-desk__slot, .post-desk__pick, .post-desk__reach, .stamp-on__remove):not(.link):is(:disabled, [aria-disabled="true"]),
2854
2869
  :where(.chip--pick, .tile--pick, .mood__opt, .toggle-field, label.btn):has(:disabled) { opacity: var(--off); }
2855
2870
  .sheet-stack--tap > .card > .card__head > :is(h1, h2, h3):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); } /* the keyboard's way into a stack sheet */
2856
2871
  @media (prefers-reduced-motion: reduce) { :root { --press-scale: 1; } } /* the give goes; the dim, the shade and the focus ring stay */
@@ -3115,8 +3130,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3115
3130
  The ring turns; words beside it are optional and take the caption's look. Still when motion is reduced. */
3116
3131
  .spinner { display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2); vertical-align: middle; font: var(--type-caption); color: var(--spinner-ink, currentColor); }
3117
3132
  .spinner > .icon { flex: none; }
3118
- @media (prefers-reduced-motion: no-preference) { .spinner > .icon { animation: spinner-turn 0.9s linear infinite; } }
3119
- @keyframes spinner-turn { to { rotate: 1turn; } }
3133
+ @media (prefers-reduced-motion: no-preference) { .spinner > .icon { animation: turn 0.9s linear infinite; } } /* `turn`: tokens.css */
3120
3134
 
3121
3135
  /* ---- progress: one straight line, the done share solid, the rest faint (Lh 2026-09-22) — a voice note's
3122
3136
  timeline, a download, a step count as a line. <div class="progress" role="progressbar" style="--progress-at: 0.4"></div>
@@ -3303,8 +3317,8 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3303
3317
  .chip-grid > .chip { justify-self: stretch; justify-content: center; min-height: var(--btn-h); padding-inline: var(--sp-2); }
3304
3318
  .chip--pick { position: relative; } /* the input covers the whole chip, on a row as in a grid */
3305
3319
  /* the hidden input, the same shape wherever a native radio/checkbox drives a picked look:
3306
- a chip, a mood dot, a tile (media.css) */
3307
- :is(.chip--pick, .mood__opt, .tile--pick) > input { appearance: none; position: absolute; inset: 0; margin: 0; opacity: 0; cursor: inherit; }
3320
+ a chip, a mood dot, a tile (media.css), a sticky's tone swatch (sticky.css) — the native input lies over its whole box, unseen */
3321
+ :is(.chip--pick, .mood__opt, .tile--pick, .sticky-pick__swatch) > input { appearance: none; position: absolute; inset: 0; margin: 0; opacity: 0; cursor: inherit; }
3308
3322
  :is(.chip--pick, .tile--pick):has(:checked) { background: var(--accent); color: var(--on-accent); border-color: transparent; }
3309
3323
  /* --sign: the trailing + that turns 45° into an × when picked — one mask, one rotate, no swap (Lh 2026-09-16) */
3310
3324
  .chip--sign { padding-right: var(--sp-2); }
@@ -3862,7 +3876,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3862
3876
  box-shadow: calc(-1 * var(--sp-0)) calc(-1 * var(--sp-0)) var(--sp-1) rgb(0 0 0 / 0.08);
3863
3877
  }
3864
3878
  /* things stuck ON the note (a stamp, placed by hand): a layer over the whole paper. A tap between them still reaches the words under it.
3865
- A stamp on a note is the stamp's small size whatever the note's (48 × 60, --stamp-sm-size: stamp.css), and may lie over the words. */
3879
+ A stamp on a note is the stamp's small size whatever the note's (48 × 60, --stamp-sm-size: stamp.css; `StampOn main` is the main size), and may lie over the words. */
3866
3880
  .sticky__stuck { position: absolute; inset: 0; pointer-events: none; }
3867
3881
  .sticky--flat > .sticky__paper { clip-path: none; }
3868
3882
  .sticky--flat > .sticky__paper > .sticky__foot { padding-inline-end: 0; }
@@ -3950,7 +3964,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3950
3964
  box-shadow: inset 0 0 0 var(--border-w) var(--ink-tint); /* a pale tone still has an edge on a pale ground */
3951
3965
  cursor: pointer;
3952
3966
  }
3953
- .sticky-pick__swatch > input { appearance: none; position: absolute; inset: 0; margin: 0; opacity: 0; cursor: inherit; }
3967
+ /* its hidden input: the picks' one rule, choice.css */
3954
3968
  .sticky-pick__swatch:has(> :checked) { outline: var(--rule-w) solid currentColor; outline-offset: var(--sp-0); }
3955
3969
  .sticky-pick__swatch:has(> :focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
3956
3970
 
@@ -4053,6 +4067,49 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
4053
4067
  .sticky-board > :nth-child(4n + 3) { rotate: var(--sticky-board-turn-3, 2.75deg); }
4054
4068
  .sticky-board > :nth-child(4n) { rotate: var(--sticky-board-turn-4, -1.75deg); }
4055
4069
 
4070
+ /* ---- sticky pile: one to six notes lying one over another — the newest on top (Lh 2026-10-11) ----
4071
+ <div class="sticky-pile sticky-pile--tap" style="--_n: 3">
4072
+ <div class="sticky-pile__notes">
4073
+ <div class="sticky">…</div><div class="sticky">…</div><div class="sticky">…</div>
4074
+ <button class="sticky-pile__press" aria-label="Three new notes"></button>
4075
+ </div>
4076
+ </div>
4077
+ The FIRST note is on top; each one under it is shifted a step right and down (--sticky-pile-step-x 24, --sticky-pile-step-y 8) so a strip of it peeks
4078
+ out. All lie in ONE grid cell. The turn of each is the board's, by its place among the children (4n + 1 … 4n): the first, the top, is always −3°, whatever the count.
4079
+ The notes under the top one draw no foot. The pile is centred in its column (the root is the column's width and a size container) and never
4080
+ wider than it: the steps are shortened to what the column leaves. --_n is the count, written by the component: data, not a knob.
4081
+ The room round the notes (--sticky-pile-pad) is for the turned corners and the shadow.
4082
+ --tap: the whole pile is the button, a layer UNDER the notes (the last child): the notes let a touch through to it (pointer-events), a stamp on the top note keeps its own.
4083
+ The press look is actions.css's (DIM); the ring is the button's own, round the whole pile. */
4084
+ .sticky-pile {
4085
+ --_size: var(--sticky-size, calc(var(--sp-12) * 3.5));
4086
+ --_pad: var(--sticky-pile-pad, var(--sp-2));
4087
+ --_step-x: min(var(--sticky-pile-step-x, var(--sp-6)), (100cqw - var(--_size) - 2 * var(--_pad)) / max(var(--_n) - 1, 1));
4088
+ --_step-y: var(--sticky-pile-step-y, var(--sp-2));
4089
+ container-type: inline-size;
4090
+ display: grid;
4091
+ justify-items: center;
4092
+ }
4093
+ .sticky-pile__notes {
4094
+ position: relative;
4095
+ display: grid;
4096
+ grid-template: var(--_size) / var(--_size);
4097
+ padding: var(--_pad) calc(var(--_pad) + (var(--_n) - 1) * var(--_step-x)) calc(var(--_pad) + (var(--_n) - 1) * var(--_step-y)) var(--_pad);
4098
+ }
4099
+ .sticky-pile__notes > .sticky { --_depth: 0; grid-area: 1 / 1; z-index: calc(7 - var(--_depth)); translate: calc(var(--_depth) * var(--_step-x)) calc(var(--_depth) * var(--_step-y)); }
4100
+ .sticky-pile__notes > .sticky:nth-child(2) { --_depth: 1; }
4101
+ .sticky-pile__notes > .sticky:nth-child(3) { --_depth: 2; }
4102
+ .sticky-pile__notes > .sticky:nth-child(4) { --_depth: 3; }
4103
+ .sticky-pile__notes > .sticky:nth-child(5) { --_depth: 4; }
4104
+ .sticky-pile__notes > .sticky:nth-child(6) { --_depth: 5; }
4105
+ .sticky-pile__notes > .sticky:nth-child(n + 2) .sticky__foot { display: none; }
4106
+ .sticky-pile__notes > .sticky:nth-child(4n + 1) { rotate: var(--sticky-board-turn-1, -3deg); }
4107
+ .sticky-pile__notes > .sticky:nth-child(4n + 2) { rotate: var(--sticky-board-turn-2, 1.5deg); }
4108
+ .sticky-pile__notes > .sticky:nth-child(4n + 3) { rotate: var(--sticky-board-turn-3, 2.75deg); }
4109
+ .sticky-pile__notes > .sticky:nth-child(4n) { rotate: var(--sticky-board-turn-4, -1.75deg); }
4110
+ .sticky-pile__press { position: absolute; inset: 0; margin: 0; padding: 0; border: 0; appearance: none; background: none; -webkit-tap-highlight-color: transparent; }
4111
+ .sticky-pile--tap .sticky-pile__notes > .sticky { pointer-events: none; }
4112
+
4056
4113
  /* ---- sticky answer: the note you answer on — it waits at the screen's foot and comes to the top to be written on ----
4057
4114
  <div class="sticky-answer sticky-answer--writing">
4058
4115
  <div class="sticky-answer__scrim"></div>
@@ -4316,17 +4373,57 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
4316
4373
  }
4317
4374
  /* the SMALL stamp: stuck on a surface (a note). 48 × 60 whatever the surface's size. The stamp carries the size and the `.stamp-on` box is as wide as its stamp */
4318
4375
  .stamp-on > .stamp { width: var(--stamp-sm-size, var(--tap)); max-width: none; }
4376
+ /* --main: the same stuck stamp at the stamp's MAIN size, 96 × 120 (--stamp-size), to cover what it lies on (the owner's call, 2026-10-11: the home's note).
4377
+ After the rule above, at the same weight: `main` is the one place a stuck stamp reads --stamp-size. */
4378
+ .stamp-on--main > .stamp { width: var(--stamp-size, calc(var(--tap) * 2)); }
4319
4379
  .stamp-on--move { will-change: transform; } /* its own layer: Safari left the stamp's shadow behind as a trail along the drag when it was painted with the note (seen on the simulator, 2026-10-07) */
4320
4380
  .stamp-on--move { cursor: grab; touch-action: none; -webkit-user-select: none; user-select: none; transition: scale var(--motion-press) ease-out; }
4321
4381
  .stamp-on--move:focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
4322
4382
  .stamp-on.is-held { cursor: grabbing; z-index: 1; scale: 1.06; } /* lifted off the paper while the finger holds it */
4323
4383
  @media (prefers-reduced-motion: reduce) { .stamp-on.is-held { scale: 1; } }
4324
- /* --arrive: the stamp sticks ITSELF (StampOn `arrive`) — struck onto its place: shown large and faint, pressed a little past its
4325
- size, then at rest. The component reads the animation's name to tell the app it has landed. Knob: --stamp-on-arrive-time */
4384
+
4385
+ /* ---- the cross that takes a stamp off (`StampOn onRemove`): a small dark disc with an x on the stamp's top right corner ----
4386
+ <div class="stamp-on stamp-on--move">…<button class="stamp-on__remove" aria-label="Remove"><span class="stamp-on__remove-disc"><svg class="icon">x</svg></span></button></div>
4387
+ The button is the touch target — at least --tap-floor (44) square, invisible, centred on the corner — and the disc inside it is what is seen
4388
+ (24). It is NOT turned with the stamp: a hand turns a stamp, a button stays square to the screen, so the cross counter-turns by the stamp's own turn. Like the
4389
+ stamp it brings its ground and its ink (a dark disc, a pale x and a pale ring: seen on any paper and over any picture). */
4390
+ .stamp-on__remove-disc {
4391
+ display: grid;
4392
+ place-items: center;
4393
+ width: var(--stamp-on-remove-size, var(--sp-6));
4394
+ aspect-ratio: 1;
4395
+ border-radius: 50%;
4396
+ background-color: var(--stamp-on-remove-bg, #141414);
4397
+ box-shadow: 0 0 0 var(--sp-0) currentColor, 0 var(--sp-0) var(--sp-1) rgb(0 0 0 / 0.35);
4398
+ }
4399
+ .stamp-on__remove {
4400
+ --_hit: max(var(--tap-floor), var(--stamp-on-remove-size, var(--sp-6)));
4401
+ position: absolute;
4402
+ top: 0;
4403
+ right: 0;
4404
+ translate: 50% -50%;
4405
+ rotate: calc(var(--stamp-on-turn, 0) * -1deg);
4406
+ display: grid;
4407
+ place-items: center;
4408
+ width: var(--_hit);
4409
+ height: var(--_hit);
4410
+ padding: 0;
4411
+ border: 0;
4412
+ border-radius: 50%;
4413
+ background: none;
4414
+ color: var(--stamp-on-remove-ink, #ffffff);
4415
+ font: inherit;
4416
+ -webkit-tap-highlight-color: transparent;
4417
+ }
4418
+ .stamp-on__remove-disc > .icon { width: 60%; height: 60%; }
4419
+ @media (prefers-reduced-motion: no-preference) { .stamp-on__remove { animation: stamp-on-remove-in var(--motion-press) ease-out; } }
4420
+ @keyframes stamp-on-remove-in { from { opacity: 0; scale: 0.6; } }
4421
+
4422
+ /* --arrive: the stamp sticks ITSELF (StampOn `arrive`) — struck onto its place (`strike`: tokens.css). The component reads the
4423
+ animation's name to tell the app it has landed. Knob: --stamp-on-arrive-time */
4326
4424
  @media (prefers-reduced-motion: no-preference) {
4327
- .stamp-on--arrive { animation: stamp-on-strike var(--stamp-on-arrive-time, var(--motion-sheet)) ease-in both; }
4425
+ .stamp-on--arrive { animation: strike var(--stamp-on-arrive-time, var(--motion-sheet)) ease-in both; }
4328
4426
  }
4329
- @keyframes stamp-on-strike { from { scale: 2; opacity: 0; } 60% { scale: 0.92; opacity: 1; } }
4330
4427
 
4331
4428
  /* ---- css/post.css — the post: postmark, a postcard read (letter), written (desk), kept (box) ---- */
4332
4429
  /* cardds — the post: a postcard read (post-letter), written (post-desk) and kept (post-box), the postmark, the card stock.
@@ -4565,10 +4662,9 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
4565
4662
  .post-desk__slot { border: 0; background: transparent; color: inherit; }
4566
4663
  @media (prefers-reduced-motion: no-preference) {
4567
4664
  .post-desk__slot > .stamp { animation: post-desk-stick var(--motion-quick) ease-out both; }
4568
- .post-desk .postmark { animation: post-desk-strike var(--motion-press) ease-in both; }
4665
+ .post-desk .postmark { animation: strike var(--stamp-on-arrive-time, var(--motion-sheet)) ease-in both; } /* struck as a StampOn is (`strike`: tokens.css), in the same time */
4569
4666
  }
4570
4667
  @keyframes post-desk-stick { from { translate: 0 calc(-1 * var(--sp-8)); rotate: 14deg; opacity: 0; } }
4571
- @keyframes post-desk-strike { from { translate: calc(-1 * var(--sp-3)) calc(-1 * var(--sp-6)); rotate: -24deg; opacity: 0; } }
4572
4668
  /* the other half's showing edge brings it across: a tap-wide strip out to the screen's edge */
4573
4669
  .post-desk__reach { position: absolute; inset-block: 0; width: var(--tap); border: 0; background: transparent; }
4574
4670
  .post-desk__reach--address { inset-inline-end: calc(-1 * var(--screen-pad)); }
@@ -5312,19 +5408,37 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
5312
5408
 
5313
5409
  /* ---- css/finish.css — light, shadow, edges — only under data-finish="lit" ---- */
5314
5410
  /* ============================================================
5315
- cardds/finish.css — FINISH: light, shadow and edges. A style of its own (Lh 2026-10-02: the plain
5316
- look read flat, "not finished"), apart from colour: palette.css says WHICH colours, this says how
5317
- the material is lit. Apply with <html data-finish="lit"> beside any palette (or none, or "own").
5318
- No attribute = nothing here applies — the flat look, unchanged.
5411
+ cardds/finish.css — FINISH: the finish of a card's paper. A style of its own (Lh 2026-10-02: the plain
5412
+ look read flat, "not finished"), apart from colour: palette.css says WHICH colours, this says what the
5413
+ material is and how it is lit. Apply with <html data-finish="…"> (or on any ancestor) beside any palette,
5414
+ or none, or "own". No attribute = nothing here applies — the flat look, unchanged.
5415
+
5416
+ FINISHES — four values of the one attribute (Lh 2026-10-12, from the timebank home with each injected:
5417
+ four finishes, variations of one concept, never new pieces; teeth · tape · ticket · slab were rejected):
5418
+ lit = lit from above: a lit top edge, a hairline ring and a layered shadow that grows with height; filled buttons stand up and go down under the finger; fields and pin cells are sunk.
5419
+ print = printed paper: an ink line in the card's own ink, a hard misregistered offset instead of a blurred shadow, paper grain over the card, a dotted rule under the head as on a printed form; information inside stays flat.
5420
+ sticker = a die-cut sticker: a white cut line round the card, a short hard drop, a slight tilt on a lone card, buttons with a bottom edge that press down.
5421
+ ink = a poster / comic print: a thick line in the surface's ink round every boundary and control, a hard offset in the same ink.
5422
+ KNOBS (set in a theme, as any knob; docs/knobs.md lists them):
5423
+ print --print-off (the misregistration: how far the second pass missed) · --print-grain (the grain's opacity; a ::after, so the knob walk cannot probe it)
5424
+ sticker --sticker-cut (the paper left round the print) · --sticker-tilt (a lone card's lean; 0deg for none — a card in a stack or a list never leans)
5425
+ ink --ink-w (the line) · --ink-off (the offset)
5426
+
5427
+ Information inside a card stays flat under every finish: only a container or a control gets an edge.
5428
+ Colours are mixes of the roles (--on-surface, --card-ink, --accent, currentColor), never plain black,
5429
+ so a finish sits in any palette; the one plain white is the sticker's backing paper and the lit edge's light.
5430
+ ============================================================ */
5319
5431
 
5320
- The light comes from above:
5432
+ /* every finish: the finish IS the edge, so the wireframe's two borders step back (as [data-palette] in palette.css); ink puts its own line there below */
5433
+ [data-finish] { --card-border: transparent; --action-border: transparent; }
5434
+
5435
+ /* ============================================================
5436
+ lit — the light comes from above:
5321
5437
  - a thing that stands UP (card, sheet, button) has a lit top edge, a hairline ring and a shadow
5322
5438
  that grows with its height: card 1 · sheet / float 2 — the layers cardds already has;
5323
- - a thing that is SUNK (a field, a pin cell) has its shadow inside, under its top edge;
5324
- - information inside a card stays flat: only a container or a control gets an edge.
5439
+ - a thing that is SUNK (a field, a pin cell) has its shadow inside, under its top edge.
5325
5440
  Shadows are mixed from the ink (--on-surface), never plain black, so they sit in the palette.
5326
5441
  ============================================================ */
5327
-
5328
5442
  [data-finish="lit"] {
5329
5443
  --_shade: var(--on-surface);
5330
5444
  --finish-edge: inset 0 1px 0 rgb(255 255 255 / 0.55); /* the lit top edge */
@@ -5350,9 +5464,6 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
5350
5464
  --finish-sheen: linear-gradient(to bottom, rgb(255 255 255 / 0.16), rgb(255 255 255 / 0) 60%);
5351
5465
  --finish-sunk: inset 0 1px 2px color-mix(in srgb, var(--_shade) 14%, transparent), 0 1px 0 rgb(255 255 255 / 0.5);
5352
5466
 
5353
- /* the shadow is the edge: the wireframe's lines step back (as in every colour palette) */
5354
- --card-border: transparent;
5355
- --action-border: transparent;
5356
5467
  --outline: color-mix(in srgb, currentColor 14%, transparent);
5357
5468
  }
5358
5469
 
@@ -5394,3 +5505,74 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
5394
5505
  /* ---- sunk: what the member types into ---- */
5395
5506
  [data-finish="lit"] :is(.field, .pin__cell) { box-shadow: var(--finish-sunk); }
5396
5507
 
5508
+ /* ============================================================
5509
+ print — printed paper (riso / zine): the second pass missed by --print-off, so the card's shadow is a
5510
+ hard offset in its own ink; a line in that ink round the card; grain over the whole card; the head
5511
+ underlined by a dotted rule as on a printed form. A lone card's head is a form's head; a stacked
5512
+ sheet's, a row card's, a cover's (controls over a picture) and a folded feed card's (the head is the
5513
+ whole card) have no rule.
5514
+ ============================================================ */
5515
+ [data-finish="print"] {
5516
+ --_ink: color-mix(in srgb, currentColor 70%, transparent);
5517
+ --outline: var(--_ink);
5518
+ }
5519
+ [data-finish="print"] .card {
5520
+ border: var(--border-w) solid var(--_ink);
5521
+ box-shadow: var(--print-off, 3px) var(--print-off, 3px) 0 0 color-mix(in srgb, var(--card-ink) 28%, transparent);
5522
+ isolation: isolate; /* the grain's blend stays inside the card */
5523
+ }
5524
+ /* the grain: a ::after, not ::before — .card--cover's scrim is its ::before. Over everything in the card (z-index 1, the foot's own level), touches pass through it */
5525
+ [data-finish="print"] .card::after {
5526
+ content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 1;
5527
+ opacity: var(--print-grain, 0.32); mix-blend-mode: multiply;
5528
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.3 0 0 0 0 0.25 0 0 0 0 0.2 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
5529
+ }
5530
+ /* a dark card (the brand tone, a cover): multiply would only blacken it, so the grain lightens, fainter */
5531
+ [data-finish="print"] :is(.card--3, .card--cover)::after { mix-blend-mode: screen; opacity: calc(var(--print-grain, 0.32) * 0.56); }
5532
+ [data-finish="print"] .card:not(.card--row, .card--cover, .feed-card--small, .sheet-stack > *, .sheet-stack > :not(.sheet-stack__base) > *) > .card__head {
5533
+ padding-bottom: var(--sp-2);
5534
+ border-bottom: var(--rule-w) dotted color-mix(in srgb, currentColor 45%, transparent);
5535
+ }
5536
+ [data-finish="print"] :is(.btn, .chip) {
5537
+ border-color: var(--_ink);
5538
+ box-shadow: 2px 2px 0 0 color-mix(in srgb, currentColor 35%, transparent);
5539
+ }
5540
+ [data-finish="print"] .btn--primary { box-shadow: 2px 2px 0 0 color-mix(in srgb, var(--card-ink) 60%, transparent); }
5541
+ [data-finish="print"] :is(.btn, .chip):active { box-shadow: none; translate: 2px 2px; }
5542
+ [data-finish="print"] .card__thumb { border-style: dashed; }
5543
+
5544
+ /* ============================================================
5545
+ sticker — a die-cut sticker on a sheet: --sticker-cut of white paper left round the print, a short hard
5546
+ drop under it, a lone card leans by --sticker-tilt (the next one the other way); chunky buttons with a
5547
+ bottom edge that press down. A card in a stack, in a list, in a feed, a sheet or a modal never leans:
5548
+ those are placed by their holder, a lean would cut or cross them.
5549
+ ============================================================ */
5550
+ [data-finish="sticker"] { --outline: color-mix(in srgb, currentColor 18%, transparent); }
5551
+ [data-finish="sticker"] .card {
5552
+ box-shadow:
5553
+ 0 0 0 var(--sticker-cut, 5px) #fff,
5554
+ 0 calc(var(--sticker-cut, 5px) + 4px) 0 0 color-mix(in srgb, var(--on-surface) 16%, transparent),
5555
+ 0 calc(var(--sticker-cut, 5px) + 12px) 20px -8px color-mix(in srgb, var(--on-surface) 22%, transparent);
5556
+ }
5557
+ [data-finish="sticker"] .card:not(.sheet, .modal, .sheet-stack *, .card-list > *, .card-feed > *) { rotate: var(--sticker-tilt, 0.8deg); }
5558
+ [data-finish="sticker"] .card:not(.sheet, .modal, .sheet-stack *, .card-list > *, .card-feed > *):nth-of-type(even) { rotate: calc(-1 * var(--sticker-tilt, 0.8deg)); }
5559
+ [data-finish="sticker"] .card-list { gap: var(--sp-5); }
5560
+ [data-finish="sticker"] .btn {
5561
+ box-shadow: 0 3px 0 0 color-mix(in srgb, currentColor 30%, transparent);
5562
+ border-color: color-mix(in srgb, currentColor 25%, transparent);
5563
+ }
5564
+ [data-finish="sticker"] .btn--primary { box-shadow: 0 3px 0 0 color-mix(in srgb, var(--btn-primary-bg, var(--accent)) 55%, var(--on-surface)); }
5565
+ [data-finish="sticker"] .btn:active { box-shadow: none; translate: 0 3px; }
5566
+ [data-finish="sticker"] .chip { box-shadow: 0 0 0 3px #fff; }
5567
+
5568
+ /* ============================================================
5569
+ ink — a poster / comic print: --ink-w of the surface's ink round every boundary and control, and a hard
5570
+ offset of --ink-off in the same ink. The two wireframe borders come back as that line.
5571
+ ============================================================ */
5572
+ [data-finish="ink"] { --card-border: var(--on-surface); --action-border: var(--on-surface); --outline: currentColor; }
5573
+ [data-finish="ink"] .card { border: var(--ink-w, 2.5px) solid var(--on-surface); box-shadow: var(--ink-off, 5px) var(--ink-off, 5px) 0 0 var(--on-surface); }
5574
+ [data-finish="ink"] :is(.btn, .chip, .icon-btn:not(.icon-btn-group > *)) { border: var(--ink-w, 2.5px) solid currentColor; box-shadow: 3px 3px 0 0 currentColor; }
5575
+ [data-finish="ink"] .btn--primary { border-color: var(--on-surface); box-shadow: 3px 3px 0 0 var(--on-surface); }
5576
+ [data-finish="ink"] .toggle { box-shadow: 2px 2px 0 0 var(--on-surface); }
5577
+ [data-finish="ink"] .card__thumb { border-width: var(--ink-w, 2.5px); }
5578
+
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Children, createContext, isValidElement, useContext, useId, useLayoutEffect, useRef } from 'react';
3
3
  import { cx } from '../cx.js';
4
- import { reducedMotion, useControlled, warnOnce } from '../state.js';
4
+ import { reducedMotion, setRefs, useControlled, warnOnce } from '../state.js';
5
5
  import { Card } from './Card.js';
6
6
  import { CardHead } from './CardHead.js';
7
7
  import { CardFoot } from './CardFoot.js';
@@ -53,16 +53,9 @@ export function CardFeed({ big: bigProp, defaultBig, onBigChange, className, chi
53
53
  }
54
54
  tops.current = at;
55
55
  });
56
- const setRef = (node) => {
57
- own.current = node;
58
- if (typeof ref === 'function')
59
- ref(node);
60
- else if (ref)
61
- ref.current = node;
62
- };
63
56
  if (!shown.length)
64
57
  return null;
65
- return (_jsx(FeedCtx.Provider, { value: { big, grow: (id) => request(id, 'tap') }, children: _jsx("div", { ref: setRef, className: cx('card-feed', className), ...rest, children: shown }) }));
58
+ return (_jsx(FeedCtx.Provider, { value: { big, grow: (id) => request(id, 'tap') }, children: _jsx("div", { ref: setRefs(own, ref), className: cx('card-feed', className), ...rest, children: shown }) }));
66
59
  }
67
60
  const onSmallKey = (e) => {
68
61
  if (e.target !== e.currentTarget || (e.key !== 'Enter' && e.key !== ' '))