@flood-md/agent 0.2.10 → 0.2.12
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/DESIGN.md +8 -5
- package/README.md +1 -1
- package/SKILL.md +11 -0
- package/dist/catalog.json +1914 -278
- package/dist/materials/component-app-shell.md +49 -0
- package/dist/materials/component-app-top-bar.md +36 -0
- package/dist/materials/component-autocomplete.md +54 -0
- package/dist/materials/component-bulk-action-bar.md +52 -0
- package/dist/materials/component-button.md +1 -0
- package/dist/materials/component-card.md +2 -2
- package/dist/materials/component-collapsible.md +1 -0
- package/dist/materials/component-collection-toolbar.md +69 -0
- package/dist/materials/component-collection-view-switcher.md +49 -0
- package/dist/materials/component-combobox.md +10 -0
- package/dist/materials/component-command-palette.md +53 -0
- package/dist/materials/component-copy-button.md +1 -0
- package/dist/materials/component-data-table.md +34 -4
- package/dist/materials/component-date-picker.md +1 -0
- package/dist/materials/component-date-range-picker.md +1 -0
- package/dist/materials/component-date-time-picker.md +2 -0
- package/dist/materials/component-date-time-range-picker.md +4 -0
- package/dist/materials/component-drawer.md +1 -0
- package/dist/materials/component-editable.md +4 -0
- package/dist/materials/component-figure.md +1 -1
- package/dist/materials/component-filter-condition.md +53 -0
- package/dist/materials/component-filter-group.md +55 -0
- package/dist/materials/component-heading.md +1 -1
- package/dist/materials/component-icon-button.md +2 -0
- package/dist/materials/component-menubar.md +41 -0
- package/dist/materials/component-multi-select.md +10 -0
- package/dist/materials/component-navigation-group.md +51 -0
- package/dist/materials/component-navigation-item.md +59 -0
- package/dist/materials/component-navigation-tree.md +8 -0
- package/dist/materials/component-overflow-actions.md +47 -0
- package/dist/materials/component-page-header.md +40 -0
- package/dist/materials/component-popover.md +2 -1
- package/dist/materials/component-radio-card.md +2 -2
- package/dist/materials/component-section.md +1 -1
- package/dist/materials/component-segmented-control.md +6 -4
- package/dist/materials/component-sheet.md +49 -0
- package/dist/materials/component-sidebar.md +64 -0
- package/dist/materials/component-sparkline.md +41 -0
- package/dist/materials/component-table.md +2 -0
- package/dist/materials/component-tabs.md +2 -0
- package/dist/materials/component-text.md +2 -2
- package/dist/materials/component-toggle.md +1 -0
- package/dist/materials/component-toolbar.md +5 -0
- package/dist/materials/component-tooltip.md +1 -1
- package/dist/materials/guide-agent-knowledge.md +13 -6
- package/dist/materials/guide-agents.md +8 -1
- package/dist/materials/guide-foundations.md +45 -2
- package/dist/materials/guide-installation.md +6 -6
- package/dist/materials/guide-interface-content.md +1 -1
- package/dist/materials/guide-introduction.md +4 -2
- package/dist/materials/guide-layout.md +39 -10
- package/dist/materials/guide-motion.md +66 -0
- package/dist/materials/guide-skills.md +9 -0
- package/dist/materials/recipe-collection-views.md +670 -0
- package/dist/materials/recipe-command-palette.md +31 -52
- package/dist/materials/recipe-filter-bar.md +12 -3
- package/dist/materials/recipe-grouped-form.md +5 -0
- package/dist/materials/recipe-navigation-workspace.md +222 -0
- package/dist/materials/recipe-record-management.md +310 -0
- package/dist/materials/recipe-record-workspace.md +207 -0
- package/dist/materials/recipe-settings-workspace.md +232 -0
- package/dist/materials/token-sys.color.context.base.action-disabled.json +41 -0
- package/dist/materials/token-sys.color.context.base.action-hover.json +41 -0
- package/dist/materials/token-sys.color.context.base.action-pressed.json +41 -0
- package/dist/materials/token-sys.color.context.base.action-rest.json +41 -0
- package/dist/materials/token-sys.color.context.base.avatar-background.json +41 -0
- package/dist/materials/token-sys.color.context.base.border-decorative.json +41 -0
- package/dist/materials/token-sys.color.context.base.divider-decorative.json +41 -0
- package/dist/materials/token-sys.color.context.base.field-background.json +41 -0
- package/dist/materials/token-sys.color.context.base.field-border.json +41 -0
- package/dist/materials/token-sys.color.context.base.field-disabled.json +41 -0
- package/dist/materials/token-sys.color.context.base.field-readonly.json +41 -0
- package/dist/materials/token-sys.color.context.base.indicator-track.json +41 -0
- package/dist/materials/token-sys.color.context.base.loading-placeholder.json +41 -0
- package/dist/materials/token-sys.color.context.base.muted-background.json +41 -0
- package/dist/materials/token-sys.color.context.base.on-surface-action-disabled.json +41 -0
- package/dist/materials/token-sys.color.context.base.on-surface-action-hover.json +41 -0
- package/dist/materials/token-sys.color.context.base.on-surface-action-pressed.json +41 -0
- package/dist/materials/token-sys.color.context.base.on-surface-action-rest.json +41 -0
- package/dist/materials/token-sys.color.context.base.selection-background.json +41 -0
- package/dist/materials/token-sys.color.context.base.supporting-background.json +41 -0
- package/dist/materials/token-sys.color.context.filled.action-disabled.json +41 -0
- package/dist/materials/token-sys.color.context.filled.action-hover.json +41 -0
- package/dist/materials/token-sys.color.context.filled.action-pressed.json +41 -0
- package/dist/materials/token-sys.color.context.filled.action-rest.json +41 -0
- package/dist/materials/token-sys.color.context.filled.avatar-background.json +41 -0
- package/dist/materials/token-sys.color.context.filled.border-decorative.json +41 -0
- package/dist/materials/token-sys.color.context.filled.divider-decorative.json +41 -0
- package/dist/materials/token-sys.color.context.filled.field-background.json +41 -0
- package/dist/materials/token-sys.color.context.filled.field-border.json +41 -0
- package/dist/materials/token-sys.color.context.filled.field-disabled.json +41 -0
- package/dist/materials/token-sys.color.context.filled.field-readonly.json +41 -0
- package/dist/materials/token-sys.color.context.filled.indicator-track.json +41 -0
- package/dist/materials/token-sys.color.context.filled.loading-placeholder.json +41 -0
- package/dist/materials/token-sys.color.context.filled.muted-background.json +41 -0
- package/dist/materials/token-sys.color.context.filled.selection-background.json +41 -0
- package/dist/materials/token-sys.color.context.filled.supporting-background.json +41 -0
- package/dist/materials/token-sys.color.field.readonly.json +41 -0
- package/dist/materials/token-sys.color.surface.avatar.json +41 -0
- package/dist/materials/token-sys.color.surface.current.json +41 -0
- package/dist/materials/token-sys.color.surface.muted.json +41 -0
- package/dist/materials/token-sys.color.surface.supporting.json +41 -0
- package/dist/materials/token-sys.motion.distance-panel.json +36 -0
- package/dist/materials/token-sys.motion.distance-small.json +36 -0
- package/dist/materials/token-sys.motion.duration-enter.json +36 -0
- package/dist/materials/token-sys.motion.duration-exit.json +36 -0
- package/dist/materials/token-sys.motion.duration-layer.json +36 -0
- package/dist/materials/token-sys.motion.duration-loop.json +36 -0
- package/dist/materials/token-sys.motion.duration-press.json +36 -0
- package/dist/materials/token-sys.motion.easing-enter.json +38 -0
- package/dist/materials/token-sys.motion.easing-exit.json +38 -0
- package/knowledge.json +115 -2
- package/lib/inspect.mjs +11 -0
- package/manifest.json +11 -1
- package/package.json +9 -3
- package/references/component-families.md +150 -0
- package/references/composition.md +16 -4
- package/references/customization.md +14 -0
- package/references/flood-composition/SKILL.md +4 -0
- package/references/flood-data-display/SKILL.md +146 -0
- package/references/flood-feedback/SKILL.md +37 -0
- package/references/flood-forms/SKILL.md +31 -0
- package/references/flood-foundations/SKILL.md +36 -0
- package/references/flood-hierarchy/SKILL.md +27 -7
- package/references/flood-interface-content/SKILL.md +33 -5
- package/references/flood-motion/SKILL.md +47 -0
- package/references/flood-navigation/SKILL.md +195 -0
- package/references/flood-overlays/SKILL.md +41 -0
- package/references/flood-records/SKILL.md +65 -2
- package/references/flood-spacing/SKILL.md +45 -0
- package/references/flood-verification/SKILL.md +20 -0
- package/references/interface-content-examples.md +4 -0
- package/references/rendered-review.md +5 -0
- package/references/spacing.md +30 -0
- package/rules.json +98 -8
- package/workflows.json +28 -3
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# Record management
|
|
2
|
+
|
|
3
|
+
[Open the working composition](/playground/records).
|
|
4
|
+
|
|
5
|
+
Search requests, narrow the list by status, open one request, and edit it in a
|
|
6
|
+
Drawer. Saving applies the accepted changes and returns to the same list.
|
|
7
|
+
The example uses in-memory fixtures that reset on reload.
|
|
8
|
+
|
|
9
|
+
## Choose where details open
|
|
10
|
+
|
|
11
|
+
Use a Drawer for a bounded inspection or edit that starts and ends in the same
|
|
12
|
+
list. Use a destination page for details that need a stable URL, substantial
|
|
13
|
+
room, or several independent tasks. This example uses a named Open button
|
|
14
|
+
because opening its Drawer is an action on the current screen.
|
|
15
|
+
|
|
16
|
+
When users compare several attributes, DataTable can use a `primaryAction`
|
|
17
|
+
in the identity column. Pass the Drawer trigger handlers and ref to that
|
|
18
|
+
action. Check the row background, title and description, checkbox and any
|
|
19
|
+
secondary menu independently. Use `toolbar` and `inset="roomy"` for a heading
|
|
20
|
+
and filters that share the table surface.
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
<Drawer title="Review workspace access" size="lg"
|
|
24
|
+
trigger={buttonProps => (
|
|
25
|
+
<DataTable caption="Requests" hideCaption inset="roomy"
|
|
26
|
+
toolbar={<Heading level={2} size="section">Requests</Heading>}
|
|
27
|
+
columns={[{id: "request", label: "Request"}, {id: "status", label: "Status"}]}
|
|
28
|
+
rows={[{
|
|
29
|
+
id: "REQ-108",
|
|
30
|
+
cells: {status: "New"},
|
|
31
|
+
primaryAction: {
|
|
32
|
+
column: "request",
|
|
33
|
+
title: "Review workspace access",
|
|
34
|
+
description: "REQ-108 · Operations",
|
|
35
|
+
buttonProps,
|
|
36
|
+
},
|
|
37
|
+
}]} />
|
|
38
|
+
)}>
|
|
39
|
+
<Stack gap="8">
|
|
40
|
+
<Section title="Customer message" headingLevel={3} headingSize="group">
|
|
41
|
+
<Text as="p">Confirm who should manage member permissions.</Text>
|
|
42
|
+
</Section>
|
|
43
|
+
<Section title="Assignment" headingLevel={3} headingSize="group">
|
|
44
|
+
<Field label="Owner">{field => <Input {...field} />}</Field>
|
|
45
|
+
</Section>
|
|
46
|
+
</Stack>
|
|
47
|
+
</Drawer>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Use `size="lg"` when long messages and several field groups need the wider
|
|
51
|
+
reading area. Compose the body from Section for separately named content,
|
|
52
|
+
DescriptionList for facts and Fieldset for related controls. Check wrapping and
|
|
53
|
+
scrolling with the longest title and a real footer action before selecting a size.
|
|
54
|
+
Use the group heading size inside a detail Drawer to keep the record name prominent.
|
|
55
|
+
|
|
56
|
+
When the detail contains line items and a total, use the Table footer to mark
|
|
57
|
+
the summary and keep its amount aligned with the line-item amounts. A row header
|
|
58
|
+
labels the total, and the Table footer supplies the separator and emphasis.
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
<tfoot>
|
|
62
|
+
<tr>
|
|
63
|
+
<th scope="row" colSpan={2}>Total</th>
|
|
64
|
+
<td data-numeric>{formattedTotal}</td>
|
|
65
|
+
</tr>
|
|
66
|
+
</tfoot>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Keep browsing state and edit state separate
|
|
70
|
+
|
|
71
|
+
Keep search, filter, accepted records and the open record ID in the host.
|
|
72
|
+
Keep the list and its ScrollArea mounted while the Drawer is open. Reset the
|
|
73
|
+
scroll position only when the user changes the query or filter.
|
|
74
|
+
|
|
75
|
+
Entering edit mode copies the selected record. Cancel edit returns to details
|
|
76
|
+
without changing accepted data. Saving replaces the record by stable ID.
|
|
77
|
+
Recompute matches using the existing search and filter. If the updated record
|
|
78
|
+
leaves the results, explain why and preserve the query. For a remote save, close
|
|
79
|
+
on accepted data and retain the draft with actionable feedback on failure.
|
|
80
|
+
|
|
81
|
+
## Restore focus to a useful place
|
|
82
|
+
|
|
83
|
+
Forward the Drawer trigger props from the single overlay to the row action
|
|
84
|
+
that opens it. Focus Edit request when details open, Subject when editing starts,
|
|
85
|
+
and Edit request after Cancel edit. On close, return focus to the same row
|
|
86
|
+
action without scrolling. If the row left the results, focus Search requests
|
|
87
|
+
without clearing it.
|
|
88
|
+
|
|
89
|
+
Keep subject and ID together in the list. Status earns a stable trailing slot
|
|
90
|
+
because users filter and compare by it. Put owner and the longer summary in the
|
|
91
|
+
Drawer, where they support a decision about a single record. Put the summary
|
|
92
|
+
in the Drawer description slot so it stays grouped with the title. Keep the
|
|
93
|
+
named values together in the body below it.
|
|
94
|
+
|
|
95
|
+
Review the Drawer with a wrapped title, then with its description omitted.
|
|
96
|
+
The heading and summary should read as one group while the values begin a
|
|
97
|
+
separate block. Check the visible copy and the scrollable area above the action.
|
|
98
|
+
|
|
99
|
+
Give each DescriptionList item a stable string `id`, such as `"owner"`.
|
|
100
|
+
Open a real row during browser verification. A build alone never exercises the
|
|
101
|
+
Drawer's conditional detail content.
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
"use client";
|
|
105
|
+
|
|
106
|
+
import {useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent} from "react";
|
|
107
|
+
import {
|
|
108
|
+
Alert, Button, DataList, DataListItem, DescriptionList, Drawer, EmptyState,
|
|
109
|
+
Field, Grid, Heading, Inline, Input, ScrollArea, SearchInput, Select,
|
|
110
|
+
Stack, StatusIndicator, Surface,
|
|
111
|
+
} from "@flood-md/react";
|
|
112
|
+
|
|
113
|
+
const statuses = {
|
|
114
|
+
new: {label: "New", tone: "info"},
|
|
115
|
+
active: {label: "In progress", tone: "warning"},
|
|
116
|
+
resolved: {label: "Resolved", tone: "success"},
|
|
117
|
+
} as const;
|
|
118
|
+
|
|
119
|
+
type Status = keyof typeof statuses;
|
|
120
|
+
type RequestRecord = {
|
|
121
|
+
id: string;
|
|
122
|
+
title: string;
|
|
123
|
+
owner: string;
|
|
124
|
+
status: Status;
|
|
125
|
+
summary: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const fixtures: RequestRecord[] = [
|
|
129
|
+
{id: "REQ-104", title: "Update billing address", owner: "Mina Chen", status: "new", summary: "Use the new address on future invoices."},
|
|
130
|
+
{id: "REQ-105", title: "Restore workspace access", owner: "Alex Morgan", status: "active", summary: "A teammate cannot sign in after changing email."},
|
|
131
|
+
{id: "REQ-106", title: "Export April invoices", owner: "Noah Park", status: "resolved", summary: "Provide a copy for the finance team."},
|
|
132
|
+
{id: "REQ-107", title: "Add a backup contact", owner: "Mina Chen", status: "active", summary: "Route account notices to a second contact."},
|
|
133
|
+
{id: "REQ-108", title: "Review workspace roles", owner: "Alex Morgan", status: "new", summary: "Confirm who should manage member permissions."},
|
|
134
|
+
{id: "REQ-109", title: "Correct the renewal date", owner: "Noah Park", status: "active", summary: "The displayed date differs from the order confirmation."},
|
|
135
|
+
{id: "REQ-110", title: "Remove a duplicate invitation", owner: "Mina Chen", status: "resolved", summary: "Keep the invitation sent to the current address."},
|
|
136
|
+
{id: "REQ-111", title: "Check a missing delivery notification", owner: "Alex Morgan", status: "active", summary: "A shipping update did not reach the assigned contact."},
|
|
137
|
+
{id: "REQ-112", title: "Change invoice recipient", owner: "Noah Park", status: "new", summary: "Send future copies to the accounts inbox."},
|
|
138
|
+
{id: "REQ-113", title: "Transfer workspace ownership", owner: "Mina Chen", status: "active", summary: "The previous owner has moved to another team."},
|
|
139
|
+
{id: "REQ-114", title: "Confirm a duplicate charge", owner: "Alex Morgan", status: "resolved", summary: "Compare the two entries against the receipt."},
|
|
140
|
+
{id: "REQ-115", title: "Recover archived access settings", owner: "Noah Park", status: "active", summary: "Review the settings before the workspace is reopened."},
|
|
141
|
+
{id: "REQ-116", title: "Replace the emergency contact", owner: "Mina Chen", status: "active", summary: "Update the contact listed for urgent account issues."},
|
|
142
|
+
{id: "REQ-117", title: "Review regional account permissions", owner: "Alex Morgan", status: "active", summary: "Check access before the next regional rollout."},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
function matchesRequest(record: RequestRecord, query: string, status: Status | "all") {
|
|
146
|
+
const needle = query.trim().toLowerCase();
|
|
147
|
+
const searchable = `${record.id} ${record.title} ${record.summary}`.toLowerCase();
|
|
148
|
+
return (status === "all" || record.status === status) && searchable.includes(needle);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
152
|
+
|
|
153
|
+
export function RecordManagement() {
|
|
154
|
+
const formId = useId();
|
|
155
|
+
const [records, setRecords] = useState(fixtures);
|
|
156
|
+
const [query, setQuery] = useState("");
|
|
157
|
+
const [status, setStatus] = useState<Status | "all">("all");
|
|
158
|
+
const [recordId, setRecordId] = useState<string | null>(null);
|
|
159
|
+
const [draft, setDraft] = useState<RequestRecord | null>(null);
|
|
160
|
+
const [notice, setNotice] = useState("");
|
|
161
|
+
const search = useRef<HTMLInputElement>(null);
|
|
162
|
+
const list = useRef<HTMLDivElement>(null);
|
|
163
|
+
const subject = useRef<HTMLInputElement>(null);
|
|
164
|
+
const edit = useRef<HTMLButtonElement>(null);
|
|
165
|
+
const opener = useRef<HTMLButtonElement>(null);
|
|
166
|
+
const restore = useRef(false);
|
|
167
|
+
const returnToEdit = useRef(false);
|
|
168
|
+
const selected = records.find(record => record.id === recordId);
|
|
169
|
+
const results = records.filter(record => matchesRequest(record, query, status));
|
|
170
|
+
const editing = draft !== null;
|
|
171
|
+
const open = selected !== undefined;
|
|
172
|
+
|
|
173
|
+
useBrowserLayoutEffect(() => {
|
|
174
|
+
if (!open && restore.current) {
|
|
175
|
+
restore.current = false;
|
|
176
|
+
const target = opener.current?.isConnected ? opener.current : search.current;
|
|
177
|
+
target?.focus({preventScroll: true});
|
|
178
|
+
} else if (editing) {
|
|
179
|
+
subject.current?.focus({preventScroll: true});
|
|
180
|
+
} else if (open && returnToEdit.current) {
|
|
181
|
+
returnToEdit.current = false;
|
|
182
|
+
edit.current?.focus({preventScroll: true});
|
|
183
|
+
}
|
|
184
|
+
}, [open, editing]);
|
|
185
|
+
|
|
186
|
+
function changeQuery(nextQuery: string, nextStatus: Status | "all") {
|
|
187
|
+
setQuery(nextQuery);
|
|
188
|
+
setStatus(nextStatus);
|
|
189
|
+
setNotice("");
|
|
190
|
+
if (list.current) list.current.scrollTop = 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function closeRecord() {
|
|
194
|
+
restore.current = true;
|
|
195
|
+
returnToEdit.current = false;
|
|
196
|
+
setDraft(null);
|
|
197
|
+
setRecordId(null);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function save(event: FormEvent<HTMLFormElement>) {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
if (!draft) return;
|
|
203
|
+
const title = draft.title.trim();
|
|
204
|
+
if (!title) {
|
|
205
|
+
subject.current?.setCustomValidity("Enter a subject.");
|
|
206
|
+
subject.current?.reportValidity();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const updated = {...draft, title, owner: draft.owner.trim()};
|
|
210
|
+
setRecords(current => current.map(record => record.id === updated.id ? updated : record));
|
|
211
|
+
setNotice(matchesRequest(updated, query, status)
|
|
212
|
+
? `${updated.id} updated.`
|
|
213
|
+
: `${updated.id} updated and no longer matches the current search or filter.`);
|
|
214
|
+
closeRecord();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function cancelEdit() {
|
|
218
|
+
returnToEdit.current = true;
|
|
219
|
+
setDraft(null);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const drawerTitle = draft ? `Edit ${draft.id}` : selected?.title ?? "Request details";
|
|
223
|
+
|
|
224
|
+
return <section aria-label="Request workspace">
|
|
225
|
+
<Stack gap="6">
|
|
226
|
+
<Heading level={1} size="page">Requests</Heading>
|
|
227
|
+
<Grid gap="4" minItemWidth="12rem">
|
|
228
|
+
<Field label="Search requests">
|
|
229
|
+
{field => <SearchInput {...field} ref={search} value={query} clearLabel="Clear search"
|
|
230
|
+
placeholder="Subject, summary or request ID"
|
|
231
|
+
onChange={event => changeQuery(event.target.value, status)} />}
|
|
232
|
+
</Field>
|
|
233
|
+
<Field label="Status">
|
|
234
|
+
{field => <Select {...field} value={status}
|
|
235
|
+
onChange={event => changeQuery(query, event.target.value as Status | "all")}>
|
|
236
|
+
<option value="all">All statuses</option>
|
|
237
|
+
{Object.entries(statuses).map(([value, option]) => <option key={value} value={value}>{option.label}</option>)}
|
|
238
|
+
</Select>}
|
|
239
|
+
</Field>
|
|
240
|
+
</Grid>
|
|
241
|
+
<Drawer
|
|
242
|
+
title={drawerTitle}
|
|
243
|
+
size="lg"
|
|
244
|
+
description={editing ? undefined : selected?.summary}
|
|
245
|
+
open={open}
|
|
246
|
+
onOpenChange={open => { if (!open) closeRecord(); }}
|
|
247
|
+
initialFocusRef={edit}
|
|
248
|
+
trigger={({ref, onClick, "aria-expanded": expanded, ...trigger}) =>
|
|
249
|
+
<Surface elevation="raised" padding="4">
|
|
250
|
+
<ScrollArea ref={list} role="region" aria-label="Request results" style={{maxBlockSize: "22rem"}}>
|
|
251
|
+
{results.length > 0
|
|
252
|
+
? <DataList aria-label="Requests" className="request-results">
|
|
253
|
+
{results.map(record => <DataListItem
|
|
254
|
+
key={record.id}
|
|
255
|
+
title={record.title}
|
|
256
|
+
description={record.id}
|
|
257
|
+
metadata={<StatusIndicator {...statuses[record.status]} size="sm" />}
|
|
258
|
+
actions={<Button {...trigger} aria-expanded={Boolean(expanded && record.id === recordId)}
|
|
259
|
+
aria-label={`Open ${record.id} ${record.title}`}
|
|
260
|
+
variant="subtle" size="sm" onClick={event => {
|
|
261
|
+
opener.current = event.currentTarget;
|
|
262
|
+
ref(event.currentTarget);
|
|
263
|
+
setRecordId(record.id);
|
|
264
|
+
onClick?.(event);
|
|
265
|
+
}}>Open</Button>}
|
|
266
|
+
/>)}
|
|
267
|
+
</DataList>
|
|
268
|
+
: <EmptyState title="No matching requests" actions={<Button variant="subtle" onClick={() => {
|
|
269
|
+
search.current?.focus({preventScroll: true});
|
|
270
|
+
changeQuery("", "all");
|
|
271
|
+
}}>Clear filters</Button>} />}
|
|
272
|
+
</ScrollArea>
|
|
273
|
+
</Surface>}
|
|
274
|
+
footer={draft
|
|
275
|
+
? <Inline gap="2"><Button type="submit" form={formId}>Save changes</Button>
|
|
276
|
+
<Button variant="subtle" onClick={cancelEdit}>Cancel edit</Button></Inline>
|
|
277
|
+
: <Button ref={edit} onClick={() => { if (selected) setDraft({...selected}); }}>Edit request</Button>}
|
|
278
|
+
>
|
|
279
|
+
{draft
|
|
280
|
+
? <form id={formId} onSubmit={save}>
|
|
281
|
+
<Stack gap="4">
|
|
282
|
+
<Field label="Subject" required>
|
|
283
|
+
{field => <Input {...field} ref={subject} value={draft.title} onChange={event => {
|
|
284
|
+
event.currentTarget.setCustomValidity("");
|
|
285
|
+
setDraft({...draft, title: event.target.value});
|
|
286
|
+
}} />}
|
|
287
|
+
</Field>
|
|
288
|
+
<Field label="Owner">
|
|
289
|
+
{field => <Input {...field} value={draft.owner}
|
|
290
|
+
onChange={event => setDraft({...draft, owner: event.target.value})} />}
|
|
291
|
+
</Field>
|
|
292
|
+
<Field label="Status">
|
|
293
|
+
{field => <Select {...field} value={draft.status}
|
|
294
|
+
onChange={event => setDraft({...draft, status: event.target.value as Status})}>
|
|
295
|
+
{Object.entries(statuses).map(([value, option]) => <option key={value} value={value}>{option.label}</option>)}
|
|
296
|
+
</Select>}
|
|
297
|
+
</Field>
|
|
298
|
+
</Stack>
|
|
299
|
+
</form>
|
|
300
|
+
: selected && <DescriptionList items={[
|
|
301
|
+
{id: "request-id", term: "Request ID", description: selected.id},
|
|
302
|
+
{id: "owner", term: "Owner", description: selected.owner || "Unassigned"},
|
|
303
|
+
{id: "status", term: "Status", description: <StatusIndicator {...statuses[selected.status]} />},
|
|
304
|
+
]} />}
|
|
305
|
+
</Drawer>
|
|
306
|
+
{notice && <Alert tone="success" announcement="polite">{notice}</Alert>}
|
|
307
|
+
</Stack>
|
|
308
|
+
</section>;
|
|
309
|
+
}
|
|
310
|
+
```
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Record workspace
|
|
2
|
+
|
|
3
|
+
[Open the working composition](/playground/content#record-workspace).
|
|
4
|
+
|
|
5
|
+
This host-owned example combines a common page header, native destination links,
|
|
6
|
+
filters, applied tags, dataset selection, confirmation and a resizable editor.
|
|
7
|
+
It uses the same typed source in the playground and this document. It adds no
|
|
8
|
+
AppShell, FilterBar or persistence import to the library.
|
|
9
|
+
|
|
10
|
+
## Data and lifecycle ownership
|
|
11
|
+
|
|
12
|
+
The example starts with an empty collection when its key is absent. It never
|
|
13
|
+
replaces a malformed or unreadable collection with sample records. A failed read
|
|
14
|
+
blocks writes and offers retry, an export of the available raw snapshot and an
|
|
15
|
+
explicit replacement confirmation. Replacing records is a user action, not an
|
|
16
|
+
automatic recovery routine. Mount a separate instance/key for another collection.
|
|
17
|
+
|
|
18
|
+
A failed write retains accepted records and the current draft. Explicit Cancel
|
|
19
|
+
abandons that draft and its save error. Opening another record or destination with
|
|
20
|
+
edits requires confirmation. The collection's unresolved read error has a separate
|
|
21
|
+
lifetime. The example's local storage is a host adapter, not a recommended backend
|
|
22
|
+
for a production service or a guarantee of durable backups.
|
|
23
|
+
|
|
24
|
+
## Query and selection scope
|
|
25
|
+
|
|
26
|
+
The default loader filters/sorts a local snapshot, then returns one page through
|
|
27
|
+
the same async boundary as a remote loader. DataTable uses server paginationMode
|
|
28
|
+
and never slices that page again. An AbortController and request generation
|
|
29
|
+
discard late responses. Changing a filter resets the page and selection. Sorting
|
|
30
|
+
resets the page; selection retains its IDs. The current page's checkbox selects
|
|
31
|
+
only those rows. Bulk confirmation captures the proposed IDs before applying
|
|
32
|
+
the operation, and a failed write leaves that selection available for retry.
|
|
33
|
+
|
|
34
|
+
Injecting a real loader requires validating its payload and supplying a coherent
|
|
35
|
+
total/page result. Transport, permissions, reconciliation with other writers,
|
|
36
|
+
authorization and production data durability remain host responsibilities.
|
|
37
|
+
|
|
38
|
+
## Composition and geometry
|
|
39
|
+
|
|
40
|
+
NavigationList owns destinations, Grid/Stack own the shell and shared field
|
|
41
|
+
tracks, and the header stays at the same top anchor in short and long views.
|
|
42
|
+
The pane separator uses ResizablePanels when the workspace has room; narrower
|
|
43
|
+
containers stack the navigation and editor with existing Grid/Stack owners. Table/DataTable keep dense horizontal
|
|
44
|
+
overflow in their own region. The editor's Grid can reduce its track count with
|
|
45
|
+
enlarged text; label gaps still come from Field. No centering wrapper or bespoke
|
|
46
|
+
input/status/progress implementation repairs the layout.
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
"use client";
|
|
50
|
+
import {useEffect,useMemo,useRef,useState,type FormEvent} from "react";
|
|
51
|
+
import {Alert,AlertDialog,Button,DataTable,EmptyState,Field,Fieldset,Grid,Heading,Inline,Input,Metric,NavigationList,
|
|
52
|
+
ResizablePanels,SearchInput,Select,Stack,StatusIndicator,Surface,Tag,Text,Toolbar,
|
|
53
|
+
type DataTableSort} from "@flood-md/react";
|
|
54
|
+
|
|
55
|
+
export interface ProjectRecord {id:string; name:string; owner:string; status:"active"|"archived";}
|
|
56
|
+
type Store=Pick<Storage,"getItem"|"setItem">;
|
|
57
|
+
type Collection={state:"ready";records:ProjectRecord[]}|{state:"error";records:ProjectRecord[];raw:string|null;message:string};
|
|
58
|
+
type Draft={record:ProjectRecord;initial:ProjectRecord|null;error?:string};
|
|
59
|
+
export interface ProjectPage {rows:ProjectRecord[];total:number;}
|
|
60
|
+
export interface ProjectQuery {records:readonly ProjectRecord[];search:string;status:string;page:number;pageSize:number;sort:DataTableSort|null;signal:AbortSignal;}
|
|
61
|
+
export type ProjectPageLoader=(query:ProjectQuery)=>Promise<ProjectPage>;
|
|
62
|
+
export interface RecordWorkspaceProps {storageKey?:string;storage?:Store;loadPage?:ProjectPageLoader;}
|
|
63
|
+
|
|
64
|
+
/** Host-owned schema. A bad read never becomes fixtures or a successful empty read. */
|
|
65
|
+
export function readProjectCollection(key:string,storage?:Store):Collection {
|
|
66
|
+
let raw:string|null=null;
|
|
67
|
+
try{
|
|
68
|
+
if(!storage&&typeof window==="undefined")return {state:"ready",records:[]};
|
|
69
|
+
raw=(storage??window.localStorage).getItem(key);
|
|
70
|
+
if(raw===null)return {state:"ready",records:[]};
|
|
71
|
+
const data:unknown=JSON.parse(raw);
|
|
72
|
+
if(!data||typeof data!=="object"||!("version"in data)||data.version!==1||!("records"in data)||!Array.isArray(data.records))throw Error("schema");
|
|
73
|
+
const ids=new Set<string>();
|
|
74
|
+
const records=data.records.map((record:unknown):ProjectRecord=>{
|
|
75
|
+
if(!record||typeof record!=="object"||!("id"in record)||typeof record.id!=="string"||!record.id.trim()||ids.has(record.id)||
|
|
76
|
+
!("name"in record)||typeof record.name!=="string"||!record.name.trim()||!("owner"in record)||typeof record.owner!=="string"||
|
|
77
|
+
!("status"in record)||(record.status!=="active"&&record.status!=="archived"))throw Error("schema");
|
|
78
|
+
ids.add(record.id);return {id:record.id,name:record.name,owner:record.owner,status:record.status};
|
|
79
|
+
});
|
|
80
|
+
return {state:"ready",records};
|
|
81
|
+
}catch{return {state:"error",records:[],raw,message:"Projects could not be read."};}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The example's local adapter has the same boundary as a remote page loader. */
|
|
85
|
+
const localPage:ProjectPageLoader=async({records,search,status,page,pageSize,sort,signal})=>{
|
|
86
|
+
if(signal.aborted)throw new DOMException("Aborted","AbortError");
|
|
87
|
+
const filtered=records.filter(record=>(status==="all"||record.status===status)&&[record.name,record.owner].join(" ").toLocaleLowerCase().includes(search.toLocaleLowerCase()));
|
|
88
|
+
const sorted=sort?[...filtered].sort((a,b)=>a.name.localeCompare(b.name,undefined,{numeric:true})*(sort.direction==="ascending"?1:-1)):filtered;
|
|
89
|
+
return {rows:sorted.slice((page-1)*pageSize,page*pageSize),total:sorted.length};
|
|
90
|
+
};
|
|
91
|
+
const route=()=>typeof window!=="undefined"&&window.location.hash==="#record-workspace/overview"?"overview":"records";
|
|
92
|
+
|
|
93
|
+
/** Working host composition, not another library AppShell or persistence API. */
|
|
94
|
+
export function RecordWorkspace({storageKey="flood-recipe-projects-v1",storage,loadPage=localPage}:RecordWorkspaceProps){
|
|
95
|
+
const [collection,setCollection]=useState(()=>readProjectCollection(storageKey,storage));
|
|
96
|
+
const [view,setView]=useState(route),[search,setSearch]=useState(""),[status,setStatus]=useState("all"),[page,setPage]=useState(1),[sort,setSort]=useState<DataTableSort|null>(null);
|
|
97
|
+
const [selected,setSelected]=useState<string[]>([]),[draft,setDraft]=useState<Draft|null>(null),[revision,setRevision]=useState(0),[retry,setRetry]=useState(0);
|
|
98
|
+
const [response,setResponse]=useState<{key:string;page:ProjectPage;error?:string}|null>(null);
|
|
99
|
+
const loader = useRef(loadPage);
|
|
100
|
+
useEffect(() => { loader.current = loadPage; }, [loadPage]);
|
|
101
|
+
const [operationError,setOperationError]=useState("");
|
|
102
|
+
const [confirmation,setConfirmation]=useState<{kind:"replace"}|{kind:"delete";ids:string[]}|{kind:"leave";next:()=>void}|null>(null);
|
|
103
|
+
const workspace=useRef<HTMLElement>(null),[wide,setWide]=useState(false);
|
|
104
|
+
useEffect(()=>{const node=workspace.current;if(!node)return;const view=node.ownerDocument.defaultView!;const update=()=>setWide(node.getBoundingClientRect().width>=56*parseFloat(view.getComputedStyle(node.ownerDocument.documentElement).fontSize));update();const observer=new ResizeObserver(update);observer.observe(node);return()=>observer.disconnect();},[]);
|
|
105
|
+
const generation=useRef(0),name=useRef<HTMLInputElement>(null),[split,setSplit]=useState(58);
|
|
106
|
+
const records=collection.records,pageSize=5;
|
|
107
|
+
const key=JSON.stringify({search,status,page,sort,revision,retry});
|
|
108
|
+
const dirty=Boolean(draft&&(draft.initial?JSON.stringify(draft.record)!==JSON.stringify(draft.initial):draft.record.name||draft.record.owner||draft.record.status!=="active"));
|
|
109
|
+
useEffect(()=>{const update=()=>setView(route());window.addEventListener("hashchange",update);return()=>window.removeEventListener("hashchange",update);},[]);
|
|
110
|
+
useEffect(()=>{
|
|
111
|
+
if(collection.state!=="ready")return;
|
|
112
|
+
const controller=new AbortController(),ticket=++generation.current;
|
|
113
|
+
// A new callback identity alone does not invalidate a settled query.
|
|
114
|
+
void loader.current({records,search,status,page,pageSize,sort,signal:controller.signal}).then(result=>{
|
|
115
|
+
if(controller.signal.aborted||generation.current!==ticket)return;
|
|
116
|
+
const maxPage=Math.max(1,Math.ceil(result.total/pageSize));
|
|
117
|
+
if(page>maxPage){setPage(maxPage);return;}
|
|
118
|
+
setResponse({key,page:result});
|
|
119
|
+
}).catch(()=>{if(!controller.signal.aborted&&generation.current===ticket)setResponse({key,page:{rows:[],total:0},error:"Projects could not be loaded."});});
|
|
120
|
+
return()=>{controller.abort();};
|
|
121
|
+
},[key,records,collection.state]);
|
|
122
|
+
useEffect(()=>{if(draft)name.current?.focus({preventScroll:true});},[draft?.record.id]);
|
|
123
|
+
const current=response?.key===key?response:null,state=current?.error?"error":current?"ready":"loading";
|
|
124
|
+
const result=current?.page??{rows:[],total:response?.page.total??0};
|
|
125
|
+
const updateFilter=(nextSearch:string,nextStatus:string)=>{setSearch(nextSearch);setStatus(nextStatus);setPage(1);setSelected([]);};
|
|
126
|
+
const leave=(next:()=>void)=>{if(dirty)setConfirmation({kind:"leave",next});else{setDraft(null);next();}};
|
|
127
|
+
function persist(next:ProjectRecord[]){
|
|
128
|
+
if(collection.state!=="ready")return false;
|
|
129
|
+
try{(storage??window.localStorage).setItem(storageKey,JSON.stringify({version:1,records:next}));setCollection({state:"ready",records:next});setRevision(value=>value+1);setOperationError("");return true;}
|
|
130
|
+
catch{return false;}
|
|
131
|
+
}
|
|
132
|
+
function save(event:FormEvent<HTMLFormElement>){
|
|
133
|
+
event.preventDefault();if(!draft||collection.state!=="ready")return;
|
|
134
|
+
if(!draft.record.name.trim()){name.current?.setCustomValidity("Enter a project name.");name.current?.reportValidity();return;}
|
|
135
|
+
const record={...draft.record,name:draft.record.name.trim()},next=draft.initial?records.map(old=>old.id===record.id?record:old):[...records,record];
|
|
136
|
+
if(persist(next)){setDraft(null);setSelected(ids=>ids.filter(id=>next.some(record=>record.id===id)));}
|
|
137
|
+
else setDraft({...draft,error:"Changes could not be saved. Try again."});
|
|
138
|
+
}
|
|
139
|
+
function confirm(){
|
|
140
|
+
const pending=confirmation;setConfirmation(null);if(!pending)return;
|
|
141
|
+
if(pending.kind==="leave"){setDraft(null);pending.next();}
|
|
142
|
+
else if(pending.kind==="delete"){
|
|
143
|
+
const proposal=new Set(pending.ids),next=records.filter(record=>!proposal.has(record.id));
|
|
144
|
+
if(persist(next)){setSelected([]);if(draft&&proposal.has(draft.record.id))setDraft(null);}
|
|
145
|
+
else setOperationError("Projects could not be deleted. Try again.");
|
|
146
|
+
}else{
|
|
147
|
+
try{(storage??window.localStorage).setItem(storageKey,JSON.stringify({version:1,records:[]}));setCollection({state:"ready",records:[]});setDraft(null);setSelected([]);setPage(1);setRevision(value=>value+1);setOperationError("");}
|
|
148
|
+
catch{setOperationError("The saved collection could not be replaced.");}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function exportRecovery(){
|
|
152
|
+
if(collection.state!=="error"||collection.raw===null)return;
|
|
153
|
+
try{const url=URL.createObjectURL(new Blob([collection.raw],{type:"application/json"})),anchor=document.createElement("a");anchor.href=url;anchor.download="projects-recovery.json";anchor.click();setTimeout(()=>URL.revokeObjectURL(url),0);}
|
|
154
|
+
catch{setOperationError("The recovery snapshot could not be exported.");}
|
|
155
|
+
}
|
|
156
|
+
const editor=draft?<Surface padding="6"><form onSubmit={save}><Stack gap="6">
|
|
157
|
+
<Heading level={3} size="section">{draft.initial?"Edit project":"New project"}</Heading>
|
|
158
|
+
<Fieldset legend="Project details" hideLabel><Grid minItemWidth="12rem" gap="6">
|
|
159
|
+
<div style={{gridColumn:"1 / -1"}}><Field label="Project name" required>{props=><Input {...props} ref={name} name="name" value={draft.record.name} onChange={event=>{event.currentTarget.setCustomValidity("");setDraft({...draft,record:{...draft.record,name:event.target.value}});}}/>}</Field></div>
|
|
160
|
+
<Field label="Owner">{props=><Input {...props} name="owner" value={draft.record.owner} onChange={event=>setDraft({...draft,record:{...draft.record,owner:event.target.value}})}/>}</Field>
|
|
161
|
+
<Field label="Status">{props=><Select {...props} name="status" value={draft.record.status} onChange={event=>setDraft({...draft,record:{...draft.record,status:event.target.value as ProjectRecord["status"]}})}><option value="active">Active</option><option value="archived">Archived</option></Select>}</Field>
|
|
162
|
+
</Grid></Fieldset>
|
|
163
|
+
{draft.error&&<Alert tone="error" announcement="polite">{draft.error}</Alert>}
|
|
164
|
+
<Inline gap="3"><Button type="submit">Save project</Button><Button variant="subtle" onClick={()=>setDraft(null)}>Cancel</Button></Inline>
|
|
165
|
+
</Stack></form></Surface>:null;
|
|
166
|
+
const table=<Stack gap="6">
|
|
167
|
+
<Grid minItemWidth="12rem" gap="4">
|
|
168
|
+
<Field label="Search projects">{props=><SearchInput {...props} value={search} onChange={event=>updateFilter(event.target.value,status)} clearLabel="Clear search"/>}</Field>
|
|
169
|
+
<Field label="Status">{props=><Select {...props} value={status} onChange={event=>updateFilter(search,event.target.value)}><option value="all">All statuses</option><option value="active">Active</option><option value="archived">Archived</option></Select>}</Field>
|
|
170
|
+
</Grid>
|
|
171
|
+
{(search||status!=="all")&&<Inline gap="2">{search&&<Tag onRemove={()=>updateFilter("",status)} removeLabel="Remove search filter">{search}</Tag>}{status!=="all"&&<Tag onRemove={()=>updateFilter(search,"all")} removeLabel="Remove status filter">{status==="active"?"Active":"Archived"}</Tag>}<Button variant="ghost" size="sm" onClick={()=>updateFilter("","all")}>Clear filters</Button></Inline>}
|
|
172
|
+
{selected.length>0&&<Toolbar label="Selected projects"><Text size="compact">{selected.length} selected</Text><Button variant="subtle" size="sm" tone="danger" onClick={()=>setConfirmation({kind:"delete",ids:[...selected]})}>Delete selected</Button><Button variant="ghost" size="sm" onClick={()=>setSelected([])}>Clear selection</Button></Toolbar>}
|
|
173
|
+
{operationError&&<Alert tone="error" announcement="polite">{operationError}</Alert>}
|
|
174
|
+
{state==="ready"&&!result.total&&!search&&status==="all"?<EmptyState title="No projects"/>:<DataTable caption="Projects" hideCaption paginationMode="server" page={page} pageSize={pageSize} totalRows={result.total}
|
|
175
|
+
sort={sort} onSortChange={next=>{setSort(next);setPage(1);}} onPageChange={setPage} state={state} onRetry={()=>setRetry(value=>value+1)}
|
|
176
|
+
selectable selectionScope="dataset" selectedIds={selected} onSelectionChange={setSelected} minWidth="32rem"
|
|
177
|
+
labels={{empty:"No matching projects.",error:"Projects could not be loaded."}}
|
|
178
|
+
columns={[{id:"name",label:"Project",sortable:true},{id:"owner",label:"Owner"},{id:"state",label:"Status"},{id:"actions",label:"Actions",align:"end"}]}
|
|
179
|
+
rows={result.rows.map(record=>({id:record.id,label:record.name,cells:{name:record.name,owner:record.owner||"—",state:<StatusIndicator label={record.status==="active"?"Active":"Archived"} tone={record.status==="active"?"success":"neutral"}/>,actions:<Button variant="subtle" size="sm" onClick={()=>leave(()=>setDraft({record:{...record},initial:{...record}}))}>Edit</Button>}}))}/>}
|
|
180
|
+
</Stack>;
|
|
181
|
+
const overview=useMemo(()=>records.reduce((totals,record)=>({...totals,[record.status]:totals[record.status]+1}),{active:0,archived:0}),[records]);
|
|
182
|
+
const navigate=(href:string)=>(event:React.MouseEvent<HTMLAnchorElement>)=>{if(event.button||event.ctrlKey||event.metaKey||event.shiftKey||event.altKey)return;if(dirty){event.preventDefault();leave(()=>{window.location.hash=href;});}};
|
|
183
|
+
return <section ref={workspace} aria-label="Project workspace"><Grid gap="6" style={{gridTemplateColumns:wide?"minmax(8rem, 12rem) minmax(0, 1fr)":"minmax(0, 1fr)",alignItems:"start"}}>
|
|
184
|
+
<NavigationList label="Project navigation" currentValue={view} items={[{value:"records",label:"Projects",href:"#record-workspace/records",linkProps:{onClick:navigate("#record-workspace/records")}},{value:"overview",label:"Overview",href:"#record-workspace/overview",linkProps:{onClick:navigate("#record-workspace/overview")}}]}/>
|
|
185
|
+
<Stack gap="6" style={{minWidth:0,marginBlock:0}}>
|
|
186
|
+
<Inline align="center" gap="4" style={{justifyContent:"space-between",minBlockSize:"2.5rem"}}><Heading level={2} size="page">{view==="records"?"Projects":"Overview"}</Heading>
|
|
187
|
+
{collection.state==="ready"&&<Button onClick={()=>leave(()=>setDraft({record:{id:crypto.randomUUID(),name:"",owner:"",status:"active"},initial:null}))}>New project</Button>}</Inline>
|
|
188
|
+
{collection.state==="error"?<Stack gap="4"><Alert tone="error">{collection.message}</Alert><Inline gap="3"><Button onClick={()=>{setCollection(readProjectCollection(storageKey,storage));setOperationError("");}}>Retry reading</Button>{collection.raw!==null&&<Button variant="subtle" onClick={exportRecovery}>Export recovery snapshot</Button>}<Button variant="subtle" tone="danger" onClick={()=>setConfirmation({kind:"replace"})}>Replace collection</Button></Inline>{operationError&&<Alert tone="error">{operationError}</Alert>}</Stack>:
|
|
189
|
+
view==="overview"?<Grid minItemWidth="12rem" gap="4"><Surface padding="6"><Metric label="Active projects" value={overview.active}/></Surface><Surface padding="6"><Metric label="Archived projects" value={overview.archived}/></Surface></Grid>:
|
|
190
|
+
draft?(wide?<ResizablePanels label="Projects and editor" main={table} secondary={editor} value={split} onValueChange={setSplit} min={35} max={70}/>:<Stack gap="6">{table}{editor}</Stack>):table}
|
|
191
|
+
</Stack>
|
|
192
|
+
{confirmation&&<AlertDialog title={confirmation.kind==="replace"?"Replace unreadable records?":confirmation.kind==="delete"?"Delete selected projects?":"Discard changes?"}
|
|
193
|
+
description={confirmation.kind==="replace"?"The saved snapshot will be replaced with an empty collection. Export it before continuing if available.":confirmation.kind==="delete"?`${confirmation.ids.length} selected projects will be removed from this collection.`:"Your edits to this project will be lost."}
|
|
194
|
+
open trigger={()=>null} confirmLabel={confirmation.kind==="replace"?"Replace":confirmation.kind==="delete"?"Delete projects":"Discard changes"} cancelLabel="Keep working"
|
|
195
|
+
onConfirm={confirm} onCancel={()=>setConfirmation(null)} onOpenChange={next=>{if(!next)setConfirmation(null);}}/>}
|
|
196
|
+
</Grid></section>;
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Verification boundaries
|
|
201
|
+
|
|
202
|
+
Exercise missing/read-failed/malformed/write-failed storage, retry, explicit
|
|
203
|
+
replacement, failed save → Cancel → reopen, native required validation, paging,
|
|
204
|
+
sorting, late response rejection, captured bulk IDs, browser back/forward and
|
|
205
|
+
1280/1440 widths with 16px/24px root text. Test the last action in each scroll
|
|
206
|
+
owner. A fixture's schema or a quiet observer result is not a complete UX,
|
|
207
|
+
accessibility or remote-service certification.
|