@tristan2828/ui-foundation 4.30.1 → 4.32.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.
- package/conventions/AGENTS.md +2 -1
- package/conventions/docs/add-an-entity.md +5 -2
- package/conventions/docs/design-language.md +84 -7
- package/dist/components/app/module-card.d.ts +53 -0
- package/dist/components/app/module-card.js +95 -0
- package/dist/components/app/source-credit.d.ts +7 -0
- package/dist/components/app/source-credit.js +3 -3
- package/dist/components/app/widget-card.js +13 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/styles/index.css +14 -0
- package/styles/theme.css +5 -0
package/conventions/AGENTS.md
CHANGED
|
@@ -84,7 +84,8 @@ Every data view handles: loading, empty, error, and success. Use
|
|
|
84
84
|
`<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does,
|
|
85
85
|
and `EntityView` (one record) adds not found: a plain 404, never a retry.
|
|
86
86
|
A small widget on a hub page is a `WidgetCard`, which holds all four for
|
|
87
|
-
its own data, its error inside the card
|
|
87
|
+
its own data, its error inside the card; a hub's place (a module, with
|
|
88
|
+
one live number) is a `ModuleCard` in a `ModuleStrip`, the same way.
|
|
88
89
|
Every screen has one Playwright test per state, forced via MSW overrides
|
|
89
90
|
(`@tristan2828/ui-foundation/testing`).
|
|
90
91
|
|
|
@@ -390,8 +390,11 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
390
390
|
- **A source credit** (`footer`), only when the record's data comes
|
|
391
391
|
from an outside service whose terms ask for one (many free APIs
|
|
392
392
|
do: a film database, a games catalogue): `<SourceCredit sources={[{
|
|
393
|
-
name, href, note }]} />`, with `note` the sentence the terms ask
|
|
394
|
-
word for word
|
|
393
|
+
name, href, note, icon }]} />`, with `note` the sentence the terms ask
|
|
394
|
+
for, word for word, and `icon` the service's mark when the app has
|
|
395
|
+
one (the same component it shows for that service elsewhere; drawn
|
|
396
|
+
before the name, which stays the link's text). Logos and their rules
|
|
397
|
+
are the app's own.
|
|
395
398
|
- **Every value renders the way its table cell does**: badges, names
|
|
396
399
|
of linked records, a done-count, from the shared lookups in
|
|
397
400
|
`<entity>-format.ts`. Never re-derive one for the view.
|
|
@@ -104,7 +104,8 @@ lower; say so in a comment.
|
|
|
104
104
|
## Logos and brand marks
|
|
105
105
|
|
|
106
106
|
An app's own logo (`AppShell`'s `logo`) and the marks of the services it
|
|
107
|
-
links to (a video site's mark on "Open on …", on a `WidgetCard` about it
|
|
107
|
+
links to (a video site's mark on "Open on …", on a `WidgetCard` about it,
|
|
108
|
+
beside its name in a `SourceCredit`)
|
|
108
109
|
are **icon components** (`IconComponent`): sized by a `size-*` class,
|
|
109
110
|
`aria-hidden` passed through to the `<svg>`, with the same props as a
|
|
110
111
|
Lucide icon, so they go wherever an icon goes. Lucide has no brand logos,
|
|
@@ -143,7 +144,8 @@ Colour them in one of two ways:
|
|
|
143
144
|
one-colour mark there.
|
|
144
145
|
|
|
145
146
|
The package's own stories show the slots with made-up marks in
|
|
146
|
-
`currentColor` (`app/AppShell` With logo, `app/WidgetCard` Brand mark
|
|
147
|
+
`currentColor` (`app/AppShell` With logo, `app/WidgetCard` Brand mark,
|
|
148
|
+
`app/EntityView` Notice and footer).
|
|
147
149
|
|
|
148
150
|
## Categorical colour
|
|
149
151
|
|
|
@@ -219,7 +221,7 @@ room that stays readable.
|
|
|
219
221
|
|
|
220
222
|
## Typography
|
|
221
223
|
|
|
222
|
-
|
|
224
|
+
Six roles cover every screen. Each is one class that sets size, line
|
|
223
225
|
height and weight together, from tokens in the package's `styles/theme.css`:
|
|
224
226
|
|
|
225
227
|
| Class | For | Size / weight |
|
|
@@ -229,12 +231,17 @@ height and weight together, from tokens in the package's `styles/theme.css`:
|
|
|
229
231
|
| `type-body` | running text, table cells, form text, the pagination summary | 14px / 400 |
|
|
230
232
|
| `type-label` | column headers, field labels, a small heading over a group | 14px / 500 |
|
|
231
233
|
| `type-caption` | counts, metadata, helper text under a field | 12px / 400 |
|
|
234
|
+
| `type-numeric` | the one live number on a card (`ModuleCard`'s count), in tabular figures | 22px / 500 |
|
|
232
235
|
|
|
233
236
|
- **Use a role instead of size and weight classes,** not as well as them.
|
|
234
237
|
`type-page-title` replaces `text-lg font-semibold`. Combining a role with
|
|
235
238
|
`text-sm`, `font-medium` or `leading-*` makes two classes set the same
|
|
236
239
|
property, and which one wins depends on the order of rules in the
|
|
237
240
|
stylesheet, not on the order in `className`.
|
|
241
|
+
- **`type-numeric` sets tabular figures** (`font-variant-numeric:
|
|
242
|
+
tabular-nums`), so counts line up from card to card and don't change
|
|
243
|
+
width as they tick. A number in running text or a table cell keeps its
|
|
244
|
+
row's role; add `tabular-nums` there if a column of them should align.
|
|
238
245
|
- **Colour stays separate:** `type-caption text-muted-foreground`. A role
|
|
239
246
|
never sets a colour, so any text token pairs with it.
|
|
240
247
|
- **Why `type-*` and not `text-*`:** `cn` (tailwind-merge) treats an
|
|
@@ -292,13 +299,18 @@ set, not a `DataTable` prop:
|
|
|
292
299
|
|
|
293
300
|
| `data-density` | Changes | Table cell padding (vertical) | Header height | For |
|
|
294
301
|
|---|---|---|---|---|
|
|
295
|
-
| `compact` | table rows
|
|
302
|
+
| `compact` | table rows, and a hub's cards (`WidgetCard`, `WidgetList` rows, `ModuleCard`) | 4px | 32px | wide tables people scan, many rows per screen; a dense hub |
|
|
296
303
|
| *(unset)* | — | 8px | 40px | the default |
|
|
297
304
|
| `comfortable` | table rows only | 12px | 48px | short tables, or rows people read rather than scan |
|
|
298
305
|
| `large` | **everything**: type, spacing, control heights, icons, the sidebar, table rows | 12px | 56px | a screen read from a distance: a TV used as the desktop monitor, with a mouse and keyboard |
|
|
299
306
|
|
|
300
307
|
- `compact` and `comfortable` change padding only. Text keeps
|
|
301
308
|
`type-body`, so compact never drops below a readable size.
|
|
309
|
+
- On a hub, `compact` takes a `WidgetCard`'s padding from 16px to 12px,
|
|
310
|
+
a `WidgetList` row from 36px to 32px (6px around its line) and a
|
|
311
|
+
`ModuleCard`'s band and body by 4px each. On a phone a compact row is
|
|
312
|
+
44px instead, its link filling that height, so it stays a finger's
|
|
313
|
+
target.
|
|
302
314
|
- `large` grows the whole UI by about a quarter: body text 14 → 18px,
|
|
303
315
|
controls 32 → 40px tall, icons 16 → 20px, the sidebar 256 → 320px.
|
|
304
316
|
Breakpoints and `max-w-*` containers stay where they are, which is
|
|
@@ -306,7 +318,8 @@ set, not a `DataTable` prop:
|
|
|
306
318
|
a wide screen can fall into the phone layout).
|
|
307
319
|
- For `large`, set it on **`<html>`**. Menus, selects, dialogs and
|
|
308
320
|
tooltips are portaled to `<body>`, so a wrapper `<div>` doesn't reach
|
|
309
|
-
them. `compact` and `comfortable` only touch tables, so a
|
|
321
|
+
them. `compact` and `comfortable` only touch tables and hub cards, so a
|
|
322
|
+
wrapper is fine.
|
|
310
323
|
- Pick **one density per screen**. Two tables at different densities side
|
|
311
324
|
by side look like a mistake.
|
|
312
325
|
- Size things the way the package does and `large` reaches them for free:
|
|
@@ -319,9 +332,13 @@ set, not a `DataTable` prop:
|
|
|
319
332
|
Tailwind's own scale (`--spacing`, `--text-*`, `--radius`), the `--type-*`
|
|
320
333
|
roles and `--control-text-sm` (the small Button and Toggle). Each
|
|
321
334
|
default is the value it always had, so a screen without `data-density`
|
|
322
|
-
doesn't change. The selectors are in `styles/theme.css`.
|
|
335
|
+
doesn't change. The selectors are in `styles/theme.css`. A composite
|
|
336
|
+
reaches `compact` with the `density-compact:` variant (`styles/index.css`:
|
|
337
|
+
any ancestor with `data-density="compact"`), the way `WidgetCard` and
|
|
338
|
+
`ModuleCard` do; an app's own card can use it too. Storybook's
|
|
323
339
|
Density toolbar shows any story at a density, and `patterns/Density`
|
|
324
|
-
shows the primitives, `DataTable
|
|
340
|
+
shows the primitives, `DataTable`, the sidebar and the hub cards at
|
|
341
|
+
each. Its checks
|
|
325
342
|
measure that every step changes what it should, and that the default
|
|
326
343
|
keeps today's sizes.
|
|
327
344
|
|
|
@@ -396,6 +413,66 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
|
|
|
396
413
|
one in each state; its checks run axe and the token and glyph checks in
|
|
397
414
|
both themes and at `large`.
|
|
398
415
|
|
|
416
|
+
## Module cards (a hub's places)
|
|
417
|
+
|
|
418
|
+
Above its widgets, a hub shows the app's places: its modules, and the
|
|
419
|
+
sites outside it that it links to. Each is a `ModuleCard` in a
|
|
420
|
+
`ModuleStrip`: a small card that names the place, shows one live number,
|
|
421
|
+
and offers one action.
|
|
422
|
+
|
|
423
|
+
```tsx
|
|
424
|
+
const { data, isLoading, error, refetch } = useProjectsSummaryQuery()
|
|
425
|
+
|
|
426
|
+
<ModuleStrip aria-label="Places">
|
|
427
|
+
<ModuleCard
|
|
428
|
+
title="Projects"
|
|
429
|
+
icon={ListTodoIcon}
|
|
430
|
+
category={1}
|
|
431
|
+
to="/projects"
|
|
432
|
+
value={data?.openCount}
|
|
433
|
+
note="open"
|
|
434
|
+
action={{ to: '/projects?view=today', label: 'Today' }}
|
|
435
|
+
isLoading={isLoading}
|
|
436
|
+
error={error}
|
|
437
|
+
onRetry={() => refetch()}
|
|
438
|
+
/>
|
|
439
|
+
<ModuleCard title="Recipes" icon={RecipesMark} category={6} href="https://recipes.example.com" note="Opens its own app" />
|
|
440
|
+
</ModuleStrip>
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
- **The band names the place and links to it.** The icon takes the
|
|
444
|
+
place's categorical slot (`category`, 1–12), and the band behind the
|
|
445
|
+
icon and the name is a 10% wash of the same slot over the card, with
|
|
446
|
+
`foreground` text on it (axe measures every slot's wash in both themes).
|
|
447
|
+
Give each place the slot it has elsewhere in the app (its sidebar entry,
|
|
448
|
+
its badges), so a place is one colour everywhere. Without a `category`
|
|
449
|
+
the icon is muted and the band plain.
|
|
450
|
+
- **One number, with a short note.** `value` is the one count that says
|
|
451
|
+
what's live there ("5" open, "2" due today), in `type-numeric`; `note`
|
|
452
|
+
is a few words under it. A card with no `value` shows no number, and
|
|
453
|
+
that is not an empty state: a place outside the app usually has none,
|
|
454
|
+
and its note says what it is ("Opens its own app").
|
|
455
|
+
- **One action** at the foot: the thing most often done next ("Today",
|
|
456
|
+
"Start", "Add one"). It's described by the place's name, so several
|
|
457
|
+
"Open" links on one page stay distinct.
|
|
458
|
+
- **A place outside the app** takes `href` instead of `to` (an action
|
|
459
|
+
can too): it opens in a new tab with the external-link mark. Its icon
|
|
460
|
+
can be the service's own mark ("Logos and brand marks" above).
|
|
461
|
+
- **States are the number's.** While loading, a skeleton the number's
|
|
462
|
+
size (the note stays, it doesn't load); on an error, "Couldn't load"
|
|
463
|
+
and Retry inside the card, so one failing place never hides the
|
|
464
|
+
others. The band and the action work in every state.
|
|
465
|
+
- **The strip** is a navigation landmark (`aria-label` names it) of
|
|
466
|
+
equal columns, 170px or wider, that wrap: six to eight across a
|
|
467
|
+
desktop. On a phone it's three columns of smaller tiles with no action,
|
|
468
|
+
each tile one link to its place (at least 44px tall); Retry stays its
|
|
469
|
+
own button above it. Six places make the phone's 3 × 2 grid; past
|
|
470
|
+
that, put the rest behind one tile ("More") rather than a third row.
|
|
471
|
+
- Storybook's `app/ModuleCard` shows a hub with a strip and a widget,
|
|
472
|
+
every state, and both at `compact`; its checks run axe and the token and
|
|
473
|
+
glyph checks in both themes and at `large`, and measure the strip at a
|
|
474
|
+
desktop and a phone width.
|
|
475
|
+
|
|
399
476
|
## Notices
|
|
400
477
|
|
|
401
478
|
A `Notice` says, across the page, a state that decides what you do with
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type To } from 'react-router';
|
|
3
|
+
import type { AppError } from '../../api/contracts.js';
|
|
4
|
+
import type { IconComponent } from './icon-component.js';
|
|
5
|
+
/** One of the twelve categorical slots (design-language.md "Categorical colour"). */
|
|
6
|
+
export type CategorySlot = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
|
7
|
+
/** Where a module card's band goes: a route in the app, or another site. */
|
|
8
|
+
export type ModuleCardPlace = {
|
|
9
|
+
to: To;
|
|
10
|
+
href?: never;
|
|
11
|
+
} | {
|
|
12
|
+
href: string;
|
|
13
|
+
to?: never;
|
|
14
|
+
};
|
|
15
|
+
/** A link on a module card: a route in the app (`to`), or another site (`href`, opens in a new tab). */
|
|
16
|
+
export type ModuleCardLink = {
|
|
17
|
+
label: string;
|
|
18
|
+
} & ModuleCardPlace;
|
|
19
|
+
export type ModuleCardProps = ModuleCardPlace & {
|
|
20
|
+
/** The place's name, on the band. */
|
|
21
|
+
title: string;
|
|
22
|
+
/**
|
|
23
|
+
* The place's icon: a Lucide icon, or a brand mark for another site.
|
|
24
|
+
* Decorative: the title names the place.
|
|
25
|
+
*/
|
|
26
|
+
icon: IconComponent;
|
|
27
|
+
/** The slot the icon and the band's wash take. Without one, the icon is muted and the band plain. */
|
|
28
|
+
category?: CategorySlot;
|
|
29
|
+
/** The live number: `5`, or a short string (`'1–10 m'`). Omitted: no number. */
|
|
30
|
+
value?: number | string;
|
|
31
|
+
/** A few words under the number: "open", "due today", "Opens its own app". */
|
|
32
|
+
note?: string;
|
|
33
|
+
/** One link at the foot of the card (hidden on a phone, where the tile is the link). */
|
|
34
|
+
action?: ModuleCardLink;
|
|
35
|
+
/** True while the number loads. */
|
|
36
|
+
isLoading?: boolean;
|
|
37
|
+
error?: AppError | null;
|
|
38
|
+
onRetry?: () => void;
|
|
39
|
+
className?: string;
|
|
40
|
+
};
|
|
41
|
+
export declare function ModuleCard({ title, icon: Icon, category, to, href, value, note, action, isLoading, error, onRetry, className, }: ModuleCardProps): import("react").JSX.Element;
|
|
42
|
+
export interface ModuleStripProps {
|
|
43
|
+
/** Names the strip's navigation landmark: "Places". */
|
|
44
|
+
'aria-label': string;
|
|
45
|
+
/** ModuleCards, one per place. */
|
|
46
|
+
children: ReactNode;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A hub's places, a ModuleCard each: equal columns of 170px or more that
|
|
51
|
+
* wrap, and three columns of tiles on a phone.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ModuleStrip({ 'aria-label': label, children, className }: ModuleStripProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A place on a hub page (issue #224): one of the app's modules, or a site
|
|
3
|
+
// outside it, as a small card in a strip across the top of the page.
|
|
4
|
+
//
|
|
5
|
+
// - The band: the place's icon, tinted with its categorical slot, and its
|
|
6
|
+
// name, on a 10% wash of the same slot over the card. The band is the
|
|
7
|
+
// link to the place (`to`, or `href` for another site, which opens in a
|
|
8
|
+
// new tab with the external-link mark).
|
|
9
|
+
// - The body: one live number (`value`, in type-numeric's tabular figures)
|
|
10
|
+
// and a short `note` under it ("open", "due today"). No `value` means no
|
|
11
|
+
// number, not an empty state: a place outside the app has none.
|
|
12
|
+
// - The foot: one `action` link ("Today", "Start").
|
|
13
|
+
//
|
|
14
|
+
// States work like WidgetCard's, for the number only: a skeleton the
|
|
15
|
+
// number's size while loading, and on an error a short line and Retry
|
|
16
|
+
// inside the card, so one failing place never hides the others. The band
|
|
17
|
+
// and the action stay usable in every state.
|
|
18
|
+
//
|
|
19
|
+
// ModuleStrip lays the cards out: equal columns of 170px or more that
|
|
20
|
+
// wrap (six to eight across a desktop), and on a phone three columns of
|
|
21
|
+
// smaller tiles with no action, each tile a whole link (the band link's
|
|
22
|
+
// target stretched over the card) at least 44px tall. Retry sits above
|
|
23
|
+
// that target. data-density="compact" tightens the band and the body.
|
|
24
|
+
import { Children, useId } from 'react';
|
|
25
|
+
import { Link } from 'react-router';
|
|
26
|
+
import { AlertCircleIcon, ExternalLinkIcon } from 'lucide-react';
|
|
27
|
+
import { cn } from 'cn';
|
|
28
|
+
import { Button } from '../ui/button.js';
|
|
29
|
+
import { Card } from '../ui/card.js';
|
|
30
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
31
|
+
// Whole class names per slot, so Tailwind generates them.
|
|
32
|
+
const ICON_CLASS = {
|
|
33
|
+
1: 'text-category-1',
|
|
34
|
+
2: 'text-category-2',
|
|
35
|
+
3: 'text-category-3',
|
|
36
|
+
4: 'text-category-4',
|
|
37
|
+
5: 'text-category-5',
|
|
38
|
+
6: 'text-category-6',
|
|
39
|
+
7: 'text-category-7',
|
|
40
|
+
8: 'text-category-8',
|
|
41
|
+
9: 'text-category-9',
|
|
42
|
+
10: 'text-category-10',
|
|
43
|
+
11: 'text-category-11',
|
|
44
|
+
12: 'text-category-12',
|
|
45
|
+
};
|
|
46
|
+
// The band's wash: the slot at 10% over the card, so foreground text on
|
|
47
|
+
// it keeps its contrast (axe measures it in both themes).
|
|
48
|
+
const BAND_CLASS = {
|
|
49
|
+
1: 'bg-category-1/10',
|
|
50
|
+
2: 'bg-category-2/10',
|
|
51
|
+
3: 'bg-category-3/10',
|
|
52
|
+
4: 'bg-category-4/10',
|
|
53
|
+
5: 'bg-category-5/10',
|
|
54
|
+
6: 'bg-category-6/10',
|
|
55
|
+
7: 'bg-category-7/10',
|
|
56
|
+
8: 'bg-category-8/10',
|
|
57
|
+
9: 'bg-category-9/10',
|
|
58
|
+
10: 'bg-category-10/10',
|
|
59
|
+
11: 'bg-category-11/10',
|
|
60
|
+
12: 'bg-category-12/10',
|
|
61
|
+
};
|
|
62
|
+
// The band link. On a phone its ::after covers the whole card, so the tile
|
|
63
|
+
// is one target; the focus ring moves to that ::after with it.
|
|
64
|
+
const BAND_LINK_CLASS = 'group/band flex min-h-9 items-center gap-2 px-3 py-1.5 type-label text-foreground outline-none ' +
|
|
65
|
+
'focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-ring/50 ' +
|
|
66
|
+
'density-compact:min-h-8 density-compact:py-1 ' +
|
|
67
|
+
'max-sm:gap-1.5 max-sm:px-2 max-sm:after:absolute max-sm:after:inset-0 max-sm:after:rounded-xl ' +
|
|
68
|
+
'max-sm:focus-visible:ring-0 max-sm:focus-visible:after:ring-3 max-sm:focus-visible:after:ring-inset ' +
|
|
69
|
+
'max-sm:focus-visible:after:ring-ring/50';
|
|
70
|
+
const ACTION_CLASS = 'flex items-center gap-1 border-t border-border px-3 py-2 type-label text-foreground underline ' +
|
|
71
|
+
'underline-offset-4 outline-none hover:decoration-2 focus-visible:ring-3 focus-visible:ring-inset ' +
|
|
72
|
+
'focus-visible:ring-ring/50 density-compact:py-1.5 max-sm:hidden';
|
|
73
|
+
function NewTabMark() {
|
|
74
|
+
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
|
+
}
|
|
76
|
+
export function ModuleCard({ title, icon: Icon, category, to, href, value, note, action, isLoading = false, error, onRetry, className, }) {
|
|
77
|
+
const titleId = useId();
|
|
78
|
+
const bodyId = useId();
|
|
79
|
+
const state = error ? 'error' : isLoading ? 'loading' : 'success';
|
|
80
|
+
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
|
+
const bandProps = {
|
|
82
|
+
'aria-describedby': bodyId,
|
|
83
|
+
'data-slot': 'module-card-band',
|
|
84
|
+
className: cn(BAND_LINK_CLASS, category && BAND_CLASS[category]),
|
|
85
|
+
};
|
|
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 &&
|
|
87
|
+
(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 }) })))] }));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A hub's places, a ModuleCard each: equal columns of 170px or more that
|
|
91
|
+
* wrap, and three columns of tiles on a phone.
|
|
92
|
+
*/
|
|
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 })) }) }));
|
|
95
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IconComponent } from './icon-component.js';
|
|
1
2
|
export type Source = {
|
|
2
3
|
/** The service's name, the link's text: "TMDB". */
|
|
3
4
|
name: string;
|
|
@@ -5,6 +6,12 @@ export type Source = {
|
|
|
5
6
|
href: string;
|
|
6
7
|
/** A sentence the service's terms ask for, shown after the list as written. */
|
|
7
8
|
note?: string;
|
|
9
|
+
/**
|
|
10
|
+
* The service's mark, drawn before its name inside the link and hidden
|
|
11
|
+
* from screen readers: the name stays the link's text. The app's own
|
|
12
|
+
* component, the one it shows for this service elsewhere.
|
|
13
|
+
*/
|
|
14
|
+
icon?: IconComponent;
|
|
8
15
|
};
|
|
9
16
|
export type SourceCreditProps = {
|
|
10
17
|
sources: Source[];
|
|
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
// Credits the outside services a record's data comes from, as many free
|
|
3
3
|
// APIs require on every screen showing their data ("This product uses the
|
|
4
4
|
// TMDB API but is not endorsed or certified by TMDB"). EntityView's
|
|
5
|
-
// `footer` is its usual home.
|
|
6
|
-
// its usage rules are the app's.
|
|
5
|
+
// `footer` is its usual home. A source's mark is the app's own icon
|
|
6
|
+
// component (`icon`), drawn before its name; its usage rules are the app's.
|
|
7
7
|
import { Fragment } from 'react';
|
|
8
8
|
import { ExternalLinkIcon } from 'lucide-react';
|
|
9
9
|
function joined(index, count) {
|
|
@@ -17,5 +17,5 @@ export function SourceCredit({ sources, lead = 'Data from' }) {
|
|
|
17
17
|
if (sources.length === 0)
|
|
18
18
|
return null;
|
|
19
19
|
const notes = sources.flatMap((source) => (source.note ? [source.note] : []));
|
|
20
|
-
return (_jsxs("p", { "data-slot": "source-credit", className: "type-caption text-muted-foreground", children: [lead, ' ', sources.map((source, index) => (_jsxs(Fragment, { children: [joined(index, sources.length), _jsxs("a", { href: source.href, target: "_blank", rel: "noreferrer", className: "rounded-sm underline underline-offset-2 outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50", children: [source.name, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })] }, source.name))), ".", notes.map((note) => ` ${note}`).join('')] }));
|
|
20
|
+
return (_jsxs("p", { "data-slot": "source-credit", className: "type-caption text-muted-foreground", children: [lead, ' ', sources.map(({ icon: Icon, ...source }, index) => (_jsxs(Fragment, { children: [joined(index, sources.length), _jsxs("a", { href: source.href, target: "_blank", rel: "noreferrer", className: "rounded-sm underline underline-offset-2 outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "mr-1 inline size-3.5 align-text-bottom" }), source.name, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })] }, source.name))), ".", notes.map((note) => ` ${note}`).join('')] }));
|
|
21
21
|
}
|
|
@@ -21,6 +21,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
21
21
|
// muted note ("2 h ago") and an optional trailing element (a badge, a
|
|
22
22
|
// switch). A trailing control is labelled by the row's text: pass a
|
|
23
23
|
// function and it gets the id to put in `aria-labelledby`.
|
|
24
|
+
//
|
|
25
|
+
// Under data-density="compact" (#224) the card's padding drops to 12px and
|
|
26
|
+
// a row's to 4px, for a dense hub; text stays at type-body. On a phone a
|
|
27
|
+
// compact row is 44px tall and its link fills that height, so it stays a
|
|
28
|
+
// finger's target.
|
|
24
29
|
import { useId } from 'react';
|
|
25
30
|
import { Link } from 'react-router';
|
|
26
31
|
import { ExternalLinkIcon } from 'lucide-react';
|
|
@@ -34,11 +39,14 @@ export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoadin
|
|
|
34
39
|
const headingId = useId();
|
|
35
40
|
const Heading = `h${headingLevel}`;
|
|
36
41
|
const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
|
|
37
|
-
return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))] }), _jsx(CardContent, { "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
|
|
42
|
+
return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))] }), _jsx(CardContent, { "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
|
|
38
43
|
}
|
|
39
44
|
// One row's height, shared by the skeleton and the list so the card doesn't
|
|
40
|
-
// jump: py-2 around one line of type-body (or a small control).
|
|
41
|
-
|
|
45
|
+
// jump: py-2 around one line of type-body (or a small control). Compact
|
|
46
|
+
// is 32px (6px around the line); on a phone it's 44px, the link's own
|
|
47
|
+
// padding making up the height (ROW_LINK_CLASS).
|
|
48
|
+
const ROW_CLASS = 'flex min-h-9 items-center gap-3 py-2 density-compact:min-h-8 density-compact:py-1 ' +
|
|
49
|
+
'max-sm:density-compact:min-h-11 max-sm:density-compact:py-0';
|
|
42
50
|
// Varied widths, so the skeleton reads as a list of titles, not a grid.
|
|
43
51
|
const SKELETON_WIDTHS = ['w-3/5', 'w-2/5', 'w-1/2', 'w-2/3', 'w-1/3', 'w-3/5'];
|
|
44
52
|
function WidgetListSkeleton({ rows }) {
|
|
@@ -49,7 +57,8 @@ export function WidgetList({ children, className }) {
|
|
|
49
57
|
return (_jsx("ul", { "data-slot": "widget-list", className: cn('flex flex-col divide-y divide-border', className), children: children }));
|
|
50
58
|
}
|
|
51
59
|
const ROW_LINK_CLASS = 'min-w-0 truncate rounded-sm font-medium text-foreground underline-offset-4 outline-none ' +
|
|
52
|
-
'hover:underline focus-visible:underline focus-visible:ring-3 focus-visible:ring-ring/50'
|
|
60
|
+
'hover:underline focus-visible:underline focus-visible:ring-3 focus-visible:ring-ring/50 ' +
|
|
61
|
+
'max-sm:density-compact:py-3';
|
|
53
62
|
export function WidgetListItem({ children, to, href, note, trailing, className }) {
|
|
54
63
|
const textId = useId();
|
|
55
64
|
const text = to !== undefined ? (_jsx(Link, { id: textId, to: to, title: children, className: ROW_LINK_CLASS, children: children })) : href !== undefined ? (_jsxs("a", { id: textId, href: href, target: "_blank", rel: "noreferrer", title: children, className: cn(ROW_LINK_CLASS, 'flex items-center gap-1'), children: [_jsx("span", { className: "truncate", children: children }), _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (_jsx("span", { id: textId, title: children, className: "min-w-0 truncate text-foreground", children: children }));
|
package/dist/index.d.ts
CHANGED
|
@@ -36,6 +36,7 @@ export { ImageField, type ImageFieldProps } from './components/app/image-field.j
|
|
|
36
36
|
export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
|
|
37
37
|
export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
|
|
38
38
|
export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
|
|
39
|
+
export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
|
|
39
40
|
export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
40
41
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
41
42
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,7 @@ export { ImageField } from './components/app/image-field.js';
|
|
|
40
40
|
export { DEFAULT_IMAGE_TYPES } from './lib/image-file.js';
|
|
41
41
|
export { formatPlainDate } from './lib/plain-date.js';
|
|
42
42
|
export { ChoiceCell, LinkCell, NameCell, } from './components/app/row-edit-cells.js';
|
|
43
|
+
export { ModuleCard, ModuleStrip, } from './components/app/module-card.js';
|
|
43
44
|
export { WidgetCard, WidgetList, WidgetListItem, } from './components/app/widget-card.js';
|
|
44
45
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
45
46
|
// Screens outside the app shell
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.32.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": {
|
package/styles/index.css
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
|
|
17
17
|
@custom-variant dark (&:is(.dark *));
|
|
18
18
|
|
|
19
|
+
/* Compact density beyond tables (design-language.md "Density"): a
|
|
20
|
+
composite's own rows and padding tighten under data-density="compact"
|
|
21
|
+
on any ancestor, the way the table's tokens do. `density-compact:py-1`.
|
|
22
|
+
Text never shrinks under it. */
|
|
23
|
+
@custom-variant density-compact (&:where([data-density="compact"], [data-density="compact"] *));
|
|
24
|
+
|
|
19
25
|
@theme inline {
|
|
20
26
|
--font-heading: var(--font-sans);
|
|
21
27
|
--font-sans: 'Geist Variable', sans-serif;
|
|
@@ -113,6 +119,14 @@
|
|
|
113
119
|
line-height: var(--type-caption-leading);
|
|
114
120
|
font-weight: var(--type-caption-weight);
|
|
115
121
|
}
|
|
122
|
+
/* Counts on a hub's cards: tabular figures, so numbers line up from card
|
|
123
|
+
to card and don't shift width as they change. */
|
|
124
|
+
@utility type-numeric {
|
|
125
|
+
font-size: var(--type-numeric-size);
|
|
126
|
+
line-height: var(--type-numeric-leading);
|
|
127
|
+
font-weight: var(--type-numeric-weight);
|
|
128
|
+
font-variant-numeric: tabular-nums;
|
|
129
|
+
}
|
|
116
130
|
@utility type-heading-1 {
|
|
117
131
|
font-size: var(--type-heading-1-size);
|
|
118
132
|
line-height: var(--type-heading-1-leading);
|
package/styles/theme.css
CHANGED
|
@@ -138,6 +138,9 @@
|
|
|
138
138
|
--type-caption-size: 0.75rem; /* counts, metadata, helper text */
|
|
139
139
|
--type-caption-leading: 1rem;
|
|
140
140
|
--type-caption-weight: 400;
|
|
141
|
+
--type-numeric-size: 1.375rem; /* a count on a hub's ModuleCard, in tabular figures */
|
|
142
|
+
--type-numeric-leading: 1.75rem;
|
|
143
|
+
--type-numeric-weight: 500;
|
|
141
144
|
/*
|
|
142
145
|
* Headings inside written text (a record's notes: <Markdown> and the
|
|
143
146
|
* rich-text editor), not the screen's own outline. Three clear steps
|
|
@@ -309,6 +312,8 @@
|
|
|
309
312
|
--type-label-leading: 1.625rem;
|
|
310
313
|
--type-caption-size: 0.9375rem;
|
|
311
314
|
--type-caption-leading: 1.25rem;
|
|
315
|
+
--type-numeric-size: 1.75rem;
|
|
316
|
+
--type-numeric-leading: 2.25rem;
|
|
312
317
|
--type-heading-1-size: 2.1875rem;
|
|
313
318
|
--type-heading-1-leading: 2.8125rem;
|
|
314
319
|
--type-heading-2-size: 1.5625rem;
|