@tristan2828/ui-foundation 4.46.0 → 4.47.0

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.
@@ -4,21 +4,21 @@
4
4
  ## Index
5
5
 
6
6
  - [The reference files](#the-reference-files) · lines 37–59
7
- - [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–715
7
+ - [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–720
8
8
  - Step 1: Add `<Entity>` to `openapi.yaml` · lines 99–106
9
9
  - Step 2: `npm run gen:api` · lines 107–108
10
10
  - Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 109–120
11
11
  - Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines 121–130
12
12
  - Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 131–135
13
- - Step 6: Copy the reference files, one for one, not just the two screens · lines 136–607
14
- - Step 7: Register routes · lines 608–618
15
- - Step 8: Add Playwright specs for all three screens · lines 619–697
16
- - Step 9: Register the nav entry and route names · lines 698–712
17
- - Step 10: `npm run verify`. · lines 713–715
18
- - [What not to copy](#what-not-to-copy) · lines 717–743
19
- - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 745–762
20
- - [Deleting](#deleting) · lines 764–793
21
- - [Fields only a script writes](#fields-only-a-script-writes) · lines 795–816
13
+ - Step 6: Copy the reference files, one for one, not just the two screens · lines 136–612
14
+ - Step 7: Register routes · lines 613–623
15
+ - Step 8: Add Playwright specs for all three screens · lines 624–702
16
+ - Step 9: Register the nav entry and route names · lines 703–717
17
+ - Step 10: `npm run verify`. · lines 718–720
18
+ - [What not to copy](#what-not-to-copy) · lines 722–748
19
+ - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 750–767
20
+ - [Deleting](#deleting) · lines 769–798
21
+ - [Fields only a script writes](#fields-only-a-script-writes) · lines 800–821
22
22
 
23
23
  <!-- /index -->
24
24
 
@@ -409,7 +409,12 @@ is a filled-in example while the Widgets demo is still here).
409
409
  and every `placement` from the copied view (one column). Widget's
410
410
  view is `rail`, with Details in the rail. The rail's sections come
411
411
  first in the page (header, rail, main) at every width, so the
412
- view's specs find them first too.
412
+ view's specs find them first too. When the plan says the main
413
+ column comes first on a phone (the record is opened for its notes
414
+ or its checklist), pass `mobileOrder="main-first"`: header, main,
415
+ rail where the columns stack, and in the DOM at every width, with
416
+ the rail still on the right two columns wide. Never re-order
417
+ `sections` by the window's width yourself.
413
418
  - **A subtitle** (`subtitle`), only when the plan names a line that
414
419
  says what the record is for (a goal, a one-line summary): under the
415
420
  title in muted body type. When the plan marks it editable, wrap it
@@ -13,14 +13,14 @@
13
13
  - [Written text (notes)](#written-text-notes) · lines 277–328
14
14
  - [Density](#density) · lines 330–387
15
15
  - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 389–503
16
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 505–563
17
- - [Notices](#notices) · lines 565–593
18
- - [A value over time](#a-value-over-time) · lines 595–628
19
- - [A list as a board](#a-list-as-a-board) · lines 630–681
20
- - [A list as cards](#a-list-as-cards) · lines 683–729
21
- - [Key numbers on a record](#key-numbers-on-a-record) · lines 731–753
22
- - [Picking at random](#picking-at-random) · lines 755–786
23
- - [Filling in what's missing](#filling-in-whats-missing) · lines 788–838
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 505–574
17
+ - [Notices](#notices) · lines 576–604
18
+ - [A value over time](#a-value-over-time) · lines 606–639
19
+ - [A list as a board](#a-list-as-a-board) · lines 641–692
20
+ - [A list as cards](#a-list-as-cards) · lines 694–740
21
+ - [Key numbers on a record](#key-numbers-on-a-record) · lines 742–764
22
+ - [Picking at random](#picking-at-random) · lines 766–797
23
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 799–849
24
24
 
25
25
  <!-- /index -->
26
26
 
@@ -557,10 +557,21 @@ const { data, isLoading, error, refetch } = useProjectsSummaryQuery()
557
557
  each tile one link to its place (at least 44px tall); Retry stays its
558
558
  own button above it. Six places make the phone's 3 × 2 grid; past
559
559
  that, put the rest behind one tile ("More") rather than a third row.
560
+ - **A row of chips on a phone** (`<ModuleStrip mobile="row">`), when the
561
+ hub's widgets should start on the first screen, or there are more
562
+ places than six tiles: below the sidebar's breakpoint the strip is one
563
+ row that scrolls sideways, a chip per place with its icon in its slot's
564
+ colour, its name and its number (no note, no action). While the number
565
+ loads, a small skeleton; on an error, a muted dash ("Couldn't load" to
566
+ a screen reader), with Retry left to the desktop card and the place's
567
+ own page. The row scrolls inside itself and the page never does. A
568
+ desktop shows the cards either way, so don't render your own row
569
+ beside a strip hidden on a phone.
560
570
  - Storybook's `app/ModuleCard` shows a hub with a strip and a widget,
561
- every state, and both at `compact`; its checks run axe and the token and
562
- glyph checks in both themes and at `large`, and measure the strip at a
563
- desktop and a phone width.
571
+ every state, both at `compact`, and both again as a row of chips (Row,
572
+ Row states); its checks run axe and the token and glyph checks in both
573
+ themes and at `large`, measure the strip at a desktop and a phone
574
+ width, and run axe on the chips at a phone's.
564
575
 
565
576
  ## Notices
566
577
 
@@ -52,6 +52,11 @@ export type EntityViewPlacement = 'main' | 'rail';
52
52
  * column on the right (one column on a narrow screen, rail first).
53
53
  */
54
54
  export type EntityViewLayout = 'column' | 'rail';
55
+ /**
56
+ * Which column comes first where the rail layout is one column (a phone,
57
+ * or a narrow content area): `rail-first` (the default) or `main-first`.
58
+ */
59
+ export type EntityViewMobileOrder = 'rail-first' | 'main-first';
55
60
  export type EntityViewProps = {
56
61
  /** The record's name or title, the page's <h1>, and the browser tab's once it loads (`documentTitle`). */
57
62
  title?: string;
@@ -102,6 +107,14 @@ export type EntityViewProps = {
102
107
  footer?: ReactNode;
103
108
  /** `rail` puts the sections placed `rail` in a column on the right. Defaults to `column`. */
104
109
  layout?: EntityViewLayout;
110
+ /**
111
+ * In the `rail` layout, where its columns stack (a phone): the rail
112
+ * before the main column (`rail-first`, the default), or the main column
113
+ * straight after the header and its `strip` (`main-first`). Two columns
114
+ * wide, the rail stays on the right either way. The `column` layout
115
+ * ignores it.
116
+ */
117
+ mobileOrder?: EntityViewMobileOrder;
105
118
  /** The rail's landmark name (its `<aside>`). Defaults to the title + " details". */
106
119
  railLabel?: string;
107
120
  isLoading: boolean;
@@ -114,4 +127,4 @@ export type EntityViewProps = {
114
127
  label: string;
115
128
  };
116
129
  };
117
- export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
130
+ export declare function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout, mobileOrder, railLabel, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -40,8 +40,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
40
40
  // content area's whole width, sections placed `rail` go in a narrow column
41
41
  // on the right that stays in view while the main column scrolls (an issue
42
42
  // tracker's shape), and the rest fill the main column. Below the width two
43
- // columns need, one column: header, rail, main. The DOM is in that order at
44
- // every width, so reading and tab order match the screen.
43
+ // columns need, one column: header, rail, main, or header, main, rail with
44
+ // `mobileOrder="main-first"` (a record whose long content is what you open
45
+ // it for: notes, a checklist). The DOM is in that order at every width, so
46
+ // reading and tab order match the screen: beside each other, the columns'
47
+ // places come from the grid, not the DOM.
45
48
  import { useEffect, useId, useState, useSyncExternalStore } from 'react';
46
49
  import { Link } from 'react-router';
47
50
  import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
@@ -90,7 +93,7 @@ function ViewSection({ section, inRail = false }) {
90
93
  // two columns follows the width the page actually has (the sidebar open
91
94
  // or not), not the window's: two from 56rem (896px), where the main
92
95
  // column still gets ~32rem beside a 22rem rail. Below that, one column in
93
- // DOM order (header, rail, main); from it, the grid puts the rail on the
96
+ // DOM order (header, rail, main, or main first); from it, the grid puts the rail on the
94
97
  // right of row 1 and main on its left, both starting under the header.
95
98
  const RAIL_LAYOUT = {
96
99
  page: '@container/view flex min-w-0 flex-col gap-6',
@@ -109,10 +112,11 @@ function SkeletonCard({ rows, stacked }) {
109
112
  }
110
113
  // The page's shape before the record arrives, in the layout it will have,
111
114
  // so nothing jumps when it loads.
112
- function EntityViewSkeleton({ layout }) {
115
+ function EntityViewSkeleton({ layout, mobileOrder }) {
113
116
  const header = (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] })] }));
114
117
  if (layout === 'rail') {
115
- return (_jsxs("div", { className: RAIL_LAYOUT.page, "data-state": "loading", "data-layout": "rail", children: [header, _jsxs("div", { className: RAIL_LAYOUT.columns, children: [_jsx("div", { className: cn(RAIL_LAYOUT.rail, 'flex flex-col gap-6'), children: _jsx(SkeletonCard, { rows: 4, stacked: true }) }), _jsx("div", { className: RAIL_LAYOUT.main, children: _jsx(SkeletonCard, { rows: 6, stacked: false }) })] })] }));
118
+ const main = (_jsx("div", { className: RAIL_LAYOUT.main, children: _jsx(SkeletonCard, { rows: 6, stacked: false }) }));
119
+ return (_jsxs("div", { className: RAIL_LAYOUT.page, "data-state": "loading", "data-layout": "rail", children: [header, _jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, _jsx("div", { className: cn(RAIL_LAYOUT.rail, 'flex flex-col gap-6'), children: _jsx(SkeletonCard, { rows: 4, stacked: true }) }), mobileOrder === 'rail-first' && main] })] }));
116
120
  }
117
121
  return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [header, Array.from({ length: 2 }, (_, section) => (_jsx(SkeletonCard, { rows: 4, stacked: false }, section)))] }));
118
122
  }
@@ -143,7 +147,7 @@ function Rail({ label, sections }) {
143
147
  const [ref, fits] = useFitsWindow();
144
148
  return (_jsx("aside", { ref: ref, "aria-label": label, "data-fits": fits ? '' : undefined, className: cn(RAIL_LAYOUT.rail, RAIL_LAYOUT.sticky, 'flex min-w-0 flex-col gap-6'), children: sections.map((section) => (_jsx(ViewSection, { section: section, inRail: true }, section.title))) }));
145
149
  }
146
- export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', railLabel, isLoading, error, onRetry, back, }) {
150
+ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges, actions, notice, strip, sections, footer, layout = 'column', mobileOrder = 'rail-first', railLabel, isLoading, error, onRetry, back, }) {
147
151
  // Editing in place: one store per view, and the leave-page prompt only
148
152
  // while a field is open (or one is always editable).
149
153
  const [store] = useState(() => new EditInPlaceStore());
@@ -157,8 +161,9 @@ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges,
157
161
  if (error)
158
162
  return _jsx(ErrorState, { error: error, onRetry: onRetry });
159
163
  if (isLoading)
160
- return _jsx(EntityViewSkeleton, { layout: layout });
164
+ return _jsx(EntityViewSkeleton, { layout: layout, mobileOrder: mobileOrder });
161
165
  const railSections = sections.filter((section) => section.placement === 'rail');
162
166
  const mainSections = sections.filter((section) => section.placement !== 'rail');
163
- return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), _jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) })] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
167
+ const main = (_jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) }));
168
+ return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), mobileOrder === 'rail-first' && main] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
164
169
  }
@@ -38,16 +38,25 @@ export type ModuleCardProps = ModuleCardPlace & {
38
38
  onRetry?: () => void;
39
39
  className?: string;
40
40
  };
41
+ /** How a ModuleStrip shows its places on a phone: `tiles` (the default), or one sideways `row` of chips. */
42
+ export type ModuleStripMobile = 'tiles' | 'row';
41
43
  export declare function ModuleCard({ title, icon: Icon, category, to, href, value, note, action, isLoading, error, onRetry, className, }: ModuleCardProps): import("react").JSX.Element;
42
44
  export interface ModuleStripProps {
43
45
  /** Names the strip's navigation landmark: "Places". */
44
46
  'aria-label': string;
45
47
  /** ModuleCards, one per place. */
46
48
  children: ReactNode;
49
+ /**
50
+ * On a phone: three columns of `tiles` (the default), or one `row` of
51
+ * chips (icon, name, number) that scrolls sideways, below the sidebar's
52
+ * breakpoint. A desktop shows the cards either way.
53
+ */
54
+ mobile?: ModuleStripMobile;
47
55
  className?: string;
48
56
  }
49
57
  /**
50
58
  * A hub's places, a ModuleCard each: equal columns of 170px or more that
51
- * wrap, and three columns of tiles on a phone.
59
+ * wrap, and on a phone three columns of tiles, or one sideways row of
60
+ * chips (`mobile="row"`).
52
61
  */
53
- export declare function ModuleStrip({ 'aria-label': label, children, className }: ModuleStripProps): import("react").JSX.Element;
62
+ export declare function ModuleStrip({ 'aria-label': label, children, mobile, className }: ModuleStripProps): import("react").JSX.Element;
@@ -21,7 +21,16 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
21
21
  // smaller tiles with no action, each tile a whole link (the band link's
22
22
  // target stretched over the card) at least 44px tall. Retry sits above
23
23
  // that target. data-density="compact" tightens the band and the body.
24
- import { Children, useId } from 'react';
24
+ //
25
+ // `mobile="row"` makes the strip one sideways-scrolling row of chips below
26
+ // the sidebar's breakpoint (md) instead, so a hub's widgets start on the
27
+ // first screen: each place's icon in its slot's colour, its name and its
28
+ // number, a skeleton while that loads and a dash when it fails (Retry is
29
+ // the card's; the place's own page says what went wrong). Each ModuleCard
30
+ // renders its chip beside its card, one shown at each width. The row
31
+ // scrolls inside itself and is `relative`, so a chip's sr-only text
32
+ // (absolutely placed) scrolls with it instead of widening the page.
33
+ import { Children, createContext, useContext, useId } from 'react';
25
34
  import { Link } from 'react-router';
26
35
  import { AlertCircleIcon, ExternalLinkIcon } from 'lucide-react';
27
36
  import { cn } from 'cn';
@@ -67,29 +76,60 @@ const BAND_LINK_CLASS = 'group/band flex min-h-9 items-center gap-2 px-3 py-1.5
67
76
  'max-sm:gap-1.5 max-sm:px-2 max-sm:after:absolute max-sm:after:inset-0 max-sm:after:rounded-xl ' +
68
77
  'max-sm:focus-visible:ring-0 max-sm:focus-visible:after:ring-3 max-sm:focus-visible:after:ring-inset ' +
69
78
  'max-sm:focus-visible:after:ring-ring/50';
79
+ // The chip on a phone in a `mobile="row"` strip. Its focus ring is inset:
80
+ // the row clips anything drawn outside it.
81
+ const CHIP_CLASS = 'flex min-h-11 items-center gap-1.5 rounded-full border border-border bg-card px-3 type-label ' +
82
+ 'whitespace-nowrap text-foreground outline-none hover:bg-accent focus-visible:ring-3 ' +
83
+ 'focus-visible:ring-inset focus-visible:ring-ring/50';
70
84
  const ACTION_CLASS = 'flex items-center gap-1 border-t border-border px-3 py-2 type-label text-foreground underline ' +
71
85
  'underline-offset-4 outline-none hover:decoration-2 focus-visible:ring-3 focus-visible:ring-inset ' +
72
86
  'focus-visible:ring-ring/50 density-compact:py-1.5 max-sm:hidden';
73
87
  function NewTabMark() {
74
88
  return (_jsxs(_Fragment, { children: [_jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] }));
75
89
  }
90
+ const StripMobileContext = createContext('tiles');
91
+ function ChipValue({ state, value }) {
92
+ if (state === 'loading')
93
+ return (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-4 w-4" }), _jsx("span", { className: "sr-only", children: "Loading" })] }));
94
+ if (state === 'error')
95
+ return (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { "aria-hidden": "true", children: "\u2014" }), _jsx("span", { className: "sr-only", children: "Couldn't load" })] }));
96
+ if (value === undefined)
97
+ return null;
98
+ return (_jsx("span", { "data-slot": "module-card-chip-value", className: "font-semibold tabular-nums", children: value }));
99
+ }
100
+ function ModuleChip({ title, icon: Icon, category, to, href, value, state, }) {
101
+ const content = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": "true", className: cn('size-4 shrink-0', category ? ICON_CLASS[category] : 'text-muted-foreground') }), title, _jsx(ChipValue, { state: state, value: value })] }));
102
+ const chipProps = { 'data-slot': 'module-card-chip', 'data-state': state, className: cn(CHIP_CLASS, 'md:hidden') };
103
+ return href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...chipProps, children: [content, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...chipProps, children: content }));
104
+ }
76
105
  export function ModuleCard({ title, icon: Icon, category, to, href, value, note, action, isLoading = false, error, onRetry, className, }) {
77
106
  const titleId = useId();
78
107
  const bodyId = useId();
79
108
  const state = error ? 'error' : isLoading ? 'loading' : 'success';
109
+ const mobile = useContext(StripMobileContext);
80
110
  const band = (_jsxs(_Fragment, { children: [_jsx(Icon, { "aria-hidden": "true", className: cn('size-4 shrink-0', category ? ICON_CLASS[category] : 'text-muted-foreground') }), _jsx("span", { id: titleId, className: "min-w-0 flex-1 truncate underline-offset-4 group-hover/band:underline", children: title })] }));
81
111
  const bandProps = {
82
112
  'aria-describedby': bodyId,
83
113
  'data-slot': 'module-card-band',
84
114
  className: cn(BAND_LINK_CLASS, category && BAND_CLASS[category]),
85
115
  };
86
- return (_jsxs(Card, { "data-slot": "module-card", "data-state": state, className: cn('relative h-full min-w-0 gap-0 py-0', className), children: [href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...bandProps, children: [band, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...bandProps, children: band })), _jsxs("div", { id: bodyId, "data-slot": "module-card-body", "aria-busy": state === 'loading' || undefined, className: "flex flex-1 flex-col gap-0.5 px-3 py-2 density-compact:py-1.5 max-sm:px-2", children: [state === 'error' ? (_jsxs(_Fragment, { children: [_jsxs("p", { className: "flex items-center gap-1.5 type-body text-foreground", children: [_jsx(AlertCircleIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-destructive-text" }), _jsxs("span", { className: "min-w-0", children: ["Couldn't load", error?.message && _jsxs("span", { className: "sr-only", children: [": ", error.message] })] })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, "aria-describedby": titleId, className: "relative z-10 mt-1 self-start", children: "Retry" }))] })) : state === 'loading' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sr-only", children: "Loading" }), _jsx(Skeleton, { "data-state": "loading", className: "my-0.5 h-6 w-12" })] })) : (value !== undefined && (_jsx("span", { "data-slot": "module-card-value", className: "type-numeric text-foreground", children: value }))), note && state !== 'error' && (_jsx("span", { className: "truncate type-body text-muted-foreground", title: note, children: note }))] }), action &&
116
+ const card = (_jsxs(Card, { "data-slot": "module-card", "data-state": state, className: cn('relative h-full min-w-0 gap-0 py-0', mobile === 'row' && 'max-md:hidden', className), children: [href !== undefined ? (_jsxs("a", { href: href, target: "_blank", rel: "noreferrer", ...bandProps, children: [band, _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: to, ...bandProps, children: band })), _jsxs("div", { id: bodyId, "data-slot": "module-card-body", "aria-busy": state === 'loading' || undefined, className: "flex flex-1 flex-col gap-0.5 px-3 py-2 density-compact:py-1.5 max-sm:px-2", children: [state === 'error' ? (_jsxs(_Fragment, { children: [_jsxs("p", { className: "flex items-center gap-1.5 type-body text-foreground", children: [_jsx(AlertCircleIcon, { "aria-hidden": "true", className: "size-4 shrink-0 text-destructive-text" }), _jsxs("span", { className: "min-w-0", children: ["Couldn't load", error?.message && _jsxs("span", { className: "sr-only", children: [": ", error.message] })] })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, "aria-describedby": titleId, className: "relative z-10 mt-1 self-start", children: "Retry" }))] })) : state === 'loading' ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "sr-only", children: "Loading" }), _jsx(Skeleton, { "data-state": "loading", className: "my-0.5 h-6 w-12" })] })) : (value !== undefined && (_jsx("span", { "data-slot": "module-card-value", className: "type-numeric text-foreground", children: value }))), note && state !== 'error' && (_jsx("span", { className: "truncate type-body text-muted-foreground", title: note, children: note }))] }), action &&
87
117
  (action.href !== undefined ? (_jsxs("a", { href: action.href, target: "_blank", rel: "noreferrer", "aria-describedby": titleId, className: ACTION_CLASS, children: [_jsx("span", { className: "truncate", children: action.label }), _jsx(NewTabMark, {})] })) : (_jsx(Link, { to: action.to, "aria-describedby": titleId, className: ACTION_CLASS, children: _jsx("span", { className: "truncate", children: action.label }) })))] }));
118
+ if (mobile === 'tiles')
119
+ return card;
120
+ return (_jsxs(_Fragment, { children: [card, _jsx(ModuleChip, { title: title, icon: Icon, category: category, to: to, href: href, value: value, state: state })] }));
88
121
  }
122
+ // The list's columns: whole class names, so Tailwind generates them.
123
+ const STRIP_LIST = {
124
+ tiles: 'grid grid-cols-3 gap-2 sm:grid-cols-[repeat(auto-fill,minmax(--spacing(42.5),1fr))]',
125
+ row: 'flex gap-2 md:grid md:grid-cols-[repeat(auto-fill,minmax(--spacing(42.5),1fr))]',
126
+ };
89
127
  /**
90
128
  * A hub's places, a ModuleCard each: equal columns of 170px or more that
91
- * wrap, and three columns of tiles on a phone.
129
+ * wrap, and on a phone three columns of tiles, or one sideways row of
130
+ * chips (`mobile="row"`).
92
131
  */
93
- export function ModuleStrip({ 'aria-label': label, children, className }) {
94
- return (_jsx("nav", { "aria-label": label, "data-slot": "module-strip", className: className, children: _jsx("ul", { className: "grid grid-cols-3 gap-2 sm:grid-cols-[repeat(auto-fill,minmax(--spacing(42.5),1fr))]", children: Children.map(children, (child) => child && _jsx("li", { className: "min-w-0", children: child })) }) }));
132
+ export function ModuleStrip({ 'aria-label': label, children, mobile = 'tiles', className }) {
133
+ const row = mobile === 'row';
134
+ return (_jsx(StripMobileContext.Provider, { value: mobile, children: _jsx("nav", { "aria-label": label, "data-slot": "module-strip", "data-mobile": mobile, className: cn(row && 'relative min-w-0 max-md:overflow-x-auto max-md:[scrollbar-width:none]', className), children: _jsx("ul", { className: STRIP_LIST[mobile], children: Children.map(children, (child) => child && _jsx("li", { className: cn('min-w-0', row && 'max-md:shrink-0'), children: child })) }) }) }));
95
135
  }
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { type RowGroup } from './components/app/table-groups.js';
11
11
  export { byDay, type ByDayOptions } from './lib/group-by-day.js';
12
12
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
13
13
  export { KeyStats, type KeyStat, type KeyStatsProps } from './components/app/key-stats.js';
14
- export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
14
+ export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewMobileOrder, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
15
15
  export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListAddProps, type EditListItemProps, } from './components/app/edit-in-place.js';
16
16
  export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
17
17
  export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
@@ -44,7 +44,7 @@ export { ImageField, type ImageFieldProps } from './components/app/image-field.j
44
44
  export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
45
45
  export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
46
46
  export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
47
- export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
47
+ export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripMobile, type ModuleStripProps, } from './components/app/module-card.js';
48
48
  export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, type WidgetCardGroupProps, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
49
49
  export { WidgetLayoutSheet, type WidgetLayoutSheetProps } from './components/app/widget-layout-sheet.js';
50
50
  export { ThemeProvider } from './components/theme-provider.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.46.0",
3
+ "version": "4.47.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {