@usableapp/cardds 0.7.104 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  > For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap. Each release section after 0.7.97 carries one `Components:` line, written by `node scripts/components-changed.mjs <previous tag>`: the components the release changed, or `all` when a file changed that any component can read (a token, the build), or `none`; check only the screens that import those.
4
4
 
5
+ ## 0.7.105 — 2026-10-21
6
+
7
+ A card foot of big round buttons, and a card drawn as the gallery draws it.
8
+
9
+ Components: all (a new root token, --tap-xl; the components that change are IconBtn, CardFoot, Card and JourneyBadge)
10
+
11
+ ### Added
12
+ - **Big round buttons, and a foot made of them** (a consumer's ask, approved: a card foot of three big round buttons, one centred group, `IconBtn` only). Before → after: `IconBtn` had two sizes (`sm`, the default 48px) and everything bigger was the knob `--icon-btn-size`, whose icon stayed 28px, with no primary fill and no ceiling for a word at a big text size; a card foot could only share its line between `Btn`s. Now: `IconBtn size="lg"` (56px, `--tap-lg`) and `size="xl"` (68px, the new `--tap-xl`), all on `--tap-base` like the other sizes (knobs `--icon-btn-lg-size`, `--icon-btn-xl-size`; `--icon-btn-size` and `sm` as before); in `lg` and `xl` the icon is a share of the circle (`--icon-btn-icon`, 7 / 12 by default, the share the 48px button's 28px icon has) and a short word child grows with the text size up to `--icon-btn-word-max` of the circle (0.45) and no further; `IconBtn primary` is the primary `Btn`'s fill from the same tokens (`--btn-primary-bg` / `--btn-primary-ink`, orchard's yellow included), beside `invert`, the accent; `CardFoot round` (on a `Card`: `footRound`) is the foot of a few round buttons: each keeps its size, they stand as one group in the middle with a gap of `--sp-6` (`--card-foot-gap` sets it), on a turning two-tone card too. The Btn foot, the default and `sm` buttons, and every story drawn before are unchanged (style snapshot identical). The script answers `all` because the new root token `--tap-xl` is in tokens.css; what a consumer sees change is only `IconBtn`, `CardFoot` and `Card`, and only where the new props are used. Stories: `IconBtn/Rounds`, `IconBtn/Primary`, `IconBtn/WordAndIconKnobs`, `Card/CardFootRound`, `Card/TurnsRoundFoot`.
13
+
14
+ ### Fixed
15
+ - **A card as the gallery draws it, on a phone too: the title stops at three lines, the date keeps its own height, a turning card hugs its faces** (timebank's help card on a phone, 2026-10-21: "ต้นแบบออกแบบไว้ดี แต่งานจริง เละ"). Components: BtnTrio, CallSheet, Card, CardFeed, CardHead, FeedCard, Home, JourneyBadge, Modal, ModalAsk, ModalBody, NumberBand, NumberBandStack, Sheet, SheetStack, StackSheet, Walkthrough (`node scripts/components-changed.mjs`: every piece that draws a card's head, for the three-line title). Nothing for a consumer to change.
16
+ - **A card's title: three lines at most** (Lh 2026-10-21: "มันควรจะ max สามบรรทัด"; reverses 2026-09-16's "no clamp"). Before → after: the heading in a card's own head wrapped to as many lines as its words took (a request's title ran to five lines on a phone) → it is cut after three lines with an ellipsis. Knob `--card-head-title-lines` (3).
17
+ - **`JourneyBadge stretch`: no taller than its own three lines.** Before → after: as tall as the title block beside it, so beside a long title the date was a tall block with its words in the middle → as tall as the title block up to its three lines (day · number · month, `--sp-3` above and below), at the head's top. It turns to three lines from 3.5rem of room (was 5.5rem), so a two-line title gets them, as the 2026-10-19 rule said. A one-line title: unchanged (64.7 at 375).
18
+ - **`Card back`: the card is as tall as its taller face.** Before → after: the middle was one fixed height on both faces (`--card-middle-h`, default 6.25 taps, 275 at 375), so a short front and a short back (a request and its code) stood in a tall empty band → each face fills the card's height and the middle takes what the head and the foot leave, so the card hugs the taller face and still never changes height as it turns. `--card-middle-h` still fixes the middle where a theme sets it; its default is now `auto`. The face is a flex column (was a grid). New story `Card · TurnsLongTitle`; tests/card-turns.spec.js.
19
+ - What the style snapshot of the 492 stories moved: every heading in a card's head computes `display: flow-root` and `overflow: hidden`, with 0.2em of room over its first line given back by a negative margin (a stacked Thai mark rises over the line's box and the clip would cut it), so the words stand where they stood in all 262 such stories; `Card · LongHeading` and `CardHead · CardHeadTop` are cut to three lines (28 and 55 shorter); `Card · Turns`, `TurnsTwoLineTitle` and `TurnsControlled` are 25 shorter (the middle hugs the taller face, no longer 6.25 taps); the date in `Turns`, `TurnsControlled` and `PaperBands` keeps its size (only its new cap shows), `TurnsTwoLineTitle`'s is 81 (was 128); `SheetStack · Display` moves by 0.02px (its heading's own margin takes the room back).
20
+
5
21
  ## 0.7.104 — 2026-10-21
6
22
 
7
23
  Every value a component shows comes from a token, so what the gallery shows is what a theme gets (Lh 2026-10-20). Also: the gallery shows every foundation token, the catalog finds a piece by a plain intention, the number pieces are ruled (Stat and Tile stat drop as twins of NumberBand and KvCell), and CSS and JS written twice are written once.
package/css/actions.css CHANGED
@@ -82,6 +82,14 @@
82
82
  }
83
83
  .icon-btn--invert { background: var(--icon-btn-invert-bg, var(--accent)); color: var(--icon-btn-invert-ink, var(--on-accent)); border-color: transparent; }
84
84
  .icon-btn--sm { width: var(--icon-btn-sm-size, var(--tap-sm)); height: var(--icon-btn-sm-size, var(--tap-sm)); font-size: var(--fs-title); }
85
+ /* the bigger round sizes (`IconBtn size`): lg is the hero control's size (--tap-lg), xl the biggest (--tap-xl), both on --tap-base like every size.
86
+ What stands inside is a share of the circle (`--_ib`, its side), so it grows with it: the ICON is --icon-btn-icon of the side (7/12, the share of the default 48px
87
+ button's 28px icon), and a WORD (a string child) keeps to --icon-btn-word-max of the side (0.45: a word of four letters stays inside) — it still
88
+ grows with the member's text size, to that ceiling and no further. The default and the sm button keep their icon tokens and their word as before. */
89
+ .icon-btn--lg { --_ib: var(--icon-btn-lg-size, var(--tap-lg)); width: var(--_ib); height: var(--_ib); }
90
+ .icon-btn--xl { --_ib: var(--icon-btn-xl-size, var(--tap-xl)); width: var(--_ib); height: var(--_ib); }
91
+ :is(.icon-btn--lg, .icon-btn--xl) { font-size: min(var(--fs-lg), calc(var(--_ib) * var(--icon-btn-word-max, 0.45))); }
92
+ :is(.icon-btn--lg, .icon-btn--xl) > .icon { --icon-size: calc(var(--_ib) * var(--icon-btn-icon, 7 / 12)); --icon-sm-size: var(--icon-size); --icon-lg-size: var(--icon-size); } /* the Icon's own knobs */
85
93
  /* ---- icon button group: up to four icon-only commands in ONE frame (Lh 2026-09-29) ----
86
94
  <div class="icon-btn-group" role="group"><button class="icon-btn" aria-label="…">…</button>…</div>
87
95
  One pill as tall as a button, the buttons share its width, a hairline between them; in a foot it takes the row. */
@@ -139,7 +147,8 @@
139
147
  min-height: var(--btn-h);
140
148
  text-decoration: none; /* .btn works as <a> too */
141
149
  }
142
- .btn--primary { background: var(--btn-primary-bg, var(--accent)); color: var(--btn-primary-ink, var(--on-accent)); border-color: transparent; }
150
+ /* primary: a Btn and a round IconBtn fill alike, from the same tokens (the palette's primary — orchard's yellow — retunes both) */
151
+ :is(.btn--primary, .icon-btn--primary) { background: var(--btn-primary-bg, var(--accent)); color: var(--btn-primary-ink, var(--on-accent)); border-color: transparent; }
143
152
  /* quiet keeps a faint outline so it still reads as a button on colored palettes;
144
153
  it is ink on whatever it sits on — inherit, or on the dark slot (card--3) it
145
154
  would take the light-card action ink and vanish */
package/css/card.css CHANGED
@@ -75,12 +75,23 @@
75
75
  .card__head > :is(h1, h2, h3, div, p):first-child { flex: 1; min-width: 0; }
76
76
 
77
77
  /* Skeleton head — the heading in a card's OWN head, and only there.
78
- The heading wraps freely: no line budget, no squeeze, no clamp (Lh
79
- 2026-09-16 — a height rule on words only distorts them; content that needs
80
- more rows takes more rows). The group that holds heading ·
78
+ The heading wraps, no squeeze, up to three lines and cut there with an
79
+ ellipsis (Lh 2026-10-21: "มันควรจะ max สามบรรทัด" — a request's title ran to
80
+ four lines on a phone; reverses 2026-09-16's "no clamp"). The count is the knob
81
+ --card-head-title-lines. The group that holds heading ·
81
82
  caption is the head's own wrapping row (above) — no wrapper element, no
82
83
  height, only the row gap. */
83
- .card__head :is(h1, h2, h3) { overflow-wrap: anywhere; }
84
+ .card__head :is(h1, h2, h3) {
85
+ overflow-wrap: anywhere;
86
+ display: -webkit-box;
87
+ -webkit-box-orient: vertical;
88
+ -webkit-line-clamp: var(--card-head-title-lines, 3);
89
+ line-clamp: var(--card-head-title-lines, 3);
90
+ overflow: hidden;
91
+ padding-block-start: var(--_mark-room); /* a stacked Thai mark rises over the first line's box: room inside the clip, given back outside */
92
+ margin-block-start: calc(-1 * var(--_mark-room));
93
+ --_mark-room: 0.2em;
94
+ }
84
95
 
85
96
  /* a Stamp in a card's head (`Card end={<Stamp …/>}`): the words stand at the left, the stamp at the right BESIDE BOTH LINES — the heading and the
86
97
  caption pair up beside it, not the stamp beside the heading alone with the caption left under it. The stamp is its own size (--stamp-size,
@@ -115,12 +126,14 @@
115
126
 
116
127
  /* ---- two faces: the WHOLE card turns (`Card back`, Lh 2026-10-19). The article is the turning box (.flip, base.css) and only that;
117
128
  each face is the card's paper — head · middle · foot — and both lie in one grid cell. The head and the foot are the same on both
118
- faces; the middle is one fixed height (--card-middle-h, the theme's call), so the card never changes height as it turns. The hidden
129
+ faces; the card is as tall as its taller face and each face fills it, the middle taking what the head and the foot leave (2026-10-21,
130
+ timebank: a fixed middle left a short front a tall empty band), so the card never changes height as it turns; --card-middle-h fixes
131
+ the middle's height instead, the theme's call. The hidden
119
132
  face is inert. Each face holds an unseen turn button: the keyboard's way to turn it; its ring is drawn on the face. */
120
133
  .card--turns { background: none; border: 0; padding: 0; cursor: pointer; touch-action: manipulation; }
121
134
  .card__face {
122
- display: grid;
123
- align-content: start;
135
+ display: flex;
136
+ flex-direction: column;
124
137
  gap: var(--card-gap);
125
138
  min-width: 0;
126
139
  padding: var(--card-pad);
@@ -131,7 +144,7 @@
131
144
  position: relative;
132
145
  }
133
146
  .card__face > * { min-width: 0; }
134
- .card__middle { display: grid; align-content: start; gap: var(--card-gap); height: var(--card-middle-h, calc(var(--tap-base) * 25 / 4)); overflow: hidden; }
147
+ .card__middle { flex: 1 0 auto; display: grid; align-content: start; gap: var(--card-gap); height: var(--card-middle-h, auto); overflow: hidden; }
135
148
  .card__turn { position: absolute; inset: 0; width: var(--px); height: var(--px); padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); }
136
149
  .card__face:has(> .card__turn:focus-visible) { outline: var(--focus); outline-offset: calc(-1 * var(--sp-1)); }
137
150
  .card__head > .chip-row > .chip > .icon { --icon-display: inline-block; --icon-align: -0.15em; --icon-margin: 0 var(--chip-sm-gap, var(--sp-1)) 0 0; }
@@ -183,6 +196,9 @@
183
196
  whole short row of its own, centred — it never shares the buttons' line */
184
197
  .card__foot--link { flex-wrap: wrap; align-content: end; row-gap: var(--card-foot-link-gap, var(--sp-1)); }
185
198
  .card__foot-link { flex: 1 0 100%; display: flex; justify-content: center; }
199
+ /* round (CardFoot round): the foot holds a few round IconBtns, each its own size (they never share the line), as ONE group in the middle with
200
+ a gap that does not follow the card's width. The gap is wider than the Btns' (a miss between two round targets): --card-foot-gap sets it */
201
+ .card__foot--round { justify-content: center; gap: var(--card-foot-gap, var(--sp-6)); }
186
202
 
187
203
  /* media placeholder for wireframes — currentColor so it survives any palette */
188
204
  .ph {
package/css/finish.css CHANGED
@@ -76,7 +76,7 @@
76
76
  }
77
77
  /* the main button throws its own colour */
78
78
  [data-finish="lit"] .btn--primary,
79
- [data-finish="lit"] .icon-btn--invert:not(.icon-btn-group > *) {
79
+ [data-finish="lit"] :is(.icon-btn--invert, .icon-btn--primary):not(.icon-btn-group > *) {
80
80
  box-shadow: var(--finish-edge),
81
81
  0 0.0625rem 0.125rem color-mix(in srgb, var(--_shade) 20%, transparent),
82
82
  0 0.5rem 1rem -0.25rem color-mix(in srgb, var(--accent) 45%, transparent);
package/css/journey.css CHANGED
@@ -51,9 +51,11 @@
51
51
 
52
52
  /* stretch: a date in a head (Lh 2026-10-19) — as tall as the title block beside it and no taller (the head stretches it; size
53
53
  containment keeps its own words from adding height). Its height picks the form: short (a one-line title) = two lines, the day name
54
- over the number and the month on one line; tall (a two-line title) = three lines, day · number · month. */
54
+ over the number and the month on one line; tall (a two-line title) = three lines, day · number · month. It grows no taller than
55
+ its three lines (2026-10-21, timebank: beside a four-line title it was a tall empty block); held there, it stands at the head's top. */
55
56
  .tile-badge--stretch {
56
57
  align-self: stretch;
58
+ max-block-size: calc((2 * var(--fs-caption) + var(--fs-lg)) * var(--tile-badge-line, var(--lh-badge)) + 2 * var(--sp-3));
57
59
  container-type: size;
58
60
  inline-size: calc(var(--fs-lg) * 3.25);
59
61
  grid-template-columns: auto auto;
@@ -67,7 +69,7 @@
67
69
  }
68
70
  .tile-badge--stretch > * { line-height: inherit; }
69
71
  .tile-badge--stretch > :first-child { grid-column: 1 / -1; }
70
- @container (min-height: 5.5rem) {
72
+ @container (min-height: 3.5rem) { /* just under the three lines' own height, the most the badge grows to */
71
73
  .tile-badge--stretch > * { grid-column: 1 / -1; }
72
74
  }
73
75
 
package/css/palette.css CHANGED
@@ -98,7 +98,7 @@
98
98
  [data-palette="orchard"] .field { background: var(--field-bg, #ffffff); color: var(--ink-onlight); }
99
99
  [data-palette="orchard"] .pin__cell { background: var(--pin-cell-bg, #ffffff); color: var(--ink-onlight); }
100
100
  /* the vivid ones, tried on the live parts (Lh 2026-09-25): primary = yellow · a user avatar = fuchsia · ring = sky · a switch on = sky · maple only on small dots (the count, dot a); the chosen stack tab stays moss */
101
- [data-palette="orchard"] .btn--primary { background: var(--btn-primary-bg, var(--highlight-2)); color: var(--btn-primary-ink, var(--ink-onlight)); }
101
+ [data-palette="orchard"] :is(.btn--primary, .icon-btn--primary) { background: var(--btn-primary-bg, var(--highlight-2)); color: var(--btn-primary-ink, var(--ink-onlight)); }
102
102
  [data-palette="orchard"] .avatar:not(.avatar--outline, .avatar--add, .avatar--on) { background: var(--avatar-bg, var(--highlight-4)); --dark: 1; }
103
103
  [data-palette="orchard"] .ring::before { background: conic-gradient(var(--highlight-3) calc(var(--value) * 1%), var(--ink-tint) 0); }
104
104
  [data-palette="orchard"] .toggle:checked { background: var(--highlight-3); --accent-dark: 0; } /* sky is a light ground: its knob is onlight */
package/css/stack.css CHANGED
@@ -90,7 +90,7 @@
90
90
  letter-spacing: var(--track-display);
91
91
  /* the FIRST line sits on the centre of the peek strip (--stack-step tall,
92
92
  measured from the card's top edge); line two runs under the next card */
93
- margin-top: calc((var(--stack-step) - 1lh) / 2 - var(--sp-5));
93
+ margin-top: calc((var(--stack-step) - 1lh) / 2 - var(--sp-5) - var(--_mark-room)); /* --_mark-room: card.css, the room for the marks over line one */
94
94
  }
95
95
  /* fanned stack: a toggle opens the stack wider so every peek shows more */
96
96
  .sheet-stack--fanned { --stack-step: calc(var(--tap-base) * 19 / 6); }
package/css/tokens.css CHANGED
@@ -47,6 +47,7 @@
47
47
  --tap: max(var(--tap-floor), var(--tap-base)); /* 48 primary touch target */
48
48
  --tap-sm: max(var(--tap-floor), calc(var(--tap-base) * 11 / 12)); /* 44 minimum touch target */
49
49
  --tap-lg: max(var(--tap-floor), calc(var(--tap-base) * 7 / 6)); /* 56 hero controls: send, field with action */
50
+ --tap-xl: max(var(--tap-floor), calc(var(--tap-base) * 17 / 12)); /* 68 the biggest round control: a foot of a few big round buttons (IconBtn xl) */
50
51
  --btn-h: calc(var(--tap-base) * 13 / 12); /* 52 button height */
51
52
  --btn-xl-h: calc(var(--tap-base) * 11 / 6); /* 88 the one giant CTA */
52
53
  --chip-h: calc(var(--tap-base) * 19 / 24); /* 38 chip height */
@@ -5,17 +5,26 @@ export interface IconBtnProps extends ComponentProps<'button'> {
5
5
  label: string;
6
6
  /** the icon to show (sized to the button); or pass an Icon as children */
7
7
  icon?: IconName;
8
- /** 40px, for a card head or a top bar; default 48px */
8
+ /** 40px, for a card head or a top bar; default 48px (the short form of `size="sm"`) */
9
9
  sm?: boolean;
10
+ /** the round sizes, all on `--tap-base`: `sm` (44) · default (48) · `lg` (56, the hero control) · `xl` (68, the biggest — a card foot of a few big
11
+ * round buttons). In `lg` and `xl` the icon is a share of the circle (knob `--icon-btn-icon`) and a word child keeps inside it
12
+ * (knob `--icon-btn-word-max`). Each size has its knob: `--icon-btn-lg-size`, `--icon-btn-xl-size` */
13
+ size?: 'sm' | 'lg' | 'xl';
10
14
  /** filled accent (the forward arrow, the send button) */
11
15
  invert?: boolean;
16
+ /** filled with the primary Btn's fill (the palette's primary, `--btn-primary-bg`): the one answer, on a card whose foot is round buttons.
17
+ * `invert` is the accent, `primary` the primary — the same fill on the wireframe, two on a palette that gives them two colours */
18
+ primary?: boolean;
12
19
  /** a link styled as a round button */
13
20
  href?: string;
14
21
  }
15
- /** IconBtn — the round icon button: `sm` in heads and bars, full size in rows and composers, `invert` for the one filled move.
22
+ /** IconBtn — the round icon button: `sm` in heads and bars, full size in rows and composers, `invert` for the one filled move, `primary` for the
23
+ * primary fill. `size="lg"` / `"xl"` are the big rounds (the foot of a few round answers: `<CardFoot round>`); a short WORD may stand in one
24
+ * as its child (`<IconBtn size="xl" primary label="ช่วยเหลือ">ช่วย</IconBtn>`): it grows with the member's text size up to the circle's share.
16
25
  * It is a button and nothing more: to open a Menu, give it the platform's `popoverTarget={the Menu's id}` and render that `<Menu popover actions id>`
17
26
  * wherever you like — the panel anchors to the button that opened it. To open anything else, `onClick`. */
18
- export declare function IconBtn({ label, icon, sm, invert, href, className, type, children, ...rest }: IconBtnProps): import("react").DetailedReactHTMLElement<{
27
+ export declare function IconBtn({ label, icon, sm, size, invert, primary, href, className, type, children, ...rest }: IconBtnProps): import("react").DetailedReactHTMLElement<{
19
28
  ref?: import("react").Ref<HTMLButtonElement> | undefined;
20
29
  key?: import("react").Key | null | undefined;
21
30
  disabled?: boolean | undefined;
@@ -1,11 +1,17 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createElement } from 'react';
3
3
  import { cx } from '../cx.js';
4
+ import { warnOnce } from '../state.js';
4
5
  import { Icon } from '../type/Icon.js';
5
- /** IconBtn — the round icon button: `sm` in heads and bars, full size in rows and composers, `invert` for the one filled move.
6
+ /** IconBtn — the round icon button: `sm` in heads and bars, full size in rows and composers, `invert` for the one filled move, `primary` for the
7
+ * primary fill. `size="lg"` / `"xl"` are the big rounds (the foot of a few round answers: `<CardFoot round>`); a short WORD may stand in one
8
+ * as its child (`<IconBtn size="xl" primary label="ช่วยเหลือ">ช่วย</IconBtn>`): it grows with the member's text size up to the circle's share.
6
9
  * It is a button and nothing more: to open a Menu, give it the platform's `popoverTarget={the Menu's id}` and render that `<Menu popover actions id>`
7
10
  * wherever you like — the panel anchors to the button that opened it. To open anything else, `onClick`. */
8
- export function IconBtn({ label, icon, sm, invert, href, className, type, children, ...rest }) {
9
- const btn = createElement(href ? 'a' : 'button', { href, type: href ? undefined : type ?? 'button', 'aria-label': label, className: cx('icon-btn', sm && 'icon-btn--sm', invert && 'icon-btn--invert', className), ...rest }, icon ? _jsx(Icon, { name: icon, size: sm ? 'sm' : 'md' }) : children);
11
+ export function IconBtn({ label, icon, sm, size, invert, primary, href, className, type, children, ...rest }) {
12
+ if (invert && primary)
13
+ warnOnce('icon-btn-fill', 'cardds IconBtn: `invert` and `primary` together — two fills for one button; `primary` is drawn');
14
+ const step = size ?? (sm ? 'sm' : undefined);
15
+ const btn = createElement(href ? 'a' : 'button', { href, type: href ? undefined : type ?? 'button', 'aria-label': label, className: cx('icon-btn', step && `icon-btn--${step}`, invert && 'icon-btn--invert', primary && 'icon-btn--primary', className), ...rest }, icon ? _jsx(Icon, { name: icon, size: step === 'sm' ? 'sm' : 'md' }) : children);
10
16
  return btn;
11
17
  }
package/dist/cardds.css CHANGED
@@ -88,6 +88,7 @@
88
88
  --tap: max(var(--tap-floor), var(--tap-base)); /* 48 primary touch target */
89
89
  --tap-sm: max(var(--tap-floor), calc(var(--tap-base) * 11 / 12)); /* 44 minimum touch target */
90
90
  --tap-lg: max(var(--tap-floor), calc(var(--tap-base) * 7 / 6)); /* 56 hero controls: send, field with action */
91
+ --tap-xl: max(var(--tap-floor), calc(var(--tap-base) * 17 / 12)); /* 68 the biggest round control: a foot of a few big round buttons (IconBtn xl) */
91
92
  --btn-h: calc(var(--tap-base) * 13 / 12); /* 52 button height */
92
93
  --btn-xl-h: calc(var(--tap-base) * 11 / 6); /* 88 the one giant CTA */
93
94
  --chip-h: calc(var(--tap-base) * 19 / 24); /* 38 chip height */
@@ -525,7 +526,7 @@
525
526
  [data-palette="orchard"] .field { background: var(--field-bg, #ffffff); color: var(--ink-onlight); }
526
527
  [data-palette="orchard"] .pin__cell { background: var(--pin-cell-bg, #ffffff); color: var(--ink-onlight); }
527
528
  /* the vivid ones, tried on the live parts (Lh 2026-09-25): primary = yellow · a user avatar = fuchsia · ring = sky · a switch on = sky · maple only on small dots (the count, dot a); the chosen stack tab stays moss */
528
- [data-palette="orchard"] .btn--primary { background: var(--btn-primary-bg, var(--highlight-2)); color: var(--btn-primary-ink, var(--ink-onlight)); }
529
+ [data-palette="orchard"] :is(.btn--primary, .icon-btn--primary) { background: var(--btn-primary-bg, var(--highlight-2)); color: var(--btn-primary-ink, var(--ink-onlight)); }
529
530
  [data-palette="orchard"] .avatar:not(.avatar--outline, .avatar--add, .avatar--on) { background: var(--avatar-bg, var(--highlight-4)); --dark: 1; }
530
531
  [data-palette="orchard"] .ring::before { background: conic-gradient(var(--highlight-3) calc(var(--value) * 1%), var(--ink-tint) 0); }
531
532
  [data-palette="orchard"] .toggle:checked { background: var(--highlight-3); --accent-dark: 0; } /* sky is a light ground: its knob is onlight */
@@ -999,12 +1000,23 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
999
1000
  .card__head > :is(h1, h2, h3, div, p):first-child { flex: 1; min-width: 0; }
1000
1001
 
1001
1002
  /* Skeleton head — the heading in a card's OWN head, and only there.
1002
- The heading wraps freely: no line budget, no squeeze, no clamp (Lh
1003
- 2026-09-16 — a height rule on words only distorts them; content that needs
1004
- more rows takes more rows). The group that holds heading ·
1003
+ The heading wraps, no squeeze, up to three lines and cut there with an
1004
+ ellipsis (Lh 2026-10-21: "มันควรจะ max สามบรรทัด" — a request's title ran to
1005
+ four lines on a phone; reverses 2026-09-16's "no clamp"). The count is the knob
1006
+ --card-head-title-lines. The group that holds heading ·
1005
1007
  caption is the head's own wrapping row (above) — no wrapper element, no
1006
1008
  height, only the row gap. */
1007
- .card__head :is(h1, h2, h3) { overflow-wrap: anywhere; }
1009
+ .card__head :is(h1, h2, h3) {
1010
+ overflow-wrap: anywhere;
1011
+ display: -webkit-box;
1012
+ -webkit-box-orient: vertical;
1013
+ -webkit-line-clamp: var(--card-head-title-lines, 3);
1014
+ line-clamp: var(--card-head-title-lines, 3);
1015
+ overflow: hidden;
1016
+ padding-block-start: var(--_mark-room); /* a stacked Thai mark rises over the first line's box: room inside the clip, given back outside */
1017
+ margin-block-start: calc(-1 * var(--_mark-room));
1018
+ --_mark-room: 0.2em;
1019
+ }
1008
1020
 
1009
1021
  /* a Stamp in a card's head (`Card end={<Stamp …/>}`): the words stand at the left, the stamp at the right BESIDE BOTH LINES — the heading and the
1010
1022
  caption pair up beside it, not the stamp beside the heading alone with the caption left under it. The stamp is its own size (--stamp-size,
@@ -1039,12 +1051,14 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1039
1051
 
1040
1052
  /* ---- two faces: the WHOLE card turns (`Card back`, Lh 2026-10-19). The article is the turning box (.flip, base.css) and only that;
1041
1053
  each face is the card's paper — head · middle · foot — and both lie in one grid cell. The head and the foot are the same on both
1042
- faces; the middle is one fixed height (--card-middle-h, the theme's call), so the card never changes height as it turns. The hidden
1054
+ faces; the card is as tall as its taller face and each face fills it, the middle taking what the head and the foot leave (2026-10-21,
1055
+ timebank: a fixed middle left a short front a tall empty band), so the card never changes height as it turns; --card-middle-h fixes
1056
+ the middle's height instead, the theme's call. The hidden
1043
1057
  face is inert. Each face holds an unseen turn button: the keyboard's way to turn it; its ring is drawn on the face. */
1044
1058
  .card--turns { background: none; border: 0; padding: 0; cursor: pointer; touch-action: manipulation; }
1045
1059
  .card__face {
1046
- display: grid;
1047
- align-content: start;
1060
+ display: flex;
1061
+ flex-direction: column;
1048
1062
  gap: var(--card-gap);
1049
1063
  min-width: 0;
1050
1064
  padding: var(--card-pad);
@@ -1055,7 +1069,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1055
1069
  position: relative;
1056
1070
  }
1057
1071
  .card__face > * { min-width: 0; }
1058
- .card__middle { display: grid; align-content: start; gap: var(--card-gap); height: var(--card-middle-h, calc(var(--tap-base) * 25 / 4)); overflow: hidden; }
1072
+ .card__middle { flex: 1 0 auto; display: grid; align-content: start; gap: var(--card-gap); height: var(--card-middle-h, auto); overflow: hidden; }
1059
1073
  .card__turn { position: absolute; inset: 0; width: var(--px); height: var(--px); padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); }
1060
1074
  .card__face:has(> .card__turn:focus-visible) { outline: var(--focus); outline-offset: calc(-1 * var(--sp-1)); }
1061
1075
  .card__head > .chip-row > .chip > .icon { --icon-display: inline-block; --icon-align: -0.15em; --icon-margin: 0 var(--chip-sm-gap, var(--sp-1)) 0 0; }
@@ -1107,6 +1121,9 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1107
1121
  whole short row of its own, centred — it never shares the buttons' line */
1108
1122
  .card__foot--link { flex-wrap: wrap; align-content: end; row-gap: var(--card-foot-link-gap, var(--sp-1)); }
1109
1123
  .card__foot-link { flex: 1 0 100%; display: flex; justify-content: center; }
1124
+ /* round (CardFoot round): the foot holds a few round IconBtns, each its own size (they never share the line), as ONE group in the middle with
1125
+ a gap that does not follow the card's width. The gap is wider than the Btns' (a miss between two round targets): --card-foot-gap sets it */
1126
+ .card__foot--round { justify-content: center; gap: var(--card-foot-gap, var(--sp-6)); }
1110
1127
 
1111
1128
  /* media placeholder for wireframes — currentColor so it survives any palette */
1112
1129
  .ph {
@@ -1721,7 +1738,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1721
1738
  letter-spacing: var(--track-display);
1722
1739
  /* the FIRST line sits on the centre of the peek strip (--stack-step tall,
1723
1740
  measured from the card's top edge); line two runs under the next card */
1724
- margin-top: calc((var(--stack-step) - 1lh) / 2 - var(--sp-5));
1741
+ margin-top: calc((var(--stack-step) - 1lh) / 2 - var(--sp-5) - var(--_mark-room)); /* --_mark-room: card.css, the room for the marks over line one */
1725
1742
  }
1726
1743
  /* fanned stack: a toggle opens the stack wider so every peek shows more */
1727
1744
  .sheet-stack--fanned { --stack-step: calc(var(--tap-base) * 19 / 6); }
@@ -2830,9 +2847,11 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2830
2847
 
2831
2848
  /* stretch: a date in a head (Lh 2026-10-19) — as tall as the title block beside it and no taller (the head stretches it; size
2832
2849
  containment keeps its own words from adding height). Its height picks the form: short (a one-line title) = two lines, the day name
2833
- over the number and the month on one line; tall (a two-line title) = three lines, day · number · month. */
2850
+ over the number and the month on one line; tall (a two-line title) = three lines, day · number · month. It grows no taller than
2851
+ its three lines (2026-10-21, timebank: beside a four-line title it was a tall empty block); held there, it stands at the head's top. */
2834
2852
  .tile-badge--stretch {
2835
2853
  align-self: stretch;
2854
+ max-block-size: calc((2 * var(--fs-caption) + var(--fs-lg)) * var(--tile-badge-line, var(--lh-badge)) + 2 * var(--sp-3));
2836
2855
  container-type: size;
2837
2856
  inline-size: calc(var(--fs-lg) * 3.25);
2838
2857
  grid-template-columns: auto auto;
@@ -2846,7 +2865,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2846
2865
  }
2847
2866
  .tile-badge--stretch > * { line-height: inherit; }
2848
2867
  .tile-badge--stretch > :first-child { grid-column: 1 / -1; }
2849
- @container (min-height: 5.5rem) {
2868
+ @container (min-height: 3.5rem) { /* just under the three lines' own height, the most the badge grows to */
2850
2869
  .tile-badge--stretch > * { grid-column: 1 / -1; }
2851
2870
  }
2852
2871
 
@@ -2947,6 +2966,14 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2947
2966
  }
2948
2967
  .icon-btn--invert { background: var(--icon-btn-invert-bg, var(--accent)); color: var(--icon-btn-invert-ink, var(--on-accent)); border-color: transparent; }
2949
2968
  .icon-btn--sm { width: var(--icon-btn-sm-size, var(--tap-sm)); height: var(--icon-btn-sm-size, var(--tap-sm)); font-size: var(--fs-title); }
2969
+ /* the bigger round sizes (`IconBtn size`): lg is the hero control's size (--tap-lg), xl the biggest (--tap-xl), both on --tap-base like every size.
2970
+ What stands inside is a share of the circle (`--_ib`, its side), so it grows with it: the ICON is --icon-btn-icon of the side (7/12, the share of the default 48px
2971
+ button's 28px icon), and a WORD (a string child) keeps to --icon-btn-word-max of the side (0.45: a word of four letters stays inside) — it still
2972
+ grows with the member's text size, to that ceiling and no further. The default and the sm button keep their icon tokens and their word as before. */
2973
+ .icon-btn--lg { --_ib: var(--icon-btn-lg-size, var(--tap-lg)); width: var(--_ib); height: var(--_ib); }
2974
+ .icon-btn--xl { --_ib: var(--icon-btn-xl-size, var(--tap-xl)); width: var(--_ib); height: var(--_ib); }
2975
+ :is(.icon-btn--lg, .icon-btn--xl) { font-size: min(var(--fs-lg), calc(var(--_ib) * var(--icon-btn-word-max, 0.45))); }
2976
+ :is(.icon-btn--lg, .icon-btn--xl) > .icon { --icon-size: calc(var(--_ib) * var(--icon-btn-icon, 7 / 12)); --icon-sm-size: var(--icon-size); --icon-lg-size: var(--icon-size); } /* the Icon's own knobs */
2950
2977
  /* ---- icon button group: up to four icon-only commands in ONE frame (Lh 2026-09-29) ----
2951
2978
  <div class="icon-btn-group" role="group"><button class="icon-btn" aria-label="…">…</button>…</div>
2952
2979
  One pill as tall as a button, the buttons share its width, a hairline between them; in a foot it takes the row. */
@@ -3004,7 +3031,8 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3004
3031
  min-height: var(--btn-h);
3005
3032
  text-decoration: none; /* .btn works as <a> too */
3006
3033
  }
3007
- .btn--primary { background: var(--btn-primary-bg, var(--accent)); color: var(--btn-primary-ink, var(--on-accent)); border-color: transparent; }
3034
+ /* primary: a Btn and a round IconBtn fill alike, from the same tokens (the palette's primary — orchard's yellow — retunes both) */
3035
+ :is(.btn--primary, .icon-btn--primary) { background: var(--btn-primary-bg, var(--accent)); color: var(--btn-primary-ink, var(--on-accent)); border-color: transparent; }
3008
3036
  /* quiet keeps a faint outline so it still reads as a button on colored palettes;
3009
3037
  it is ink on whatever it sits on — inherit, or on the dark slot (card--3) it
3010
3038
  would take the light-card action ink and vanish */
@@ -6012,7 +6040,7 @@ button.calendar__label:focus-visible { outline: var(--focus); outline-offset: va
6012
6040
  }
6013
6041
  /* the main button throws its own colour */
6014
6042
  [data-finish="lit"] .btn--primary,
6015
- [data-finish="lit"] .icon-btn--invert:not(.icon-btn-group > *) {
6043
+ [data-finish="lit"] :is(.icon-btn--invert, .icon-btn--primary):not(.icon-btn-group > *) {
6016
6044
  box-shadow: var(--finish-edge),
6017
6045
  0 0.0625rem 0.125rem color-mix(in srgb, var(--_shade) 20%, transparent),
6018
6046
  0 0.5rem 1rem -0.25rem color-mix(in srgb, var(--accent) 45%, transparent);
@@ -43,6 +43,8 @@ export interface CardProps extends ComponentProps<'article'> {
43
43
  foot?: ReactNode;
44
44
  /** the small Link under the foot's buttons (CardFoot `link`) */
45
45
  footLink?: ReactNode;
46
+ /** the foot is a few big ROUND buttons (CardFoot `round`): `foot` holds `IconBtn`s, stood as one centred group a fixed gap apart */
47
+ footRound?: boolean;
46
48
  /** set by `SheetStack` from the app's `open` and `refreshing`. The card renders its place, its grip and its pull. Leave it unset on any other card */
47
49
  stack?: StackPlace;
48
50
  /** THE CARD LEAVES — the card floats away: up and out with a small turn, as one piece with all it holds (a stamp that has just landed on it goes with it).
@@ -59,8 +61,8 @@ export interface CardProps extends ComponentProps<'article'> {
59
61
  onRemove?: () => void;
60
62
  /** THE BACK — the card has two faces and the member turns the WHOLE card over: a tap anywhere on it that is not on a control asks for the other
61
63
  * face. The head and the foot (`heading`/`caption`/`end` or a CardHead child, `foot`) are given once and drawn on BOTH faces, so they read the same
62
- * either way; the children are the front's middle, `back` the back's. The middle is one fixed height on both faces (knob `--card-middle-h`), so the
63
- * card never changes height when it turns. The hidden face is `inert`; an `id` in the head or the foot would be drawn twice, so give it none.
64
+ * either way; the children are the front's middle, `back` the back's. The card is as tall as its taller face and each face fills it, the middle taking
65
+ * what is left, so the card never changes height when it turns (knob `--card-middle-h` fixes the middle's height instead). The hidden face is `inert`; an `id` in the head or the foot would be drawn twice, so give it none.
64
66
  * Not on a `row`, `sm` or `stack` card */
65
67
  back?: ReactNode;
66
68
  /** CONTROLLED: the face that shows — pair it with `onFaceChange`. The app owns it */
@@ -87,7 +89,7 @@ export interface CardProps extends ComponentProps<'article'> {
87
89
  * TWO FACES (`back`): the whole card turns over on a tap off a control (the flip mechanic it shares with Postcard); the head and the foot
88
90
  * read the same on both faces, the middle is one fixed height. `paper="bands"`: paper head and foot bands edge to edge, the tint between.
89
91
  */
90
- export declare function Card({ tone, paper, sm, row, cover, fold, centre, band, as, heading, caption, end, level, foot, footLink, stack, leave, onLeft, onRemove, removeLabel, back, face, defaultFace, onFaceChange, toBackLabel, toFrontLabel, className, style, children, onClick, ref, ...rest }: CardProps): import("react").DetailedReactHTMLElement<{
92
+ export declare function Card({ tone, paper, sm, row, cover, fold, centre, band, as, heading, caption, end, level, foot, footLink, footRound, stack, leave, onLeft, onRemove, removeLabel, back, face, defaultFace, onFaceChange, toBackLabel, toFrontLabel, className, style, children, onClick, ref, ...rest }: CardProps): import("react").DetailedReactHTMLElement<{
91
93
  onClick: (e: MouseEvent<HTMLElement>) => void;
92
94
  inert?: boolean | undefined;
93
95
  "aria-busy"?: (boolean | "true" | "false") | undefined;
@@ -19,7 +19,7 @@ import { StackHeadCtx, StackPullCtx, ownHead, stackPaint, useStackCard } from '.
19
19
  * TWO FACES (`back`): the whole card turns over on a tap off a control (the flip mechanic it shares with Postcard); the head and the foot
20
20
  * read the same on both faces, the middle is one fixed height. `paper="bands"`: paper head and foot bands edge to edge, the tint between.
21
21
  */
22
- export function Card({ tone, paper, sm, row, cover, fold, centre, band, as = 'article', heading, caption, end, level, foot, footLink, stack, leave, onLeft, onRemove, removeLabel, back, face, defaultFace = 'front', onFaceChange, toBackLabel, toFrontLabel, className, style, children, onClick, ref, ...rest }) {
22
+ export function Card({ tone, paper, sm, row, cover, fold, centre, band, as = 'article', heading, caption, end, level, foot, footLink, footRound, stack, leave, onLeft, onRemove, removeLabel, back, face, defaultFace = 'front', onFaceChange, toBackLabel, toFrontLabel, className, style, children, onClick, ref, ...rest }) {
23
23
  if (onRemove && !row)
24
24
  warnOnce('card-remove-row', 'cardds Card: `onRemove` draws its cross on a `row` card only');
25
25
  if (back != null && (row || sm || stack))
@@ -118,10 +118,10 @@ export function Card({ tone, paper, sm, row, cover, fold, centre, band, as = 'ar
118
118
  const ownHeadKid = kids.find((c) => isValidElement(c) && c.type === CardHead);
119
119
  const ownFootKid = kids.find((c) => isValidElement(c) && c.type === CardFoot);
120
120
  const front = kids.filter((c) => c !== ownHeadKid && c !== ownFootKid);
121
- const side = (which, middle) => (_jsxs("div", { className: cx('card__face flip__face', `flip__face--${which}`), ...((which === 'back') !== flipped ? { inert: true } : {}), children: [_jsx("button", { type: "button", className: "card__turn", "aria-label": which === 'front' ? toBackLabel ?? word('turnToBack') : toFrontLabel ?? word('turnToFront'), onClick: () => ask(which === 'front' ? 'back' : 'front') }), head ? _jsx(CardHead, { heading: heading, caption: caption, end: end, level: level }) : ownHeadKid, _jsx("div", { className: "card__middle", children: which === 'front' ? front : middle }), tail ? _jsx(CardFoot, { link: footLink, children: foot }) : ownFootKid] }, which));
121
+ const side = (which, middle) => (_jsxs("div", { className: cx('card__face flip__face', `flip__face--${which}`), ...((which === 'back') !== flipped ? { inert: true } : {}), children: [_jsx("button", { type: "button", className: "card__turn", "aria-label": which === 'front' ? toBackLabel ?? word('turnToBack') : toFrontLabel ?? word('turnToFront'), onClick: () => ask(which === 'front' ? 'back' : 'front') }), head ? _jsx(CardHead, { heading: heading, caption: caption, end: end, level: level }) : ownHeadKid, _jsx("div", { className: "card__middle", children: which === 'front' ? front : middle }), tail ? _jsx(CardFoot, { link: footLink, round: footRound, children: foot }) : ownFootKid] }, which));
122
122
  return createElement(as, props, inner(_jsxs(_Fragment, { children: [side('front', null), side('back', back)] })));
123
123
  }
124
124
  if (!head && !tail)
125
125
  return createElement(as, props, inner(_jsxs(_Fragment, { children: [children, cross] })));
126
- return createElement(as, props, inner(_jsxs(_Fragment, { children: [head && _jsx(CardHead, { heading: heading, caption: caption, end: end, level: level }, "head"), Children.toArray(children), tail && _jsx(CardFoot, { link: footLink, children: foot }, "foot"), cross] })));
126
+ return createElement(as, props, inner(_jsxs(_Fragment, { children: [head && _jsx(CardHead, { heading: heading, caption: caption, end: end, level: level }, "head"), Children.toArray(children), tail && _jsx(CardFoot, { link: footLink, round: footRound, children: foot }, "foot"), cross] })));
127
127
  }
@@ -3,8 +3,13 @@ export interface CardFootProps extends ComponentProps<'div'> {
3
3
  /** a SMALL LINK under the buttons, centred on its own short row (a `Link sm` — "details for developers") — the same as an
4
4
  * ActionBar's ctaLink (Lh 2026-09-23). It never takes a share of the buttons' line */
5
5
  link?: ReactNode;
6
+ /** a foot of a few ROUND buttons (`IconBtn`, a size `lg` / `xl` for the big ones): they keep their own size instead of sharing the line, and
7
+ * stand as ONE group in the middle of the foot, a fixed gap apart (wider than the Btns' — `--card-foot-gap` sets it). The Btn foot is
8
+ * unchanged: this is the other foot, for a few big round answers */
9
+ round?: boolean;
6
10
  }
7
11
  /** CardFoot — the card's action buttons on one line: one takes the width, up to four share it evenly. Btns only in the row; a small
8
12
  * link goes in `link`, under it. A `Btn form` keeps its own size here: as wide as its words, centred — for a foot whose one button
9
- * should not fill the card. */
10
- export declare function CardFoot({ link, className, children, ...rest }: CardFootProps): import("react").JSX.Element;
13
+ * should not fill the card. With `round` it is the foot of a few round `IconBtn`s (`size="xl"`, one `primary` with a word, the others
14
+ * with icons): one centred group, a fixed gap between them. */
15
+ export declare function CardFoot({ link, round, className, children, ...rest }: CardFootProps): import("react").JSX.Element;
@@ -2,7 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
3
  /** CardFoot — the card's action buttons on one line: one takes the width, up to four share it evenly. Btns only in the row; a small
4
4
  * link goes in `link`, under it. A `Btn form` keeps its own size here: as wide as its words, centred — for a foot whose one button
5
- * should not fill the card. */
6
- export function CardFoot({ link, className, children, ...rest }) {
7
- return (_jsxs("div", { className: cx('card__foot', link != null && 'card__foot--link', className), ...rest, children: [children, link != null && _jsx("div", { className: "card__foot-link", children: link })] }));
5
+ * should not fill the card. With `round` it is the foot of a few round `IconBtn`s (`size="xl"`, one `primary` with a word, the others
6
+ * with icons): one centred group, a fixed gap between them. */
7
+ export function CardFoot({ link, round, className, children, ...rest }) {
8
+ return (_jsxs("div", { className: cx('card__foot', link != null && 'card__foot--link', round && 'card__foot--round', className), ...rest, children: [children, link != null && _jsx("div", { className: "card__foot-link", children: link })] }));
8
9
  }