@usableapp/cardds 0.7.71 → 0.7.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +24 -0
  3. package/bin/cardds.mjs +59 -3
  4. package/css/people.css +6 -0
  5. package/css/stamp.css +4 -0
  6. package/dist/actions/Chip.d.ts +7 -0
  7. package/dist/actions/Dropdown.d.ts +1 -0
  8. package/dist/actions/Fab.d.ts +3 -0
  9. package/dist/actions/IconBtnGroup.d.ts +1 -0
  10. package/dist/cardds.css +10 -0
  11. package/dist/cards/Card.d.ts +2 -0
  12. package/dist/cards/CardHead.d.ts +5 -0
  13. package/dist/cards/CardHead.js +5 -5
  14. package/dist/catalog.json +1 -0
  15. package/dist/choice/Calendar.d.ts +4 -2
  16. package/dist/choice/DayStrip.d.ts +1 -0
  17. package/dist/choice/Mood.d.ts +1 -0
  18. package/dist/choice/Slider.d.ts +2 -0
  19. package/dist/forms/Composer.d.ts +2 -0
  20. package/dist/forms/Field.d.ts +2 -0
  21. package/dist/forms/FileBtn.d.ts +1 -0
  22. package/dist/journey/NoteRow.d.ts +1 -0
  23. package/dist/journey/Route.d.ts +2 -0
  24. package/dist/layover/Callout.d.ts +4 -1
  25. package/dist/lists/Kv.d.ts +3 -0
  26. package/dist/lists/Row.d.ts +3 -1
  27. package/dist/media/Mosaic.d.ts +2 -0
  28. package/dist/media/Postcard.d.ts +4 -0
  29. package/dist/media/Tile.d.ts +6 -0
  30. package/dist/numbers/Bars.d.ts +1 -0
  31. package/dist/numbers/DotGrid.d.ts +1 -0
  32. package/dist/numbers/Picker.d.ts +1 -0
  33. package/dist/numbers/Track.d.ts +1 -0
  34. package/dist/people/Avatar.d.ts +7 -2
  35. package/dist/people/Avatar.js +4 -4
  36. package/dist/people/AvatarPick.d.ts +1 -0
  37. package/dist/post/PostLetter.d.ts +2 -1
  38. package/dist/scaffold/LayerHistory.d.ts +1 -0
  39. package/dist/scaffold/Screen.d.ts +1 -0
  40. package/dist/sheets/StackSheet.d.ts +2 -0
  41. package/dist/stamp/StampBook.d.ts +3 -1
  42. package/dist/stamp/StampBook.js +2 -2
  43. package/dist/templates/CallSheet.d.ts +2 -0
  44. package/dist/templates/Home.d.ts +2 -0
  45. package/dist/templates/Walkthrough.d.ts +5 -1
  46. package/lib/catalog.mjs +203 -0
  47. package/lib/mcp.mjs +107 -0
  48. package/lib/role.mjs +30 -0
  49. package/package.json +3 -2
@@ -12,11 +12,13 @@ export interface CalendarProps extends Omit<ComponentProps<'div'>, 'title' | 'on
12
12
  defaultSelected?: number;
13
13
  /** a day was tapped */
14
14
  onSelect?: (day: number) => void;
15
- /** the head's two buttons: the app turns the month (it owns `title`, `days`, `start`) */
15
+ /** the previous-month button was tapped: the app turns the month (it owns `title`, `days`, `start`) */
16
16
  onPrev?: () => void;
17
+ /** the next-month button was tapped: the app turns the month, as with `onPrev` */
17
18
  onNext?: () => void;
18
- /** their accessible names */
19
+ /** the previous button's accessible name (default "Previous", by `<html lang>`) */
19
20
  prevLabel?: string;
21
+ /** the next button's accessible name (default "Next", by `<html lang>`) */
20
22
  nextLabel?: string;
21
23
  /** softly marked days */
22
24
  marks?: number[];
@@ -8,6 +8,7 @@ export interface DayProps extends Omit<ComponentProps<'button'>, 'name'> {
8
8
  name: ReactNode;
9
9
  /** the date number */
10
10
  num: ReactNode;
11
+ /** this day is the picked one (aria-selected; the app keeps which) */
11
12
  selected?: boolean;
12
13
  /** a mark under the number (something is on) */
13
14
  dot?: boolean;
@@ -7,6 +7,7 @@ export interface MoodProps extends Omit<ComponentProps<'fieldset'>, 'onChange' |
7
7
  value?: MoodValue;
8
8
  /** UNCONTROLLED: the dot picked on arrival; the Mood then keeps its own */
9
9
  defaultValue?: MoodValue;
10
+ /** the group's accessible name (default "Mood", by `<html lang>`) */
10
11
  label?: string;
11
12
  /** the dot the member tapped */
12
13
  onChange?: (value: MoodValue) => void;
@@ -4,7 +4,9 @@ export interface SliderProps extends Omit<ComponentProps<'input'>, 'className' |
4
4
  label?: ReactNode;
5
5
  /** one sample letter IN the knob, at the type size the slider sets (a text-size slider's ก) */
6
6
  glyph?: ReactNode;
7
+ /** classes for the slider's label wrapper — the range input itself takes none */
7
8
  className?: string;
9
+ /** inline style for the label wrapper, not the range input */
8
10
  style?: React.CSSProperties;
9
11
  }
10
12
  /** Slider — one value along a hairline rail with a 48px knob; the whole range is thumb-tall. */
@@ -6,7 +6,9 @@ export interface ComposerProps extends Omit<ComponentProps<'textarea'>, 'classNa
6
6
  lead?: ReactNode;
7
7
  /** the right button of the bar (an `IconBtn invert` — send) */
8
8
  send?: ReactNode;
9
+ /** classes for the composer's wrapper, not the textarea */
9
10
  className?: string;
11
+ /** inline style for the wrapper, not the textarea */
10
12
  style?: React.CSSProperties;
11
13
  }
12
14
  /** Composer — a big write-in area (display type) with a bottom bar: icon · hint · send. The write-in step's sheet content. */
@@ -24,7 +24,9 @@ export interface FieldProps extends Omit<ComponentProps<'input'>, 'className' |
24
24
  * → 081-234-5678). Only digits are kept; a mark comes only once a digit follows it, so Backspace never sticks on a dash. The
25
25
  * app's `onChange` reads the shaped value (the digits alone: `value.replace(/\D/g, '')`). Lh 2026-09-25 */
26
26
  mask?: string;
27
+ /** classes for the field's label wrapper, not the input */
27
28
  className?: string;
29
+ /** inline style for the label wrapper, not the input */
28
30
  style?: React.CSSProperties;
29
31
  }
30
32
  /** the digits of `v`, written into `mask`; a mark is written only when a digit comes after it */
@@ -8,6 +8,7 @@ export interface FileBtnProps extends Omit<ComponentProps<'input'>, 'type' | 'cl
8
8
  block?: boolean;
9
9
  /** the button's words (and an Icon, if any) */
10
10
  children?: ReactNode;
11
+ /** classes for the button (the label around the hidden file input) */
11
12
  className?: string;
12
13
  }
13
14
  /**
@@ -1,6 +1,7 @@
1
1
  import type { ComponentProps } from 'react';
2
2
  import { type IconName } from '../type/Icon.js';
3
3
  export interface NoteRowProps extends ComponentProps<'div'> {
4
+ /** the icon before the words (default clock) */
4
5
  icon?: IconName;
5
6
  }
6
7
  /** NoteRow — icon + caption: transit hints, fine print. */
@@ -9,7 +9,9 @@ export interface RoutePoint {
9
9
  time: ReactNode;
10
10
  }
11
11
  export interface RouteProps extends ComponentProps<'div'> {
12
+ /** where it starts: place · code · time */
12
13
  from: RoutePoint;
14
+ /** where it ends: place · code · time */
13
15
  to: RoutePoint;
14
16
  /** the icon on the dotted connector (plane by default) */
15
17
  via?: IconName;
@@ -1,9 +1,12 @@
1
1
  import type { ComponentProps, ReactNode } from 'react';
2
2
  export interface CalloutProps extends Omit<ComponentProps<'div'>, 'title'> {
3
+ /** the callout's first line (a place, a name) */
3
4
  title: ReactNode;
5
+ /** a second, smaller line under the title */
4
6
  sub?: ReactNode;
5
- /** position over the stage background (CSS lengths / percentages) */
7
+ /** the callout's top edge, over the stage background (a CSS length / percentage) */
6
8
  top?: string;
9
+ /** the callout's centre line, from the left of the stage background (a CSS length / percentage) */
7
10
  left?: string;
8
11
  }
9
12
  /** Callout — a small card with a tail, pinned over a map or a media. */
@@ -19,8 +19,11 @@ export interface KvGridProps extends ComponentProps<'div'> {
19
19
  /** KvGrid — value-over-label cells split by a rule (Sex | Weight); children are KVCells. */
20
20
  export declare function KvGrid({ className, ...rest }: KvGridProps): import("react").JSX.Element;
21
21
  export interface KvCellProps extends ComponentProps<'div'> {
22
+ /** an icon before the value */
22
23
  icon?: IconName;
24
+ /** the big value */
23
25
  value: ReactNode;
26
+ /** the caption under the value */
24
27
  label: ReactNode;
25
28
  }
26
29
  /** KvCell — an icon, a big value, a caption label. */
@@ -13,8 +13,9 @@ export interface RowProps extends Omit<ComponentProps<'div'>, 'title'> {
13
13
  body?: ReactNode;
14
14
  /** the trailing control: an IconBtn, a Chip, a Check, a Toggle, a caption */
15
15
  end?: ReactNode;
16
- /** a trailing value + caption, right-aligned */
16
+ /** a trailing value, right-aligned (title type) */
17
17
  endValue?: ReactNode;
18
+ /** the muted caption under `endValue` */
18
19
  endCaption?: ReactNode;
19
20
  /** menu row: more air, a bigger title */
20
21
  lg?: boolean;
@@ -22,6 +23,7 @@ export interface RowProps extends Omit<ComponentProps<'div'>, 'title'> {
22
23
  done?: boolean;
23
24
  /** div (default), a link, a label wrapping a Check/Toggle, li */
24
25
  as?: 'div' | 'a' | 'label' | 'li';
26
+ /** makes the row a link (an `a`); not with `more`, whose row is a summary */
25
27
  href?: string;
26
28
  /** what the row EXPANDS to show under itself (who liked, the rest of a list): the row becomes a `<details>`' summary */
27
29
  more?: ReactNode;
@@ -1,10 +1,12 @@
1
1
  import type { ComponentProps } from 'react';
2
2
  export interface MosaicProps extends ComponentProps<'div'> {
3
+ /** the photos' one accessible name — the mosaic is a single `img` (default "Photos", by `<html lang>`) */
3
4
  label?: string;
4
5
  }
5
6
  /** Mosaic — one big photo + two small in a card (three Placeholders or imgs). */
6
7
  export declare function Mosaic({ label, className, ...rest }: MosaicProps): import("react").JSX.Element;
7
8
  export interface WaveProps extends ComponentProps<'span'> {
9
+ /** the waveform's accessible name (default "Voice note", by `<html lang>`) */
8
10
  label?: string;
9
11
  /** the bars' heights, 0–1, left to right — a real recording's levels; default: a mock-up shape */
10
12
  levels?: number[];
@@ -2,6 +2,7 @@ import { type ComponentProps, type ReactNode } from 'react';
2
2
  export interface PostcardProps extends ComponentProps<'div'> {
3
3
  /** the photo on the front; none = the wireframe box */
4
4
  photo?: string | null;
5
+ /** the photo's alt text; with no photo, the wireframe box's accessible name */
5
6
  alt?: string;
6
7
  /** whose card it is — the big line of the band */
7
8
  name: ReactNode;
@@ -21,8 +22,11 @@ export interface PostcardProps extends ComponentProps<'div'> {
21
22
  liked?: boolean;
22
23
  /** the heart was tapped: the state it asks for. No `liked` and no `onLike` = no heart */
23
24
  onLike?: (liked: boolean) => void;
25
+ /** the heart's accessible name (default "ถูกใจ") */
24
26
  likeLabel?: string;
27
+ /** the accessible name of the front's turn-over button (default "พลิกดูด้านหลัง") */
25
28
  frontLabel?: string;
29
+ /** the accessible name of the back's turn-over button (default "พลิกดูด้านหน้า") */
26
30
  backLabel?: string;
27
31
  }
28
32
  /**
@@ -3,6 +3,7 @@ import { type IconName } from '../type/Icon.js';
3
3
  export interface TileGridProps extends ComponentProps<'div'> {
4
4
  /** three across on a phone (attachments, upload kinds) */
5
5
  cols3?: boolean;
6
+ /** the grid takes the card's side padding back and runs to its edges (`.bleed`, card.css) */
6
7
  bleed?: boolean;
7
8
  }
8
9
  /** TileGrid — big square Tiles in a responsive grid. */
@@ -18,10 +19,15 @@ export interface TileProps extends Omit<ComponentProps<'div'>, 'onChange'> {
18
19
  sub?: ReactNode;
19
20
  /** a pick tile: a checkbox (or radio) inside, filled when checked */
20
21
  pick?: boolean;
22
+ /** what the pick tile's input is: checkbox (default) for pick-many, radio for pick-one */
21
23
  type?: 'checkbox' | 'radio';
24
+ /** the input's name (a pick tile): the radios of one choice share it (that is what makes them one group); a checkbox's is its form field */
22
25
  name?: string;
26
+ /** CONTROLLED: picked (a pick tile). The app owns it — pair it with `onChange` */
23
27
  checked?: boolean;
28
+ /** UNCONTROLLED: picked on arrival; the input then keeps its own */
24
29
  defaultChecked?: boolean;
30
+ /** the native input's change event: the tile was picked, or (checkbox) toggled */
25
31
  onChange?: React.ChangeEventHandler<HTMLInputElement>;
26
32
  /** filled accent (a static "on" tile) */
27
33
  on?: boolean;
@@ -6,6 +6,7 @@ export interface BarsProps extends ComponentProps<'div'> {
6
6
  /** Bars — three or four bars with a count on top, the glance chart; children are Bar. */
7
7
  export declare function Bars({ max, className, style, ...rest }: BarsProps): import("react").JSX.Element;
8
8
  export interface BarProps extends ComponentProps<'div'> {
9
+ /** the bar's height, against `Bars max` (the tallest bar's value) */
9
10
  value: number;
10
11
  /** the highlighted bar (accent, its count in a pill) */
11
12
  on?: boolean;
@@ -6,6 +6,7 @@ export interface DotGridProps extends ComponentProps<'div'> {
6
6
  done: number;
7
7
  /** dots per row (12) */
8
8
  cols?: number;
9
+ /** the grid's accessible name (default "{done} of {total}") */
9
10
  label?: string;
10
11
  }
11
12
  /** DotGrid — progress over time as a grid of dots, one element and three numbers (`--total`, `--done`, `--cols`). */
@@ -8,6 +8,7 @@ export interface PickerProps extends Omit<ComponentProps<'div'>, 'onChange' | 'd
8
8
  onChange?: (value: string) => void;
9
9
  /** the unit under the rail ("hours") */
10
10
  unit?: ReactNode;
11
+ /** the picker's accessible name (aria-label) */
11
12
  label?: string;
12
13
  }
13
14
  /**
@@ -1,5 +1,6 @@
1
1
  import type { ComponentProps } from 'react';
2
2
  export interface TrackProps extends ComponentProps<'ol'> {
3
+ /** the track's accessible name (default "Progress", by `<html lang>`) */
3
4
  label?: string;
4
5
  }
5
6
  /** Track — step progress in a dark pill: done · done · todo; children are TrackSteps holding an Icon each. */
@@ -15,10 +15,13 @@ export interface AvatarProps extends ComponentProps<'span'> {
15
15
  center?: boolean;
16
16
  /** a photo instead of initials */
17
17
  src?: string;
18
+ /** the photo's alt text; empty (the default) = decorative */
18
19
  alt?: string;
19
20
  /** an icon instead of initials */
20
21
  icon?: IconName;
22
+ /** the element: span (default), a when it has an `href`, or button for a tap target */
21
23
  as?: 'span' | 'a' | 'button';
24
+ /** makes the avatar a link (an `a`) */
22
25
  href?: string;
23
26
  /** the initials */
24
27
  children?: ReactNode;
@@ -310,6 +313,8 @@ export declare function Avatar({ size, outline, add, on, halo, center, src, alt,
310
313
  className: string;
311
314
  }, HTMLSpanElement>;
312
315
  export interface AvatarStackProps extends ComponentProps<'span'> {
316
+ /** the count that trails the stack ("+9"): plain text, no round, no fill, in title type — it reads at arm's length. Use it for a stack of Stamps or when the count is the point; a count that should look like one more head is `<Avatar size="sm" on>+7</Avatar>` as the last child */
317
+ more?: ReactNode;
313
318
  }
314
- /** AvatarStack — overlapping small Avatars with an optional `on` "+N" tail. */
315
- export declare function AvatarStack({ className, ...rest }: AvatarStackProps): import("react").JSX.Element;
319
+ /** AvatarStack — overlapping small Avatars, or small Stamps (`bare`), side by side; a trailing count is `more` (or an `on` Avatar as the last child). */
320
+ export declare function AvatarStack({ more, className, children, ...rest }: AvatarStackProps): import("react").JSX.Element;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createElement } from 'react';
3
3
  import { cx } from '../cx.js';
4
4
  import { Icon } from '../type/Icon.js';
@@ -7,7 +7,7 @@ export function Avatar({ size, outline, add, on, halo, center, src, alt = '', ic
7
7
  const el = as ?? (href ? 'a' : 'span');
8
8
  return createElement(el, { href, type: el === 'button' ? 'button' : undefined, className: cx('avatar', size && `avatar--${size}`, outline && 'avatar--outline', add && 'avatar--add', on && 'avatar--on', halo && 'avatar--halo', center && 'avatar--center', className), ...rest }, src ? _jsx("img", { src: src, alt: alt }) : icon ? _jsx(Icon, { name: icon }) : children);
9
9
  }
10
- /** AvatarStack — overlapping small Avatars with an optional `on` "+N" tail. */
11
- export function AvatarStack({ className, ...rest }) {
12
- return _jsx("span", { className: cx('avatar-stack', className), ...rest });
10
+ /** AvatarStack — overlapping small Avatars, or small Stamps (`bare`), side by side; a trailing count is `more` (or an `on` Avatar as the last child). */
11
+ export function AvatarStack({ more, className, children, ...rest }) {
12
+ return (_jsxs("span", { className: cx('avatar-stack', className), ...rest, children: [children, more != null && _jsx("span", { className: "avatar-stack__more", children: more })] }));
13
13
  }
@@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from 'react';
2
2
  export interface AvatarPickProps extends ComponentProps<'div'> {
3
3
  /** run the strip to the card's edges */
4
4
  bleed?: boolean;
5
+ /** the strip's accessible name (aria-label) */
5
6
  label?: string;
6
7
  }
7
8
  /** AvatarPick — the chooser row: big heads with name and role under, scrolling sideways; children are AvatarPickItems. */
@@ -36,8 +36,9 @@ export interface PostLetterProps extends ComponentProps<'div'> {
36
36
  backLabel?: string;
37
37
  /** the tab's words while the back is in front */
38
38
  pictureLabel?: string;
39
- /** the play control's names */
39
+ /** the play control's name while the note is paused (default "Play", by `<html lang>`) */
40
40
  playLabel?: string;
41
+ /** the play control's name while the note plays (default "Pause", by `<html lang>`) */
41
42
  pauseLabel?: string;
42
43
  /** the small word before the name (default "To", by `<html lang>`) */
43
44
  toLabel?: ReactNode;
@@ -14,6 +14,7 @@ export interface LayerHistoryProps {
14
14
  replace: (state: EntryState, search?: LayerSearch) => void;
15
15
  /** back by this many entries (`navigate(-entries)`) */
16
16
  back: (entries: number) => void;
17
+ /** the app's screens: whatever calls `useLayer` / `useLayers` stands under it */
17
18
  children?: ReactNode;
18
19
  }
19
20
  /**
@@ -5,6 +5,7 @@ export interface ScreenProps extends ComponentProps<'div'> {
5
5
  fill?: boolean;
6
6
  /** a colour preset from palette.css; omit for the black-and-white wireframe */
7
7
  palette?: Palette;
8
+ /** the element: div (default), main for the page's landmark, or section */
8
9
  as?: 'div' | 'main' | 'section';
9
10
  /** TOP: a `TopBar` / `AppBar`, or `"lead"` — no bar, the content starts `--screen-lead` (15% of the screen) down */
10
11
  top?: ReactNode | 'lead';
@@ -23,7 +23,9 @@ export interface StackSheetProps extends Omit<ComponentProps<'article'>, 'title'
23
23
  tabs?: StackSheetTab[];
24
24
  /** CONTROLLED: the tab shown (its id) — with `onTabChange`; or `defaultTab` (default: the first) */
25
25
  tab?: string;
26
+ /** UNCONTROLLED: the tab shown first (its id, default the first); the sheet then keeps its own. Pair of `tab` */
26
27
  defaultTab?: string;
28
+ /** a tab was tapped: its id */
27
29
  onTabChange?: (tab: string) => void;
28
30
  /** the SHEET FOOT: the CTA at the sheet's bottom (one row) */
29
31
  foot?: ReactNode;
@@ -4,9 +4,11 @@ export interface StampBookProps extends ComponentProps<'div'> {
4
4
  places?: number;
5
5
  /** ONE line of small stamps that scrolls sideways, no ground — the tray a member picks from */
6
6
  strip?: boolean;
7
+ /** the page with NO ground — no fill, no padding, no round: three stamps across fill the whole width of what holds the book, each the full width of its third (the Stamp's width cap is lifted) with the least gap that keeps them apart. Put the name INSIDE the stamp (`Stamp name`), not under it */
8
+ bare?: boolean;
7
9
  }
8
10
  /** StampBook — the album page: StampCells three across on an album ground, the free places drawn empty up to `places`. */
9
- export declare function StampBook({ places, strip, className, children, ...rest }: StampBookProps): import("react").JSX.Element;
11
+ export declare function StampBook({ places, strip, bare, className, children, ...rest }: StampBookProps): import("react").JSX.Element;
10
12
  export interface StampCellProps extends Omit<ComponentProps<'span'>, 'onClick'> {
11
13
  /** a short line under the stamp — who it is from */
12
14
  label?: ReactNode;
@@ -4,9 +4,9 @@ import { cx } from '../cx.js';
4
4
  import { Icon } from '../type/Icon.js';
5
5
  import { StampPlace } from './Stamp.js';
6
6
  /** StampBook — the album page: StampCells three across on an album ground, the free places drawn empty up to `places`. */
7
- export function StampBook({ places = 0, strip, className, children, ...rest }) {
7
+ export function StampBook({ places = 0, strip, bare, className, children, ...rest }) {
8
8
  const free = Math.max(0, places - Children.count(children));
9
- return (_jsxs("div", { className: cx('stamp-book', strip && 'stamp-book--strip', className), ...rest, children: [children, Array.from({ length: free }, (_, i) => _jsx(StampPlace, { faint: true, "aria-hidden": "true" }, i))] }));
9
+ return (_jsxs("div", { className: cx('stamp-book', strip && 'stamp-book--strip', bare && 'stamp-book--bare', className), ...rest, children: [children, Array.from({ length: free }, (_, i) => _jsx(StampPlace, { faint: true, "aria-hidden": "true" }, i))] }));
10
10
  }
11
11
  /** StampCell — one place of a StampBook: a Stamp (the child) and a short label under it. */
12
12
  export function StampCell({ label, onClick, on, className, children, ...rest }) {
@@ -39,7 +39,9 @@ export interface CallSheetProps extends Omit<ComponentProps<typeof Screen>, 'chi
39
39
  ways?: CallSheetWay[];
40
40
  /** CONTROLLED: which way's sheet is up — an index, or null for none (`true` = the first); with `onOpenChange`; or `defaultOpen` */
41
41
  open?: number | boolean | null;
42
+ /** UNCONTROLLED: the way whose sheet is up on arrival (an index, `true` = the first, null = none); the template then keeps its own. Pair of `open` */
42
43
  defaultOpen?: number | boolean | null;
44
+ /** a sheet came up or went away: whether one is up, and which way's (its index, or null) */
43
45
  onOpenChange?: (open: boolean, way: number | null) => void;
44
46
  /** the sheets' resting states once up (default "peek full") */
45
47
  states?: string;
@@ -29,7 +29,9 @@ export interface HomeProps extends Omit<ComponentProps<typeof Screen>, 'children
29
29
  sheets: HomeSheet[];
30
30
  /** CONTROLLED: the open sheet's index, or null — with `onOpenChange`; or `defaultOpen` */
31
31
  open?: number | null;
32
+ /** UNCONTROLLED: the sheet open on arrival (its index), or null for none; the Home then keeps its own. Pair of `open` */
32
33
  defaultOpen?: number | null;
34
+ /** a sheet asks to come up or fold: the index it asks for, or null for none */
33
35
  onOpenChange?: (open: number | null) => void;
34
36
  /** the open sheet's scroller was already at the top and the finger pulled down past the commit. The stack asks with that index. It does not fetch. Omit it and a pull in the body only scrolls */
35
37
  onRefresh?: (index: number) => void;
@@ -15,10 +15,13 @@ export interface WalkthroughCta {
15
15
  go: (to: number) => void;
16
16
  }
17
17
  export interface WalkthroughProps extends Omit<ComponentProps<typeof Screen>, 'children' | 'fill' | 'top' | 'bottom'> {
18
+ /** the pages in order — a picture, a heading and the words each */
18
19
  pages: WalkthroughPage[];
19
20
  /** the page shown (0-based), controlled — with `onAtChange`; or `defaultAt` */
20
21
  at?: number;
22
+ /** UNCONTROLLED: the page shown on arrival (0-based, default 0); the walk then keeps its own. Pair of `at` */
21
23
  defaultAt?: number;
24
+ /** a page was turned to: its index */
22
25
  onAtChange?: (at: number) => void;
23
26
  /** the ONE row of the bottom panel. Omitted: the round pager (back · next). A function gets `{ at, count, go }`,
24
27
  * e.g. the pager until the last page, then one wide Btn. */
@@ -29,8 +32,9 @@ export interface WalkthroughProps extends Omit<ComponentProps<typeof Screen>, 'c
29
32
  ctaLink?: ReactNode;
30
33
  /** layers over the screen: the app's `SheetStage dim` + `Modal` that a link or button opens (the app wires it) */
31
34
  children?: ReactNode;
32
- /** the pager's labels */
35
+ /** the pager's back button name (default "Back", by `<html lang>`) */
33
36
  backLabel?: string;
37
+ /** the pager's next button name (default "Next", by `<html lang>`) */
34
38
  nextLabel?: string;
35
39
  }
36
40
  /**
@@ -0,0 +1,203 @@
1
+ // lib/catalog.mjs — what an AI session asks of the catalog (dist/catalog.json, built by scripts/build-catalog.mjs) instead of reading src/.
2
+ // Pure functions over the loaded catalog, no dependency beyond node: `npx cardds list|get|find|scaffold` and `npx cardds mcp` (lib/mcp.mjs) are two doors to these.
3
+ // Shipped in the package. The order of a session: find (has a screen like this been drawn?) → scaffold it, or get the components → check.
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { checkSource } from '../lint/rules.mjs';
9
+ import { SHAPES, KINDS, shapeOf, interactOf } from './role.mjs';
10
+
11
+ const PKG = join(dirname(fileURLToPath(import.meta.url)), '..');
12
+ const CSS = 'node_modules/@usableapp/cardds/dist/cardds.css';
13
+ const OTHERS = 5; // stories of OTHER components that show one in use: how many come with their code (`Btn` is in 117)
14
+
15
+ /** the catalog of the package, or of a live repo at `root` (rebuilt first, so it is never stale) */
16
+ export function load({ root } = {}) {
17
+ if (root && existsSync(join(root, 'scripts', 'build-catalog.mjs'))) {
18
+ execFileSync(process.execPath, [join(root, 'scripts', 'build-catalog.mjs'), root], { stdio: ['ignore', 'ignore', 'inherit'] });
19
+ }
20
+ const file = join(root ?? PKG, 'dist', 'catalog.json');
21
+ if (!existsSync(file)) throw new Error(`no catalog at ${file} — \`npm run build\` in the cardds repo makes it`);
22
+ return JSON.parse(readFileSync(file, 'utf8'));
23
+ }
24
+
25
+ export { SHAPES, KINDS };
26
+
27
+ // `represents` is the concept (person · group · time…) or, for a control that stands for no one thing, the KIND of data it carries (words · number · picture · signal); `—` is none, so []
28
+ const representsOf = ({ represents }) => represents.filter((v) => v !== '—');
29
+
30
+ /** the names only — the first call of nearly every session, so it stays small. `group` is the src folder; `represents`, `shape` and `interact` say what it is for */
31
+ export const list = (cat) => cat.components.map(({ name, group, role }) => ({ name, group, represents: representsOf(role), shape: shapeOf(role), interact: interactOf(role) }));
32
+
33
+ const sentence = (text) => {
34
+ const flat = text.replace(/\s+/g, ' ').trim();
35
+ const one = flat.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? flat;
36
+ return one.length > 160 ? `${one.slice(0, 157)}…` : one;
37
+ };
38
+ const isSlot = (type) => /\bReactNode\b/.test(type);
39
+
40
+ function distance(a, b) {
41
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
42
+ for (let i = 1; i <= a.length; i++) {
43
+ let prev = row[0];
44
+ row[0] = i;
45
+ for (let j = 1; j <= b.length; j++) {
46
+ const keep = row[j];
47
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
48
+ prev = keep;
49
+ }
50
+ }
51
+ return row[b.length];
52
+ }
53
+ const closest = (names, wanted) => {
54
+ const w = wanted.toLowerCase();
55
+ const rank = (n) => (n.toLowerCase().includes(w) || w.includes(n.toLowerCase()) ? 0 : 100) + distance(n.toLowerCase(), w);
56
+ return [...names].sort((a, b) => rank(a) - rank(b)).slice(0, 5);
57
+ };
58
+
59
+ /** one component: its props (a ReactNode prop is a slot) and its variations — the stories of its own file first, then a few that use it */
60
+ export function get(cat, name, { html = false, all = false } = {}) {
61
+ const c = cat.components.find((x) => x.name.toLowerCase() === String(name).toLowerCase());
62
+ if (!c) return { error: `no component "${name}"`, closest: closest(cat.components.map((x) => x.name), String(name)) };
63
+ const show = (s) => ({ id: s.id, description: s.description, tsx: s.tsx, ...(html && { html: s.html }) });
64
+ const own = cat.stories.filter((s) => s.file === c.name);
65
+ const elsewhere = cat.stories.filter((s) => s.file !== c.name && s.uses.includes(c.name));
66
+ const shown = all ? elsewhere : [...elsewhere].sort((a, b) => a.tsx.length - b.tsx.length).slice(0, OTHERS);
67
+ return {
68
+ name: c.name,
69
+ group: c.group,
70
+ description: c.description,
71
+ ...(c.role && { role: c.role }),
72
+ element: c.element,
73
+ props: c.props.map((p) => ({ ...p, ...(isSlot(p.type) && { slot: true }) })),
74
+ variations: [...own, ...elsewhere.filter((s) => shown.includes(s))].map(show),
75
+ ...(elsewhere.length > shown.length && { alsoUsedIn: elsewhere.filter((s) => !shown.includes(s)).map((s) => s.id) }),
76
+ };
77
+ }
78
+
79
+ const words = (text) => text.replace(/([a-z\d])([A-Z])/g, '$1 $2').toLowerCase().match(/[\p{L}\p{M}\p{N}]+/gu) ?? [];
80
+ const STOP = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'into', 'of', 'to', 'in', 'on', 'an', 'a']);
81
+ const tokens = (query) => [...new Set(words(String(query)).filter((t) => t.length > 1 && !STOP.has(t)))];
82
+ const stem = (t) => (t.length > 3 && t.endsWith('s') ? t.slice(0, -1) : t);
83
+ const hits = (text, token) => words(text).some((w) => w.startsWith(token) || w.startsWith(stem(token)));
84
+
85
+ const TOP = 8; // answers per kind; the components that match every axis are never cut
86
+
87
+ /** the closed vocabulary of the axes `find` takes, `represents` read from the catalog's own roles (docs/roles.md does not ship). `group` = either group shape */
88
+ export const vocabulary = (cat) => ({
89
+ represents: [...new Set(cat.components.flatMap((c) => representsOf(c.role)))].sort(),
90
+ shape: ['one', 'group-brief', 'group-detail', 'group'],
91
+ interact: KINDS,
92
+ });
93
+ const roleOf = (c) => ({ represents: representsOf(c.role), shape: shapeOf(c.role), interact: interactOf(c.role) });
94
+
95
+ /** the axes a caller gave, checked against the vocabulary: `{ error }`, or only the ones in force (an empty list is no axis) */
96
+ function wanted(cat, { represents, shape, interact } = {}) {
97
+ const many = (v) => (v === undefined ? [] : [v].flat());
98
+ const vocab = vocabulary(cat);
99
+ const kinds = interact === false ? false : many(interact);
100
+ const unknown = (axis, given, allowed) => given.filter((v) => !allowed.includes(v)).map((v) => `${axis} "${v}" (one of: ${allowed.join(' · ')})`);
101
+ const errors = [...unknown('represents', many(represents), vocab.represents), ...unknown('shape', many(shape), vocab.shape), ...unknown('interact', kinds || [], vocab.interact)];
102
+ if (errors.length) return { error: `no such value — ${errors.join('; ')}` };
103
+ return { want: { ...(many(represents).length && { represents: many(represents) }), ...(shape && { shape }), ...((kinds === false || kinds.length) && { interact: kinds }) } };
104
+ }
105
+ /** a role matches when it matches EVERY axis in force: holds every `represents` asked, is the shape, has every kind of interaction asked (`false` = only shows) */
106
+ const matches = (role, want) => !!role
107
+ && (!want.represents || want.represents.every((v) => role.represents.includes(v)))
108
+ && (!want.shape || (want.shape === 'group' ? role.shape.startsWith('group') : role.shape === want.shape))
109
+ && (want.interact === undefined || (want.interact === false ? role.interact === false : want.interact.every((k) => role.interact && role.interact.includes(k))));
110
+
111
+ /** a screen is a composition, its axes spread over its parts: for each axis SOME used component answers it (represents: holds any value asked, the looser reading
112
+ * — a part that holds every one is the stronger match, which `matches` finds; shape: has it; interact: has every kind asked, or for `none` every part only shows) */
113
+ const matchesUnion = (roles, want) => roles.length > 0
114
+ && (!want.represents || roles.some((r) => want.represents.some((v) => r.represents.includes(v))))
115
+ && (!want.shape || roles.some((r) => matches(r, { shape: want.shape })))
116
+ && (want.interact === undefined || (want.interact === false ? roles.every((r) => r.interact === false) : roles.some((r) => matches(r, { interact: want.interact }))));
117
+
118
+ /** short ranked answer to "what is there for this?". `axes` ({ represents?, shape?, interact? } — what it represents, how many, what the member can do with it) are
119
+ * asked first: whatever matches every one of them (a component, or a screen where one component does, then a screen whose parts do between them) ranks above whatever matches only words;
120
+ * with axes and no words they are the whole answer. Among screens that tie on words, the one whose description says the axes comes first, then the one where a single component matches them all.
121
+ * Then the words: screens already drawn, then components, then what they are used with.
122
+ * A query that matches nothing (another language, say) returns `fallback: true`, a hint and how many screens are drawn — not their ids:
123
+ * they did not help to choose; ask again in English or by the axes, or `scaffold` with no id lists them. */
124
+ export function find(cat, query, axes = {}) {
125
+ const asked = wanted(cat, axes);
126
+ if (asked.error) return asked;
127
+ const active = Object.keys(asked.want).length > 0;
128
+ const roles = new Map(cat.components.map((c) => [c.name, roleOf(c)]));
129
+ const toks = tokens(query);
130
+ const axisWords = [...(asked.want.represents ?? []), ...(asked.want.interact || [])]; // the axis values as words: a screen's own description may say them
131
+ const rank = (items, fields, level, said = () => 0) => {
132
+ const scored = items.map((item) => {
133
+ let matched = 0, score = 0;
134
+ for (const t of toks) {
135
+ const w = fields(item).filter(([text]) => hits(text, t)).reduce((sum, [, weight]) => sum + weight, 0);
136
+ if (w) { matched++; score += w; }
137
+ }
138
+ return { item, matched, score, level: active ? level(item) : 0, said: active ? said(item) : 0 };
139
+ });
140
+ const byWords = (a, b) => b.matched - a.matched || b.score - a.score;
141
+ const byAxes = (a, b) => b.said - a.said || b.level - a.level; // among equal words: the screen that says the axes itself, then one component over several
142
+ return { axis: scored.filter((r) => r.level).sort((a, b) => byWords(a, b) || byAxes(a, b)), rest: scored.filter((r) => r.matched && !r.level).sort(byWords) };
143
+ };
144
+ const tier = ({ axis, rest }, cap) => [axis.slice(0, cap), rest.slice(0, Math.max(0, cap - axis.length))];
145
+ const screenOf = ({ item: s }) => ({ kind: 'screen', id: s.id, summary: sentence(s.description) });
146
+ const componentOf = ({ item: c }) => ({ kind: 'component', name: c.name, summary: sentence(c.description), role: roles.get(c.name) });
147
+ const screens = cat.stories.filter((s) => s.screen);
148
+ const level = (s) => { // 2: one component of the screen matches every axis; 1: its parts do between them
149
+ const parts = s.uses.map((u) => roles.get(u)).filter(Boolean);
150
+ return !matchesUnion(parts, asked.want) ? 0 : parts.some((r) => matches(r, asked.want)) ? 2 : 1; // `none` is about the whole screen: one show-only part is not enough
151
+ };
152
+ const said = (s) => axisWords.filter((v) => hits(s.description, v)).length;
153
+ const [screenAxis, screenRest] = tier(rank(screens, (s) => [[s.id, 5], [s.description, 3], [s.uses.join(' '), 1]], level, said), TOP);
154
+ const components = rank(cat.components, (c) => [[c.name, 6], [c.description, 2], [c.props.map((p) => p.name).join(' '), 1]], (c) => Number(matches(roles.get(c.name), asked.want)));
155
+ const [componentAxis, componentRest] = tier(components, Math.max(TOP, components.axis.length));
156
+ const ranked = [...screenAxis.map(screenOf), ...componentAxis.map(componentOf), ...screenRest.map(screenOf), ...componentRest.map(componentOf)];
157
+ if (!ranked.length && active && !toks.length) return { query, results: [], hint: 'no component or screen has that role: drop an axis, or add English words' };
158
+ if (!ranked.length) return { query, fallback: true, hint: 'the names and descriptions are English: ask again in English words, or by the axes (represents · shape · interact); `scaffold` with no id lists the screens', screens: screens.length };
159
+
160
+ const matched = new Set(ranked.filter((r) => r.kind === 'component').map((r) => r.name));
161
+ const together = new Map();
162
+ for (const s of cat.stories) if (s.uses.some((u) => matched.has(u))) for (const u of s.uses) if (!matched.has(u)) together.set(u, (together.get(u) ?? 0) + 1);
163
+ const usedTogether = [...together].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 8).map(([name, stories]) => ({ name, stories }));
164
+ return { query, results: ranked, usedTogether };
165
+ }
166
+
167
+ // the parts that put themselves where they belong by JS at runtime — over the content, open, at the edge: the static html shows them in the wrong place
168
+ const PLACED_BY_JS = ['Sheet', 'SheetStack', 'SheetSteps', 'SheetStage', 'Step', 'Modal', 'Menu', 'Dropdown', 'Snackbar', 'FloatBar'];
169
+
170
+ /** no id: the screens already drawn. With one: a file to start from — `tsx` (the code, a string) or `html` (the look, static: `{ html, warning? }`, the warning when the picture lies) */
171
+ export function scaffold(cat, id, { format = 'tsx', css = CSS } = {}) {
172
+ if (!id) return cat.stories.filter((s) => s.screen).map((s) => ({ id: s.id, summary: sentence(s.description) }));
173
+ const s = cat.stories.find((x) => x.id.toLowerCase() === String(id).toLowerCase());
174
+ if (!s) return { error: `no story "${id}"`, closest: closest(cat.stories.map((x) => x.id), String(id)) };
175
+ if (format === 'html') {
176
+ if (!s.html) return { error: `${s.id} has no rendered html: ${s.htmlError ?? 'it did not render'}` };
177
+ const placed = [...new Set(s.uses.filter((u) => PLACED_BY_JS.includes(u)))];
178
+ const warning = placed.length ? `${placed.join(', ')} ${placed.length > 1 ? 'are' : 'is'} placed by JS at runtime, so this static picture is NOT the layout (a sheet over the content, a menu not open): use format tsx to see it live` : undefined;
179
+ return { html: `<!doctype html>${warning ? `\n<!-- ${warning} -->` : ''}
180
+ <html lang="en">
181
+ <head>
182
+ <meta charset="utf-8">
183
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
184
+ <title>${s.id}</title>
185
+ <link rel="stylesheet" href="${css}">
186
+ </head>
187
+ <body>
188
+ <!-- ${s.id}, rendered once: the look only, no behaviour. \`scaffold ${s.id}\` (tsx) is the working code -->
189
+ ${s.html}
190
+ </body>
191
+ </html>
192
+ `, ...(warning && { warning }) };
193
+ }
194
+ const glue = [...s.imports.matchAll(/import (.+?) from '\.\/_[^']*';/g)]
195
+ .flatMap((m) => m[1].replace(/[{}]/g, '').split(',').map((n) => n.trim().replace(/^type /, '').split(/ as /).pop()).filter(Boolean));
196
+ const taken = /\bScreen\b/.test(s.imports) || /^(?:function|const|class|type|interface) Screen\b/m.test(s.tsx);
197
+ const as = taken ? 'MyScreen' : 'Screen';
198
+ const note = glue.length ? `// story glue from ./_*, not part of the package — replace or drop: ${glue.join(', ')}\n` : '';
199
+ return `${note}${s.imports}\n\n${s.tsx.replace(new RegExp(`^function ${s.name}\\b`, 'm'), `export default function ${as}`)}\n`;
200
+ }
201
+
202
+ /** the rules of lint/rules.mjs over a piece of code */
203
+ export const check = (code, filename = 'screen.tsx') => checkSource(code, filename);