@usableapp/cardds 0.7.42 → 0.7.44

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,20 @@
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.44 — 2026-10-02
6
+
7
+ ### Added
8
+ - **The signal set is the standard** (Lh 2026-10-02): four colours, four meanings, in every project and in the wireframe — `--signal-ok` green (it went right) · `--signal-warn` yellow (look out) · `--signal-error` red (it went wrong) · `--signal-info` blue (for your information). A theme may retune a shade in its `theme.css`, never a meaning. New: `--signal-ok-soft` · `--signal-warn-soft` · `--signal-error-soft` · `--signal-info-soft` — the light ground of each (the signal washed 14% into the surface); it follows the shade you set. Never the colour alone: an icon or words say it too.
9
+ - **`Dot tone`** and **`Chip dot`**: `ok` · `warn` · `error` · `info` — `<Chip dot="ok">พร้อม</Chip>`. `dot="a"` / `"b"` are unchanged. Story `Dot · Tones`.
10
+
11
+ ### Changed
12
+ - **`Snackbar` stands on the ok signal**: its ground is `--signal-ok-soft` with no border (was white with a border), and its glyph is `check` in `--signal-ok` by default — before, no `icon` meant no glyph; now pass `icon={false}` for none. New knob `--snackbar-icon-ink`. A screen that passed `icon="check"` needs no change.
13
+
14
+ ## 0.7.43 — 2026-10-02
15
+
16
+ ### Added
17
+ - **`Snackbar` — a quick good-news line over the screen** (Lh 2026-10-02): something went well, said in passing; a warning or a question stays a `Modal`. A child of the `Screen`: `<Snackbar icon="check" onClosed={…}>ส่งคำขอแล้ว</Snackbar>` slides in from the top, stays `duration` (4000 ms; 0 = until the app closes it), slides out and calls `onClosed` — unmount it there. `open` + `onOpenChange(open, 'timeout' | 'tap')` or `defaultOpen`; `caption` = a second small line; `bottom` = at the foot, above the bottom layer; `onTap` = it can be tapped (open the notification it tells about). One at a time — the queue and the notification list are the app's. Knobs `--snackbar-*` (docs/knobs.md). Stories `Snackbar · Success` · `Controlled` · `Bottom` · `LongWords`.
18
+
5
19
  ## 0.7.42 — 2026-10-02
6
20
 
7
21
  ### Added
package/README.md CHANGED
@@ -306,6 +306,16 @@ whose markup has rules, because the sheet's box is the step itself:
306
306
  opened, a `SheetStack` sheet opened, a `Modal` arrived. Once it has settled: the field marked `data-autofocus`, else its
307
307
  first field, else its heading (a step's title, a modal's question) or, for a sheet come onto the screen, its handle. A field
308
308
  that already has the cursor keeps it. One mechanic for all of them (`focusWhenActive`, src/state.ts); an app writes no focus code.
309
+ - **The signal set — four colours, four meanings, everywhere** (Lh 2026-10-02): `--signal-ok` green (it went right) ·
310
+ `--signal-warn` yellow (look out) · `--signal-error` red (it went wrong) · `--signal-info` blue (for your information).
311
+ The wireframe has them too. A theme may retune a shade, never a meaning. Each is dark enough for words and edges;
312
+ `--signal-*-soft` is its light ground (the signal washed 14% into the surface). Never the colour alone: an icon or
313
+ words say it too. Read by: `Field`/`Pin`/`FormNote` error, `Dot tone` / `Chip dot="ok|warn|error|info"`, `Snackbar` (ok).
314
+ - **Good news is a `Snackbar`, a warning or a question is a `Modal`** (Lh 2026-10-02): a `Snackbar` is a quick line over the
315
+ screen — something went well, said in passing. A child of the `Screen`; it slides in from the top (or `bottom`, above the
316
+ bottom layer), stays `duration` (4 s), slides out, never takes the cursor, never stops the member. `open` +
317
+ `onOpenChange(open, reason)` or `defaultOpen`; unmount it in `onClosed`. One at a time: the queue and the notification
318
+ list are the app's — the app shows its newest notification in it, `onTap` opens that notification.
309
319
  - **cardds names its own controls, in the page's language** (Lh 2026-09-29): a handle, a back button, a Pin's cells… read
310
320
  their name from `src/words.ts` by `<html lang>` (en, th ship; `setWords(lang, {…})` adds one). An app passes no label that
311
321
  only repeats these words; a prop still wins.
@@ -618,7 +628,7 @@ That file is where a project's look lives. Bases and roles:
618
628
  | the size **of** things | `--tap-base` (3rem = 48) | `--tap*`, `--btn-h`, `--chip-h`, `--bar-h`, `--header-h`, `--fab`, `--icon*`, `--avatar*`, `--dot*`, `--ring*`, `--tile-*`. `--tap*` never under `--tap-floor` (44px) |
619
629
  | faces | `--font-ui`, `--font-display` | |
620
630
  | colour | `--color-surface`, `--color-ink`, `--color-paper`, `--color-brand`, `--color-on-brand` — no default: unset = the wireframe | every role: surface / words, the three card tones (paper · the brand washed 14% into the surface · the brand), the muted inks (the ink washed into their ground), accent, neutral actions, `--dot-a` |
621
- | colour roles (the escape hatch) | `--surface`, `--on-surface(-muted)`, `--card-{1,2,3}-{bg,ink}`, `--card-border`, `--card-muted`, `--card-2-muted`, `--accent`, `--on-accent`, `--action-{bg,ink,border}`, `--pos`, `--neg`, `--dot-{a,b}` | a role set directly wins over its derivation; the tints derive from the roles |
631
+ | colour roles (the escape hatch) | `--surface`, `--on-surface(-muted)`, `--card-{1,2,3}-{bg,ink}`, `--card-border`, `--card-muted`, `--card-2-muted`, `--accent`, `--on-accent`, `--action-{bg,ink,border}`, `--pos`, `--neg`, `--dot-{a,b}`, `--signal-{ok,warn,error,info}` | a role set directly wins over its derivation; the tints derive from the roles |
622
632
 
623
633
  Rules:
624
634
 
package/css/actions.css CHANGED
@@ -179,6 +179,11 @@
179
179
  The drop: a line box is centred on its ascender room (Thai marks), the letters beside the dot sit lower — the dot rides down to the
180
180
  letters' own middle, in em so it follows the text size (Lh 2026-09-22: "ตัวหนังสือกับไฟเสมอกัน") */
181
181
  .dot--b { background: var(--dot-b); }
182
+ /* the signal set on a dot (Lh 2026-10-02): the words beside it say what — never the colour alone */
183
+ .dot--ok { background: var(--signal-ok); }
184
+ .dot--warn { background: var(--signal-warn); }
185
+ .dot--error { background: var(--signal-error); }
186
+ .dot--info { background: var(--signal-info); }
182
187
  .chip--on { background: var(--chip-on-bg, var(--accent)); color: var(--chip-on-ink, var(--on-accent)); border-color: transparent; }
183
188
  /* muted: an untouched default — the words wash out, the edge stays; emphasis is the difference from the neighbours (README 13) */
184
189
  .chip--muted { color: var(--chip-muted-ink, var(--card-muted)); }
@@ -359,7 +364,7 @@
359
364
  pointer, no press, --off. Zero specificity everywhere: a component's own rule always wins. A NEW control joins a list here. */
360
365
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
361
366
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
362
- a.avatar, button.avatar, .menu__item) {
367
+ a.avatar, button.avatar, .menu__item, button.snackbar__body) {
363
368
  cursor: pointer;
364
369
  touch-action: manipulation;
365
370
  transition: scale var(--motion-press) ease-out;
@@ -378,7 +383,7 @@
378
383
  }
379
384
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
380
385
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
381
- a.avatar, button.avatar, .menu__item):active:not(:disabled, [aria-disabled="true"], :has(:disabled)) { scale: var(--press-scale); }
386
+ a.avatar, button.avatar, .menu__item, button.snackbar__body):active:not(:disabled, [aria-disabled="true"], :has(:disabled)) { scale: var(--press-scale); }
382
387
  :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):active:not([aria-disabled="true"], :has(:disabled)) { opacity: var(--press-dim); }
383
388
  .sheet-step:has(> .sheet-step__go:active) { opacity: var(--press-dim); } /* the step bar's button is invisible over it: the bar answers */
384
389
  /* SHADE — what has something BEHIND it (a peeked sheet over the pile, a postcard's face over its back) must not turn see-through:
@@ -388,17 +393,17 @@
388
393
  /* the focus ring: on the control, or on the label that wraps a hidden input (chip, tile, file button, a row that is a label) */
389
394
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
390
395
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
391
- a.avatar, button.avatar, .menu__item):focus-visible,
396
+ a.avatar, button.avatar, .menu__item, button.snackbar__body):focus-visible,
392
397
  :where(.chip--pick, .tile--pick, label.btn, label.row, .toggle-field):has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
393
398
  :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):not(label, .toggle-field):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
394
399
  /* disabled: no pointer anywhere; a control fades (a link and a row keep their own muted look) */
395
400
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
396
401
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
397
- a.avatar, button.avatar, .menu__item, a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):is(:disabled, [aria-disabled="true"]),
402
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):is(:disabled, [aria-disabled="true"]),
398
403
  :where(.chip--pick, .tile--pick, .mood__opt, .toggle-field, label.btn, label.row):has(:disabled) { cursor: default; }
399
404
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
400
405
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
401
- a.avatar, button.avatar, .menu__item):not(.link):is(:disabled, [aria-disabled="true"]),
406
+ a.avatar, button.avatar, .menu__item, button.snackbar__body):not(.link):is(:disabled, [aria-disabled="true"]),
402
407
  :where(.chip--pick, .tile--pick, .mood__opt, .toggle-field, label.btn):has(:disabled) { opacity: var(--off); }
403
408
  .sheet-stack--tap > .card > .card__head > :is(h1, h2, h3):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); } /* the keyboard's way into a stack sheet */
404
409
  @media (prefers-reduced-motion: reduce) { :root { --press-scale: 1; } } /* the give goes; the dim, the shade and the focus ring stay */
package/css/layover.css CHANGED
@@ -308,3 +308,38 @@
308
308
  .splash__mark > * { display: block; width: 100%; height: auto; }
309
309
  .splash__text { margin: 0; font: var(--type-body); color: var(--splash-text-ink, var(--on-surface-muted)); }
310
310
  @media (prefers-reduced-motion: reduce) { .splash { transition: none; } }
311
+ /* ---- snackbar: a quick good-news line over the screen — slides in, stays a moment, slides out (Lh 2026-10-02) ----
312
+ <div class="snackbar snackbar--open" role="status"><div class="snackbar__body"><svg class="icon"/>
313
+ <span class="snackbar__words"><span class="snackbar__text">…</span><span class="snackbar__caption">…</span></span></div></div>
314
+ A LAYER of the screen (its child), over the top bar; --bottom = at the foot, above the screen's bottom layer (--bottom-h).
315
+ Its ground is the OK signal's soft ground (--signal-ok-soft), its glyph the signal itself: the sign that it went right. Rigid like a sheet: `translate` only. `button.snackbar__body` = it can be tapped. Never a warning — that is a Modal. */
316
+ .snackbar {
317
+ --_out: -1;
318
+ position: fixed; inset-inline: 0; top: calc(var(--sp-3) + var(--safe-top));
319
+ max-width: var(--screen-w); margin-inline: auto; padding-inline: var(--screen-pad, var(--sp-4));
320
+ display: grid; justify-items: center;
321
+ z-index: calc(var(--z-top) + 1);
322
+ pointer-events: none; visibility: hidden;
323
+ translate: 0 calc(var(--_out) * (100% + var(--sp-8) + var(--safe-top)));
324
+ transition: translate var(--motion-sheet) ease, visibility 0s var(--motion-sheet);
325
+ }
326
+ .screen--fill > .snackbar, .sheet-stage > .snackbar { position: absolute; }
327
+ .snackbar--bottom { --_out: 1; top: auto; bottom: calc(var(--sp-3) + var(--bottom-h, 0px) + max(var(--safe-bottom), var(--kb, 0px))); }
328
+ .snackbar--open { translate: 0; visibility: visible; transition-delay: 0s; }
329
+ .snackbar__body {
330
+ display: flex; align-items: center; gap: var(--snackbar-gap, var(--sp-3));
331
+ max-width: 100%; min-height: var(--snackbar-min-h, var(--tap));
332
+ padding: var(--snackbar-pad-y, var(--sp-2)) var(--snackbar-pad-x, var(--sp-5));
333
+ border: var(--border-w) solid var(--snackbar-border, transparent);
334
+ border-radius: var(--snackbar-radius, var(--r-card));
335
+ background: var(--snackbar-bg, var(--signal-ok-soft));
336
+ color: var(--snackbar-ink, var(--on-surface));
337
+ box-shadow: var(--snackbar-shadow, var(--shadow-float));
338
+ font: inherit; text-align: start;
339
+ pointer-events: auto;
340
+ }
341
+ .snackbar__body > .icon { flex: none; color: var(--snackbar-icon-ink, var(--signal-ok)); }
342
+ .snackbar__words { display: grid; min-width: 0; overflow-wrap: anywhere; }
343
+ .snackbar__text { font: var(--snackbar-text-font, var(--type-body)); }
344
+ .snackbar__caption { font: var(--snackbar-caption-font, var(--type-caption)); opacity: var(--dim); }
345
+ @media (prefers-reduced-motion: reduce) { .snackbar { transition: none; } }
@@ -49,5 +49,7 @@
49
49
  --card-border --card-muted --card-2-muted
50
50
  --accent --on-accent --action-bg --action-ink --action-border
51
51
  --pos --neg --dot-a --dot-b
52
+ --signal-ok --signal-warn --signal-error --signal-info (the signal set: green · yellow · red · blue — retune the shade,
53
+ keep the meaning; dark enough for words. --signal-*-soft, the light ground of each, follows by itself)
52
54
  --dim (0.7: how far secondary words fade on a dark card, a drawer, a tile's sub line — one share for all) */
53
55
  }
package/css/tokens.css CHANGED
@@ -210,12 +210,15 @@
210
210
  --neg: #3d3d3d;
211
211
  --dot-a: var(--color-brand, #141414);
212
212
  --dot-b: #8a8a8a;
213
- --signal-warn: #8a6d00; /* the full signal set (Lh 2026-09-25): error · warn · ok · info — each with an icon and words */
213
+ /* THE SIGNAL SET (Lh 2026-09-25; the standard, Lh 2026-10-02): ok (green) · warn (yellow) · error (red) · info (blue) — the same four
214
+ meanings in every project, wireframe included; a theme may retune the shade, never the meaning. Each is dark enough for words and
215
+ edges and comes with an icon and words, never the colour alone. `--signal-*-soft` (below :root) = its light ground. */
216
+ --signal-warn: #8a6d00;
214
217
  --signal-ok: #2e7d32;
215
218
  --signal-info: #2f5f9e;
216
219
  /* highlights (Lh 2026-09-25): four atmosphere colours a palette may set for decoration — a count, a mark, a chart dot; the wireframe keeps them grey */
217
220
  --highlight-1: #3d3d3d; --highlight-2: #8a8a8a; --highlight-3: #5f5f5f; --highlight-4: #b0b0b0;
218
- --signal-error: #c62828; /* the ONE colour in the wireframe (Lh 2026-09-24): something went wrong — a field's edge, the words that say what. Always with an icon and words, never the colour alone */
221
+ --signal-error: #c62828; /* something went wrong — a field's edge, the words that say what */
219
222
 
220
223
  /* derived tints — read the element's own ink, so they work on any slot */
221
224
  --outline: var(--action-border); /* control edges (palette.css softens) */
@@ -244,6 +247,14 @@
244
247
  bars take over the page when the app runs edge to edge (viewport-fit=cover: Android Chrome, an iPhone home-screen app).
245
248
  PUBLISHED: backgrounds still run to the edge; what the member reads or taps stands clear of them. 0 without cover.
246
249
  --_safe-b-open = the bottom inset the keyboard does NOT already cover (a keyboard up hides the gesture bar). */
250
+ /* a signal's SOFT ground: the signal washed 14% into the surface (tone 2's formula) — what a Snackbar stands on. Re-derived where a
251
+ palette is set, so a palette's own signal shade gives its own soft */
252
+ :root, [data-palette] {
253
+ --signal-ok-soft: color-mix(in srgb, var(--signal-ok) 14%, var(--surface));
254
+ --signal-warn-soft: color-mix(in srgb, var(--signal-warn) 14%, var(--surface));
255
+ --signal-error-soft: color-mix(in srgb, var(--signal-error) 14%, var(--surface));
256
+ --signal-info-soft: color-mix(in srgb, var(--signal-info) 14%, var(--surface));
257
+ }
247
258
  :root { --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); }
248
259
  * { --_safe-b-open: max(0px, var(--safe-bottom) - var(--kb, 0px)); }
249
260
 
@@ -1,11 +1,12 @@
1
1
  import { type ComponentProps, type ReactNode } from 'react';
2
+ import { type DotTone } from './Dot.js';
2
3
  export interface ChipProps extends ComponentProps<'span'> {
3
4
  /** filled accent: the selected / live state */
4
5
  on?: boolean;
5
6
  /** quiet: an untouched default value — muted words, the thin edge, nothing to find the eye (README rule 13) */
6
7
  muted?: boolean;
7
- /** a leading signal dot: `a` (the accent signal) or `b` (the secondary) */
8
- dot?: 'a' | 'b';
8
+ /** a leading signal dot: `a` (the accent signal), `b` (the secondary), or one of the signal set — ok · warn · error · info */
9
+ dot?: 'a' | 'b' | DotTone;
9
10
  /** the element: span (default), a link, or a button */
10
11
  as?: 'span' | 'a' | 'button';
11
12
  href?: string;
@@ -5,7 +5,7 @@ import { Dot } from './Dot.js';
5
5
  /** Chip — a status or attribute tag (with an optional signal Dot), or a small tap target; `on` when it is the live one. For a choice use ChipPick (`sign` for the +/× interest picker). */
6
6
  export function Chip({ on, muted, dot, as, href, className, children, ...rest }) {
7
7
  const el = as ?? (href ? 'a' : 'span');
8
- return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('chip', on && 'chip--on', muted && 'chip--muted', className), ...rest }, dot && _jsx(Dot, { b: dot === 'b' }), children);
8
+ return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('chip', on && 'chip--on', muted && 'chip--muted', className), ...rest }, dot && _jsx(Dot, { b: dot === 'b', tone: dot === 'a' || dot === 'b' ? undefined : dot }), children);
9
9
  }
10
10
  /** ChipPick — a chip that is a choice: full-width in a ChipGrid (9:00 · 9:30 · …), or `sign` on a ChipRow (+ off, × on — the
11
11
  * interest picker); the picked one fills with accent. A native radio/checkbox inside the label carries the state. */
@@ -1,7 +1,10 @@
1
1
  import type { ComponentProps } from 'react';
2
+ export type DotTone = 'ok' | 'warn' | 'error' | 'info';
2
3
  export interface DotProps extends ComponentProps<'span'> {
3
4
  /** the secondary signal colour */
4
5
  b?: boolean;
6
+ /** a signal from the standard set: ok (green) · warn (yellow) · error (red) · info (blue) — the words beside it say what */
7
+ tone?: DotTone;
5
8
  }
6
9
  /** Dot — the signal dot in chips, legends and row metas. */
7
- export declare function Dot({ b, className, ...rest }: DotProps): import("react").JSX.Element;
10
+ export declare function Dot({ b, tone, className, ...rest }: DotProps): import("react").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from '../cx.js';
3
3
  /** Dot — the signal dot in chips, legends and row metas. */
4
- export function Dot({ b, className, ...rest }) {
5
- return _jsx("span", { className: cx('dot', b && 'dot--b', className), ...rest });
4
+ export function Dot({ b, tone, className, ...rest }) {
5
+ return _jsx("span", { className: cx('dot', b && 'dot--b', tone && `dot--${tone}`, className), ...rest });
6
6
  }
package/dist/cardds.css CHANGED
@@ -251,12 +251,15 @@
251
251
  --neg: #3d3d3d;
252
252
  --dot-a: var(--color-brand, #141414);
253
253
  --dot-b: #8a8a8a;
254
- --signal-warn: #8a6d00; /* the full signal set (Lh 2026-09-25): error · warn · ok · info — each with an icon and words */
254
+ /* THE SIGNAL SET (Lh 2026-09-25; the standard, Lh 2026-10-02): ok (green) · warn (yellow) · error (red) · info (blue) — the same four
255
+ meanings in every project, wireframe included; a theme may retune the shade, never the meaning. Each is dark enough for words and
256
+ edges and comes with an icon and words, never the colour alone. `--signal-*-soft` (below :root) = its light ground. */
257
+ --signal-warn: #8a6d00;
255
258
  --signal-ok: #2e7d32;
256
259
  --signal-info: #2f5f9e;
257
260
  /* highlights (Lh 2026-09-25): four atmosphere colours a palette may set for decoration — a count, a mark, a chart dot; the wireframe keeps them grey */
258
261
  --highlight-1: #3d3d3d; --highlight-2: #8a8a8a; --highlight-3: #5f5f5f; --highlight-4: #b0b0b0;
259
- --signal-error: #c62828; /* the ONE colour in the wireframe (Lh 2026-09-24): something went wrong — a field's edge, the words that say what. Always with an icon and words, never the colour alone */
262
+ --signal-error: #c62828; /* something went wrong — a field's edge, the words that say what */
260
263
 
261
264
  /* derived tints — read the element's own ink, so they work on any slot */
262
265
  --outline: var(--action-border); /* control edges (palette.css softens) */
@@ -285,6 +288,14 @@
285
288
  bars take over the page when the app runs edge to edge (viewport-fit=cover: Android Chrome, an iPhone home-screen app).
286
289
  PUBLISHED: backgrounds still run to the edge; what the member reads or taps stands clear of them. 0 without cover.
287
290
  --_safe-b-open = the bottom inset the keyboard does NOT already cover (a keyboard up hides the gesture bar). */
291
+ /* a signal's SOFT ground: the signal washed 14% into the surface (tone 2's formula) — what a Snackbar stands on. Re-derived where a
292
+ palette is set, so a palette's own signal shade gives its own soft */
293
+ :root, [data-palette] {
294
+ --signal-ok-soft: color-mix(in srgb, var(--signal-ok) 14%, var(--surface));
295
+ --signal-warn-soft: color-mix(in srgb, var(--signal-warn) 14%, var(--surface));
296
+ --signal-error-soft: color-mix(in srgb, var(--signal-error) 14%, var(--surface));
297
+ --signal-info-soft: color-mix(in srgb, var(--signal-info) 14%, var(--surface));
298
+ }
288
299
  :root { --safe-top: env(safe-area-inset-top, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px); }
289
300
  * { --_safe-b-open: max(0px, var(--safe-bottom) - var(--kb, 0px)); }
290
301
 
@@ -2234,6 +2245,11 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2234
2245
  The drop: a line box is centred on its ascender room (Thai marks), the letters beside the dot sit lower — the dot rides down to the
2235
2246
  letters' own middle, in em so it follows the text size (Lh 2026-09-22: "ตัวหนังสือกับไฟเสมอกัน") */
2236
2247
  .dot--b { background: var(--dot-b); }
2248
+ /* the signal set on a dot (Lh 2026-10-02): the words beside it say what — never the colour alone */
2249
+ .dot--ok { background: var(--signal-ok); }
2250
+ .dot--warn { background: var(--signal-warn); }
2251
+ .dot--error { background: var(--signal-error); }
2252
+ .dot--info { background: var(--signal-info); }
2237
2253
  .chip--on { background: var(--chip-on-bg, var(--accent)); color: var(--chip-on-ink, var(--on-accent)); border-color: transparent; }
2238
2254
  /* muted: an untouched default — the words wash out, the edge stays; emphasis is the difference from the neighbours (README 13) */
2239
2255
  .chip--muted { color: var(--chip-muted-ink, var(--card-muted)); }
@@ -2414,7 +2430,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2414
2430
  pointer, no press, --off. Zero specificity everywhere: a component's own rule always wins. A NEW control joins a list here. */
2415
2431
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2416
2432
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2417
- a.avatar, button.avatar, .menu__item) {
2433
+ a.avatar, button.avatar, .menu__item, button.snackbar__body) {
2418
2434
  cursor: pointer;
2419
2435
  touch-action: manipulation;
2420
2436
  transition: scale var(--motion-press) ease-out;
@@ -2433,7 +2449,7 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2433
2449
  }
2434
2450
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2435
2451
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2436
- a.avatar, button.avatar, .menu__item):active:not(:disabled, [aria-disabled="true"], :has(:disabled)) { scale: var(--press-scale); }
2452
+ a.avatar, button.avatar, .menu__item, button.snackbar__body):active:not(:disabled, [aria-disabled="true"], :has(:disabled)) { scale: var(--press-scale); }
2437
2453
  :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):active:not([aria-disabled="true"], :has(:disabled)) { opacity: var(--press-dim); }
2438
2454
  .sheet-step:has(> .sheet-step__go:active) { opacity: var(--press-dim); } /* the step bar's button is invisible over it: the bar answers */
2439
2455
  /* SHADE — what has something BEHIND it (a peeked sheet over the pile, a postcard's face over its back) must not turn see-through:
@@ -2443,17 +2459,17 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2443
2459
  /* the focus ring: on the control, or on the label that wraps a hidden input (chip, tile, file button, a row that is a label) */
2444
2460
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2445
2461
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2446
- a.avatar, button.avatar, .menu__item):focus-visible,
2462
+ a.avatar, button.avatar, .menu__item, button.snackbar__body):focus-visible,
2447
2463
  :where(.chip--pick, .tile--pick, label.btn, label.row, .toggle-field):has(:focus-visible) { outline: var(--focus); outline-offset: var(--sp-1); }
2448
2464
  :where(a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):not(label, .toggle-field):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); }
2449
2465
  /* disabled: no pointer anywhere; a control fades (a link and a row keep their own muted look) */
2450
2466
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2451
2467
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2452
- a.avatar, button.avatar, .menu__item, a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):is(:disabled, [aria-disabled="true"]),
2468
+ a.avatar, button.avatar, .menu__item, button.snackbar__body, a.row, button.row, label.row, .row-expand > summary, .banner__summary, a.card, label.card, .toggle-field):is(:disabled, [aria-disabled="true"]),
2453
2469
  :where(.chip--pick, .tile--pick, .mood__opt, .toggle-field, label.btn, label.row):has(:disabled) { cursor: default; }
2454
2470
  :where(.btn, .icon-btn, .link, a.chip, button.chip, .chip--pick, a.tile, button.tile, .tile--pick, .day, .segment__item, .calendar__day,
2455
2471
  .float-bar__item, .avatar-pick__item, .add-row, .picker__item, .dropdown__btn, .appbar__who, .check, .toggle, .mood__opt, .sheet__handle,
2456
- a.avatar, button.avatar, .menu__item):not(.link):is(:disabled, [aria-disabled="true"]),
2472
+ a.avatar, button.avatar, .menu__item, button.snackbar__body):not(.link):is(:disabled, [aria-disabled="true"]),
2457
2473
  :where(.chip--pick, .tile--pick, .mood__opt, .toggle-field, label.btn):has(:disabled) { opacity: var(--off); }
2458
2474
  .sheet-stack--tap > .card > .card__head > :is(h1, h2, h3):focus-visible { outline: var(--focus); outline-offset: var(--sp-1); } /* the keyboard's way into a stack sheet */
2459
2475
  @media (prefers-reduced-motion: reduce) { :root { --press-scale: 1; } } /* the give goes; the dim, the shade and the focus ring stay */
@@ -3935,4 +3951,39 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
3935
3951
  .splash__mark > * { display: block; width: 100%; height: auto; }
3936
3952
  .splash__text { margin: 0; font: var(--type-body); color: var(--splash-text-ink, var(--on-surface-muted)); }
3937
3953
  @media (prefers-reduced-motion: reduce) { .splash { transition: none; } }
3954
+ /* ---- snackbar: a quick good-news line over the screen — slides in, stays a moment, slides out (Lh 2026-10-02) ----
3955
+ <div class="snackbar snackbar--open" role="status"><div class="snackbar__body"><svg class="icon"/>
3956
+ <span class="snackbar__words"><span class="snackbar__text">…</span><span class="snackbar__caption">…</span></span></div></div>
3957
+ A LAYER of the screen (its child), over the top bar; --bottom = at the foot, above the screen's bottom layer (--bottom-h).
3958
+ Its ground is the OK signal's soft ground (--signal-ok-soft), its glyph the signal itself: the sign that it went right. Rigid like a sheet: `translate` only. `button.snackbar__body` = it can be tapped. Never a warning — that is a Modal. */
3959
+ .snackbar {
3960
+ --_out: -1;
3961
+ position: fixed; inset-inline: 0; top: calc(var(--sp-3) + var(--safe-top));
3962
+ max-width: var(--screen-w); margin-inline: auto; padding-inline: var(--screen-pad, var(--sp-4));
3963
+ display: grid; justify-items: center;
3964
+ z-index: calc(var(--z-top) + 1);
3965
+ pointer-events: none; visibility: hidden;
3966
+ translate: 0 calc(var(--_out) * (100% + var(--sp-8) + var(--safe-top)));
3967
+ transition: translate var(--motion-sheet) ease, visibility 0s var(--motion-sheet);
3968
+ }
3969
+ .screen--fill > .snackbar, .sheet-stage > .snackbar { position: absolute; }
3970
+ .snackbar--bottom { --_out: 1; top: auto; bottom: calc(var(--sp-3) + var(--bottom-h, 0px) + max(var(--safe-bottom), var(--kb, 0px))); }
3971
+ .snackbar--open { translate: 0; visibility: visible; transition-delay: 0s; }
3972
+ .snackbar__body {
3973
+ display: flex; align-items: center; gap: var(--snackbar-gap, var(--sp-3));
3974
+ max-width: 100%; min-height: var(--snackbar-min-h, var(--tap));
3975
+ padding: var(--snackbar-pad-y, var(--sp-2)) var(--snackbar-pad-x, var(--sp-5));
3976
+ border: var(--border-w) solid var(--snackbar-border, transparent);
3977
+ border-radius: var(--snackbar-radius, var(--r-card));
3978
+ background: var(--snackbar-bg, var(--signal-ok-soft));
3979
+ color: var(--snackbar-ink, var(--on-surface));
3980
+ box-shadow: var(--snackbar-shadow, var(--shadow-float));
3981
+ font: inherit; text-align: start;
3982
+ pointer-events: auto;
3983
+ }
3984
+ .snackbar__body > .icon { flex: none; color: var(--snackbar-icon-ink, var(--signal-ok)); }
3985
+ .snackbar__words { display: grid; min-width: 0; overflow-wrap: anywhere; }
3986
+ .snackbar__text { font: var(--snackbar-text-font, var(--type-body)); }
3987
+ .snackbar__caption { font: var(--snackbar-caption-font, var(--type-caption)); opacity: var(--dim); }
3988
+ @media (prefers-reduced-motion: reduce) { .snackbar { transition: none; } }
3938
3989
 
package/dist/index.d.ts CHANGED
@@ -79,6 +79,7 @@ export * from './media/Tile.js';
79
79
  export * from './media/Mosaic.js';
80
80
  export * from './layover/Banner.js';
81
81
  export * from './layover/Callout.js';
82
+ export * from './layover/Snackbar.js';
82
83
  export * from './journey/Route.js';
83
84
  export * from './journey/TileBadge.js';
84
85
  export * from './journey/NoteRow.js';
package/dist/index.js CHANGED
@@ -83,6 +83,7 @@ export * from './media/Tile.js';
83
83
  export * from './media/Mosaic.js';
84
84
  export * from './layover/Banner.js';
85
85
  export * from './layover/Callout.js';
86
+ export * from './layover/Snackbar.js';
86
87
  export * from './journey/Route.js';
87
88
  export * from './journey/TileBadge.js';
88
89
  export * from './journey/NoteRow.js';
@@ -0,0 +1,30 @@
1
+ import { type ComponentProps, type ReactNode } from 'react';
2
+ import type { IconName } from '../type/icons.js';
3
+ export type SnackbarReason = 'timeout' | 'tap';
4
+ export interface SnackbarProps extends Omit<ComponentProps<'div'>, 'onClick'> {
5
+ /** CONTROLLED: in sight — with `onOpenChange`; or `defaultOpen` */
6
+ open?: boolean;
7
+ /** UNCONTROLLED: starts in sight (default true — mounting it is showing it) */
8
+ defaultOpen?: boolean;
9
+ /** it asks to leave: its time is up (`timeout`) or it was tapped (`tap`) */
10
+ onOpenChange?: (open: boolean, reason: SnackbarReason) => void;
11
+ /** how long it stays, in ms (default 4000); 0 = until the app closes it */
12
+ duration?: number;
13
+ /** the glyph before the words — `check` by default (it went right); `false` = none */
14
+ icon?: IconName | false;
15
+ /** a second, smaller line under the words */
16
+ caption?: ReactNode;
17
+ /** at the screen's foot, above its bottom layer, instead of the top */
18
+ bottom?: boolean;
19
+ /** the member tapped it (the app opens what it tells about — its notification); it then asks to leave */
20
+ onTap?: () => void;
21
+ /** it has slid out: the app unmounts it now, or shows the next one */
22
+ onClosed?: () => void;
23
+ }
24
+ /**
25
+ * Snackbar — a quick GOOD-NEWS line laid over the screen: something went well, said in passing: a check and the words on the soft tone. It slides in from the screen's top
26
+ * (or `bottom`), stays `duration`, slides out, never takes the cursor and never stops the member. Not for a warning or a question —
27
+ * that is a `Modal`. A child of the `Screen`. Controlled like an input: `open` + `onOpenChange(open, reason)`, or `defaultOpen`;
28
+ * ONE at a time — the queue is the app's (what it shows is the app's own notification, kept in the app's list; a tap = `onTap`).
29
+ */
30
+ export declare function Snackbar({ open, defaultOpen, onOpenChange, duration, icon, caption, bottom, onTap, onClosed, className, children, ...rest }: SnackbarProps): import("react").JSX.Element;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { cx } from '../cx.js';
4
+ import { Icon } from '../type/Icon.js';
5
+ import { reducedMotion, useControlled, useLatest } from '../state.js';
6
+ /**
7
+ * Snackbar — a quick GOOD-NEWS line laid over the screen: something went well, said in passing: a check and the words on the soft tone. It slides in from the screen's top
8
+ * (or `bottom`), stays `duration`, slides out, never takes the cursor and never stops the member. Not for a warning or a question —
9
+ * that is a `Modal`. A child of the `Screen`. Controlled like an input: `open` + `onOpenChange(open, reason)`, or `defaultOpen`;
10
+ * ONE at a time — the queue is the app's (what it shows is the app's own notification, kept in the app's list; a tap = `onTap`).
11
+ */
12
+ export function Snackbar({ open, defaultOpen = true, onOpenChange, duration = 4000, icon = 'check', caption, bottom, onTap, onClosed, className, children, ...rest }) {
13
+ const [shown, request] = useControlled(open, defaultOpen, onOpenChange);
14
+ const [entered, setEntered] = useState(false); // its first frame is out of sight, so one mounted open slides in
15
+ const closed = useLatest(onClosed);
16
+ const up = shown && entered;
17
+ useEffect(() => { const f = requestAnimationFrame(() => setEntered(true)); return () => cancelAnimationFrame(f); }, []);
18
+ useEffect(() => {
19
+ if (!up || !duration)
20
+ return;
21
+ const t = setTimeout(() => request(false, 'timeout'), duration);
22
+ return () => clearTimeout(t);
23
+ }, [up, duration, request]);
24
+ useEffect(() => {
25
+ if (shown || !entered)
26
+ return;
27
+ const ms = reducedMotion() ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--motion-sheet')) * 1000 || 350;
28
+ const t = setTimeout(() => closed.current?.(), ms);
29
+ return () => clearTimeout(t);
30
+ }, [shown, entered]);
31
+ const words = (_jsxs(_Fragment, { children: [icon && _jsx(Icon, { name: icon }), _jsxs("span", { className: "snackbar__words", children: [_jsx("span", { className: "snackbar__text", children: children }), caption != null && _jsx("span", { className: "snackbar__caption", children: caption })] })] }));
32
+ return (_jsx("div", { className: cx('snackbar', bottom && 'snackbar--bottom', up && 'snackbar--open', className), role: "status", "aria-live": "polite", inert: !up, ...rest, children: onTap
33
+ ? _jsx("button", { type: "button", className: "snackbar__body", onClick: () => { onTap(); request(false, 'tap'); }, children: words })
34
+ : _jsx("div", { className: "snackbar__body", children: words }) }));
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.42",
3
+ "version": "0.7.44",
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.",