@flood-md/agent 0.2.11 → 0.2.13

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 (69) hide show
  1. package/DESIGN.md +10 -1
  2. package/README.md +9 -4
  3. package/SKILL.md +16 -5
  4. package/dist/catalog.json +816 -232
  5. package/dist/materials/component-alert-dialog.md +1 -0
  6. package/dist/materials/component-app-shell.md +3 -1
  7. package/dist/materials/component-bar-chart.md +52 -0
  8. package/dist/materials/component-bar-list.md +41 -0
  9. package/dist/materials/component-box-plot.md +46 -0
  10. package/dist/materials/component-bullet-chart.md +43 -0
  11. package/dist/materials/component-code-snippet.md +3 -1
  12. package/dist/materials/component-collection-toolbar.md +4 -1
  13. package/dist/materials/component-command-palette.md +1 -0
  14. package/dist/materials/component-copy-button.md +2 -0
  15. package/dist/materials/component-data-table.md +21 -3
  16. package/dist/materials/component-dialog.md +2 -0
  17. package/dist/materials/component-donut-chart.md +46 -0
  18. package/dist/materials/component-drawer.md +2 -0
  19. package/dist/materials/component-dumbbell-chart.md +46 -0
  20. package/dist/materials/component-editable.md +4 -0
  21. package/dist/materials/component-heading.md +1 -1
  22. package/dist/materials/component-heatmap.md +45 -0
  23. package/dist/materials/component-histogram.md +46 -0
  24. package/dist/materials/component-image-preview.md +1 -0
  25. package/dist/materials/component-line-chart.md +50 -0
  26. package/dist/materials/component-menu.md +2 -0
  27. package/dist/materials/component-range-chart.md +46 -0
  28. package/dist/materials/component-scatter-chart.md +48 -0
  29. package/dist/materials/component-section.md +1 -1
  30. package/dist/materials/component-sheet.md +50 -0
  31. package/dist/materials/component-sparkline.md +41 -0
  32. package/dist/materials/component-table.md +3 -1
  33. package/dist/materials/component-toolbar.md +1 -0
  34. package/dist/materials/component-waterfall-chart.md +47 -0
  35. package/dist/materials/guide-agent-knowledge.md +5 -5
  36. package/dist/materials/guide-agents.md +35 -2
  37. package/dist/materials/guide-charts.md +34 -0
  38. package/dist/materials/guide-foundations.md +2 -2
  39. package/dist/materials/guide-installation.md +6 -6
  40. package/dist/materials/guide-introduction.md +2 -1
  41. package/dist/materials/guide-motion.md +19 -10
  42. package/dist/materials/guide-skills.md +10 -0
  43. package/dist/materials/recipe-collection-views.md +72 -47
  44. package/dist/materials/recipe-navigation-workspace.md +3 -0
  45. package/dist/materials/recipe-record-management.md +109 -61
  46. package/knowledge.json +16 -0
  47. package/lib/inspect.d.ts +14 -2
  48. package/lib/inspect.mjs +65 -0
  49. package/manifest.json +11 -1
  50. package/package.json +1 -1
  51. package/references/component-families.md +14 -0
  52. package/references/composition.md +12 -0
  53. package/references/flood-composition/SKILL.md +7 -0
  54. package/references/flood-data-display/SKILL.md +114 -0
  55. package/references/flood-feedback/SKILL.md +12 -0
  56. package/references/flood-forms/SKILL.md +5 -0
  57. package/references/flood-hierarchy/SKILL.md +4 -0
  58. package/references/flood-interface-content/SKILL.md +7 -0
  59. package/references/flood-motion/SKILL.md +3 -0
  60. package/references/flood-overlays/SKILL.md +11 -0
  61. package/references/flood-records/SKILL.md +32 -0
  62. package/references/flood-screen-composition/SKILL.md +48 -0
  63. package/references/flood-spacing/SKILL.md +7 -0
  64. package/references/flood-verification/SKILL.md +32 -0
  65. package/references/flood-visual-review/SKILL.md +59 -0
  66. package/references/rendered-review.md +49 -3
  67. package/references/routing.md +2 -1
  68. package/rules.json +37 -1
  69. package/workflows.json +22 -0
@@ -61,7 +61,9 @@ checkboxes with no resulting action.
61
61
 
62
62
  Selecting projects reveals one shared action bar floating at the viewport bottom. It shows the
63
63
  selected count, changes status, confirms archiving and clears the
64
- selection. Selection spans the filtered client collection across its pages.
64
+ selection. DataTable uses `showSelection={false}` to keep its result range and
65
+ pagination while announcing the selected count without displaying it again.
66
+ Selection spans the filtered client collection across its pages.
65
67
  Changing the query resets it. Archiving captures IDs and names before opening
66
68
  the confirmation. Cancel keeps the selection and records intact.
67
69
 
@@ -70,6 +72,10 @@ records after the optional onUpdateStatus callback accepts the whole operation.
70
72
  Reload returns to the declared fixtures. A rejection keeps accepted records and
71
73
  selection intact. Retry uses the failed operation's captured IDs and status.
72
74
  Dismiss or a deliberate query/selection change abandons its feedback.
75
+ Report results in a floating ToastRegion so the table stays in place.
76
+ Use the default bottom-end placement. Success expires automatically. A failed
77
+ update offers a persistent Retry action. While the selection rail is visible,
78
+ raise the toast above it without changing the table's scroll clearance.
73
79
 
74
80
  Only one update can run at a time. Pending actions are disabled. If the user
75
81
  changes the query or selection while an update is pending, its result still
@@ -113,7 +119,7 @@ export function ProjectSelectionActions({ projects, pending, onUpdate, onClear }
113
119
  <VisuallyHidden role="status">{pending ? 'Updating…' : ''}</VisuallyHidden>
114
120
  {archive && <AlertDialog
115
121
  title={`Archive ${archive.length} ${archive.length === 1 ? 'project' : 'projects'}?`}
116
- description={`${archive.map(project => project.name).join(', ')} will move to Archived.`}
122
+ description={`${archive.map(project => project.name).join(', ')}\n${archive.length === 1 ? 'It' : 'They'} will move to Archived.`}
117
123
  confirmLabel="Archive projects"
118
124
  cancelLabel="Keep projects"
119
125
  open
@@ -129,29 +135,52 @@ export function ProjectSelectionActions({ projects, pending, onUpdate, onClear }
129
135
 
130
136
  The panel uses the existing floating-surface tokens and a viewport-safe inset.
131
137
  It leaves normal document flow and stays centered when the document scrolls.
138
+ Check the table header position and scroll dimensions with zero, one and
139
+ several selected records. A collection that fits should keep fitting on selection.
132
140
  BulkActionBar keeps a single pill-shaped row with at most two visible commands
133
141
  by default. Lower-priority commands move into More as space shrinks; the host
134
- does not add a second row of commands. ResizeObserver measures its actual height, and the
135
- collection reserves enough bottom space for the final rows and pagination to
136
- scroll above the panel. Keep this measured clearance with the host styles.
142
+ keeps the commands on one row. Budget bottom clearance in the base layout
143
+ using the control height, the panel chrome and the viewport inset. At the end
144
+ of a scroll, the final rows and pagination should be reachable above the panel.
145
+ Keep the scroll area inside the bordered Surface and clip the frame at its
146
+ rounded corners so the table footer cannot cover the outline.
137
147
 
138
148
  ```css
139
149
  .collection-examples { padding-block:var(--flood-sys-space-8); min-inline-size:0; }
140
150
  .collection-view-controls { min-inline-size:0; }
141
151
  .flood-root .collection-filter-editor { inline-size:min(42rem,calc(100vw - 2rem)); }
142
152
 
153
+ .collection-projects {
154
+ --collection-selection-inset: max(var(--flood-sys-space-4), env(safe-area-inset-bottom));
155
+ --collection-selection-control-height: var(--flood-sys-control-sm-height);
156
+ --collection-selection-height: calc(var(--collection-selection-control-height) + 2 * (var(--flood-sys-space-1) + var(--flood-sys-stroke-width)));
157
+ padding-block-end: calc(var(--collection-selection-inset) + var(--collection-selection-height) + var(--flood-sys-space-2));
158
+ }
159
+
160
+ @media (pointer: coarse) {
161
+ .collection-projects {
162
+ --collection-selection-control-height: max(var(--flood-sys-control-sm-height), var(--flood-sys-size-touch));
163
+ }
164
+ }
165
+
166
+ .collection-table-frame {
167
+ border: var(--flood-sys-stroke-width) solid var(--flood-sys-color-border-decorative);
168
+ overflow: clip;
169
+ }
170
+
143
171
  .collection-selection-panel {
144
172
  position: fixed;
145
173
  inset-inline: 0;
146
- inset-block-end: max(var(--flood-sys-space-4), env(safe-area-inset-bottom));
174
+ inset-block-end: var(--collection-selection-inset);
147
175
  margin-inline: auto;
148
176
  inline-size: min(26rem, calc(100% - 2 * var(--flood-sys-space-4)));
149
177
  max-inline-size: calc(100% - 2 * var(--flood-sys-space-4));
150
178
  z-index: var(--flood-sys-layer-sticky);
151
179
  }
152
180
 
153
- .collection-projects[data-selected="true"] {
154
- padding-block-end: calc(var(--collection-selection-height, 0px) + 2 * var(--flood-sys-space-4) + env(safe-area-inset-bottom));
181
+ .flood-root .collection-selection-panel + .flood-toast-region {
182
+ inset-block-end: calc(var(--collection-selection-inset) + var(--collection-selection-height) + var(--flood-sys-space-2));
183
+ max-block-size: calc(100dvh - var(--collection-selection-inset) - var(--collection-selection-height) - var(--flood-sys-space-6));
155
184
  }
156
185
  ```
157
186
 
@@ -210,6 +239,12 @@ defines reset scope and supplies canClear for any state held by quickFilters.
210
239
  At most two task-important quick properties remain inline; additional properties
211
240
  belong inside Filters. Summaries outside the panel name the property and preserve
212
241
  any-match meaning. Container width, rather than viewport size, governs reflow.
242
+ When search and filters share a wide row with a status or view selector, use
243
+ `align="end"` on CollectionToolbar. Check that the final control reaches the
244
+ row's trailing content edge and that the row wraps with enlarged text.
245
+ The Projects table places its Columns control in CollectionToolbar's `actions`
246
+ slot via DataTable's `toolbar` render function. It shares the tool row with
247
+ search and filters, and the commands wrap as a group in a narrow container.
213
248
 
214
249
  CollectionViewSwitcher owns the separate accepted-view choice and adaptive
215
250
  commands. It reuses Field/Select and Button/OverflowActions with the same size.
@@ -222,7 +257,7 @@ SearchInput, Select, Menu, MultiSelect and SegmentedControl own input/choice beh
222
257
  Popover contains the condition draft and Tag removes applied conditions. Button
223
258
  and IconButton perform actions. Several view commands use OverflowActions; a
224
259
  single command uses Button directly. Field/Fieldset, Grid/Stack/Inline, Dialog,
225
- AlertDialog and Alert retain their own roles. DataTable/StatusIndicator and
260
+ AlertDialog and ToastRegion retain their own roles. DataTable/StatusIndicator and
226
261
  FileList/FileItem/FileMeta demonstrate two result structures. No owner is inserted
227
262
  merely to increase its usage count, and no new package atom is needed for layout.
228
263
 
@@ -293,7 +328,7 @@ export function readCollectionViews(key:string,schema:CollectionSchema,storage:V
293
328
 
294
329
  ```tsx
295
330
  "use client";
296
- import {useEffect,useRef,useState,type FormEvent} from 'react';
331
+ import {useEffect,useRef,useState,type FormEvent,type ReactNode} from 'react';
297
332
  import {Alert,AlertDialog,Button,CollectionToolbar,CollectionViewSwitcher,Dialog,Field,FilterGroup,IconSlot,Inline,Input,Menu,Select,Stack,Tag,type OverflowAction} from '@flood-md/react';
298
333
  import {collectionQueryKey,copyCollectionQuery,parseCollectionQuery,readCollectionViews,type CollectionQuery,type CollectionSchema,type CollectionView,type FilterProperty,type ViewRead,type ViewStorage} from './collection-views-model';
299
334
 
@@ -340,13 +375,13 @@ export function AppliedFilterControl({ property, value, onChange }: {
340
375
 
341
376
  interface CollectionViewControlsProps {
342
377
  label:string;schema:CollectionSchema;presets:readonly CollectionView[];
343
- value:CollectionQuery;onValueChange:(query:CollectionQuery)=>void;storageKey:string;storage?:ViewStorage;
378
+ value:CollectionQuery;onValueChange:(query:CollectionQuery)=>void;storageKey:string;storage?:ViewStorage;toolbarActions?:ReactNode;
344
379
  }
345
380
  type NameDraft={kind:'create'|'rename';id?:string;name:string;snapshot:CollectionQuery;error?:string;invalid?:string};
346
381
  const inlineFilterLimit = 2;
347
382
 
348
383
  /** Host recipe: shared behaviour, not a second Flood component implementation. */
349
- export function CollectionViewControls({label,schema,presets,value,onValueChange,storageKey,storage}:CollectionViewControlsProps){
384
+ export function CollectionViewControls({label,schema,presets,value,onValueChange,storageKey,storage,toolbarActions}:CollectionViewControlsProps){
350
385
  const [saved,setSaved]=useState<ViewRead|{state:'loading';views:[]}>({state:'loading',views:[]}),[selectedId,setSelectedId]=useState(presets[0].id);
351
386
  const [editing,setEditing]=useState(false),[draft,setDraft]=useState(()=>copyCollectionQuery(value)),[draftError,setDraftError]=useState('');
352
387
  const [advancedEditor, setAdvancedEditor] = useState(false);
@@ -435,6 +470,7 @@ export function CollectionViewControls({label,schema,presets,value,onValueChange
435
470
  {saved.state==='error'&&<Stack gap="3"><Alert tone="error">Saved views could not be read.</Alert><Inline gap="3"><Button size="sm" variant="subtle" onClick={()=>setReadAttempt(attempt=>attempt+1)}>Retry saved views</Button>{saved.raw!==null&&<Button size="sm" variant="subtle" onClick={exportSnapshot}>Export snapshot</Button>}<Button size="sm" variant="subtle" tone="danger" onClick={()=>{viewInput.current?.focus({preventScroll:true});setConfirmation({kind:'replace'});}}>Replace saved views</Button></Inline></Stack>}
436
471
  {operationError&&<Alert tone="error" announcement="polite"><Inline gap="3">{operationError.message}<Button size="sm" variant="ghost" onClick={()=>setOperationError(null)}>Dismiss</Button></Inline></Alert>}
437
472
  <CollectionToolbar label={label+' collection toolbar'} searchValue={value.search}
473
+ actions={toolbarActions}
438
474
  onSearchValueChange={search=>onValueChange({...value,search})}
439
475
  searchLabel={'Search '+label.toLowerCase()} searchPlaceholder={'Search '+label.toLowerCase()+'…'} clearSearchLabel={'Clear '+label.toLowerCase()+' search'}
440
476
  appliedFiltersLabel={label+' applied filters'}
@@ -496,8 +532,8 @@ chosen consumer with actual data and their own chrome.
496
532
 
497
533
  ```tsx
498
534
  "use client";
499
- import {useEffect,useRef,useState} from 'react';
500
- import {Alert,Button,DataTable,EmptyState,Field,FileItem,FileList,FloodRoot,Heading,Inline,Select,Stack,StatusIndicator,Tabs} from '@flood-md/react';
535
+ import {useRef,useState} from 'react';
536
+ import {Button,DataTable,EmptyState,Field,FileItem,FileList,FloodRoot,Heading,Select,Stack,StatusIndicator,Surface,Tabs,ToastRegion,type ToastItem} from '@flood-md/react';
501
537
  import {SiteHeader} from './site-header';
502
538
  import {SiteFooter} from './site-footer';
503
539
  import {CollectionViewControls} from './recipes/collection-views';
@@ -532,10 +568,8 @@ export function ProjectCollection({storage,onUpdateStatus}: {
532
568
  const [page, setPage] = useState(1);
533
569
  const [selection, setSelection] = useState<readonly string[]>([]);
534
570
  const [pending, setPending] = useState(false);
535
- const [feedback, setFeedback] = useState<
536
- { kind: 'success'; message: string } |
537
- { kind: 'error'; update: ProjectStatusUpdate; message: string } | null
538
- >(null);
571
+ const [feedback, setFeedback] = useState<ToastItem | null>(null);
572
+ const feedbackSequence = useRef(0);
539
573
  const inFlight = useRef(false);
540
574
  const collection = useRef<HTMLElement>(null);
541
575
  const selectionPanel = useRef<HTMLDivElement>(null);
@@ -549,20 +583,6 @@ export function ProjectCollection({storage,onUpdateStatus}: {
549
583
  const selectedProjects=rows.filter(project=>selection.includes(project.id));
550
584
  const hasSelection = selectedProjects.length > 0;
551
585
 
552
- useEffect(() => {
553
- const panel = selectionPanel.current;
554
- const host = collection.current;
555
- if (!hasSelection || !panel || !host) return;
556
- const measure = () => host.style.setProperty('--collection-selection-height', `${panel.getBoundingClientRect().height}px`);
557
- measure();
558
- const observer = new ResizeObserver(measure);
559
- observer.observe(panel);
560
- return () => {
561
- observer.disconnect();
562
- host.style.removeProperty('--collection-selection-height');
563
- };
564
- }, [hasSelection]);
565
-
566
586
  async function updateStatus(proposal: ProjectStatusUpdate) {
567
587
  if (inFlight.current) return;
568
588
  const update = { ids: [...new Set(proposal.ids)], status: proposal.status };
@@ -580,33 +600,38 @@ export function ProjectCollection({storage,onUpdateStatus}: {
580
600
  setSelection(current => current.filter(id => !ids.has(id)));
581
601
  setPage(1);
582
602
  const result = update.status === 'active' ? 'set active' : update.status;
583
- setFeedback({ kind: 'success', message: `${ids.size} ${ids.size === 1 ? 'project' : 'projects'} ${result}.` });
603
+ setFeedback({ id: String(++feedbackSequence.current), status: 'success', title: `${ids.size} ${ids.size === 1 ? 'project' : 'projects'} ${result}.` });
584
604
  } catch {
585
- setFeedback({ kind: 'error', update, message: `Couldn’t update ${update.ids.length} ${update.ids.length === 1 ? 'project' : 'projects'}.` });
605
+ setFeedback({
606
+ id: String(++feedbackSequence.current),
607
+ status: 'error',
608
+ title: `Couldn’t update ${update.ids.length} ${update.ids.length === 1 ? 'project' : 'projects'}.`,
609
+ action: { label: 'Retry update', onClick: () => {
610
+ collection.current?.focus({ preventScroll: true });
611
+ void updateStatus(update);
612
+ }},
613
+ });
586
614
  } finally {
587
615
  inFlight.current = false;
588
616
  setPending(false);
589
617
  }
590
618
  }
591
619
 
592
- return <section ref={collection} tabIndex={-1} className="collection-projects" data-selected={hasSelection || undefined} aria-label="Projects collection"><Stack gap="6">
593
- <CollectionViewControls label="Projects" schema={projectSchema} presets={projectPresets} value={query} onValueChange={change} storageKey="flood-project-views-v1" storage={storage}/>
620
+ return <section ref={collection} tabIndex={-1} className="collection-projects" aria-label="Projects collection"><Stack gap="6">
594
621
  {hasSelection&&<div ref={selectionPanel} className="collection-selection-panel"><ProjectSelectionActions projects={selectedProjects} pending={pending} onUpdate={update=>{void updateStatus(update);}} onClear={()=>{
595
622
  collection.current?.focus({preventScroll:true});setSelection([]);setFeedback(null);
596
623
  }}/></div>}
597
- {feedback&&<Alert tone={feedback.kind==='error'?'error':'success'} announcement="polite"><Inline gap="3">
598
- {feedback.message}
599
- {feedback.kind==='error'&&<Button size="sm" variant="subtle" disabled={pending} onClick={()=>{
600
- collection.current?.focus({preventScroll:true});void updateStatus(feedback.update);
601
- }}>Retry update</Button>}
602
- <Button size="sm" variant="ghost" disabled={pending} onClick={()=>{
603
- collection.current?.focus({preventScroll:true});setFeedback(null);
604
- }}>Dismiss</Button>
605
- </Inline></Alert>}
606
- <DataTable caption="Projects" hideCaption minWidth="36rem" selectable pageSize={5} page={page} onPageChange={setPage} selectedIds={selection} onSelectionChange={ids=>{setSelection(ids);setFeedback(null);}} sort={query.sort} onSortChange={sort=>change({...query,sort})}
624
+ <ToastRegion label="Project updates" items={feedback ? [feedback] : []}
625
+ onDismiss={id=>setFeedback(current=>current?.id===id?null:current)}/>
626
+ <Surface padding="0" className="collection-table-frame">
627
+ <DataTable caption="Projects" hideCaption minWidth="36rem" selectable showSelection={false} pageSize={5} page={page} onPageChange={setPage} selectedIds={selection} onSelectionChange={ids=>{setSelection(ids);setFeedback(null);}} sort={query.sort} onSortChange={sort=>change({...query,sort})}
628
+ inset="roomy" showColumnVisibility
629
+ toolbar={({columns}) => <CollectionViewControls label="Projects" schema={projectSchema} presets={projectPresets}
630
+ value={query} onValueChange={change} storageKey="flood-project-views-v1" storage={storage} toolbarActions={columns}/>}
607
631
  emptyContent={<EmptyState title="No matching projects" actions={<Button variant="subtle" onClick={()=>{change({...query,search:'',conditions:[]});collection.current?.querySelector<HTMLInputElement>('.flood-collection-toolbar input')?.focus({preventScroll:true});}}>Reset filters</Button>}/>}
608
- columns={[{id:'name',label:'Project',sortable:true},{id:'owner',label:'Owner',sortable:true},{id:'status',label:'Status',sortable:true},{id:'priority',label:'Priority',sortable:true}]}
632
+ columns={[{id:'name',label:'Project',sortable:true,hideable:false},{id:'owner',label:'Owner',sortable:true},{id:'status',label:'Status',sortable:true},{id:'priority',label:'Priority',sortable:true}]}
609
633
  rows={rows.map(project=>({id:project.id,label:project.name,cells:{name:project.name,owner:owners.find(owner=>owner.value===project.owner)!.label,status:<StatusIndicator label={projectSchema.properties[0].options.find(option=>option.value===project.status)!.label} tone={project.status==='active'?'success':project.status==='paused'?'warning':'neutral'}/>,priority:project.priority==='urgent'?'Urgent':'Normal'},sortValues:{name:project.name,owner:project.owner,status:project.status,priority:project.priority==='urgent'?1:0}}))}/>
634
+ </Surface>
610
635
  </Stack></section>;
611
636
  }
612
637
 
@@ -62,6 +62,9 @@ embedded shell an explicit block size. Choose `scroll="page"` for natural
62
62
  document scrolling. Place a persistent top bar and footer in the shell slots,
63
63
  and PageHeader inside main. The shell has a skip target and owns the scroll
64
64
  boundary; the host owns routes and persisted choices.
65
+ For a full-window workspace with a padded content edge, use
66
+ `scrollContentInset="none"` when that padding already clears the overlay rail.
67
+ Verify that the document width stays fixed as its collection grows and shrinks.
65
68
 
66
69
  For a single Sidebar, opt into `navigationDrawer` with a width threshold in CSS
67
70
  pixels and a `renderContent(close)` function. AppShell observes its own width.
@@ -2,22 +2,22 @@
2
2
 
3
3
  [Open the working composition](/playground/records).
4
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.
5
+ Search requests, narrow the table by status, open one request, and edit it in a
6
+ Drawer. Saving updates the request and returns to the same table.
7
7
  The example uses in-memory fixtures that reset on reload.
8
8
 
9
9
  ## Choose where details open
10
10
 
11
11
  Use a Drawer for a bounded inspection or edit that starts and ends in the same
12
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.
13
+ room, or several independent tasks.
15
14
 
16
15
  When users compare several attributes, DataTable can use a `primaryAction`
17
16
  in the identity column. Pass the Drawer trigger handlers and ref to that
18
17
  action. Check the row background, title and description, checkbox and any
19
18
  secondary menu independently. Use `toolbar` and `inset="roomy"` for a heading
20
- and filters that share the table surface.
19
+ and filters that share the table surface. Use the request title as the visible
20
+ action and keep its ID beside it for disambiguation.
21
21
 
22
22
  ```tsx
23
23
  <Drawer title="Review workspace access" size="lg"
@@ -36,11 +36,11 @@ and filters that share the table surface.
36
36
  },
37
37
  }]} />
38
38
  )}>
39
- <Stack gap="6">
40
- <Section title="Customer message">
39
+ <Stack gap="8">
40
+ <Section title="Customer message" headingLevel={3} headingSize="group">
41
41
  <Text as="p">Confirm who should manage member permissions.</Text>
42
42
  </Section>
43
- <Section title="Assignment">
43
+ <Section title="Assignment" headingLevel={3} headingSize="group">
44
44
  <Field label="Owner">{field => <Input {...field} />}</Field>
45
45
  </Section>
46
46
  </Stack>
@@ -51,17 +51,36 @@ Use `size="lg"` when long messages and several field groups need the wider
51
51
  reading area. Compose the body from Section for separately named content,
52
52
  DescriptionList for facts and Fieldset for related controls. Check wrapping and
53
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
+ Keep the request facts in one DescriptionList and use a compact, captioned Table
56
+ for related records that have comparable columns, such as activity dates and events.
57
+ Give that table its own heading when it identifies a distinct section. The Drawer
58
+ body owns vertical scrolling and the footer keeps the next action within reach.
59
+
60
+ When the detail contains line items and a total, use the Table footer to mark
61
+ the summary and keep its amount aligned with the line-item amounts. A row header
62
+ labels the total, and the Table footer supplies the separator and emphasis.
63
+
64
+ ```tsx
65
+ <tfoot>
66
+ <tr>
67
+ <th scope="row" colSpan={2}>Total</th>
68
+ <td data-numeric>{formattedTotal}</td>
69
+ </tr>
70
+ </tfoot>
71
+ ```
54
72
 
55
73
  ## Keep browsing state and edit state separate
56
74
 
57
75
  Keep search, filter, accepted records and the open record ID in the host.
58
- Keep the list and its ScrollArea mounted while the Drawer is open. Reset the
76
+ Keep the DataTable and its ScrollArea mounted while the Drawer is open. Reset the
59
77
  scroll position only when the user changes the query or filter.
60
78
 
61
79
  Entering edit mode copies the selected record. Cancel edit returns to details
62
80
  without changing accepted data. Saving replaces the record by stable ID.
63
- Recompute matches using the existing search and filter. If the updated record
64
- leaves the results, explain why and preserve the query. For a remote save, close
81
+ Recompute matches using the existing search and filter. Use a floating ToastRegion
82
+ for the save result so it cannot move the table. If the updated record leaves
83
+ the results, explain why and preserve the query. For a remote save, close
65
84
  on accepted data and retain the draft with actionable feedback on failure.
66
85
 
67
86
  ## Restore focus to a useful place
@@ -69,8 +88,9 @@ on accepted data and retain the draft with actionable feedback on failure.
69
88
  Forward the Drawer trigger props from the single overlay to the row action
70
89
  that opens it. Focus Edit request when details open, Subject when editing starts,
71
90
  and Edit request after Cancel edit. On close, return focus to the same row
72
- action without scrolling. If the row left the results, focus Search requests
73
- without clearing it.
91
+ action without scrolling. Drawer resolves `getFallbackFocus` after closing
92
+ if the row left the results. The recipe returns Search requests and keeps
93
+ the current query.
74
94
 
75
95
  Keep subject and ID together in the list. Status earns a stable trailing slot
76
96
  because users filter and compare by it. Put owner and the longer summary in the
@@ -91,9 +111,10 @@ Drawer's conditional detail content.
91
111
 
92
112
  import {useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent} from "react";
93
113
  import {
94
- Alert, Button, DataList, DataListItem, DescriptionList, Drawer, EmptyState,
95
- Field, Grid, Heading, Inline, Input, ScrollArea, SearchInput, Select,
96
- Stack, StatusIndicator, Surface,
114
+ Button, DataTable, DescriptionList, Drawer, EmptyState,
115
+ Field, Grid, Heading, Inline, Input, SearchInput, Section, Select,
116
+ Stack, StatusIndicator, Surface, Table, ToastRegion,
117
+ type DataTableColumn, type ToastItem,
97
118
  } from "@flood-md/react";
98
119
 
99
120
  const statuses = {
@@ -111,6 +132,24 @@ type RequestRecord = {
111
132
  summary: string;
112
133
  };
113
134
 
135
+ const requestColumns: DataTableColumn[] = [
136
+ {id: "subject", label: "Subject", sortable: true},
137
+ {id: "status", label: "Status", sortable: true},
138
+ ];
139
+
140
+ const activityByRequest: Record<string, readonly {date: string; activity: string}[]> = {
141
+ "REQ-105": [
142
+ {date: "2026-11-10", activity: "Access issue reported"},
143
+ {date: "2026-11-11", activity: "Identity verified"},
144
+ {date: "2026-11-12", activity: "Recovery link sent"},
145
+ ],
146
+ "REQ-117": [
147
+ {date: "2026-11-11", activity: "Permission review requested"},
148
+ {date: "2026-11-12", activity: "Regional roles exported"},
149
+ ],
150
+ };
151
+ const activityDate = new Intl.DateTimeFormat("en", {month: "short", day: "numeric", timeZone: "UTC"});
152
+
114
153
  const fixtures: RequestRecord[] = [
115
154
  {id: "REQ-104", title: "Update billing address", owner: "Mina Chen", status: "new", summary: "Use the new address on future invoices."},
116
155
  {id: "REQ-105", title: "Restore workspace access", owner: "Alex Morgan", status: "active", summary: "A teammate cannot sign in after changing email."},
@@ -143,25 +182,20 @@ export function RecordManagement() {
143
182
  const [status, setStatus] = useState<Status | "all">("all");
144
183
  const [recordId, setRecordId] = useState<string | null>(null);
145
184
  const [draft, setDraft] = useState<RequestRecord | null>(null);
146
- const [notice, setNotice] = useState("");
185
+ const [notice, setNotice] = useState<ToastItem | null>(null);
147
186
  const search = useRef<HTMLInputElement>(null);
148
- const list = useRef<HTMLDivElement>(null);
187
+ const table = useRef<HTMLDivElement>(null);
149
188
  const subject = useRef<HTMLInputElement>(null);
150
189
  const edit = useRef<HTMLButtonElement>(null);
151
- const opener = useRef<HTMLButtonElement>(null);
152
- const restore = useRef(false);
153
190
  const returnToEdit = useRef(false);
154
191
  const selected = records.find(record => record.id === recordId);
192
+ const activity = selected ? activityByRequest[selected.id] : undefined;
155
193
  const results = records.filter(record => matchesRequest(record, query, status));
156
194
  const editing = draft !== null;
157
195
  const open = selected !== undefined;
158
196
 
159
197
  useBrowserLayoutEffect(() => {
160
- if (!open && restore.current) {
161
- restore.current = false;
162
- const target = opener.current?.isConnected ? opener.current : search.current;
163
- target?.focus({preventScroll: true});
164
- } else if (editing) {
198
+ if (editing) {
165
199
  subject.current?.focus({preventScroll: true});
166
200
  } else if (open && returnToEdit.current) {
167
201
  returnToEdit.current = false;
@@ -172,12 +206,11 @@ export function RecordManagement() {
172
206
  function changeQuery(nextQuery: string, nextStatus: Status | "all") {
173
207
  setQuery(nextQuery);
174
208
  setStatus(nextStatus);
175
- setNotice("");
176
- if (list.current) list.current.scrollTop = 0;
209
+ setNotice(null);
210
+ table.current?.querySelector('[role="region"][aria-label="Requests"]')?.scrollTo({top: 0});
177
211
  }
178
212
 
179
213
  function closeRecord() {
180
- restore.current = true;
181
214
  returnToEdit.current = false;
182
215
  setDraft(null);
183
216
  setRecordId(null);
@@ -194,9 +227,12 @@ export function RecordManagement() {
194
227
  }
195
228
  const updated = {...draft, title, owner: draft.owner.trim()};
196
229
  setRecords(current => current.map(record => record.id === updated.id ? updated : record));
197
- setNotice(matchesRequest(updated, query, status)
198
- ? `${updated.id} updated.`
199
- : `${updated.id} updated and no longer matches the current search or filter.`);
230
+ setNotice({
231
+ id: updated.id,
232
+ title: `${updated.id} updated`,
233
+ description: matchesRequest(updated, query, status) ? undefined : "It no longer matches your search or filter.",
234
+ status: "success",
235
+ });
200
236
  closeRecord();
201
237
  }
202
238
 
@@ -231,35 +267,37 @@ export function RecordManagement() {
231
267
  open={open}
232
268
  onOpenChange={open => { if (!open) closeRecord(); }}
233
269
  initialFocusRef={edit}
234
- trigger={({ref, onClick, "aria-expanded": expanded, ...trigger}) =>
235
- <Surface elevation="raised" padding="4">
236
- <ScrollArea ref={list} role="region" aria-label="Request results" style={{maxBlockSize: "22rem"}}>
237
- {results.length > 0
238
- ? <DataList aria-label="Requests">
239
- {results.map(record => <DataListItem
240
- key={record.id}
241
- title={record.title}
242
- description={record.id}
243
- metadata={<StatusIndicator {...statuses[record.status]} size="sm" />}
244
- actions={<Button {...trigger} aria-expanded={Boolean(expanded && record.id === recordId)}
245
- aria-label={`Open ${record.id} ${record.title}`}
246
- variant="subtle" size="sm" onClick={event => {
247
- opener.current = event.currentTarget;
248
- ref(event.currentTarget);
249
- setRecordId(record.id);
250
- onClick?.(event);
251
- }}>Open</Button>}
252
- />)}
253
- </DataList>
254
- : <EmptyState title="No matching requests" actions={<Button variant="subtle" onClick={() => {
255
- search.current?.focus({preventScroll: true});
256
- changeQuery("", "all");
257
- }}>Clear filters</Button>} />}
258
- </ScrollArea>
270
+ getFallbackFocus={() => search.current}
271
+ trigger={({ref, onClick, ...trigger}) =>
272
+ <Surface elevation="raised" padding="0" className="request-table-frame">
273
+ <DataTable ref={table} caption="Requests" hideCaption inset="roomy" minWidth="min-content"
274
+ maxViewportHeight="26rem" stickyHeader pageSize={fixtures.length}
275
+ columns={requestColumns} rows={results.map(record => ({
276
+ id: record.id,
277
+ cells: {status: <StatusIndicator {...statuses[record.status]} size="sm" />},
278
+ sortValues: {status: statuses[record.status].label},
279
+ primaryAction: {
280
+ column: "subject", title: record.title, description: record.id,
281
+ buttonProps: {
282
+ ...trigger,
283
+ "aria-expanded": open && record.id === recordId,
284
+ "aria-label": `${record.title}, ${record.id}`,
285
+ onClick: event => {
286
+ ref(event.currentTarget);
287
+ setRecordId(record.id);
288
+ onClick?.(event);
289
+ },
290
+ },
291
+ },
292
+ }))}
293
+ emptyContent={<EmptyState title="No matching requests" actions={<Button variant="subtle" onClick={() => {
294
+ search.current?.focus({preventScroll: true});
295
+ changeQuery("", "all");
296
+ }}>Clear filters</Button>} />} />
259
297
  </Surface>}
260
298
  footer={draft
261
- ? <Inline gap="2"><Button type="submit" form={formId}>Save changes</Button>
262
- <Button variant="subtle" onClick={cancelEdit}>Cancel edit</Button></Inline>
299
+ ? <Inline gap="2"><Button variant="subtle" onClick={cancelEdit}>Cancel edit</Button>
300
+ <Button type="submit" form={formId}>Save changes</Button></Inline>
263
301
  : <Button ref={edit} onClick={() => { if (selected) setDraft({...selected}); }}>Edit request</Button>}
264
302
  >
265
303
  {draft
@@ -283,13 +321,23 @@ export function RecordManagement() {
283
321
  </Field>
284
322
  </Stack>
285
323
  </form>
286
- : selected && <DescriptionList items={[
324
+ : selected && <Stack gap="8"><DescriptionList items={[
287
325
  {id: "request-id", term: "Request ID", description: selected.id},
288
326
  {id: "owner", term: "Owner", description: selected.owner || "Unassigned"},
289
327
  {id: "status", term: "Status", description: <StatusIndicator {...statuses[selected.status]} />},
290
- ]} />}
328
+ ]} />
329
+ {activity && <Section title="Activity" headingLevel={3} headingSize="group">
330
+ <Table caption="Request activity" hideCaption density="compact">
331
+ <thead><tr><th scope="col">Date</th><th scope="col">Event</th></tr></thead>
332
+ <tbody>{activity.map(item => <tr key={item.date}>
333
+ <td><time dateTime={item.date}>{activityDate.format(new Date(item.date))}</time></td>
334
+ <td>{item.activity}</td>
335
+ </tr>)}</tbody>
336
+ </Table>
337
+ </Section>}
338
+ </Stack>}
291
339
  </Drawer>
292
- {notice && <Alert tone="success" announcement="polite">{notice}</Alert>}
340
+ <ToastRegion items={notice ? [notice] : []} onDismiss={() => setNotice(null)} />
293
341
  </Stack>
294
342
  </section>;
295
343
  }
package/knowledge.json CHANGED
@@ -21,6 +21,11 @@
21
21
  "name": "Data & feedback",
22
22
  "description": "Structured content, files and honest operation states."
23
23
  },
24
+ {
25
+ "id": "charts",
26
+ "name": "Charts",
27
+ "description": "Visualize trends, comparisons, distributions and change."
28
+ },
24
29
  {
25
30
  "id": "quality",
26
31
  "name": "Quality",
@@ -65,10 +70,16 @@
65
70
  "verification": [
66
71
  "quality"
67
72
  ],
73
+ "visual-review": [
74
+ "quality"
75
+ ],
68
76
  "interface-content": [
69
77
  "composition",
70
78
  "quality"
71
79
  ],
80
+ "screen-composition": [
81
+ "composition"
82
+ ],
72
83
  "hierarchy": [
73
84
  "foundations",
74
85
  "composition"
@@ -247,6 +258,10 @@
247
258
  "content": [
248
259
  "data-feedback"
249
260
  ],
261
+ "charts": [
262
+ "charts",
263
+ "data-feedback"
264
+ ],
250
265
  "feedback": [
251
266
  "data-feedback"
252
267
  ]
@@ -328,6 +343,7 @@
328
343
  }
329
344
  ],
330
345
  "documentGroups": {
346
+ "charts.md": ["charts", "data-feedback", "composition"],
331
347
  "recipes/record-management.md": ["data-feedback", "composition"],
332
348
  "recipes/settings-workspace.md": ["interaction", "composition"],
333
349
  "README.md": [
package/lib/inspect.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface InspectionTarget {tag:string;id:string|null;className:string;label:string;}
2
- export interface InspectionIssue {id:'icons.text-glyph'|'icons.unslotted-svg'|'table.mixed-row-centre'|'table.word-fragment'|'copy.action-echo'|'copy.unowned-support'|'forms.track-drift'|'forms.content-track-drift'|'layout.page-overflow';target:InspectionTarget;related?:InspectionTarget;message:string;offset?:number;word?:string;}
2
+ export interface InspectionIssue {id:'icons.text-glyph'|'icons.unslotted-svg'|'table.mixed-row-centre'|'table.word-fragment'|'copy.action-echo'|'copy.unowned-support'|'forms.track-drift'|'forms.content-track-drift'|'layout.page-overflow'|'layout.record-scroll-owner';target:InspectionTarget;related?:InspectionTarget;message:string;offset?:number;word?:string;}
3
3
  export interface RenderedUIInspection {
4
4
  schemaVersion:1;issues:InspectionIssue[];copy:InspectionTarget[];
5
5
  fields:(InspectionTarget&{x:number;y:number;width:number;height:number})[];
@@ -7,6 +7,18 @@ export interface RenderedUIInspection {
7
7
  }
8
8
  /** Invoke inside a browser document, e.g. page.evaluate(inspectRenderedUI). No mutation or network calls. */
9
9
  export declare function inspectRenderedUI(options?:{rootSelector?:string;maxElements?:number}):RenderedUIInspection;
10
- export interface SourceUIIssue {id:'composition.native-dialog'|'composition.native-control'|'composition.layout-owner'|'composition.scaled-minimum';line:number;column:number;excerpt:string;owner:string;message:string;}
10
+ export interface WorkspaceScrollOptions {
11
+ /** Selector for one full-window AppShell. Defaults to .flood-app-shell. */
12
+ rootSelector?: string;
13
+ /** Set true for overflowing fixtures or false for fixtures whose content must fit. */
14
+ expectOverflow?: boolean;
15
+ }
16
+ /** Run with page.evaluate after the workspace settles. Throws on a broken scroll contract. */
17
+ export declare function assertWorkspaceScroll(options?: WorkspaceScrollOptions): {
18
+ documentGutter: number;
19
+ documentOverflow: number;
20
+ workspaceOverflow: number;
21
+ };
22
+ export interface SourceUIIssue {id:'composition.native-dialog'|'composition.native-control'|'composition.page-scroll'|'composition.layout-owner'|'composition.scaled-minimum';line:number;column:number;excerpt:string;owner:string;message:string;}
11
23
  /** Text observations, not an AST or conformance check. No file access, execution or network. */
12
24
  export declare function inspectSourceUI(source:string,options?:{format?:'jsx'|'css';maxFindings?:number}):{schemaVersion:1;issues:SourceUIIssue[];truncated:boolean;notChecked:string[]};