zz-meridian 0.2.0 → 0.3.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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/payload/.env.example +2 -0
  3. package/payload/CHANGELOG.md +33 -0
  4. package/payload/app/(dashboard)/README.md +1 -1
  5. package/payload/app/(dashboard)/error.tsx +10 -2
  6. package/payload/app/not-found/README.md +4 -4
  7. package/payload/docs/assistant.md +1 -1
  8. package/payload/docs/distribution.md +8 -6
  9. package/payload/package.json +1 -1
  10. package/payload/scripts/assistant.ts +17 -3
  11. package/payload/scripts/check.ts +10 -1
  12. package/payload/scripts/fake-llm.ts +15 -0
  13. package/payload/scripts/keyboard.ts +10 -0
  14. package/payload/scripts/verify.config.ts +13 -0
  15. package/payload/scripts/verify.ts +65 -0
  16. package/payload/skills/zz-meridian/references/customize.md +8 -0
  17. package/payload/skills/zz-meridian/references/existing-project.md +11 -0
  18. package/payload/skills/zz-meridian/references/validation.md +4 -0
  19. package/payload/src/components/base/shell/README.md +1 -0
  20. package/payload/src/components/base/shell/index.tsx +4 -1
  21. package/payload/src/components/charts/timeline/README.md +3 -1
  22. package/payload/src/components/charts/timeline/index.tsx +9 -1
  23. package/payload/src/components/patterns/assistant/README.md +2 -2
  24. package/payload/src/components/patterns/assistant/index.tsx +23 -3
  25. package/payload/src/components/patterns/assistant/preview.tsx +2 -1
  26. package/payload/src/components/patterns/assistant/text.tsx +25 -0
  27. package/payload/src/components/patterns/command-palette/README.md +1 -1
  28. package/payload/src/components/patterns/filter-bar/README.md +3 -3
  29. package/payload/src/components/patterns/filter-bar/index.tsx +11 -7
  30. package/payload/src/components/patterns/form-section/README.md +5 -1
  31. package/payload/src/components/patterns/form-section/index.tsx +71 -36
  32. package/payload/src/components/patterns/form-section/preview.tsx +17 -0
  33. package/payload/src/components/patterns/period-select/index.tsx +4 -3
  34. package/payload/src/components/patterns/rail/README.md +2 -0
  35. package/payload/src/components/ui/card/README.md +2 -2
  36. package/payload/src/components/ui/card/index.tsx +28 -2
  37. package/payload/src/components/ui/card/preview.tsx +25 -0
  38. package/payload/src/components/ui/segmented/README.md +1 -1
  39. package/payload/src/components/ui/segmented/index.tsx +4 -1
  40. package/payload/src/lib/collection.ts +29 -5
  41. package/payload/src/lib/period.ts +11 -0
  42. package/payload/src/views/not-found-address.tsx +4 -4
  43. package/payload/src/views/not-found.tsx +10 -2
  44. package/payload/tests/assistant-text.test.tsx +40 -0
  45. package/payload/tests/collection.test.ts +19 -0
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+
3
+ import { Prose } from '@/components/patterns/prose';
4
+
5
+ /**
6
+ * One text part of an assistant reply: the model's markdown, read by the same safe reader as the rest of the product.
7
+ *
8
+ * Every model this template has been pointed at answers in markdown, so a plain paragraph shows `**bold**`, `- `
9
+ * bullets and `|---|` table rules literally. `Prose` renders it, and it is the reader that is already safe for content
10
+ * nobody vetted: raw HTML stays text and every URL passes `safeMarkdownUrl`, so an answer cannot smuggle markup or a
11
+ * `javascript:` link into the panel.
12
+ *
13
+ * The wrapper carries `data-assistant-text` because a reply can now be a list, a table or a code block: a check that
14
+ * reads the reply has one stable handle instead of guessing which element the text ended up in.
15
+ *
16
+ * The first and last child margins are trimmed: a markdown paragraph carries `my-3`, which would push the reply away
17
+ * from its "Assistant" caption and the next message in the thread's 16px rhythm.
18
+ */
19
+ export function AssistantText({ text }: { text: string }) {
20
+ return (
21
+ <div data-assistant-text className="min-w-0">
22
+ <Prose size="sm" className="max-w-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{text}</Prose>
23
+ </div>
24
+ );
25
+ }
@@ -16,7 +16,7 @@ A Dialog (`surface-raised`, `radius-xl` 24px, `shadow-overlay`, 600px wide, 14%
16
16
 
17
17
  ## Data
18
18
 
19
- Commands are built from the `nav` prop, the same groups the Rail gets (one "Go to" per destination, its group as the hint) and the appearance actions. A match ranks by where the query starts in the label, then by the original order; the group hint also matches ("operate" finds Health, Customers, API keys).
19
+ Commands are built from the `nav` prop, the same groups the Rail gets (one "Go to" per destination, its group as the hint) and the appearance actions. A match ranks by where the query starts in the label, then by the original order; the group hint also matches ("operate" finds Health, Customers, API keys). Like the Rail's, `nav` carries each entry's icon as a component, so it cannot be built in a server component — see the Rail's README.
20
20
 
21
21
  ## States
22
22
 
@@ -6,10 +6,10 @@ Status: beta
6
6
 
7
7
  ## Anatomy
8
8
 
9
- 1. **Search**: a small Search input, up to 288px wide from 768px, full width below; `/` focuses it from anywhere.
9
+ 1. **Search**: a small Search input, up to 288px wide once the bar is 832px, full width below; `/` focuses it from anywhere.
10
10
  2. **Filters**: one small Select per dimension, its name inside the trigger in `ink-3` ("Status All"). A filter that is on takes `accent-tint` with an `accent-line` outline, so the reader sees at once what narrows the list.
11
11
  3. **Clear**: a ghost button, shown only while something is filtered.
12
- 4. **Result**: a quiet count of what passes ("5 of 240"), `text-xs` `ink-3`, from 1024px.
12
+ 4. **Result**: a quiet count of what passes ("5 of 240"), `text-xs` `ink-3`, once the bar is 960px.
13
13
  5. **View controls** (optional): a Segmented control or a menu on the right.
14
14
  6. **Filters button** (phones): with a count of active filters on an `accent` disc; it opens a Sheet holding each filter as a Field, the view controls, Clear filters and Show results.
15
15
  7. **Provenance line** (when `setBy`): the Agent mark (small), "Set by Claude · these filters came from the assistant", and Clear.
@@ -46,7 +46,7 @@ Every control is `control-sm` (32px; 28px compact), 8px apart. The bar sits insi
46
46
  ## Surfaces
47
47
 
48
48
  - **Console**: one row.
49
- - **Mobile**: under 768px, search plus the Filters button; the Sheet rises from the bottom with Show results as its primary action.
49
+ - **Mobile**: once the bar is narrower than 832px, search plus the Filters button; the Sheet rises from the bottom with Show results as its primary action. The bar reads its OWN width (a container query), not the window's, because the rail, a split row, the assistant's column and an embed all narrow the bar without narrowing the window: 832px keeps the search and the filters on one row, 960px also shows the result count.
50
50
  - **Embed**: inline views show no bar (the tool's arguments are the filters, named in the title); fullscreen shows the full bar with the provenance line.
51
51
 
52
52
  ## Agents
@@ -24,6 +24,10 @@ export type Filter = {
24
24
  * Search, filters and view controls for one list, in one row. On phones the search takes the row and the filters move
25
25
  * behind one Filters button that opens a sheet. When an agent set the filters, the bar says so ("Set by Claude")
26
26
  * until a person changes them, and Clear returns every filter to its off state.
27
+ *
28
+ * The bar reads its OWN width, not the window's: it is a container, and the row collapses on `@max-[52rem]` rather
29
+ * than on a viewport breakpoint. A bar in a split row, beside the assistant's column or inside an embed is narrow
30
+ * while the window is wide, and a viewport breakpoint would keep every filter in a row that has no room for them.
27
31
  */
28
32
  export function FilterBar({
29
33
  search,
@@ -69,7 +73,7 @@ export function FilterBar({
69
73
  });
70
74
 
71
75
  return (
72
- <div className={cn('flex min-w-0 flex-col gap-3', className)}>
76
+ <div className={cn('@container flex min-w-0 flex-col gap-3', className)}>
73
77
  <div className="flex min-w-0 items-center gap-2">
74
78
  {search ? (
75
79
  <SearchInput
@@ -78,14 +82,14 @@ export function FilterBar({
78
82
  onValueChange={search.onChange}
79
83
  placeholder={search.placeholder ?? 'Search'}
80
84
  shortcut="/"
81
- className="min-w-0 flex-1 md:max-w-72"
85
+ className="min-w-0 flex-1 @min-[52rem]:max-w-72"
82
86
  />
83
87
  ) : null}
84
- <div className="flex min-w-0 items-center gap-2 max-md:hidden">{selects('sm')}</div>
88
+ <div className="flex min-w-0 items-center gap-2 @max-[52rem]:hidden">{selects('sm')}</div>
85
89
  {filters.length ? (
86
90
  <Sheet open={open} onOpenChange={setOpen}>
87
91
  <SheetTrigger asChild>
88
- <Button size="sm" icon={<SlidersHorizontal />} className="md:hidden">
92
+ <Button size="sm" icon={<SlidersHorizontal />} className="@min-[52rem]:hidden">
89
93
  Filters{active ? <span className="t-num -mr-0.5 grid h-4.5 min-w-4.5 place-items-center rounded-full bg-accent px-1 text-2xs font-semibold text-on-accent">{active}</span> : null}
90
94
  </Button>
91
95
  </SheetTrigger>
@@ -107,11 +111,11 @@ export function FilterBar({
107
111
  </Sheet>
108
112
  ) : null}
109
113
  {onClear && active ? (
110
- <Button size="sm" variant="ghost" icon={<X />} onClick={onClear} className="max-md:hidden">Clear</Button>
114
+ <Button size="sm" variant="ghost" icon={<X />} onClick={onClear} className="@max-[52rem]:hidden">Clear</Button>
111
115
  ) : null}
112
116
  <div className="ml-auto flex shrink-0 items-center gap-3">
113
- {result ? <span className="t-num text-xs whitespace-nowrap text-ink-3 max-lg:hidden">{result}</span> : null}
114
- {view ? <div className="max-md:hidden">{view}</div> : null}
117
+ {result ? <span className="t-num text-xs whitespace-nowrap text-ink-3 @max-[60rem]:hidden">{result}</span> : null}
118
+ {view ? <div className="@max-[52rem]:hidden">{view}</div> : null}
115
119
  </div>
116
120
  </div>
117
121
  {setBy && active ? (
@@ -21,6 +21,8 @@ Status: beta
21
21
  | Saves on its own | `onSave`, `onDiscard`, `dirty` | Text fields, selects: anything that should not apply on each keystroke |
22
22
  | Applies at once | no `onSave` | Switches and segmented controls; add a footnote that says so |
23
23
  | Danger zone | `tone="critical"` | Irreversible actions; the title and border take the critical tone |
24
+ | Layout only | `as="div"` | The same head and card with no `<form>` around them: a card holding a table that runs edge to edge (tokens, people, members), or a form of its own, since forms cannot nest. Nothing submits, so there is no save bar — its children save themselves |
25
+ | Layout only, flush | `as="div" flush` | The table runs to the card's edges; the card clips it and drops its header row's top border, so the card's edge is the table's edge |
24
26
 
25
27
  ## Sizes
26
28
 
@@ -50,7 +52,9 @@ Status: beta
50
52
 
51
53
  ## Composition
52
54
 
53
- A form element holding a fieldset; Field, Input, Select, Switch and Segmented from Layer 2; Banner for errors; Button for the save bar; Toast for confirmation. Settings pages stack sections 56px apart at the data width, on the same left edge as every other page.
55
+ A form element holding a fieldset, or — with `as="div"` — a plain element holding a div; Field, Input, Select, Switch and Segmented from Layer 2; Table when the section holds one; Banner for errors; Button for the save bar; Toast for confirmation. Settings pages stack sections 56px apart at the data width, on the same left edge as every other page.
56
+
57
+ `SettingRow` is a section's own child and needs nothing from `FormSection`: with `as="div"` and no `onSave`, a switch that applies the moment it is flipped sits in the same layout, saving itself.
54
58
 
55
59
  ## Data
56
60
 
@@ -6,11 +6,18 @@ import { cn } from '@/lib/cn';
6
6
  import { Banner } from '@/components/ui/banner';
7
7
  import { Button } from '@/components/ui/button';
8
8
 
9
+ /** The title column and the card column, shared by the form and the layout-only variant. */
10
+ const GRID = 'grid gap-x-10 gap-y-5 @3xl:grid-cols-[15rem_minmax(0,64rem)]';
11
+
9
12
  /**
10
13
  * A titled group of settings that saves on its own. Its title and one sentence sit on the left, the fields in a card on
11
14
  * the right (stacked on narrow widths). With `onSave`, editing shows a save bar that stays in view until the change is
12
15
  * saved or discarded; without it, every control applies at once and the section says so. `tone="critical"` is the
13
16
  * danger zone.
17
+ *
18
+ * `as="div"` is the same layout with no `<form>` around it, for a section that cannot have one: a card holding a table
19
+ * that runs edge to edge (access tokens, people, members), or a form of its own, since forms cannot nest. It has no
20
+ * save bar — nothing submits — so its children save themselves. `flush` drops the body's padding for that table.
14
21
  */
15
22
  export function FormSection({
16
23
  title,
@@ -24,6 +31,8 @@ export function FormSection({
24
31
  onDiscard,
25
32
  saveLabel = 'Save changes',
26
33
  tone = 'default',
34
+ as = 'form',
35
+ flush = false,
27
36
  footnote,
28
37
  className,
29
38
  }: {
@@ -42,6 +51,10 @@ export function FormSection({
42
51
  onDiscard?: () => void;
43
52
  saveLabel?: string;
44
53
  tone?: 'default' | 'critical';
54
+ /** `div` renders no `<form>`, for a card that holds a table or a form of its own. See the doc comment. */
55
+ as?: 'form' | 'div';
56
+ /** No padding around the body: for a table that runs edge to edge. */
57
+ flush?: boolean;
45
58
  /** A quiet line under the fields: "Changes apply at once". */
46
59
  footnote?: ReactNode;
47
60
  className?: string;
@@ -50,45 +63,67 @@ export function FormSection({
50
63
  e.preventDefault();
51
64
  if (dirty && !saving) void onSave?.();
52
65
  };
53
- return (
54
- <section className={cn('@container', className)}>
55
- <form onSubmit={submit} className="grid gap-x-10 gap-y-5 @3xl:grid-cols-[15rem_minmax(0,64rem)]">
56
- <header className="min-w-0 @3xl:pt-1">
57
- <h2 className={cn('t-card', tone === 'critical' && 'text-critical-ink')}>{title}</h2>
58
- {description ? <p className="t-small mt-2 text-pretty text-ink-2">{description}</p> : null}
59
- </header>
60
- <div className="min-w-0">
61
- <div className={cn('relative rounded-lg border bg-surface shadow-card', tone === 'critical' ? 'border-critical/30' : 'border-line')}>
62
- <fieldset disabled={Boolean(readOnly) || saving} className="m-0 flex min-w-0 flex-col gap-5 border-0 p-(--card-pad)">
63
- {error ? <Banner tone="critical" title="Not saved">{error}</Banner> : null}
64
- {readOnly ? (
65
- <p className="flex items-center gap-2 rounded-md bg-surface-sunk px-3 py-2 text-xs text-ink-2"><Lock className="size-3.5 shrink-0 text-ink-3" />{readOnly}</p>
66
- ) : null}
67
- {children}
68
- </fieldset>
69
- {onSave ? (
70
- <div
71
- aria-hidden={!dirty}
72
- className={cn(
73
- 'sticky bottom-4 z-10 grid transition-[grid-template-rows,opacity] duration-(--dur-enter) ease-out',
74
- dirty ? 'grid-rows-[1fr] opacity-100' : 'pointer-events-none grid-rows-[0fr] opacity-0',
75
- )}
76
- >
77
- <div className="overflow-hidden">
78
- <div className="flex flex-wrap items-center gap-3 rounded-b-lg border-t border-line bg-surface-raised/90 px-(--card-pad) py-3 backdrop-blur-md">
79
- <p className="flex items-center gap-2 text-sm text-ink-2"><span aria-hidden className="size-1.5 rounded-full bg-accent" />Unsaved changes</p>
80
- <div className="ml-auto flex items-center gap-2">
81
- <Button variant="ghost" size="sm" onClick={onDiscard} disabled={saving} tabIndex={dirty ? 0 : -1}>Discard</Button>
82
- <Button type="submit" variant="primary" size="sm" busy={saving} tabIndex={dirty ? 0 : -1}>{saveLabel}</Button>
83
- </div>
84
- </div>
66
+ const body = cn(
67
+ 'm-0 flex min-w-0 flex-col border-0',
68
+ // The same rule Card and CardBody apply to a table as the first child, in the shape this body is built from: the
69
+ // Table component wraps its table in a div, so the table is one level down, and `data-flush` is how the card above
70
+ // knows to clip its own rounded corner.
71
+ flush
72
+ ? 'has-[>div:first-child>table]:overflow-hidden [&>div:first-child>table>thead>tr>th]:border-t-0'
73
+ : 'gap-5 p-(--card-pad)',
74
+ );
75
+ const inner = (
76
+ <>
77
+ {error ? <Banner tone="critical" title="Not saved">{error}</Banner> : null}
78
+ {readOnly ? (
79
+ <p className="flex items-center gap-2 rounded-md bg-surface-sunk px-3 py-2 text-xs text-ink-2"><Lock className="size-3.5 shrink-0 text-ink-3" />{readOnly}</p>
80
+ ) : null}
81
+ {children}
82
+ </>
83
+ );
84
+ // The two columns, in the shape both variants share. Built once and placed inside whichever element wraps it, rather
85
+ // than a wrapper component chosen per render: a component defined here would be a new type on every render, and React
86
+ // would remount the fields inside it, dropping focus on each keystroke.
87
+ const cols = (
88
+ <>
89
+ <header className="min-w-0 @3xl:pt-1">
90
+ <h2 className={cn('t-card', tone === 'critical' && 'text-critical-ink')}>{title}</h2>
91
+ {description ? <p className="t-small mt-2 text-pretty text-ink-2">{description}</p> : null}
92
+ </header>
93
+ <div className="min-w-0">
94
+ <div className={cn('relative rounded-lg border bg-surface shadow-card', tone === 'critical' ? 'border-critical/30' : 'border-line', flush && 'has-[>[data-flush]:first-child]:overflow-hidden')}>
95
+ {as === 'form' ? (
96
+ <fieldset data-flush={flush ? '' : undefined} disabled={Boolean(readOnly) || saving} className={body}>{inner}</fieldset>
97
+ ) : (
98
+ <div data-flush={flush ? '' : undefined} className={body}>{inner}</div>
99
+ )}
100
+ {onSave && as === 'form' ? (
101
+ <div
102
+ aria-hidden={!dirty}
103
+ className={cn(
104
+ 'sticky bottom-4 z-10 grid transition-[grid-template-rows,opacity] duration-(--dur-enter) ease-out',
105
+ dirty ? 'grid-rows-[1fr] opacity-100' : 'pointer-events-none grid-rows-[0fr] opacity-0',
106
+ )}
107
+ >
108
+ <div className="overflow-hidden">
109
+ <div className="flex flex-wrap items-center gap-3 rounded-b-lg border-t border-line bg-surface-raised/90 px-(--card-pad) py-3 backdrop-blur-md">
110
+ <p className="flex items-center gap-2 text-sm text-ink-2"><span aria-hidden className="size-1.5 rounded-full bg-accent" />Unsaved changes</p>
111
+ <div className="ml-auto flex items-center gap-2">
112
+ <Button variant="ghost" size="sm" onClick={onDiscard} disabled={saving} tabIndex={dirty ? 0 : -1}>Discard</Button>
113
+ <Button type="submit" variant="primary" size="sm" busy={saving} tabIndex={dirty ? 0 : -1}>{saveLabel}</Button>
85
114
  </div>
86
115
  </div>
87
- ) : null}
116
+ </div>
88
117
  </div>
89
- {footnote ? <p className="t-caption mt-2.5">{footnote}</p> : null}
90
- </div>
91
- </form>
118
+ ) : null}
119
+ </div>
120
+ {footnote ? <p className="t-caption mt-2.5">{footnote}</p> : null}
121
+ </div>
122
+ </>
123
+ );
124
+ return (
125
+ <section className={cn('@container', className)}>
126
+ {as === 'form' ? <form onSubmit={submit} className={GRID}>{cols}</form> : <div className={GRID}>{cols}</div>}
92
127
  </section>
93
128
  );
94
129
  }
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
6
6
  import { Field } from '@/components/ui/field';
7
7
  import { Input } from '@/components/ui/input';
8
8
  import { Switch } from '@/components/ui/switch';
9
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
9
10
  import { FormSection, SettingRow } from '.';
10
11
  import { app, domain } from '@/app.config';
11
12
 
@@ -28,6 +29,22 @@ export default function FormSectionPreview() {
28
29
  <Field label="Email">{(p) => <Input {...p} defaultValue={`finance@${domain}`} />}</Field>
29
30
  </FormSection>
30
31
  </Specimen>
32
+ <Specimen label="Layout only" note="No form around the card, so it can hold a table that runs edge to edge — or a form of its own, since forms cannot nest. Nothing submits, so a setting that applies at once simply sits in the same layout." stack>
33
+ <FormSection as="div" flush title="Access tokens" description="Tokens this workspace has issued. A token is shown once, when it is created.">
34
+ <Table>
35
+ <TableHead>
36
+ <TableRow>
37
+ <TableHeader>Name</TableHeader>
38
+ <TableHeader>Last used</TableHeader>
39
+ </TableRow>
40
+ </TableHead>
41
+ <TableBody>
42
+ <TableRow><TableCell>ci-deploy</TableCell><TableCell muted>2 days ago</TableCell></TableRow>
43
+ <TableRow><TableCell>grafana-read</TableCell><TableCell muted>Never</TableCell></TableRow>
44
+ </TableBody>
45
+ </Table>
46
+ </FormSection>
47
+ </Specimen>
31
48
  <Specimen label="Applies at once" note="No onSave: switches take effect immediately and the footnote says so." stack>
32
49
  <FormSection title="Notifications" description={`What ${app.name} emails you about.`} footnote="Changes apply at once.">
33
50
  <Switch label="Incident alerts" description="When a service is degraded or down." defaultChecked />
@@ -1,14 +1,15 @@
1
1
  'use client';
2
2
 
3
3
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4
- import { PERIODS, PERIOD_LABEL, type Period } from '@/lib/period';
4
+ import { PERIODS, PERIOD_LABEL, PERIOD_SHORT, type Period } from '@/lib/period';
5
5
  import { Segmented } from '@/components/ui/segmented';
6
6
 
7
7
  /** One period the control offers: its value, the short label it shows ("30D") and the full name in its title. */
8
8
  export type PeriodOption<P extends string = Period> = { value: P; short: string; label: string };
9
9
 
10
- const SHORT: Record<Period, string> = { '7d': '7D', '30d': '30D', '90d': '90D', all: 'All' };
11
- const DEFAULT_OPTIONS: PeriodOption[] = PERIODS.map((p) => ({ value: p, short: SHORT[p], label: PERIOD_LABEL[p] }));
10
+ // Both labels come from `@/lib/period`, beside `PERIODS`: a product that adds a period there (a 24-hour one) must not
11
+ // have to patch this component to make the types line up.
12
+ const DEFAULT_OPTIONS: PeriodOption[] = PERIODS.map((p) => ({ value: p, short: PERIOD_SHORT[p], label: PERIOD_LABEL[p] }));
12
13
 
13
14
  /**
14
15
  * The reporting period for a whole page. Given `onChange`, it is controlled: the page holds the period (in state, in
@@ -16,6 +16,8 @@ Status: beta
16
16
 
17
17
  App mark, Avatar and Appearance menu, on `frame` (a translucent wash with `backdrop-blur-xl`) and a `line` hairline on its right edge. The navigation is the `nav` prop: the template passes `nav` from `src/app.config.ts`, and a product whose destinations depend on the person (admin or member, platform or team) passes the groups that person may see; the rail holds no route knowledge of its own. Pass the same groups to the Command palette.
18
18
 
19
+ **`nav` cannot be built in a server component.** Each entry carries its Lucide icon as a component, and a function cannot cross from a server component to a client one — React refuses at runtime with "Functions cannot be passed directly to Client Components", so every page answers 500 while types, lint and tests stay green. Build the groups in a client module (`src/views/console-chrome.tsx` is the template's: `'use client'`, importing `nav` and narrowing it by who is signed in) and render that from the layout. `pnpm verify` catches it: it builds and serves the app and audits every page, so a 500 fails there.
20
+
19
21
  ## Sizes
20
22
 
21
23
  | Part | Value |
@@ -9,7 +9,7 @@ Status: beta
9
9
  1. **Container**: `surface`, 1px `line` border, `radius-lg` 12px, `shadow-card`.
10
10
  2. **Top edge**: a 1px `highlight-top` line inset 12px from each side. Transparent on light, where the shadow lifts the card; a faint lit edge on dark, where shadows do not read.
11
11
  3. **Header** (`CardHeader`, optional): title, an optional description under it, actions on the right.
12
- 4. **Body** (`CardBody`): the content; `flush` removes the padding for a list or table that runs edge to edge.
12
+ 4. **Body** (`CardBody`): the content; `flush` removes the padding for a list or table that runs edge to edge, and a `Table` as its first child is then clipped by the body with its header row's top border dropped — the card's edge is the table's edge, with no second line under it and no square corner over the card's rounded one. A second table in the same body keeps its top border.
13
13
  5. **Footer** (`CardFooter`, optional): a quiet band with a hairline above: a link to the full view, a caption.
14
14
 
15
15
  ## Variants
@@ -86,4 +86,4 @@ import { Card, CardBody, CardFooter, CardHeader } from '@/components/ui/card';
86
86
  </Card>
87
87
  ```
88
88
 
89
- `Card`: `interactive` and div attributes. `CardHeader`: `title`, `description`, `actions`, `divided`. `CardBody`: `flush`. `CardFooter`: div attributes.
89
+ `Card`: `interactive` and div attributes. `CardHeader`: `title`, `description`, `actions`, `divided`, `wrap` (let the title wrap to as many lines as it needs, for a title that is the point of the card rather than a label in a list). `CardBody`: `flush` (no padding, and a first-child `Table` is clipped with its top border dropped). `CardFooter`: div attributes.
@@ -11,6 +11,10 @@ export function Card({ className, interactive, ...rest }: HTMLAttributes<HTMLDiv
11
11
  className={cn(
12
12
  'relative flex min-w-0 flex-col rounded-lg border border-line bg-surface shadow-card',
13
13
  'before:pointer-events-none before:absolute before:inset-x-3 before:top-0 before:h-px before:bg-highlight-top',
14
+ // A flush body as the first child means its content fills the corner — a table's header row, whose square
15
+ // `surface-sunk` fill would otherwise stand over this card's rounded one. The body clips what is inside it;
16
+ // only clipping HERE makes the corner round. Narrow on purpose: it is exactly the card-with-a-table case.
17
+ 'has-[>[data-flush]:first-child]:overflow-hidden',
14
18
  interactive && 'edge-lit edge-hover transition-[box-shadow,border-color,transform] duration-(--dur-enter) hover:border-line-strong hover:shadow-halo',
15
19
  className,
16
20
  )}
@@ -26,6 +30,7 @@ export function CardHeader({
26
30
  actions,
27
31
  className,
28
32
  divided,
33
+ wrap,
29
34
  }: {
30
35
  title: ReactNode;
31
36
  description?: ReactNode;
@@ -33,11 +38,14 @@ export function CardHeader({
33
38
  className?: string;
34
39
  /** A hairline under the head, when the body is a table or a list that runs edge to edge. */
35
40
  divided?: boolean;
41
+ /** Let the title wrap instead of truncating. For a title that is the point of the card — an objective, a record's
42
+ * name — rather than a label in a list, where one line and an ellipsis is the right answer. */
43
+ wrap?: boolean;
36
44
  }) {
37
45
  return (
38
46
  <div className={cn('flex items-start gap-4 px-(--card-pad) pt-[calc(var(--card-pad)-4px)]', divided ? 'border-b border-line pb-3.5' : 'pb-1', className)}>
39
47
  <div className="min-w-0 flex-1">
40
- <h2 className="t-card truncate">{title}</h2>
48
+ <h2 className={cn('t-card', wrap ? 'text-pretty' : 'truncate')}>{title}</h2>
41
49
  {description ? <p className="t-caption mt-1 text-pretty">{description}</p> : null}
42
50
  </div>
43
51
  {actions ? <div className="-my-1 flex shrink-0 items-center gap-1.5">{actions}</div> : null}
@@ -46,7 +54,25 @@ export function CardHeader({
46
54
  }
47
55
 
48
56
  export function CardBody({ className, flush, ...rest }: HTMLAttributes<HTMLDivElement> & { flush?: boolean }) {
49
- return <div className={cn('min-w-0 flex-1', flush ? '' : 'px-(--card-pad) pt-3 pb-(--card-pad)', className)} {...rest} />;
57
+ return (
58
+ <div
59
+ // `data-flush` is how the Card above clips its own rounded corner when this is its first child. See `Card`.
60
+ data-flush={flush ? '' : undefined}
61
+ className={cn(
62
+ 'min-w-0 flex-1',
63
+ flush
64
+ // A table as the body's first child runs edge to edge, so the body clips it and drops the header's own top
65
+ // border. Without the clip the header's `surface-sunk` fill squares off the card's rounded top corners, and
66
+ // without the drop its `border-y` draws a second line directly under the card's own edge — most visible in
67
+ // dark, where both lines are the same colour. DataTable's own section already did this; a plain Card with a
68
+ // Table did not. Only the FIRST table loses its top border: a second one is separated from what is above it.
69
+ ? 'has-[>div:first-child>table]:overflow-hidden [&>div:first-child>table>thead>tr>th]:border-t-0'
70
+ : 'px-(--card-pad) pt-3 pb-(--card-pad)',
71
+ className,
72
+ )}
73
+ {...rest}
74
+ />
75
+ );
50
76
  }
51
77
 
52
78
  /** A quiet band at the foot of a card: a link to the full view, a caption. */
@@ -5,6 +5,7 @@ import { Specimen, Plane } from '@/system/specimen';
5
5
  import { Button } from '@/components/ui/button';
6
6
  import { Badge } from '@/components/ui/badge';
7
7
  import { Card, CardBody, CardFooter, CardHeader } from '.';
8
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
8
9
 
9
10
  const ROWS = [
10
11
  ['POST /v1/messages', '612ms'],
@@ -46,6 +47,30 @@ export default function CardPreview() {
46
47
  </Card>
47
48
  </Plane>
48
49
  </Specimen>
50
+ <Specimen label="Flush, a table first" note="A flush body clips a table that is its first child, and drops the header row's top border: the card's edge is the table's edge, with no second line under it and no square corner over the card's round one." stack>
51
+ <Plane>
52
+ <Card className="max-w-md">
53
+ <CardBody flush>
54
+ <Table>
55
+ <TableHead>
56
+ <TableRow>
57
+ <TableHeader>Endpoint</TableHeader>
58
+ <TableHeader align="right">p95</TableHeader>
59
+ </TableRow>
60
+ </TableHead>
61
+ <TableBody>
62
+ {ROWS.map(([r, v]) => (
63
+ <TableRow key={r}>
64
+ <TableCell><span className="font-mono text-xs">{r}</span></TableCell>
65
+ <TableCell align="right" numeric>{v}</TableCell>
66
+ </TableRow>
67
+ ))}
68
+ </TableBody>
69
+ </Table>
70
+ </CardBody>
71
+ </Card>
72
+ </Plane>
73
+ </Specimen>
49
74
  <Specimen label="Interactive" note="The whole card is one link: the border strengthens and it lifts on hover." stack>
50
75
  <Plane className="grid gap-4 sm:grid-cols-2">
51
76
  <Card interactive className="p-(--card-pad)">
@@ -37,7 +37,7 @@ The thumb slides and resizes over `dur-enter` with `ease-out`; labels change col
37
37
  ## Surfaces
38
38
 
39
39
  - **Console**: in the masthead (the period) or a card head (a view switch).
40
- - **Mobile**: it never wraps; shorten labels ("30D") or move it to its own row.
40
+ - **Mobile**: it never wraps. When the labels are wider than the room the track has, the track scrolls sideways with the edge fade (`scroll-fade-x`), in a card head with counts in the labels ("All 37 · Idea 3 · Scored 25") as much as anywhere else; shorten labels ("30D") where you can, and prefer five options or fewer.
41
41
  - **Embed**: the period control in the embed head, `sm`.
42
42
 
43
43
  ## Agents
@@ -46,7 +46,10 @@ export function Segmented<V extends string>({
46
46
  value={value}
47
47
  onValueChange={(v) => v && onChange(v as V)}
48
48
  aria-label={label}
49
- className={cn('relative inline-flex shrink-0 items-center rounded-md bg-surface-sunk p-0.5 ring-1 ring-line ring-inset', className)}
49
+ className={cn(
50
+ 'scroll-fade-x relative inline-flex min-w-0 max-w-full items-center overflow-x-auto rounded-md bg-surface-sunk p-0.5 ring-1 ring-line ring-inset [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
51
+ className,
52
+ )}
50
53
  >
51
54
  {thumb ? (
52
55
  <span
@@ -11,13 +11,26 @@ export type Collection<T extends Record<string, unknown>, K extends keyof T & st
11
11
  name: string;
12
12
  label: string;
13
13
  description: string;
14
- /** The record without its key. */
14
+ /** The record without its key, as a write takes it. */
15
15
  fields: z.ZodObject;
16
+ /**
17
+ * Fields the data layer works out and no write ever takes: a score, a band, a stage, a value joined in from another
18
+ * table. They live on the record, so a page reads them and the assistant may filter on them, and they are never in
19
+ * `fields` — a change cannot set one, because `patchOf` builds its shape from `fields` alone.
20
+ */
21
+ derived?: (keyof T & string)[];
16
22
  key: K;
17
23
  title: (row: T) => string;
18
24
  query: (q: Query) => Promise<{ rows: T[]; total: number }>;
19
- create?: (input: Omit<T, K>) => Promise<T>;
20
- update?: (ids: string[], patch: Partial<Omit<T, K>>) => Promise<T[]>;
25
+ /**
26
+ * A plain record, validated by `fields`, and never `Omit<T, K>`.
27
+ *
28
+ * A record type carries fields a write does not take — the worked-out ones above, and data joined in from elsewhere —
29
+ * and a form hands over strings, so `Omit<T, K>` demands things the caller cannot supply and every real data layer
30
+ * ends up casting around it. The write shape is `fields`; this says so, and `fields.parse` is what holds it.
31
+ */
32
+ create?: (input: Record<string, unknown>) => Promise<T>;
33
+ update?: (ids: string[], patch: Record<string, unknown>) => Promise<T[]>;
21
34
  remove?: (ids: string[]) => Promise<number>;
22
35
  /** Operations only a page may perform; the assistant and an MCP server never get them. */
23
36
  pageOnly?: Op[];
@@ -63,6 +76,7 @@ export function arrayCollection<T extends Record<string, unknown>, K extends key
63
76
  label: string;
64
77
  description: string;
65
78
  fields: z.ZodObject;
79
+ derived?: (keyof T & string)[];
66
80
  key: K;
67
81
  title: (row: T) => string;
68
82
  rows: T[];
@@ -82,6 +96,9 @@ export function arrayCollection<T extends Record<string, unknown>, K extends key
82
96
  const missing = (ids: string[]) => ids.filter((id) => !rows().some((r) => r[key] === id));
83
97
  const pick = (ids: string[]) => {
84
98
  const gone = missing(ids);
99
+ // The sentence is the whole diagnosis, and the same one reaches the form's banner, the assistant's tool result and
100
+ // a REST route of your own: `src/lib/assistant/tools.ts` wraps a thrown message, and the sample's server actions
101
+ // return it, so nothing has to be translated between them. A REST surface maps it to a status where it lives.
85
102
  if (gone.length) throw new Error(`No ${name} with id ${gone.join(', ')}`);
86
103
  return rows().filter((r) => ids.includes(r[key] as string));
87
104
  };
@@ -91,6 +108,7 @@ export function arrayCollection<T extends Record<string, unknown>, K extends key
91
108
  label: def.label,
92
109
  description: def.description,
93
110
  fields: def.fields,
111
+ derived: def.derived,
94
112
  key,
95
113
  title: def.title,
96
114
  pageOnly: def.pageOnly,
@@ -160,9 +178,15 @@ export function visibleFields(c: AnyCollection): z.ZodObject {
160
178
  return c.fields.omit(Object.fromEntries(((c.hidden ?? []) as string[]).map((f) => [f, true])) as Record<string, true>).strict();
161
179
  }
162
180
 
163
- /** The input schema of the query tool: field names come from the collection, so an unknown or hidden field is rejected. */
181
+ /** The input schema of the query tool: field names come from the collection, so an unknown or hidden field is rejected.
182
+ *
183
+ * `derived` fields are named here and nowhere else: they are readable and queryable, and no write can set one, because
184
+ * `fields` is what a write takes. `visibleFields` has already dropped the hidden ones from `fields`, so only a hidden
185
+ * derived field needs dropping here. */
164
186
  export function queryInput(c: AnyCollection) {
165
- const field = z.enum([c.key, ...Object.keys(visibleFields(c).shape)] as [string, ...string[]]);
187
+ const hidden = new Set((c.hidden ?? []) as string[]);
188
+ const derived = ((c.derived ?? []) as string[]).filter((f) => !hidden.has(f));
189
+ const field = z.enum([c.key, ...Object.keys(visibleFields(c).shape), ...derived] as [string, ...string[]]);
166
190
  const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
167
191
  return z.object({
168
192
  where: z.array(z.object({ field, op: z.enum(['eq', 'ne', 'gt', 'lt', 'contains', 'in']), value: z.union([scalar, z.array(scalar)]) })).optional(),
@@ -9,6 +9,8 @@
9
9
  export const PERIODS = ['7d', '30d', '90d', 'all'] as const;
10
10
  export type Period = (typeof PERIODS)[number];
11
11
 
12
+ /** The picker's own default. Read it with `parsePeriod(undefined)`, which returns this for anything it does not know —
13
+ * a product's own period context does not need a second constant to start from. */
12
14
  const DEFAULT_PERIOD: Period = '30d';
13
15
 
14
16
  export const PERIOD_LABEL: Record<Period, string> = {
@@ -18,6 +20,15 @@ export const PERIOD_LABEL: Record<Period, string> = {
18
20
  all: 'All time',
19
21
  };
20
22
 
23
+ /** The picker's short label per period ("30D"), here beside the full name so one file holds the vocabulary: a product
24
+ * that adds its own period edits this file and the picker follows, with nothing to patch in the component. */
25
+ export const PERIOD_SHORT: Record<Period, string> = {
26
+ '7d': '7D',
27
+ '30d': '30D',
28
+ '90d': '90D',
29
+ all: 'All',
30
+ };
31
+
21
32
  /** Days in a period, or `null` for `all`. */
22
33
  export const PERIOD_DAYS: Record<Period, number | null> = {
23
34
  '7d': 7,
@@ -7,7 +7,7 @@ import { app } from '@/app.config';
7
7
  import { Button } from '@/components/ui/button';
8
8
  import { openCommand } from '@/components/patterns/command-palette';
9
9
  import { cn } from '@/lib/cn';
10
- import { nearestOf, readable } from '@/views/not-found';
10
+ import { nearestOf, readable, homeLabel } from '@/views/not-found';
11
11
 
12
12
  /**
13
13
  * The address that led nowhere, walked back to the deepest page that exists: that part is a link, the part after it is
@@ -43,7 +43,7 @@ export function MissingAddress({ size = 'display' }: { size?: 'display' | 'quiet
43
43
  }
44
44
 
45
45
  /**
46
- * The ways back: the nearest page that exists first, then the Overview when that is somewhere else. Inside the shell,
46
+ * The ways back: the nearest page that exists first, then the home page when that is somewhere else. Inside the shell,
47
47
  * where the command palette lives, a miss at the root offers search instead. The caller lays them out.
48
48
  */
49
49
  export function WayBack({ size = 'lg', search = false }: { size?: 'md' | 'lg'; search?: boolean }) {
@@ -52,10 +52,10 @@ export function WayBack({ size = 'lg', search = false }: { size?: 'md' | 'lg'; s
52
52
  return (
53
53
  <>
54
54
  <Button asChild variant="primary" size={size}>
55
- <Link href={n.href}><ArrowLeft />{atRoot ? 'Go to Overview' : `Back to ${n.label}`}</Link>
55
+ <Link href={n.href}><ArrowLeft />{atRoot ? `Go to ${homeLabel}` : `Back to ${n.label}`}</Link>
56
56
  </Button>
57
57
  {!atRoot ? (
58
- <Button asChild size={size}><Link href="/">Go to Overview</Link></Button>
58
+ <Button asChild size={size}><Link href="/">Go to {homeLabel}</Link></Button>
59
59
  ) : search ? (
60
60
  <Button size={size} icon={<Search />} onClick={openCommand}>Search pages</Button>
61
61
  ) : null}