@usableapp/cardds 0.7.21 → 0.7.23

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.23 — 2026-09-29
6
+
7
+ ### Added
8
+ - `IconBtnGroup` (Lh 2026-09-29): up to FOUR icon-only `IconBtn`s in ONE frame. They share its width, with a rule between them in the frame's colour. At home in a `CardFoot` (card or sheet), where it takes the row. More than 4 → a dev warning.
9
+ - Plain: commands, each on its own `onClick`. Pressing dims the button; it never shrinks.
10
+ - `select="one"`: one on at a time. `select="many"`: each on or off by itself. A pressed button STAYS filled (`aria-pressed`). Each child names itself with `value`. The group takes `value` + `onChange`, or `defaultValue`.
11
+ - Knobs: `--icon-btn-group-h` · `-border` · `-radius` · `-bg` · `-ink` · `-rule` · `-on-bg` · `-on-ink`. Gallery: `/IconBtnGroup`.
12
+
13
+ ## 0.7.22 — 2026-09-29
14
+
15
+ ### Changed
16
+ - `StackSheet` (so `Home`): the open sheet's back button now stands at the head's far END, after the tabs, with a chevron pointing DOWN because the sheet folds down (Lh 2026-09-29). Before: at the start, chevron left. `backAt="start"` brings back the old place.
17
+ - `StackBack end`: the same for a head written by hand (`.sheet-stack__back--end`). Without `end` nothing changes.
18
+
5
19
  ## 0.7.21 — 2026-09-29
6
20
 
7
21
  ### Fixed
package/css/actions.css CHANGED
@@ -93,6 +93,24 @@
93
93
  }
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
+ /* ---- icon button group: up to four icon-only commands in ONE frame (Lh 2026-09-29) ----
97
+ <div class="icon-btn-group" role="group"><button class="icon-btn" aria-label="…">…</button>…</div>
98
+ One pill as tall as a button, the buttons share its width, a hairline between them; in a foot it takes the row. */
99
+ .icon-btn-group {
100
+ display: flex; min-width: 0;
101
+ min-height: var(--icon-btn-group-h, var(--btn-h));
102
+ border: var(--border-w) solid var(--icon-btn-group-border, var(--outline));
103
+ border-radius: var(--icon-btn-group-radius, var(--r-chip));
104
+ background: var(--icon-btn-group-bg, var(--action-bg));
105
+ color: var(--icon-btn-group-ink, var(--action-ink));
106
+ overflow: clip;
107
+ }
108
+ .icon-btn-group > .icon-btn { flex: 1 1 0; min-width: 0; width: auto; height: auto; border: 0; border-radius: 0; background: transparent; color: inherit; box-shadow: none; }
109
+ .icon-btn-group > .icon-btn + .icon-btn { border-inline-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
110
+ .card__foot > .icon-btn-group { flex: 1 1 0; }
111
+ .icon-btn-group > .icon-btn[aria-pressed="true"] { background: var(--icon-btn-group-on-bg, var(--accent)); color: var(--icon-btn-group-on-ink, var(--on-accent)); } /* `select`: a pressed button STAYS filled (Lh 2026-09-29) */
112
+ .icon-btn-group > .icon-btn:active:not(:disabled) { scale: 1; opacity: var(--press-dim); } /* a part of one frame DIMS, it never shrinks inside it */
113
+
96
114
  /* fab: the square floating action, bottom-right of a stage or screen */
97
115
  .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
116
 
package/css/stack.css CHANGED
@@ -311,7 +311,13 @@
311
311
  /* ---- the back button, SMALL (Lh 2026-09-23: "give the sheet head more room" — and then "a glyph alone does not read as
312
312
  tappable"): a SMALL DISC — its ring kept, 32px instead of 44, the chevron in it at 20 — so the head gives it 32px + a gap
313
313
  (was 56px). Its tap target stays 44px: an invisible ::after around the disc. ---- */
314
- .sheet-stack--tap > .card.is-open > .card__head { padding-left: var(--stack-back-room, calc(var(--_back-size) + var(--sp-2))); }
314
+ .sheet-stack--tap > .card.is-open > .card__head:has(> .sheet-stack__back:not(.sheet-stack__back--end)) { padding-left: var(--stack-back-room, calc(var(--_back-size) + var(--sp-2))); }
315
+ /* AT THE END (Lh 2026-09-29, the home): after the tabs, chevron down — the open sheet folds down. The head's end gives it its room
316
+ while open; the last tab then sits on the inset (no reach into the padding) */
317
+ .sheet-stack--tap .sheet-stack__back--end { left: auto; right: 0; }
318
+ .sheet-stack--tap .card__head:has(> .sheet-stack__back--end) { transition: padding-right var(--motion-sheet) ease; }
319
+ .sheet-stack--tap > .card.is-open > .card__head:has(> .sheet-stack__back--end) { padding-right: var(--stack-back-room, calc(var(--_back-size) + var(--sp-2))); }
320
+ .sheet-stack--tap > .card.is-open > .card__head:has(> .sheet-stack__back--end) > .stack-sheet__tabs { margin-right: 0; }
315
321
  .sheet-stack--tap .sheet-stack__back > .icon { width: var(--stack-back-icon, calc(var(--tap-base) * 13 / 24)); height: var(--stack-back-icon, calc(var(--tap-base) * 13 / 24)); flex: none; } /* 26: the chevron fills the small disc (Lh 2026-09-23) */
316
322
  .sheet-stack--tap .sheet-stack__back::after { content: ""; position: absolute; inset: calc((var(--_back-size) - var(--tap-sm)) / 2); }
317
323
 
@@ -0,0 +1,19 @@
1
+ import { type ComponentProps } from 'react';
2
+ type Picked = string | null | string[];
3
+ export interface IconBtnGroupProps extends Omit<ComponentProps<'div'>, 'onChange' | 'defaultValue'> {
4
+ /** the group's accessible name (what these commands act on) */
5
+ label?: string;
6
+ /** buttons that STAY ON when pressed (Lh 2026-09-29): `one` — one on at a time, pressing another moves it (value: the on
7
+ * button's `value`, or null) · `many` — each on or off by itself (value: the on ones' values). None = plain commands. */
8
+ select?: 'one' | 'many';
9
+ /** which are on — each child IconBtn names itself with its own `value` */
10
+ value?: Picked;
11
+ defaultValue?: Picked;
12
+ /** asks the app: `one` → the pressed value · `many` → the new list */
13
+ onChange?: (value: Picked) => void;
14
+ }
15
+ /** IconBtnGroup — a panel of up to FOUR icon-only buttons in ONE frame, sharing its width, a rule between them in the frame's
16
+ * colour (Lh 2026-09-29). Each is an `IconBtn` (its `label` is its spoken name). Plain: each does its own thing on its onClick.
17
+ * With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot. */
18
+ export declare function IconBtnGroup({ label, select, value, defaultValue, onChange, className, children, ...rest }: IconBtnGroupProps): import("react").JSX.Element;
19
+ export {};
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Children, cloneElement, isValidElement } from 'react';
3
+ import { cx } from '../cx.js';
4
+ import { useControlled, warnOnce } from '../state.js';
5
+ /** IconBtnGroup — a panel of up to FOUR icon-only buttons in ONE frame, sharing its width, a rule between them in the frame's
6
+ * colour (Lh 2026-09-29). Each is an `IconBtn` (its `label` is its spoken name). Plain: each does its own thing on its onClick.
7
+ * With `select`, a pressed button stays filled (aria-pressed): `one` or `many`. At home in a card's or a sheet's foot. */
8
+ export function IconBtnGroup({ label, select, value, defaultValue, onChange, className, children, ...rest }) {
9
+ if (Children.count(children) > 4)
10
+ warnOnce('icon-btn-group', 'cardds IconBtnGroup: more than 4 buttons — keep 4, put the rest in a Menu behind one of them');
11
+ const [shown, request] = useControlled(value, defaultValue ?? (select === 'many' ? [] : null), onChange);
12
+ const on = (v) => (Array.isArray(shown) ? shown.includes(v) : shown === v);
13
+ const items = !select ? children : Children.map(children, (c) => {
14
+ if (!isValidElement(c))
15
+ return c;
16
+ const el = c;
17
+ const v = el.props.value;
18
+ if (v == null)
19
+ return c;
20
+ return cloneElement(el, {
21
+ 'aria-pressed': on(v),
22
+ onClick: (e) => {
23
+ el.props.onClick?.(e);
24
+ if (select === 'one')
25
+ request(v);
26
+ else {
27
+ const list = Array.isArray(shown) ? shown : [];
28
+ request(on(v) ? list.filter((x) => x !== v) : [...list, v]);
29
+ }
30
+ },
31
+ });
32
+ });
33
+ return _jsx("div", { role: "group", "aria-label": label, className: cx('icon-btn-group', className), ...rest, children: items });
34
+ }
package/dist/cardds.css CHANGED
@@ -1268,7 +1268,13 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
1268
1268
  /* ---- the back button, SMALL (Lh 2026-09-23: "give the sheet head more room" — and then "a glyph alone does not read as
1269
1269
  tappable"): a SMALL DISC — its ring kept, 32px instead of 44, the chevron in it at 20 — so the head gives it 32px + a gap
1270
1270
  (was 56px). Its tap target stays 44px: an invisible ::after around the disc. ---- */
1271
- .sheet-stack--tap > .card.is-open > .card__head { padding-left: var(--stack-back-room, calc(var(--_back-size) + var(--sp-2))); }
1271
+ .sheet-stack--tap > .card.is-open > .card__head:has(> .sheet-stack__back:not(.sheet-stack__back--end)) { padding-left: var(--stack-back-room, calc(var(--_back-size) + var(--sp-2))); }
1272
+ /* AT THE END (Lh 2026-09-29, the home): after the tabs, chevron down — the open sheet folds down. The head's end gives it its room
1273
+ while open; the last tab then sits on the inset (no reach into the padding) */
1274
+ .sheet-stack--tap .sheet-stack__back--end { left: auto; right: 0; }
1275
+ .sheet-stack--tap .card__head:has(> .sheet-stack__back--end) { transition: padding-right var(--motion-sheet) ease; }
1276
+ .sheet-stack--tap > .card.is-open > .card__head:has(> .sheet-stack__back--end) { padding-right: var(--stack-back-room, calc(var(--_back-size) + var(--sp-2))); }
1277
+ .sheet-stack--tap > .card.is-open > .card__head:has(> .sheet-stack__back--end) > .stack-sheet__tabs { margin-right: 0; }
1272
1278
  .sheet-stack--tap .sheet-stack__back > .icon { width: var(--stack-back-icon, calc(var(--tap-base) * 13 / 24)); height: var(--stack-back-icon, calc(var(--tap-base) * 13 / 24)); flex: none; } /* 26: the chevron fills the small disc (Lh 2026-09-23) */
1273
1279
  .sheet-stack--tap .sheet-stack__back::after { content: ""; position: absolute; inset: calc((var(--_back-size) - var(--tap-sm)) / 2); }
1274
1280
 
@@ -2111,6 +2117,24 @@ button:enabled { cursor: pointer; } /* a disabled one keeps the arrow */
2111
2117
  }
2112
2118
  .icon-btn--invert { background: var(--icon-btn-invert-bg, var(--accent)); color: var(--icon-btn-invert-ink, var(--on-accent)); border-color: transparent; }
2113
2119
  .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); }
2120
+ /* ---- icon button group: up to four icon-only commands in ONE frame (Lh 2026-09-29) ----
2121
+ <div class="icon-btn-group" role="group"><button class="icon-btn" aria-label="…">…</button>…</div>
2122
+ One pill as tall as a button, the buttons share its width, a hairline between them; in a foot it takes the row. */
2123
+ .icon-btn-group {
2124
+ display: flex; min-width: 0;
2125
+ min-height: var(--icon-btn-group-h, var(--btn-h));
2126
+ border: var(--border-w) solid var(--icon-btn-group-border, var(--outline));
2127
+ border-radius: var(--icon-btn-group-radius, var(--r-chip));
2128
+ background: var(--icon-btn-group-bg, var(--action-bg));
2129
+ color: var(--icon-btn-group-ink, var(--action-ink));
2130
+ overflow: clip;
2131
+ }
2132
+ .icon-btn-group > .icon-btn { flex: 1 1 0; min-width: 0; width: auto; height: auto; border: 0; border-radius: 0; background: transparent; color: inherit; box-shadow: none; }
2133
+ .icon-btn-group > .icon-btn + .icon-btn { border-inline-start: var(--border-w) solid var(--icon-btn-group-rule, var(--icon-btn-group-border, var(--outline))); }
2134
+ .card__foot > .icon-btn-group { flex: 1 1 0; }
2135
+ .icon-btn-group > .icon-btn[aria-pressed="true"] { background: var(--icon-btn-group-on-bg, var(--accent)); color: var(--icon-btn-group-on-ink, var(--on-accent)); } /* `select`: a pressed button STAYS filled (Lh 2026-09-29) */
2136
+ .icon-btn-group > .icon-btn:active:not(:disabled) { scale: 1; opacity: var(--press-dim); } /* a part of one frame DIMS, it never shrinks inside it */
2137
+
2114
2138
  /* fab: the square floating action, bottom-right of a stage or screen */
2115
2139
  .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); }
2116
2140
 
package/dist/index.d.ts CHANGED
@@ -33,6 +33,7 @@ export * from './sheets/SheetSteps.js';
33
33
  export * from './sheets/Drawer.js';
34
34
  export * from './actions/Btn.js';
35
35
  export * from './actions/IconBtn.js';
36
+ export * from './actions/IconBtnGroup.js';
36
37
  export * from './actions/Chip.js';
37
38
  export * from './actions/Dot.js';
38
39
  export * from './actions/Dropdown.js';
package/dist/index.js CHANGED
@@ -37,6 +37,7 @@ export * from './sheets/SheetSteps.js';
37
37
  export * from './sheets/Drawer.js';
38
38
  export * from './actions/Btn.js';
39
39
  export * from './actions/IconBtn.js';
40
+ export * from './actions/IconBtnGroup.js';
40
41
  export * from './actions/Chip.js';
41
42
  export * from './actions/Dot.js';
42
43
  export * from './actions/Dropdown.js';
@@ -2,7 +2,10 @@ import type { ComponentProps } from 'react';
2
2
  export interface StackBackProps extends ComponentProps<'button'> {
3
3
  /** accessible name */
4
4
  label?: string;
5
+ /** at the head's END (after its tabs / icons), chevron DOWN — the open sheet folds down (Lh 2026-09-29, the home);
6
+ * default: at its start, chevron left */
7
+ end?: boolean;
5
8
  }
6
9
  /** StackBack — the back button a tapped stack sheet reveals in its head; first child of that sheet's CardHead. Hidden until the sheet is open.
7
10
  * A SMALL disc (Lh 2026-09-23): 32px with a chevron — the head keeps its room for the title and the tabs; the 44px tap target stays. */
8
- export declare function StackBack({ label, className, ...rest }: StackBackProps): import("react").JSX.Element;
11
+ export declare function StackBack({ label, end, className, ...rest }: StackBackProps): import("react").JSX.Element;
@@ -4,6 +4,6 @@ import { Icon } from '../type/Icon.js';
4
4
  import { IconBtn } from '../actions/IconBtn.js';
5
5
  /** StackBack — the back button a tapped stack sheet reveals in its head; first child of that sheet's CardHead. Hidden until the sheet is open.
6
6
  * A SMALL disc (Lh 2026-09-23): 32px with a chevron — the head keeps its room for the title and the tabs; the 44px tap target stays. */
7
- export function StackBack({ label = 'Back', className, ...rest }) {
8
- return _jsx(IconBtn, { sm: true, label: label, className: cx('sheet-stack__back', className), ...rest, children: _jsx(Icon, { name: "chevron-left" }) });
7
+ export function StackBack({ label = 'Back', end, className, ...rest }) {
8
+ return _jsx(IconBtn, { sm: true, label: label, className: cx('sheet-stack__back', end && 'sheet-stack__back--end', className), ...rest, children: _jsx(Icon, { name: end ? 'chevron-down' : 'chevron-left' }) });
9
9
  }
@@ -26,6 +26,8 @@ export interface StackSheetProps extends Omit<ComponentProps<'article'>, 'title'
26
26
  foot?: ReactNode;
27
27
  /** the back button's label (shown once the sheet is open) */
28
28
  backLabel?: string;
29
+ /** where the back button stands: `end` (default — after the tabs, chevron DOWN: the sheet folds down, Lh 2026-09-29) or `start` (before the title, chevron left) */
30
+ backAt?: 'start' | 'end';
29
31
  }
30
32
  /**
31
33
  * StackSheet — ONE sheet of a `SheetStack`, in the one sheet shape: SHEET HEAD (back · title · tabs or an icon) pinned at the top ·
@@ -33,4 +35,4 @@ export interface StackSheetProps extends Omit<ComponentProps<'article'>, 'title'
33
35
  * a count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
34
36
  * is closed — see `Home`). A direct child of `SheetStack`.
35
37
  */
36
- export declare function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel, className, children, ...rest }: StackSheetProps): import("react").JSX.Element;
38
+ export declare function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel, backAt, className, children, ...rest }: StackSheetProps): import("react").JSX.Element;
@@ -17,7 +17,7 @@ import { Badge } from '../numbers/Badge.js';
17
17
  * a count shows as a small circle over its icon. The body is the app's: render the page of the chosen tab (and nothing while the sheet
18
18
  * is closed — see `Home`). A direct child of `SheetStack`.
19
19
  */
20
- export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = 'Back', className, children, ...rest }) {
20
+ export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab, onTabChange, foot, backLabel = 'Back', backAt = 'end', className, children, ...rest }) {
21
21
  const [tab, pick] = useControlled(tabProp, defaultTab ?? tabs?.[0]?.id, onTabChange);
22
22
  // a title longer than its room does not wrap: it runs sideways, like a song title in a short slot — the room it overflows by
23
23
  // is measured here (the tabs may grow with the text size) and handed to the CSS as --_over
@@ -35,5 +35,5 @@ export function StackSheet({ title, tone, icon, tabs, tab: tabProp, defaultTab,
35
35
  measure();
36
36
  return watchSize([h, run], measure);
37
37
  }, [title]);
38
- return (_jsxs(Card, { tone: tone, className: cx('stack-sheet', className), ...rest, children: [_jsxs(CardHead, { children: [_jsx(StackBack, { label: backLabel }), _jsx(Text, { level: "h2", ref: titleRef, className: "stack-sheet__title", children: _jsx("span", { className: "stack-sheet__run", children: title }) }), tabs?.length ? (_jsx("span", { className: "stack-sheet__tabs", role: "tablist", children: tabs.map((t) => (_jsxs(IconBtn, { sm: true, invert: t.id === tab, role: "tab", "aria-selected": t.id === tab, label: t.label, className: "stack-sheet__tab", onClick: () => pick(t.id), children: [_jsx(Icon, { name: t.icon }), t.count ? _jsx(Badge, { className: "stack-sheet__count", children: t.count > 99 ? '99+' : t.count }) : null] }, t.id))) })) : icon] }), _jsx(SheetBody, { role: tabs?.length ? 'tabpanel' : undefined, children: children }), foot != null && _jsx(CardFoot, { children: foot })] }));
38
+ return (_jsxs(Card, { tone: tone, className: cx('stack-sheet', className), ...rest, children: [_jsxs(CardHead, { children: [backAt === 'start' && _jsx(StackBack, { label: backLabel }), _jsx(Text, { level: "h2", ref: titleRef, className: "stack-sheet__title", children: _jsx("span", { className: "stack-sheet__run", children: title }) }), tabs?.length ? (_jsx("span", { className: "stack-sheet__tabs", role: "tablist", children: tabs.map((t) => (_jsxs(IconBtn, { sm: true, invert: t.id === tab, role: "tab", "aria-selected": t.id === tab, label: t.label, className: "stack-sheet__tab", onClick: () => pick(t.id), children: [_jsx(Icon, { name: t.icon }), t.count ? _jsx(Badge, { className: "stack-sheet__count", children: t.count > 99 ? '99+' : t.count }) : null] }, t.id))) })) : icon, backAt === 'end' && _jsx(StackBack, { end: true, label: backLabel })] }), _jsx(SheetBody, { role: tabs?.length ? 'tabpanel' : undefined, children: children }), foot != null && _jsx(CardFoot, { children: foot })] }));
39
39
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usableapp/cardds",
3
- "version": "0.7.21",
3
+ "version": "0.7.23",
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.",