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.
- package/package.json +1 -1
- package/payload/.env.example +2 -0
- package/payload/CHANGELOG.md +33 -0
- package/payload/app/(dashboard)/README.md +1 -1
- package/payload/app/(dashboard)/error.tsx +10 -2
- package/payload/app/not-found/README.md +4 -4
- package/payload/docs/assistant.md +1 -1
- package/payload/docs/distribution.md +8 -6
- package/payload/package.json +1 -1
- package/payload/scripts/assistant.ts +17 -3
- package/payload/scripts/check.ts +10 -1
- package/payload/scripts/fake-llm.ts +15 -0
- package/payload/scripts/keyboard.ts +10 -0
- package/payload/scripts/verify.config.ts +13 -0
- package/payload/scripts/verify.ts +65 -0
- package/payload/skills/zz-meridian/references/customize.md +8 -0
- package/payload/skills/zz-meridian/references/existing-project.md +11 -0
- package/payload/skills/zz-meridian/references/validation.md +4 -0
- package/payload/src/components/base/shell/README.md +1 -0
- package/payload/src/components/base/shell/index.tsx +4 -1
- package/payload/src/components/charts/timeline/README.md +3 -1
- package/payload/src/components/charts/timeline/index.tsx +9 -1
- package/payload/src/components/patterns/assistant/README.md +2 -2
- package/payload/src/components/patterns/assistant/index.tsx +23 -3
- package/payload/src/components/patterns/assistant/preview.tsx +2 -1
- package/payload/src/components/patterns/assistant/text.tsx +25 -0
- package/payload/src/components/patterns/command-palette/README.md +1 -1
- package/payload/src/components/patterns/filter-bar/README.md +3 -3
- package/payload/src/components/patterns/filter-bar/index.tsx +11 -7
- package/payload/src/components/patterns/form-section/README.md +5 -1
- package/payload/src/components/patterns/form-section/index.tsx +71 -36
- package/payload/src/components/patterns/form-section/preview.tsx +17 -0
- package/payload/src/components/patterns/period-select/index.tsx +4 -3
- package/payload/src/components/patterns/rail/README.md +2 -0
- package/payload/src/components/ui/card/README.md +2 -2
- package/payload/src/components/ui/card/index.tsx +28 -2
- package/payload/src/components/ui/card/preview.tsx +25 -0
- package/payload/src/components/ui/segmented/README.md +1 -1
- package/payload/src/components/ui/segmented/index.tsx +4 -1
- package/payload/src/lib/collection.ts +29 -5
- package/payload/src/lib/period.ts +11 -0
- package/payload/src/views/not-found-address.tsx +4 -4
- package/payload/src/views/not-found.tsx +10 -2
- package/payload/tests/assistant-text.test.tsx +40 -0
- 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
|
|
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`,
|
|
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**:
|
|
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
|
|
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-
|
|
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="
|
|
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-
|
|
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-
|
|
114
|
-
{view ? <div className="max-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
116
|
+
</div>
|
|
88
117
|
</div>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
</
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
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=
|
|
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
|
|
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")
|
|
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(
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
|
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}
|