@usableapp/cardds 0.7.18 → 0.7.20

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,28 @@
2
2
 
3
3
  > For: a project that consumes `@usableapp/cardds` and its AI — what changed between versions, written as before → after, so a screen can be brought up to date without reading the source. Newest first. A consumer never edits cardds; if something here leaves you without a way to do what you did before, report the gap.
4
4
 
5
+ ## 0.7.20 — 2026-09-28
6
+
7
+ Lh, from timebank on a real Android phone (card #153). Nothing to change in a screen.
8
+
9
+ ### Changed
10
+ - A `Sheet`'s title is ONE line, cut with … when too long (was: wrapped to two lines). The thing at the head's end keeps its place; a caption still takes its own row. A wrapped title no longer makes a taller peek strip — there is none.
11
+ - `SheetSteps`: the open step's content (words, `Field`, `Pin` cells…) sits inset to the step title's words — the bar's own inset (`--sheet-step-bar-pad-x`), or its own knob `--sheet-step-inner-pad-x`.
12
+ - `SheetSteps`: room under the LAST step (`--sheet-steps-end`, default sp-6) — scrolled to the end it no longer touches the sheet's foot button.
13
+
14
+ ### Fixed
15
+ - A folded `SheetStep`'s long `summary` ran past the sheet's right edge; it is cut with … inside its row now.
16
+
17
+ ### Added
18
+ - `Bullets` + `Bullet` (lists): each item led by a small square picture one line tall (`1lh`), the words wrap beside it. `pic` = an image URL or any node, on the list (every item) or on an item (its own); none = the placeholder square. Knobs `--bullets-pic-size` · `--bullets-pic-gap` · `--bullets-pic-radius` · `--bullets-gap` · `--bullets-font`. Gallery: `/Bullets`.
19
+
20
+ ## 0.7.19 — 2026-09-28
21
+
22
+ ### Fixed
23
+ - SAFE AREA (Lh, timebank on Android Chrome: the bar's ← → buttons lay half under the gesture bar). With `viewport-fit=cover` the phone draws the page under its own bars; cardds kept nothing clear of them. Now it publishes `--safe-top` / `--safe-bottom` (`env(safe-area-inset-*)`, 0 without cover) and keeps clear of them: the `ActionBar`'s buttons (its ground still runs to the edge), a peeked `Sheet`'s head, a sheet's foot button and the end of its body, `FloatBar`, `Fab`, a stage's foot; a fill `Screen` starts under the status bar. A keyboard up covers the gesture bar, so the bottom room then goes to the keyboard alone. `--bar-reserve` includes it.
24
+ - README says the ONE viewport meta cardds expects: `width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content`.
25
+ - Nothing to change in a screen. A component of the app's own pinned to an edge reads `--safe-top` / `--safe-bottom`.
26
+
5
27
  ## 0.7.18 — 2026-09-27
6
28
 
7
29
  ### Fixed
package/README.md CHANGED
@@ -42,6 +42,19 @@ import '@usableapp/cardds/dist/cardds.css';
42
42
  import './theme.css'; // the project's own look, after the system's — see "A project's theme"
43
43
  ```
44
44
 
45
+ **The viewport meta cardds expects** (one way for every app):
46
+
47
+ ```html
48
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
49
+ ```
50
+
51
+ `viewport-fit=cover` = the app runs edge to edge under the phone's own bars; cardds publishes their room as
52
+ `--safe-top` / `--safe-bottom` (from `env(safe-area-inset-*)`, 0 without cover) and keeps what the member reads or taps
53
+ clear of them — the bottom bar's buttons, a sheet's peeked head, its foot and the end of its body, the float bar, the FAB,
54
+ a stage's foot; a fill `Screen` starts under the status bar — while every ground still runs to the edge. A
55
+ component of the app's own pinned to an edge reads the same two tokens. `interactive-widget=resizes-content` =
56
+ Android shrinks the page for the keyboard (`--kb` stays 0 there; iOS publishes it).
57
+
45
58
  Every behaviour is the component's own React code — the `Sheet`'s handle (tap, drag,
46
59
  keyboard) with the focus and on-screen-keyboard raises, the `SheetStack` opening and
47
60
  closing, the `Pin` auto-advance, the `Modal`'s leaving — each controlled like an input
package/css/actions.css CHANGED
@@ -94,7 +94,7 @@
94
94
  .icon-btn--invert { background: var(--icon-btn-invert-bg, var(--accent)); color: var(--icon-btn-invert-ink, var(--on-accent)); border-color: transparent; }
95
95
  .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); }
96
96
  /* fab: the square floating action, bottom-right of a stage or screen */
97
- .fab { width: var(--fab); height: var(--fab); border-radius: var(--r-card); position: absolute; right: var(--sp-4); bottom: calc(var(--sp-5) + var(--bar-h) + var(--sp-3)); box-shadow: var(--shadow-float); z-index: var(--z-fab); }
97
+ .fab { width: var(--fab); height: var(--fab); border-radius: var(--r-card); position: absolute; right: var(--sp-4); bottom: calc(var(--sp-5) + var(--bar-h) + var(--sp-3) + var(--safe-bottom)); box-shadow: var(--shadow-float); z-index: var(--z-fab); }
98
98
 
99
99
  /* ---- buttons ---- */
100
100
  .btn {
@@ -220,13 +220,13 @@
220
220
  (step.css); on a scrolling screen a sticky last row (below). --bar-reserve stays published — the bar's nominal
221
221
  height — for the one rule that still needs it without measuring: a .card--centre's cap (card.css).
222
222
  --kb is the on-screen keyboard's height (cardds.js): the bar rides above it. */
223
- :root { --bar-reserve: calc(var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
223
+ :root { --bar-reserve: calc(var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
224
224
  .action-bar {
225
225
  position: relative;
226
226
  z-index: var(--z-bar); /* above .sheet-stack sheets, which paint at --level 1-5 */
227
227
  max-width: var(--screen-w);
228
228
  margin-inline: auto;
229
- padding: var(--sp-3) var(--sp-4) var(--sp-5);
229
+ padding: var(--sp-3) var(--sp-4) calc(var(--sp-5) + var(--_safe-b-open)); /* its ground runs under the phone's gesture bar, its buttons stand above it */
230
230
  display: flex;
231
231
  gap: var(--sp-2);
232
232
  }
@@ -293,12 +293,12 @@
293
293
  .action-bar__tier { display: flex; align-items: center; gap: var(--sp-2); min-height: var(--tap); }
294
294
  .action-bar__tier > .btn { flex: 1; }
295
295
  .action-bar__tier--link { justify-content: center; min-height: 0; } /* a Link under the buttons, e.g. "details" that opens a Modal — its row as tall as a small link, not a tap (Lh 2026-09-23) */
296
- :has(> .action-bar--tiers) { --bar-reserve: calc(var(--tap) + var(--sp-3) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
296
+ :has(> .action-bar--tiers) { --bar-reserve: calc(var(--tap) + var(--sp-3) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
297
297
  /* more rows (Lh 2026-09-23: a bar may hold several rows of controls — keeping it short is the designer's job): each row past two
298
298
  adds a tap-tall row and its gap to the reserve, so the words above never run under the bar */
299
- :has(> .action-bar--tiers > .action-bar__tier:nth-child(3)) { --bar-reserve: calc(2 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
300
- :has(> .action-bar--tiers > .action-bar__tier:nth-child(4)) { --bar-reserve: calc(3 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
301
- :has(> .action-bar--tiers > .action-bar__tier:nth-child(5)) { --bar-reserve: calc(4 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
299
+ :has(> .action-bar--tiers > .action-bar__tier:nth-child(3)) { --bar-reserve: calc(2 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
300
+ :has(> .action-bar--tiers > .action-bar__tier:nth-child(4)) { --bar-reserve: calc(3 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
301
+ :has(> .action-bar--tiers > .action-bar__tier:nth-child(5)) { --bar-reserve: calc(4 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
302
302
 
303
303
  /* the first round button sits at the left edge, the last at the right; a skip link between them stays centred */
304
304
  :is(.action-bar--pager, .action-bar__tier--pager) > .icon-btn:first-child { margin-right: auto; }
package/css/base.css CHANGED
@@ -105,6 +105,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
105
105
  display: flex;
106
106
  flex-direction: column;
107
107
  height: 100dvh;
108
+ padding-top: var(--safe-top); /* under the phone's status bar when the app runs edge to edge (Lh 2026-09-28) */
108
109
  overflow: clip;
109
110
  position: relative; /* the box a .card--centre or a .screen__centre floats in (card.css) */
110
111
  container-type: size; /* a share of the SCREEN's height (a no-bar step's lead, step.css) reads it as cqh — a real phone's height, or the gallery's frame */
package/css/layover.css CHANGED
@@ -198,7 +198,7 @@
198
198
  .float-bar {
199
199
  position: fixed;
200
200
  inset-inline: 0;
201
- bottom: var(--sp-5);
201
+ bottom: calc(var(--sp-5) + var(--safe-bottom));
202
202
  max-width: var(--screen-w);
203
203
  margin-inline: auto;
204
204
  padding-inline: var(--float-bar-pad-x, var(--sp-4));
package/css/lists.css CHANGED
@@ -204,3 +204,13 @@
204
204
  .link:disabled { color: var(--link-disabled-ink, var(--card-muted)); cursor: default; } /* inactive = muted (a WaitLink counting down) */
205
205
  .link--wait { font-variant-numeric: tabular-nums; } /* the countdown's digits keep their width: the words do not jiggle each second */
206
206
  .card--3 .link--muted { color: var(--card-3-muted); }
207
+
208
+ /* ---- bullets: a list, each item led by a small square picture as tall as a line of its words (Lh 2026-09-28, timebank #153) ----
209
+ <ul class="bullets"><li class="bullets__item"><img class="bullets__pic" alt=""><span class="bullets__text">…</span></li></ul>
210
+ The picture is the app's (an image or any node); none = the wireframe's crossed square. It sits on the item's FIRST line
211
+ (1lh — the line's own height, so it follows the text size), the words wrap beside it, never under it. */
212
+ .bullets { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--bullets-gap, var(--sp-3)); font: var(--bullets-font, var(--type-body)); }
213
+ .bullets__item { display: flex; align-items: start; gap: var(--bullets-pic-gap, var(--sp-3)); min-width: 0; }
214
+ .bullets__pic { flex: none; inline-size: var(--bullets-pic-size, 1lh); block-size: var(--bullets-pic-size, 1lh); min-height: 0; object-fit: contain; border-radius: var(--bullets-pic-radius, 0rem); display: grid; place-items: center; }
215
+ .bullets__pic.ph { border-radius: var(--bullets-pic-radius, 0rem); }
216
+ .bullets__text { flex: 1; min-width: 0; }
package/css/sheet.css CHANGED
@@ -81,7 +81,7 @@
81
81
  border-radius: var(--r-card) var(--r-card) 0 0;
82
82
  border-bottom: 0;
83
83
  box-shadow: var(--sheet-shadow, var(--shadow-sheet));
84
- --_foot: calc(var(--card-gap) + var(--btn-h) + var(--card-pad)); /* the foot strip's height (below) — merged here, one bare .sheet rule */
84
+ --_foot: calc(var(--card-gap) + var(--btn-h) + var(--card-pad) + var(--_safe-b-open)); /* the foot strip's height (below) — merged here, one bare .sheet rule */
85
85
  }
86
86
  .sheet--full { --_y: 0%; --_cover: 0rem; }
87
87
  .sheet--half { --_y: calc(var(--sheet-half) * 100%); --_cover: calc(var(--sheet-half) * var(--_box)); }
@@ -90,7 +90,7 @@
90
90
  /* --sheet-peek is declared on the BOX the sheet slides in, not on the sheet: the words behind a peeked sheet
91
91
  have to read it too (step.css keeps that much room under them), and a token only flows down. The sheet
92
92
  inherits it. A sheet whose head is taller retunes it on the box. */
93
- :is(.step, .sheet-stage) { --_handle: var(--sp-1); --sheet-peek: calc(var(--card-pad-rest) + var(--_handle) + 2 * var(--card-gap-rest) + var(--sheet-head-h)); }
93
+ :is(.step, .sheet-stage) { --_handle: var(--sp-1); --sheet-peek: calc(var(--card-pad-rest) + var(--_handle) + 2 * var(--card-gap-rest) + var(--sheet-head-h) + var(--safe-bottom)); } /* + the gesture bar: the peeked head stands above it */
94
94
  /* the head's floor: a tap, or an h2 line with its Thai-mark padding when the type is bigger than that (a large text-size
95
95
  setting on a narrow phone) — the strip and the head read the same number, so the title row is always inside the strip */
96
96
  :root { --sheet-head-h: max(var(--tap), calc(var(--fs-h2) * 1.25 + var(--sp-2))); }
@@ -148,12 +148,12 @@
148
148
  stops that far above the sheet's edge. At peek there is only the handle and the title: the foot is gone.
149
149
  One row, ≤ 2 buttons. <div class="card__foot"><button class="btn btn--primary btn--block">…</button></div> */
150
150
  /* …and the link row under the button, when there is one (its line + the gap over it) */
151
- .sheet:has(> .card__foot--link) { --_foot: calc(var(--card-gap) + var(--btn-h) + var(--sp-1) + var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)) + var(--card-pad)); }
151
+ .sheet:has(> .card__foot--link) { --_foot: calc(var(--card-gap) + var(--btn-h) + var(--sp-1) + var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)) + var(--card-pad) + var(--_safe-b-open)); }
152
152
  .sheet > .card__foot {
153
153
  position: absolute; inset: calc(-1 * var(--border-w)) 0 0; z-index: 1; /* the sheet's border box: the same height the sheet's own % reads */
154
154
  align-items: end;
155
155
  align-content: end;
156
- padding: 0 var(--card-pad) var(--card-pad);
156
+ padding: 0 var(--card-pad) calc(var(--card-pad) + var(--_safe-b-open));
157
157
  background: linear-gradient(var(--card-bg), var(--card-bg)) bottom / 100% var(--_foot) no-repeat;
158
158
  pointer-events: none;
159
159
  translate: 0 calc(-1 * var(--_y) - var(--_drag, 0rem));
@@ -169,7 +169,7 @@
169
169
  .sheet > :not(.sheet__body) { flex-shrink: 0; }
170
170
  :is(.sheet--peek, .sheet--away) > .sheet__body { padding-bottom: 0; }
171
171
  .sheet__body > * { min-width: 0; } /* as .card > *: children shrink to the track, never widen it */
172
- .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: calc(var(--card-pad) + var(--_cover)); }
172
+ .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: calc(var(--card-pad) + var(--_cover) + var(--_safe-b-open)); }
173
173
 
174
174
  /* grab handle — first child of the sheet. Drawn as a small bar, but it is
175
175
  the thumb's grab area for the whole strip above the title: a pseudo widens
@@ -208,6 +208,10 @@
208
208
  whole ask is that switch: peek shows it (a row is --tap tall plus its own
209
209
  padding; the strip cuts in that padding) — card #67's "ไม่อยู่บ้าน" */
210
210
  .sheet > .card__head { min-height: var(--sheet-head-h); }
211
+ /* a sheet's title is ONE line, cut with … when the phone is too narrow (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน"
212
+ wrapped to two lines at 390px) — the thing at its end keeps its place beside it; a caption still takes its own row */
213
+ .sheet > .card__head:not(:has(> :is(.t-caption, .t-body))) { flex-wrap: nowrap; }
214
+ .sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
211
215
  .sheet__head {
212
216
  display: grid;
213
217
  grid-template-columns: var(--tap-sm) 1fr var(--tap-sm);
@@ -349,7 +353,9 @@
349
353
  position: absolute;
350
354
  inset-inline: 0;
351
355
  bottom: 0;
352
- height: var(--tap-lg);
356
+ height: calc(var(--tap-lg) + var(--_safe-b-open));
357
+ padding-bottom: var(--_safe-b-open);
358
+ box-sizing: border-box;
353
359
  margin: 0;
354
360
  display: grid;
355
361
  place-items: center;
@@ -379,11 +385,11 @@
379
385
  .sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
380
386
  .sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0rem)); /* the keyboard's height more: the pile of next steps stays under the keyboard, it never rises with it */ display: flex; flex-direction: column; touch-action: pan-y; position: relative; }
381
387
  .sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1rem; height: 1rem; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; font 1rem = the browser's own size, so iOS does not zoom to it */
382
- .sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
388
+ .sheet-steps__track { list-style: none; margin: 0; padding: 0 0 var(--sheet-steps-end, var(--sp-6)); flex: 1; /* room under the LAST step: scrolled to the end it never touches the foot's button (Lh 2026-09-28, #153) */ display: flex; flex-direction: column; }
383
389
 
384
390
  .sheet-step {
385
391
  position: relative; min-width: 0;
386
- display: grid; grid-template-rows: auto 0fr; align-content: start; /* mid-fold the rows sum to less than the box: the spare room stays BELOW, never spread into the title row (Lh: it looked like a spring) */
392
+ display: grid; grid-template-columns: minmax(0, 1fr); /* the column is the step's width, never its words' — a long summary is cut with …, it never pushes the row past the sheet's edge (Lh 2026-09-28, #153) */ grid-template-rows: auto 0fr; align-content: start; /* mid-fold the rows sum to less than the box: the spare room stays BELOW, never spread into the title row (Lh: it looked like a spring) */
387
393
  flex: 0 0 auto;
388
394
  transition: grid-template-rows var(--motion-sheet) ease, flex-grow var(--motion-sheet) ease;
389
395
  --_row-h: var(--sheet-steps-row-h, calc(var(--tap-base) * 2 / 3)); /* the bar's own floor, read once (__head and __go below share it) */
@@ -394,7 +400,7 @@
394
400
  .sheet-step--now { grid-template-rows: auto 1fr; flex-grow: 1; }
395
401
  .sheet-step + .sheet-step { margin-top: var(--sheet-steps-gap, var(--sp-1)); } /* the folded bars read as a pile, a little ground between them (Lh 2026-09-25) */
396
402
  .sheet-step__head { /* every step's title line is a quiet BAR (a tint of the ink, not the brand — a brand bar read as a button, Lh 2026-09-25), the same height open or folded — opening shows the white room under it, never a taller bar (Lh 2026-09-25; was a thin rule on top) */
397
- display: flex; align-items: center; gap: var(--sp-3); min-height: var(--_row-h); /* THIN: 32px, a summary line, not a button (Lh 2026-09-25) — the tap stays 44, see __go */
403
+ display: flex; align-items: center; gap: var(--sp-3); min-height: var(--_row-h); min-width: 0; /* THIN: 32px, a summary line, not a button (Lh 2026-09-25) — the tap stays 44, see __go */
398
404
  padding-inline: var(--sheet-step-bar-pad-x, var(--sp-3)); border-radius: var(--sheet-step-bar-radius, var(--r-chip));
399
405
  background: var(--sheet-step-bar-bg, var(--ink-tint)); color: var(--sheet-step-bar-ink, inherit);
400
406
  border-top: var(--sheet-step-border-w, 0rem) solid var(--sheet-step-border, currentColor); /* a rule instead of the bar: set this, the bar's bg transparent, radius 0 */
@@ -408,7 +414,7 @@
408
414
  .sheet-step__summary { color: inherit; opacity: 0.8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
409
415
  .sheet-step__check { margin-inline-start: auto; }
410
416
  .sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
411
- .sheet-step__inner { display: grid; gap: var(--card-gap); padding-block: var(--card-gap); }
417
+ .sheet-step__inner { display: grid; gap: var(--card-gap); padding-block: var(--card-gap); padding-inline: var(--sheet-step-inner-pad-x, var(--sheet-step-bar-pad-x, var(--sp-3))); } /* the open step's content in the frame of its title's words (the bar's own inset), so it reads as that step's (Lh 2026-09-28, #153) */
412
418
  .sheet-step__inner > * { min-width: 0; }
413
419
  .sheet-step__inner > .link { justify-self: center; } /* a link of its own row in a step (skip, "change number", "set PIN again"): centred under the step, as the skip (Lh 2026-09-25) */
414
420
  .sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
package/css/step.css CHANGED
@@ -136,7 +136,7 @@
136
136
  margin-inline: calc(-1 * var(--screen-pad)); /* edge to edge; its own padding is the inset */
137
137
  margin-bottom: calc(var(--sp-2) - var(--screen-gap));
138
138
  }
139
- .screen.screen--fill:has(> .appbar) { padding-top: 0; --header-gap: 0rem; }
139
+ .screen.screen--fill:has(> .appbar) { padding-top: var(--safe-top); --header-gap: 0rem; }
140
140
  /* …and a STEP touches it, as it touches a TopBar: a full sheet's top edge on the bar's bottom edge (0.6.5 left 8px of base between
141
141
  them — timebank #137); the words keep the screen's gap as their lead (--_lead, .appbar + .step above) */
142
142
  .screen--fill > .appbar:has(+ .step) { margin-bottom: calc(-1 * var(--screen-gap)); }
package/css/tokens.css CHANGED
@@ -238,6 +238,13 @@
238
238
  sheet.css sets the values; these initial values are what a sheet
239
239
  falls back to when its own declaration cannot resolve.
240
240
  ============================================================ */
241
+ /* SAFE AREA (Lh 2026-09-28, timebank on Android: the bar's buttons lay under the gesture bar) — the room the phone's own
242
+ bars take over the page when the app runs edge to edge (viewport-fit=cover: Android Chrome, an iPhone home-screen app).
243
+ PUBLISHED: backgrounds still run to the edge; what the member reads or taps stands clear of them. 0 without cover.
244
+ --_safe-b-open = the bottom inset the keyboard does NOT already cover (a keyboard up hides the gesture bar). */
245
+ :root { --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); }
246
+ * { --_safe-b-open: max(0px, var(--safe-bottom) - var(--kb, 0px)); }
247
+
241
248
  @property --sheet-peek { syntax: "<length>"; inherits: true; initial-value: 0px; }
242
249
  @property --sheet-head-h { syntax: "<length>"; inherits: true; initial-value: 48px; } /* the sheet head's floor (sheet.css) — registered so the Sheet reads it resolved */
243
250
  @property --_lead { syntax: "<length>"; inherits: true; initial-value: 0px; } /* the room above a step's words: registered so a cqh share (a no-bar step, 15% of the SCREEN) resolves where it is declared — on the step, against the screen — not on the words, against the step */
package/dist/cardds.css CHANGED
@@ -279,6 +279,13 @@
279
279
  sheet.css sets the values; these initial values are what a sheet
280
280
  falls back to when its own declaration cannot resolve.
281
281
  ============================================================ */
282
+ /* SAFE AREA (Lh 2026-09-28, timebank on Android: the bar's buttons lay under the gesture bar) — the room the phone's own
283
+ bars take over the page when the app runs edge to edge (viewport-fit=cover: Android Chrome, an iPhone home-screen app).
284
+ PUBLISHED: backgrounds still run to the edge; what the member reads or taps stands clear of them. 0 without cover.
285
+ --_safe-b-open = the bottom inset the keyboard does NOT already cover (a keyboard up hides the gesture bar). */
286
+ :root { --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); }
287
+ * { --_safe-b-open: max(0px, var(--safe-bottom) - var(--kb, 0px)); }
288
+
282
289
  @property --sheet-peek { syntax: "<length>"; inherits: true; initial-value: 0px; }
283
290
  @property --sheet-head-h { syntax: "<length>"; inherits: true; initial-value: 48px; } /* the sheet head's floor (sheet.css) — registered so the Sheet reads it resolved */
284
291
  @property --_lead { syntax: "<length>"; inherits: true; initial-value: 0px; } /* the room above a step's words: registered so a cqh share (a no-bar step, 15% of the SCREEN) resolves where it is declared — on the step, against the screen — not on the words, against the step */
@@ -553,6 +560,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
553
560
  display: flex;
554
561
  flex-direction: column;
555
562
  height: 100dvh;
563
+ padding-top: var(--safe-top); /* under the phone's status bar when the app runs edge to edge (Lh 2026-09-28) */
556
564
  overflow: clip;
557
565
  position: relative; /* the box a .card--centre or a .screen__centre floats in (card.css) */
558
566
  container-type: size; /* a share of the SCREEN's height (a no-bar step's lead, step.css) reads it as cqh — a real phone's height, or the gallery's frame */
@@ -1355,7 +1363,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1355
1363
  border-radius: var(--r-card) var(--r-card) 0 0;
1356
1364
  border-bottom: 0;
1357
1365
  box-shadow: var(--sheet-shadow, var(--shadow-sheet));
1358
- --_foot: calc(var(--card-gap) + var(--btn-h) + var(--card-pad)); /* the foot strip's height (below) — merged here, one bare .sheet rule */
1366
+ --_foot: calc(var(--card-gap) + var(--btn-h) + var(--card-pad) + var(--_safe-b-open)); /* the foot strip's height (below) — merged here, one bare .sheet rule */
1359
1367
  }
1360
1368
  .sheet--full { --_y: 0%; --_cover: 0rem; }
1361
1369
  .sheet--half { --_y: calc(var(--sheet-half) * 100%); --_cover: calc(var(--sheet-half) * var(--_box)); }
@@ -1364,7 +1372,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1364
1372
  /* --sheet-peek is declared on the BOX the sheet slides in, not on the sheet: the words behind a peeked sheet
1365
1373
  have to read it too (step.css keeps that much room under them), and a token only flows down. The sheet
1366
1374
  inherits it. A sheet whose head is taller retunes it on the box. */
1367
- :is(.step, .sheet-stage) { --_handle: var(--sp-1); --sheet-peek: calc(var(--card-pad-rest) + var(--_handle) + 2 * var(--card-gap-rest) + var(--sheet-head-h)); }
1375
+ :is(.step, .sheet-stage) { --_handle: var(--sp-1); --sheet-peek: calc(var(--card-pad-rest) + var(--_handle) + 2 * var(--card-gap-rest) + var(--sheet-head-h) + var(--safe-bottom)); } /* + the gesture bar: the peeked head stands above it */
1368
1376
  /* the head's floor: a tap, or an h2 line with its Thai-mark padding when the type is bigger than that (a large text-size
1369
1377
  setting on a narrow phone) — the strip and the head read the same number, so the title row is always inside the strip */
1370
1378
  :root { --sheet-head-h: max(var(--tap), calc(var(--fs-h2) * 1.25 + var(--sp-2))); }
@@ -1422,12 +1430,12 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1422
1430
  stops that far above the sheet's edge. At peek there is only the handle and the title: the foot is gone.
1423
1431
  One row, ≤ 2 buttons. <div class="card__foot"><button class="btn btn--primary btn--block">…</button></div> */
1424
1432
  /* …and the link row under the button, when there is one (its line + the gap over it) */
1425
- .sheet:has(> .card__foot--link) { --_foot: calc(var(--card-gap) + var(--btn-h) + var(--sp-1) + var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)) + var(--card-pad)); }
1433
+ .sheet:has(> .card__foot--link) { --_foot: calc(var(--card-gap) + var(--btn-h) + var(--sp-1) + var(--link-sm-min-h, calc(var(--tap-base) * 7 / 12)) + var(--card-pad) + var(--_safe-b-open)); }
1426
1434
  .sheet > .card__foot {
1427
1435
  position: absolute; inset: calc(-1 * var(--border-w)) 0 0; z-index: 1; /* the sheet's border box: the same height the sheet's own % reads */
1428
1436
  align-items: end;
1429
1437
  align-content: end;
1430
- padding: 0 var(--card-pad) var(--card-pad);
1438
+ padding: 0 var(--card-pad) calc(var(--card-pad) + var(--_safe-b-open));
1431
1439
  background: linear-gradient(var(--card-bg), var(--card-bg)) bottom / 100% var(--_foot) no-repeat;
1432
1440
  pointer-events: none;
1433
1441
  translate: 0 calc(-1 * var(--_y) - var(--_drag, 0rem));
@@ -1443,7 +1451,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1443
1451
  .sheet > :not(.sheet__body) { flex-shrink: 0; }
1444
1452
  :is(.sheet--peek, .sheet--away) > .sheet__body { padding-bottom: 0; }
1445
1453
  .sheet__body > * { min-width: 0; } /* as .card > *: children shrink to the track, never widen it */
1446
- .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: calc(var(--card-pad) + var(--_cover)); }
1454
+ .sheet__body:last-child { margin-bottom: calc(-1 * var(--card-pad)); padding-bottom: calc(var(--card-pad) + var(--_cover) + var(--_safe-b-open)); }
1447
1455
 
1448
1456
  /* grab handle — first child of the sheet. Drawn as a small bar, but it is
1449
1457
  the thumb's grab area for the whole strip above the title: a pseudo widens
@@ -1482,6 +1490,10 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1482
1490
  whole ask is that switch: peek shows it (a row is --tap tall plus its own
1483
1491
  padding; the strip cuts in that padding) — card #67's "ไม่อยู่บ้าน" */
1484
1492
  .sheet > .card__head { min-height: var(--sheet-head-h); }
1493
+ /* a sheet's title is ONE line, cut with … when the phone is too narrow (Lh 2026-09-28, timebank #153: "ข้อกำหนดและเงื่อนไขการใช้งาน"
1494
+ wrapped to two lines at 390px) — the thing at its end keeps its place beside it; a caption still takes its own row */
1495
+ .sheet > .card__head:not(:has(> :is(.t-caption, .t-body))) { flex-wrap: nowrap; }
1496
+ .sheet > .card__head > :is(h1, h2, h3):first-child, .sheet__title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
1485
1497
  .sheet__head {
1486
1498
  display: grid;
1487
1499
  grid-template-columns: var(--tap-sm) 1fr var(--tap-sm);
@@ -1623,7 +1635,9 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1623
1635
  position: absolute;
1624
1636
  inset-inline: 0;
1625
1637
  bottom: 0;
1626
- height: var(--tap-lg);
1638
+ height: calc(var(--tap-lg) + var(--_safe-b-open));
1639
+ padding-bottom: var(--_safe-b-open);
1640
+ box-sizing: border-box;
1627
1641
  margin: 0;
1628
1642
  display: grid;
1629
1643
  place-items: center;
@@ -1653,11 +1667,11 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1653
1667
  .sheet__body:has(> .sheet-steps) { display: flex; flex-direction: column; }
1654
1668
  .sheet-steps { flex-shrink: 0; min-height: calc(100% + var(--kb, 0rem)); /* the keyboard's height more: the pile of next steps stays under the keyboard, it never rises with it */ display: flex; flex-direction: column; touch-action: pan-y; position: relative; }
1655
1669
  .sheet-steps__holder { position: absolute; top: 0; left: 0; width: 1rem; height: 1rem; padding: 0; border: 0; opacity: 0; pointer-events: none; font-size: 1rem; } /* holds the cursor (and the phone's keyboard) while a step folds; font 1rem = the browser's own size, so iOS does not zoom to it */
1656
- .sheet-steps__track { list-style: none; margin: 0; padding: 0; flex: 1; display: flex; flex-direction: column; }
1670
+ .sheet-steps__track { list-style: none; margin: 0; padding: 0 0 var(--sheet-steps-end, var(--sp-6)); flex: 1; /* room under the LAST step: scrolled to the end it never touches the foot's button (Lh 2026-09-28, #153) */ display: flex; flex-direction: column; }
1657
1671
 
1658
1672
  .sheet-step {
1659
1673
  position: relative; min-width: 0;
1660
- display: grid; grid-template-rows: auto 0fr; align-content: start; /* mid-fold the rows sum to less than the box: the spare room stays BELOW, never spread into the title row (Lh: it looked like a spring) */
1674
+ display: grid; grid-template-columns: minmax(0, 1fr); /* the column is the step's width, never its words' — a long summary is cut with …, it never pushes the row past the sheet's edge (Lh 2026-09-28, #153) */ grid-template-rows: auto 0fr; align-content: start; /* mid-fold the rows sum to less than the box: the spare room stays BELOW, never spread into the title row (Lh: it looked like a spring) */
1661
1675
  flex: 0 0 auto;
1662
1676
  transition: grid-template-rows var(--motion-sheet) ease, flex-grow var(--motion-sheet) ease;
1663
1677
  --_row-h: var(--sheet-steps-row-h, calc(var(--tap-base) * 2 / 3)); /* the bar's own floor, read once (__head and __go below share it) */
@@ -1668,7 +1682,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1668
1682
  .sheet-step--now { grid-template-rows: auto 1fr; flex-grow: 1; }
1669
1683
  .sheet-step + .sheet-step { margin-top: var(--sheet-steps-gap, var(--sp-1)); } /* the folded bars read as a pile, a little ground between them (Lh 2026-09-25) */
1670
1684
  .sheet-step__head { /* every step's title line is a quiet BAR (a tint of the ink, not the brand — a brand bar read as a button, Lh 2026-09-25), the same height open or folded — opening shows the white room under it, never a taller bar (Lh 2026-09-25; was a thin rule on top) */
1671
- display: flex; align-items: center; gap: var(--sp-3); min-height: var(--_row-h); /* THIN: 32px, a summary line, not a button (Lh 2026-09-25) — the tap stays 44, see __go */
1685
+ display: flex; align-items: center; gap: var(--sp-3); min-height: var(--_row-h); min-width: 0; /* THIN: 32px, a summary line, not a button (Lh 2026-09-25) — the tap stays 44, see __go */
1672
1686
  padding-inline: var(--sheet-step-bar-pad-x, var(--sp-3)); border-radius: var(--sheet-step-bar-radius, var(--r-chip));
1673
1687
  background: var(--sheet-step-bar-bg, var(--ink-tint)); color: var(--sheet-step-bar-ink, inherit);
1674
1688
  border-top: var(--sheet-step-border-w, 0rem) solid var(--sheet-step-border, currentColor); /* a rule instead of the bar: set this, the bar's bg transparent, radius 0 */
@@ -1682,7 +1696,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1682
1696
  .sheet-step__summary { color: inherit; opacity: 0.8; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1683
1697
  .sheet-step__check { margin-inline-start: auto; }
1684
1698
  .sheet-step__body { min-height: 0; overflow: clip; overflow-clip-margin: var(--sp-1); }
1685
- .sheet-step__inner { display: grid; gap: var(--card-gap); padding-block: var(--card-gap); }
1699
+ .sheet-step__inner { display: grid; gap: var(--card-gap); padding-block: var(--card-gap); padding-inline: var(--sheet-step-inner-pad-x, var(--sheet-step-bar-pad-x, var(--sp-3))); } /* the open step's content in the frame of its title's words (the bar's own inset), so it reads as that step's (Lh 2026-09-28, #153) */
1686
1700
  .sheet-step__inner > * { min-width: 0; }
1687
1701
  .sheet-step__inner > .link { justify-self: center; } /* a link of its own row in a step (skip, "change number", "set PIN again"): centred under the step, as the skip (Lh 2026-09-25) */
1688
1702
  .sheet-step:not(.sheet-step--now) > .sheet-step__body { visibility: hidden; transition: visibility 0s linear var(--motion-sheet); }
@@ -1832,7 +1846,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1832
1846
  margin-inline: calc(-1 * var(--screen-pad)); /* edge to edge; its own padding is the inset */
1833
1847
  margin-bottom: calc(var(--sp-2) - var(--screen-gap));
1834
1848
  }
1835
- .screen.screen--fill:has(> .appbar) { padding-top: 0; --header-gap: 0rem; }
1849
+ .screen.screen--fill:has(> .appbar) { padding-top: var(--safe-top); --header-gap: 0rem; }
1836
1850
  /* …and a STEP touches it, as it touches a TopBar: a full sheet's top edge on the bar's bottom edge (0.6.5 left 8px of base between
1837
1851
  them — timebank #137); the words keep the screen's gap as their lead (--_lead, .appbar + .step above) */
1838
1852
  .screen--fill > .appbar:has(+ .step) { margin-bottom: calc(-1 * var(--screen-gap)); }
@@ -2098,7 +2112,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2098
2112
  .icon-btn--invert { background: var(--icon-btn-invert-bg, var(--accent)); color: var(--icon-btn-invert-ink, var(--on-accent)); border-color: transparent; }
2099
2113
  .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); }
2100
2114
  /* fab: the square floating action, bottom-right of a stage or screen */
2101
- .fab { width: var(--fab); height: var(--fab); border-radius: var(--r-card); position: absolute; right: var(--sp-4); bottom: calc(var(--sp-5) + var(--bar-h) + var(--sp-3)); box-shadow: var(--shadow-float); z-index: var(--z-fab); }
2115
+ .fab { width: var(--fab); height: var(--fab); border-radius: var(--r-card); position: absolute; right: var(--sp-4); bottom: calc(var(--sp-5) + var(--bar-h) + var(--sp-3) + var(--safe-bottom)); box-shadow: var(--shadow-float); z-index: var(--z-fab); }
2102
2116
 
2103
2117
  /* ---- buttons ---- */
2104
2118
  .btn {
@@ -2224,13 +2238,13 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2224
2238
  (step.css); on a scrolling screen a sticky last row (below). --bar-reserve stays published — the bar's nominal
2225
2239
  height — for the one rule that still needs it without measuring: a .card--centre's cap (card.css).
2226
2240
  --kb is the on-screen keyboard's height (cardds.js): the bar rides above it. */
2227
- :root { --bar-reserve: calc(var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
2241
+ :root { --bar-reserve: calc(var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
2228
2242
  .action-bar {
2229
2243
  position: relative;
2230
2244
  z-index: var(--z-bar); /* above .sheet-stack sheets, which paint at --level 1-5 */
2231
2245
  max-width: var(--screen-w);
2232
2246
  margin-inline: auto;
2233
- padding: var(--sp-3) var(--sp-4) var(--sp-5);
2247
+ padding: var(--sp-3) var(--sp-4) calc(var(--sp-5) + var(--_safe-b-open)); /* its ground runs under the phone's gesture bar, its buttons stand above it */
2234
2248
  display: flex;
2235
2249
  gap: var(--sp-2);
2236
2250
  }
@@ -2297,12 +2311,12 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2297
2311
  .action-bar__tier { display: flex; align-items: center; gap: var(--sp-2); min-height: var(--tap); }
2298
2312
  .action-bar__tier > .btn { flex: 1; }
2299
2313
  .action-bar__tier--link { justify-content: center; min-height: 0; } /* a Link under the buttons, e.g. "details" that opens a Modal — its row as tall as a small link, not a tap (Lh 2026-09-23) */
2300
- :has(> .action-bar--tiers) { --bar-reserve: calc(var(--tap) + var(--sp-3) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
2314
+ :has(> .action-bar--tiers) { --bar-reserve: calc(var(--tap) + var(--sp-3) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
2301
2315
  /* more rows (Lh 2026-09-23: a bar may hold several rows of controls — keeping it short is the designer's job): each row past two
2302
2316
  adds a tap-tall row and its gap to the reserve, so the words above never run under the bar */
2303
- :has(> .action-bar--tiers > .action-bar__tier:nth-child(3)) { --bar-reserve: calc(2 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
2304
- :has(> .action-bar--tiers > .action-bar__tier:nth-child(4)) { --bar-reserve: calc(3 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
2305
- :has(> .action-bar--tiers > .action-bar__tier:nth-child(5)) { --bar-reserve: calc(4 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6)); }
2317
+ :has(> .action-bar--tiers > .action-bar__tier:nth-child(3)) { --bar-reserve: calc(2 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
2318
+ :has(> .action-bar--tiers > .action-bar__tier:nth-child(4)) { --bar-reserve: calc(3 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
2319
+ :has(> .action-bar--tiers > .action-bar__tier:nth-child(5)) { --bar-reserve: calc(4 * (var(--tap) + var(--sp-3)) + var(--btn-h) + var(--sp-3) + var(--sp-5) + var(--sp-6) + var(--safe-bottom)); }
2306
2320
 
2307
2321
  /* the first round button sits at the left edge, the last at the right; a skip link between them stays centred */
2308
2322
  :is(.action-bar--pager, .action-bar__tier--pager) > .icon-btn:first-child { margin-right: auto; }
@@ -3234,6 +3248,16 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3234
3248
  .link--wait { font-variant-numeric: tabular-nums; } /* the countdown's digits keep their width: the words do not jiggle each second */
3235
3249
  .card--3 .link--muted { color: var(--card-3-muted); }
3236
3250
 
3251
+ /* ---- bullets: a list, each item led by a small square picture as tall as a line of its words (Lh 2026-09-28, timebank #153) ----
3252
+ <ul class="bullets"><li class="bullets__item"><img class="bullets__pic" alt=""><span class="bullets__text">…</span></li></ul>
3253
+ The picture is the app's (an image or any node); none = the wireframe's crossed square. It sits on the item's FIRST line
3254
+ (1lh — the line's own height, so it follows the text size), the words wrap beside it, never under it. */
3255
+ .bullets { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--bullets-gap, var(--sp-3)); font: var(--bullets-font, var(--type-body)); }
3256
+ .bullets__item { display: flex; align-items: start; gap: var(--bullets-pic-gap, var(--sp-3)); min-width: 0; }
3257
+ .bullets__pic { flex: none; inline-size: var(--bullets-pic-size, 1lh); block-size: var(--bullets-pic-size, 1lh); min-height: 0; object-fit: contain; border-radius: var(--bullets-pic-radius, 0rem); display: grid; place-items: center; }
3258
+ .bullets__pic.ph { border-radius: var(--bullets-pic-radius, 0rem); }
3259
+ .bullets__text { flex: 1; min-width: 0; }
3260
+
3237
3261
  /* ---- css/media.css — cover card, quote, tile grid, fold, mosaic, wave ---- */
3238
3262
  /* ============================================================
3239
3263
  cardds/media.css — media & display inside cards:
@@ -3717,7 +3741,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3717
3741
  .float-bar {
3718
3742
  position: fixed;
3719
3743
  inset-inline: 0;
3720
- bottom: var(--sp-5);
3744
+ bottom: calc(var(--sp-5) + var(--safe-bottom));
3721
3745
  max-width: var(--screen-w);
3722
3746
  margin-inline: auto;
3723
3747
  padding-inline: var(--float-bar-pad-x, var(--sp-4));
package/dist/index.d.ts CHANGED
@@ -65,6 +65,7 @@ export * from './lists/Row.js';
65
65
  export * from './lists/Kv.js';
66
66
  export * from './lists/Timeline.js';
67
67
  export * from './lists/Legend.js';
68
+ export * from './lists/Bullets.js';
68
69
  export * from './lists/Bubble.js';
69
70
  export * from './media/Quote.js';
70
71
  export * from './media/Postcard.js';
package/dist/index.js CHANGED
@@ -69,6 +69,7 @@ export * from './lists/Row.js';
69
69
  export * from './lists/Kv.js';
70
70
  export * from './lists/Timeline.js';
71
71
  export * from './lists/Legend.js';
72
+ export * from './lists/Bullets.js';
72
73
  export * from './lists/Bubble.js';
73
74
  export * from './media/Quote.js';
74
75
  export * from './media/Postcard.js';
@@ -0,0 +1,16 @@
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ /** a bullet's picture: an image URL, or any node (an Icon, an <img> of the app's own) */
3
+ export type BulletPic = string | ReactNode;
4
+ export interface BulletsProps extends ComponentProps<'ul'> {
5
+ /** the picture every item leads with, unless the item gives its own; none = a placeholder square */
6
+ pic?: BulletPic;
7
+ }
8
+ /** Bullets — a list whose items each lead with a small square PICTURE as tall as a line of their words (Lh 2026-09-28: an agreement's
9
+ * short version, one point per item); children are Bullets. Pictures are decoration (alt=""), the words carry the meaning. */
10
+ export declare function Bullets({ pic, className, ...rest }: BulletsProps): import("react").JSX.Element;
11
+ export interface BulletProps extends ComponentProps<'li'> {
12
+ /** this item's own picture (wins over the list's) */
13
+ pic?: BulletPic;
14
+ }
15
+ /** Bullet — one item of Bullets: its picture, then its words. */
16
+ export declare function Bullet({ pic, className, children, ...rest }: BulletProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext } from 'react';
3
+ import { cx } from '../cx.js';
4
+ const PicOfList = createContext(undefined);
5
+ /** Bullets — a list whose items each lead with a small square PICTURE as tall as a line of their words (Lh 2026-09-28: an agreement's
6
+ * short version, one point per item); children are Bullets. Pictures are decoration (alt=""), the words carry the meaning. */
7
+ export function Bullets({ pic, className, ...rest }) {
8
+ return (_jsx(PicOfList.Provider, { value: pic, children: _jsx("ul", { className: cx('bullets', className), ...rest }) }));
9
+ }
10
+ /** Bullet — one item of Bullets: its picture, then its words. */
11
+ export function Bullet({ pic, className, children, ...rest }) {
12
+ const ofList = useContext(PicOfList);
13
+ const shown = pic ?? ofList;
14
+ return (_jsxs("li", { className: cx('bullets__item', className), ...rest, children: [shown == null ? _jsx("span", { className: "bullets__pic ph", "aria-hidden": "true" })
15
+ : typeof shown === 'string' ? _jsx("img", { className: "bullets__pic", src: shown, alt: "" })
16
+ : _jsx("span", { className: "bullets__pic", "aria-hidden": "true", children: shown }), _jsx("span", { className: "bullets__text", children: children })] }));
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.18",
3
+ "version": "0.7.20",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "description": "card-first mobile design system, React-first: the components in src/ are thin wrappers over the CSS contract (css/*.css stays the only truth); gallery/ shows every story live (npm run dev), tests/ measures the geometry.",