@utopia-studio-design/design-system-cli 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +23 -0
  2. package/bin/utopia-ds-mcp.mjs +9 -2
  3. package/bin/utopia-ds.mjs +106 -8
  4. package/data/blocks/activity-feed/ActivityFeedBlock.tsx +255 -0
  5. package/data/blocks/activity-feed/README.md +16 -0
  6. package/data/blocks/activity-feed/activity-feed-contract.ts +96 -0
  7. package/data/blocks/activity-feed/activity-feed-fixture.ts +22 -0
  8. package/data/blocks/activity-feed/activity-feed.css +154 -0
  9. package/data/blocks/agent-status/AgentStatusBlock.tsx +313 -0
  10. package/data/blocks/agent-status/README.md +15 -0
  11. package/data/blocks/agent-status/agent-status-contract.ts +133 -0
  12. package/data/blocks/agent-status/agent-status-fixture.ts +79 -0
  13. package/data/blocks/agent-status/agent-status.css +216 -0
  14. package/data/blocks/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
  15. package/data/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
  16. package/data/blocks/analytics-dashboard/README.md +17 -0
  17. package/data/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
  18. package/data/blocks/calendar-application/CalendarApplicationBlocks.tsx +519 -0
  19. package/data/blocks/calendar-application/README.md +20 -0
  20. package/data/blocks/calendar-application/calendar-application.css +216 -0
  21. package/data/blocks/calendar-application/calendar-localization.ts +90 -0
  22. package/data/blocks/calendar-application/calendar-panel-contract.ts +72 -0
  23. package/data/blocks/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
  24. package/data/blocks/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
  25. package/data/blocks/dashboard-composer/README.md +16 -0
  26. package/data/blocks/dashboard-composer/RegistryDataBlocks.tsx +238 -0
  27. package/data/blocks/dashboard-composer/dashboard-composer.css +328 -0
  28. package/data/blocks/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
  29. package/data/blocks/database-workspace/README.md +9 -0
  30. package/data/blocks/database-workspace/database-workspace.css +107 -0
  31. package/data/blocks/interaction-workbench/InteractionWorkbench.tsx +271 -0
  32. package/data/blocks/interaction-workbench/README.md +9 -0
  33. package/data/blocks/interaction-workbench/interaction-workbench.css +226 -0
  34. package/data/docs/calendar.md +164 -0
  35. package/data/docs/charts.md +95 -0
  36. package/data/docs/composition-registry.md +174 -0
  37. package/data/docs/dashboard-composer.md +94 -0
  38. package/data/docs/database.md +68 -0
  39. package/data/docs/examples/dashboard-layout.v1.json +65 -0
  40. package/data/docs/foundations.md +1 -1
  41. package/data/docs/interaction-foundation.md +109 -0
  42. package/data/docs/quick-start-ai.md +23 -1
  43. package/data/manifests/blocks.json +392 -0
  44. package/data/manifests/catalog.json +7 -4
  45. package/data/manifests/components.json +179 -0
  46. package/data/manifests/composition-registry.json +427 -0
  47. package/data/manifests/motion-profiles.json +1 -1
  48. package/data/manifests/patterns.json +113 -1
  49. package/data/manifests/templates.json +346 -0
  50. package/data/manifests/theme-renacore.json +142 -0
  51. package/data/manifests/themes.json +314 -28
  52. package/data/templates/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
  53. package/data/templates/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
  54. package/data/templates/analytics-dashboard/README.md +6 -0
  55. package/data/templates/analytics-dashboard/analytics-dashboard.css +92 -0
  56. package/data/templates/analytics-dashboard/index.html +13 -0
  57. package/data/templates/analytics-dashboard/main.tsx +196 -0
  58. package/data/templates/analytics-dashboard/styles.css +120 -0
  59. package/data/templates/analytics-dashboard/template.manifest.json +17 -0
  60. package/data/templates/calendar-application/CalendarApplicationBlocks.tsx +519 -0
  61. package/data/templates/calendar-application/README.md +10 -0
  62. package/data/templates/calendar-application/calendar-application.css +216 -0
  63. package/data/templates/calendar-application/calendar-localization.ts +90 -0
  64. package/data/templates/calendar-application/index.html +14 -0
  65. package/data/templates/calendar-application/main.tsx +409 -0
  66. package/data/templates/calendar-application/styles.css +248 -0
  67. package/data/templates/calendar-application/template.manifest.json +27 -0
  68. package/data/templates/composition-registry-lab/README.md +9 -0
  69. package/data/templates/composition-registry-lab/index.html +14 -0
  70. package/data/templates/composition-registry-lab/main.tsx +217 -0
  71. package/data/templates/composition-registry-lab/sample-layout.json +63 -0
  72. package/data/templates/composition-registry-lab/styles.css +343 -0
  73. package/data/templates/composition-registry-lab/template.manifest.json +34 -0
  74. package/data/templates/dashboard-composer/ActivityFeedBlock.tsx +255 -0
  75. package/data/templates/dashboard-composer/AgentStatusBlock.tsx +313 -0
  76. package/data/templates/dashboard-composer/AnalyticsDashboardBlocks.tsx +107 -0
  77. package/data/templates/dashboard-composer/AnalyticsMetricBlocks.tsx +87 -0
  78. package/data/templates/dashboard-composer/CalendarApplicationBlocks.tsx +519 -0
  79. package/data/templates/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
  80. package/data/templates/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
  81. package/data/templates/dashboard-composer/DatabaseWorkspaceBlocks.tsx +240 -0
  82. package/data/templates/dashboard-composer/README.md +53 -0
  83. package/data/templates/dashboard-composer/RegistryDataBlocks.tsx +238 -0
  84. package/data/templates/dashboard-composer/activity-feed-contract.ts +96 -0
  85. package/data/templates/dashboard-composer/activity-feed-fixture.ts +22 -0
  86. package/data/templates/dashboard-composer/activity-feed.css +154 -0
  87. package/data/templates/dashboard-composer/agent-status-contract.ts +133 -0
  88. package/data/templates/dashboard-composer/agent-status-fixture.ts +79 -0
  89. package/data/templates/dashboard-composer/agent-status-lab/index.html +19 -0
  90. package/data/templates/dashboard-composer/agent-status-lab/main.tsx +151 -0
  91. package/data/templates/dashboard-composer/agent-status-lab/styles.css +139 -0
  92. package/data/templates/dashboard-composer/agent-status.css +216 -0
  93. package/data/templates/dashboard-composer/analytics-dashboard.css +92 -0
  94. package/data/templates/dashboard-composer/calendar-application.css +216 -0
  95. package/data/templates/dashboard-composer/calendar-localization.ts +90 -0
  96. package/data/templates/dashboard-composer/calendar-panel-contract.ts +72 -0
  97. package/data/templates/dashboard-composer/dashboard-composer.css +328 -0
  98. package/data/templates/dashboard-composer/database-workspace.css +107 -0
  99. package/data/templates/dashboard-composer/index.html +14 -0
  100. package/data/templates/dashboard-composer/main.tsx +550 -0
  101. package/data/templates/dashboard-composer/sample-layout.json +147 -0
  102. package/data/templates/dashboard-composer/styles.css +198 -0
  103. package/data/templates/dashboard-composer/template.manifest.json +34 -0
  104. package/data/templates/dashboard-composer/visual-editor/PuckEditorSurface.tsx +132 -0
  105. package/data/templates/dashboard-composer/visual-editor/VisualEditorApp.tsx +343 -0
  106. package/data/templates/dashboard-composer/visual-editor/index.html +17 -0
  107. package/data/templates/dashboard-composer/visual-editor/main.tsx +95 -0
  108. package/data/templates/dashboard-composer/visual-editor/styles.css +310 -0
  109. package/data/templates/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
  110. package/data/templates/database-workspace/README.md +5 -0
  111. package/data/templates/database-workspace/database-workspace.css +107 -0
  112. package/data/templates/database-workspace/index.html +14 -0
  113. package/data/templates/database-workspace/main.tsx +247 -0
  114. package/data/templates/database-workspace/styles.css +121 -0
  115. package/data/templates/database-workspace/template.manifest.json +19 -0
  116. package/data/templates/interaction-lab/InteractionWorkbench.tsx +271 -0
  117. package/data/templates/interaction-lab/README.md +7 -0
  118. package/data/templates/interaction-lab/index.html +14 -0
  119. package/data/templates/interaction-lab/interaction-workbench.css +226 -0
  120. package/data/templates/interaction-lab/main.tsx +104 -0
  121. package/data/templates/interaction-lab/styles.css +224 -0
  122. package/data/templates/interaction-lab/template.manifest.json +36 -0
  123. package/lib/api.mjs +72 -5
  124. package/lib/composition-runtime.mjs +382 -0
  125. package/package.json +4 -1
@@ -0,0 +1,247 @@
1
+ import { StrictMode, useMemo, useRef, useState } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import { Button } from '@utopia-studio-design/design-system/Button'
4
+ import type {
5
+ DatabaseMutationAdapter,
6
+ DatabaseQuery,
7
+ DatabaseRecord,
8
+ DatabaseSchema,
9
+ DatabaseState,
10
+ DatabaseView,
11
+ } from '@utopia-studio-design/design-system/Database'
12
+ import {
13
+ DatabaseTableBlock,
14
+ DatabaseToolbar,
15
+ RecordDetailDrawer,
16
+ SavedViewsBar,
17
+ } from './DatabaseWorkspaceBlocks'
18
+ import '@utopia-studio-design/design-system/core.css'
19
+ import '@utopia-studio-design/design-system/themes/utopia-default.css'
20
+ import './database-workspace.css'
21
+ import './styles.css'
22
+
23
+ const schema: DatabaseSchema = {
24
+ id: 'product-work',
25
+ title: 'Product work',
26
+ primaryPropertyId: 'title',
27
+ properties: [
28
+ { id: 'title', label: 'Work item', type: 'text', required: true, width: 260 },
29
+ {
30
+ id: 'status',
31
+ label: 'Status',
32
+ type: 'select',
33
+ width: 150,
34
+ options: [
35
+ { id: 'backlog', label: 'Backlog' },
36
+ { id: 'progress', label: 'In progress', tone: 'warning' },
37
+ { id: 'review', label: 'In review' },
38
+ { id: 'done', label: 'Done', tone: 'positive' },
39
+ ],
40
+ },
41
+ {
42
+ id: 'tags',
43
+ label: 'Tags',
44
+ type: 'multi-select',
45
+ width: 190,
46
+ options: [
47
+ { id: 'design', label: 'Design' },
48
+ { id: 'research', label: 'Research' },
49
+ { id: 'platform', label: 'Platform' },
50
+ ],
51
+ },
52
+ { id: 'due', label: 'Due date', type: 'date', width: 160 },
53
+ { id: 'owner', label: 'Owner', type: 'person', width: 150 },
54
+ { id: 'estimate', label: 'Estimate', type: 'number', min: 0, max: 100, width: 120 },
55
+ { id: 'approved', label: 'Approved', type: 'checkbox', width: 120 },
56
+ { id: 'reference', label: 'Reference', type: 'url', width: 240 },
57
+ ],
58
+ }
59
+
60
+ const titles = [
61
+ 'Audit dashboard navigation',
62
+ 'Define analytics empty states',
63
+ 'Review record detail behavior',
64
+ 'Test Arabic mixed-script labels',
65
+ 'Document optimistic recovery',
66
+ 'Validate mobile record cards',
67
+ 'Measure virtual scroll latency',
68
+ 'Prepare accessibility review',
69
+ ]
70
+ const owners = ['Aiden', 'Mina', 'Haajar', 'Sora']
71
+ const statuses = ['backlog', 'progress', 'review', 'done']
72
+
73
+ const initialRecords: DatabaseRecord[] = Array.from({ length: 1000 }, (_, index) => ({
74
+ id: `record-${index + 1}`,
75
+ values: {
76
+ title: `${titles[index % titles.length]} ${index + 1}`,
77
+ status: statuses[index % statuses.length],
78
+ tags: index % 3 === 0 ? ['design', 'research'] : index % 3 === 1 ? ['platform'] : ['design'],
79
+ due: `2026-${String(((index + 7) % 12) + 1).padStart(2, '0')}-${String((index % 24) + 1).padStart(2, '0')}`,
80
+ owner: owners[index % owners.length],
81
+ estimate: (index % 13) + 1,
82
+ approved: index % 4 === 0,
83
+ reference: `https://example.com/work/${index + 1}`,
84
+ },
85
+ }))
86
+
87
+ const views: DatabaseView[] = [
88
+ { id: 'all', label: 'All work', query: {} },
89
+ {
90
+ id: 'active',
91
+ label: 'Active',
92
+ query: { filters: [{ id: 'active-status', propertyId: 'status', operator: 'not-equals', value: 'done' }] },
93
+ },
94
+ {
95
+ id: 'review',
96
+ label: 'In review',
97
+ query: { filters: [{ id: 'review-status', propertyId: 'status', operator: 'equals', value: 'review' }] },
98
+ },
99
+ {
100
+ id: 'compact',
101
+ label: 'Compact delivery',
102
+ query: { sorts: [{ propertyId: 'due', direction: 'ascending' }] },
103
+ hiddenPropertyIds: ['tags', 'reference'],
104
+ },
105
+ {
106
+ id: 'by-status',
107
+ label: 'By status',
108
+ query: { groupBy: 'status', sorts: [{ propertyId: 'due', direction: 'ascending' }] },
109
+ },
110
+ ]
111
+
112
+ function App() {
113
+ const [records, setRecords] = useState(initialRecords)
114
+ const [activeViewId, setActiveViewId] = useState('all')
115
+ const [search, setSearch] = useState('')
116
+ const [direction, setDirection] = useState<'ltr' | 'rtl'>('ltr')
117
+ const [dark, setDark] = useState(false)
118
+ const [state, setState] = useState<DatabaseState>('ready')
119
+ const [canEdit, setCanEdit] = useState(true)
120
+ const [openRecord, setOpenRecord] = useState<DatabaseRecord | null>(null)
121
+ const [clipboardStatus, setClipboardStatus] = useState('Clipboard boundary idle')
122
+ const failNext = useRef(false)
123
+ const activeView = views.find((view) => view.id === activeViewId) ?? views[0]
124
+ const query = useMemo<DatabaseQuery>(() => ({
125
+ ...activeView.query,
126
+ filters: [
127
+ ...(activeView.query.filters ?? []),
128
+ ...(search
129
+ ? [{ id: 'search', propertyId: 'title', operator: 'contains' as const, value: search }]
130
+ : []),
131
+ ],
132
+ }), [activeView, search])
133
+ const adapter = useMemo<DatabaseMutationAdapter>(() => ({
134
+ async commit() {
135
+ const shouldFail = failNext.current
136
+ failNext.current = false
137
+ await new Promise((resolve) => window.setTimeout(resolve, 60))
138
+ if (shouldFail) throw new Error('Fixture mutation rejected')
139
+ },
140
+ }), [])
141
+
142
+ return (
143
+ <main
144
+ className="database-template"
145
+ data-color-mode={dark ? 'dark' : 'light'}
146
+ data-theme="utopia-default"
147
+ dir="ltr"
148
+ id="main-content"
149
+ >
150
+ <header className="database-template-header">
151
+ <div>
152
+ <span>Database workspace</span>
153
+ <h1>Plan product work as structured records</h1>
154
+ <p>1,000 records · schema-driven properties · controlled persistence</p>
155
+ </div>
156
+ <div>
157
+ <Button variant="outline" onClick={() => setDirection((value) => value === 'ltr' ? 'rtl' : 'ltr')}>
158
+ {direction === 'ltr' ? 'Switch grid to RTL' : 'Switch grid to LTR'}
159
+ </Button>
160
+ <Button variant="outline" onClick={() => setDark((value) => !value)}>
161
+ {dark ? 'Switch to light' : 'Switch to dark'}
162
+ </Button>
163
+ </div>
164
+ </header>
165
+
166
+ <section aria-label="Database fixture controls" className="database-fixture-controls">
167
+ <div>
168
+ <span>Data state</span>
169
+ <div>
170
+ {(['ready', 'loading', 'empty', 'error'] as DatabaseState[]).map((item) => (
171
+ <Button
172
+ aria-pressed={state === item}
173
+ key={item}
174
+ size="sm"
175
+ variant={state === item ? 'default' : 'outline'}
176
+ onClick={() => setState(item)}
177
+ >
178
+ {item}
179
+ </Button>
180
+ ))}
181
+ </div>
182
+ </div>
183
+ <Button aria-pressed={!canEdit} size="sm" variant="outline" onClick={() => setCanEdit((value) => !value)}>
184
+ {canEdit ? 'Test read-only permissions' : 'Enable editing'}
185
+ </Button>
186
+ <Button size="sm" variant="outline" onClick={() => { failNext.current = true }}>
187
+ Fail next save
188
+ </Button>
189
+ </section>
190
+
191
+ <section aria-labelledby="workspace-title" className="database-workspace">
192
+ <header>
193
+ <span>Workspace</span>
194
+ <h2 id="workspace-title">Product delivery database</h2>
195
+ <p>Double-click or press Enter on a cell to edit. Arrow keys move by logical grid direction.</p>
196
+ </header>
197
+ <SavedViewsBar activeViewId={activeViewId} views={views} onViewChange={setActiveViewId} />
198
+ <DatabaseToolbar
199
+ activeFilterCount={query.filters?.length}
200
+ activeSortCount={query.sorts?.length}
201
+ groupLabel={query.groupBy ? schema.properties.find((property) => property.id === query.groupBy)?.label : undefined}
202
+ searchValue={search}
203
+ onCreate={() => setRecords((current) => [{
204
+ id: `record-${current.length + 1}`,
205
+ values: { title: 'Untitled work item', status: 'backlog', tags: [], due: null, owner: null, estimate: null, approved: false, reference: null },
206
+ }, ...current])}
207
+ onSearchChange={setSearch}
208
+ />
209
+ <output aria-live="polite" className="database-clipboard-status">{clipboardStatus}</output>
210
+ <DatabaseTableBlock
211
+ adapter={adapter}
212
+ direction={direction}
213
+ permissions={{
214
+ edit: canEdit,
215
+ bulkActions: canEdit,
216
+ reorderColumns: canEdit,
217
+ reorderRows: canEdit,
218
+ resizeColumns: canEdit,
219
+ }}
220
+ query={query}
221
+ records={records}
222
+ schema={{ ...schema, properties: schema.properties.map((property) => ({
223
+ ...property,
224
+ hidden: activeView.hiddenPropertyIds?.includes(property.id),
225
+ })) }}
226
+ state={state}
227
+ onCopyCell={(text, _recordId, propertyId) => setClipboardStatus(`Copied ${propertyId}: ${text}`)}
228
+ onPasteCell={(_recordId, propertyId) => setClipboardStatus(`Paste requested for ${propertyId}`)}
229
+ onRecordOpen={setOpenRecord}
230
+ onRecordsChange={setRecords}
231
+ />
232
+ </section>
233
+ <RecordDetailDrawer
234
+ open={Boolean(openRecord)}
235
+ record={openRecord}
236
+ schema={schema}
237
+ onClose={() => setOpenRecord(null)}
238
+ />
239
+ </main>
240
+ )
241
+ }
242
+
243
+ createRoot(document.getElementById('root')!).render(
244
+ <StrictMode>
245
+ <App />
246
+ </StrictMode>,
247
+ )
@@ -0,0 +1,121 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ html,
6
+ body,
7
+ #root {
8
+ min-block-size: 100%;
9
+ margin: 0;
10
+ }
11
+
12
+ body {
13
+ background: var(--background);
14
+ color: var(--foreground);
15
+ font-family: var(--font-sans);
16
+ }
17
+
18
+ .skip-link {
19
+ position: fixed;
20
+ inset-block-start: var(--space-2);
21
+ inset-inline-start: var(--space-2);
22
+ z-index: var(--z-modal);
23
+ translate: 0 -200%;
24
+ }
25
+
26
+ .skip-link:focus {
27
+ translate: 0;
28
+ }
29
+
30
+ .database-template {
31
+ min-block-size: 100vh;
32
+ padding: var(--page-gutter);
33
+ background: var(--background);
34
+ color: var(--foreground);
35
+ }
36
+
37
+ .database-template > * {
38
+ max-inline-size: var(--container-wide);
39
+ margin-inline: auto;
40
+ }
41
+
42
+ .database-template-header {
43
+ display: flex;
44
+ align-items: end;
45
+ justify-content: space-between;
46
+ gap: var(--space-6);
47
+ padding-block: var(--space-6);
48
+ border-block-end: var(--border-width-hairline) solid var(--border);
49
+ }
50
+
51
+ .database-template-header > div:first-child,
52
+ .database-workspace,
53
+ .database-workspace > header {
54
+ display: grid;
55
+ gap: var(--space-2);
56
+ }
57
+
58
+ .database-template-header > div:last-child,
59
+ .database-fixture-controls,
60
+ .database-fixture-controls > div > div {
61
+ display: flex;
62
+ flex-wrap: wrap;
63
+ gap: var(--space-2);
64
+ }
65
+
66
+ .database-template :is(h1, h2, p) {
67
+ margin: 0;
68
+ }
69
+
70
+ .database-template-header span,
71
+ .database-fixture-controls > div > span,
72
+ .database-workspace > header > span {
73
+ color: var(--muted-foreground);
74
+ font-size: var(--font-size-eyebrow);
75
+ font-weight: var(--font-weight-semibold);
76
+ letter-spacing: var(--tracking-eyebrow);
77
+ text-transform: uppercase;
78
+ }
79
+
80
+ .database-template-header p,
81
+ .database-workspace > header p {
82
+ color: var(--muted-foreground);
83
+ }
84
+
85
+ .database-fixture-controls {
86
+ align-items: end;
87
+ justify-content: space-between;
88
+ padding-block: var(--space-4);
89
+ border-block-end: var(--border-width-hairline) solid var(--border);
90
+ }
91
+
92
+ .database-fixture-controls > div {
93
+ display: grid;
94
+ gap: var(--space-2);
95
+ }
96
+
97
+ .database-workspace {
98
+ min-inline-size: 0;
99
+ padding-block: var(--space-6);
100
+ }
101
+
102
+ .database-clipboard-status {
103
+ color: var(--muted-foreground);
104
+ font-size: var(--font-size-sm);
105
+ }
106
+
107
+ @media (max-width: 44rem) {
108
+ .database-template-header {
109
+ align-items: stretch;
110
+ flex-direction: column;
111
+ }
112
+
113
+ .database-template-header > div:last-child {
114
+ display: grid;
115
+ }
116
+
117
+ .database-fixture-controls {
118
+ align-items: stretch;
119
+ flex-direction: column;
120
+ }
121
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "id": "database-workspace",
3
+ "title": "Database Workspace",
4
+ "status": "available",
5
+ "route": "/templates/database-workspace/",
6
+ "entry": "templates/database-workspace/main.tsx",
7
+ "blocks": [
8
+ "database-toolbar",
9
+ "database-table-block",
10
+ "saved-views-bar",
11
+ "record-detail-drawer",
12
+ "bulk-action-bar"
13
+ ],
14
+ "states": ["loading", "empty", "error", "ready"],
15
+ "themes": ["utopia-default"],
16
+ "directions": ["ltr", "rtl"],
17
+ "responsiveMinimum": "320px",
18
+ "largeFixtureRecords": 1000
19
+ }
@@ -0,0 +1,271 @@
1
+ import * as React from 'react'
2
+ import {
3
+ createCommandHistory,
4
+ findTypeaheadMatch,
5
+ getGridNavigationTarget,
6
+ getVirtualRange,
7
+ reorderItems,
8
+ updateSelection,
9
+ type InteractionCommand,
10
+ type SelectionState,
11
+ } from '@utopia-studio-design/design-system/Interaction'
12
+ import { Button } from '@utopia-studio-design/design-system/Button'
13
+ import './interaction-workbench.css'
14
+
15
+ type WorkItem = { id: string; label: string }
16
+
17
+ const initialItems: WorkItem[] = [
18
+ { id: 'intake', label: 'Intake' },
19
+ { id: 'review', label: 'Review' },
20
+ { id: 'decision', label: 'Decision' },
21
+ { id: 'delivery', label: 'Delivery' },
22
+ { id: 'follow-up', label: 'Follow up' },
23
+ { id: 'archive', label: 'Archive' },
24
+ ]
25
+
26
+ const compactStageQuery = '(max-width: 44rem)'
27
+
28
+ function useStageColumnCount() {
29
+ const [columnCount, setColumnCount] = React.useState(2)
30
+
31
+ React.useEffect(() => {
32
+ const media = window.matchMedia(compactStageQuery)
33
+ const update = () => setColumnCount(media.matches ? 1 : 2)
34
+ update()
35
+ media.addEventListener('change', update)
36
+ return () => media.removeEventListener('change', update)
37
+ }, [])
38
+
39
+ return columnCount
40
+ }
41
+
42
+ function reorderCommand(items: WorkItem[], fromIndex: number, toIndex: number): InteractionCommand<WorkItem[]> {
43
+ const next = reorderItems(items, fromIndex, toIndex).items
44
+ return {
45
+ label: `Move ${items[fromIndex]?.label ?? 'item'}`,
46
+ execute: () => next,
47
+ undo: () => items,
48
+ }
49
+ }
50
+
51
+ export function InteractionWorkbench({ direction = 'ltr' }: { direction?: 'ltr' | 'rtl' }) {
52
+ const [items, setItems] = React.useState(initialItems)
53
+ const history = React.useRef(createCommandHistory(initialItems, setItems))
54
+ const [selection, setSelection] = React.useState<SelectionState<string>>({
55
+ selectedKeys: new Set(),
56
+ anchorKey: null,
57
+ })
58
+ const [activeIndex, setActiveIndex] = React.useState(0)
59
+ const [width, setWidth] = React.useState(320)
60
+ const [scrollOffset, setScrollOffset] = React.useState(0)
61
+ const [announcement, setAnnouncement] = React.useState('Interaction workbench ready')
62
+ const [typeahead, setTypeahead] = React.useState('')
63
+ const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([])
64
+ const dragIndex = React.useRef<number | null>(null)
65
+ const columnCount = useStageColumnCount()
66
+ const virtual = getVirtualRange({
67
+ itemCount: 1000,
68
+ itemSize: 36,
69
+ viewportSize: 180,
70
+ scrollOffset,
71
+ overscan: 3,
72
+ })
73
+
74
+ const focusIndex = (index: number) => {
75
+ setActiveIndex(index)
76
+ requestAnimationFrame(() => itemRefs.current[index]?.focus())
77
+ }
78
+
79
+ const move = (fromIndex: number, toIndex: number) => {
80
+ if (toIndex < 0 || toIndex >= items.length || fromIndex === toIndex) return
81
+ const moved = items[fromIndex]
82
+ history.current.execute(reorderCommand(items, fromIndex, toIndex))
83
+ setAnnouncement(`${moved.label} moved to position ${toIndex + 1} of ${items.length}`)
84
+ focusIndex(toIndex)
85
+ }
86
+
87
+ const onItemKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
88
+ if (columnCount === 1 && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) return
89
+ const navigationTarget = getGridNavigationTarget({
90
+ currentIndex: index,
91
+ itemCount: items.length,
92
+ columnCount,
93
+ key: event.key,
94
+ direction,
95
+ })
96
+ if (navigationTarget !== index) {
97
+ event.preventDefault()
98
+ focusIndex(navigationTarget)
99
+ return
100
+ }
101
+ if (event.key === ' ' || event.key === 'Enter') {
102
+ event.preventDefault()
103
+ setSelection((current) => updateSelection({
104
+ state: current,
105
+ orderedKeys: items.map((item) => item.id),
106
+ key: items[index].id,
107
+ behavior: event.shiftKey ? 'range' : event.metaKey || event.ctrlKey ? 'toggle' : 'replace',
108
+ }))
109
+ return
110
+ }
111
+ if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
112
+ const query = `${typeahead}${event.key}`
113
+ const match = findTypeaheadMatch(items.map((item) => item.label), query, index)
114
+ setTypeahead(query)
115
+ window.setTimeout(() => setTypeahead(''), 500)
116
+ if (match >= 0) focusIndex(match)
117
+ }
118
+ }
119
+
120
+ return (
121
+ <section className="interaction-workbench" dir={direction} aria-labelledby="interaction-workbench-title">
122
+ <header>
123
+ <div>
124
+ <p className="interaction-eyebrow">Hands-on review</p>
125
+ <h2 id="interaction-workbench-title">Try the shared interactions</h2>
126
+ <p className="interaction-supporting">The same behavior will be reused by charts, tables, and calendars.</p>
127
+ </div>
128
+ <div className="interaction-history" aria-label="History controls">
129
+ <Button size="sm" variant="outline" disabled={!history.current.canUndo()} onClick={() => {
130
+ history.current.undo()
131
+ setAnnouncement('Last move undone')
132
+ }}>Undo</Button>
133
+ <Button size="sm" variant="outline" disabled={!history.current.canRedo()} onClick={() => {
134
+ history.current.redo()
135
+ setAnnouncement('Move redone')
136
+ }}>Redo</Button>
137
+ </div>
138
+ </header>
139
+
140
+ <div className="interaction-layout">
141
+ <div className="interaction-panel">
142
+ <div className="interaction-panel-heading">
143
+ <div>
144
+ <p className="interaction-step">Task 1</p>
145
+ <h3>Select and move workflow stages</h3>
146
+ </div>
147
+ <Button
148
+ size="sm"
149
+ variant="ghost"
150
+ onClick={() => setSelection((current) => updateSelection({
151
+ state: current,
152
+ orderedKeys: items.map((item) => item.id),
153
+ behavior: 'all',
154
+ }))}
155
+ >
156
+ Select all
157
+ </Button>
158
+ </div>
159
+ <p className="interaction-instruction">
160
+ Try clicking a stage, pressing Space, dragging it, or using the − / + move buttons. Undo should restore the previous order.
161
+ </p>
162
+ <div className="interaction-current-actions" aria-label="Move focused stage">
163
+ <span>Focused stage: <strong>{items[activeIndex]?.label}</strong></span>
164
+ <div>
165
+ <Button
166
+ aria-label={`Move ${items[activeIndex]?.label} earlier`}
167
+ disabled={activeIndex === 0}
168
+ onClick={() => move(activeIndex, activeIndex - 1)}
169
+ size="sm"
170
+ variant="outline"
171
+ >
172
+ − Move earlier
173
+ </Button>
174
+ <Button
175
+ aria-label={`Move ${items[activeIndex]?.label} later`}
176
+ disabled={activeIndex === items.length - 1}
177
+ onClick={() => move(activeIndex, activeIndex + 1)}
178
+ size="sm"
179
+ variant="outline"
180
+ >
181
+ + Move later
182
+ </Button>
183
+ </div>
184
+ </div>
185
+ <ul
186
+ aria-label="Workflow stages"
187
+ className="interaction-grid"
188
+ data-column-count={columnCount}
189
+ >
190
+ {items.map((item, index) => (
191
+ <li className="interaction-item-shell" key={item.id}>
192
+ <button
193
+ aria-pressed={selection.selectedKeys.has(item.id)}
194
+ className="interaction-item"
195
+ data-workflow-stage
196
+ draggable
197
+ onDragStart={() => { dragIndex.current = index }}
198
+ onDragOver={(event) => event.preventDefault()}
199
+ onDrop={() => {
200
+ if (dragIndex.current !== null) move(dragIndex.current, index)
201
+ dragIndex.current = null
202
+ }}
203
+ onKeyDown={(event) => onItemKeyDown(event, index)}
204
+ onClick={() => {
205
+ setActiveIndex(index)
206
+ setSelection((current) => updateSelection({
207
+ state: current,
208
+ orderedKeys: items.map((entry) => entry.id),
209
+ key: item.id,
210
+ behavior: 'replace',
211
+ }))
212
+ }}
213
+ onFocus={() => setActiveIndex(index)}
214
+ ref={(node) => { itemRefs.current[index] = node }}
215
+ tabIndex={activeIndex === index ? 0 : -1}
216
+ type="button"
217
+ >
218
+ <span>{item.label}</span>
219
+ <small>{index + 1}</small>
220
+ </button>
221
+ </li>
222
+ ))}
223
+ </ul>
224
+ </div>
225
+
226
+ <div className="interaction-panel">
227
+ <div>
228
+ <p className="interaction-step">Task 2</p>
229
+ <h3>Resize and scroll a large list</h3>
230
+ </div>
231
+ <p className="interaction-instruction">
232
+ Change the panel width, then scroll toward record 1,000. Only the visible rows should render and the page itself should never scroll sideways.
233
+ </p>
234
+ <label className="interaction-resize">
235
+ <span>Panel inline size</span>
236
+ <output>{width}px</output>
237
+ <input min="240" max="480" step="8" type="range" value={width} onChange={(event) => setWidth(event.currentTarget.valueAsNumber)} />
238
+ </label>
239
+ <div className="interaction-virtual-shell" style={{ inlineSize: `min(100%, ${width}px)` }}>
240
+ <div
241
+ className="interaction-virtual-scroll"
242
+ onScroll={(event) => setScrollOffset(event.currentTarget.scrollTop)}
243
+ tabIndex={0}
244
+ aria-label="One thousand virtualized records"
245
+ role="list"
246
+ >
247
+ <div style={{ blockSize: virtual.totalSize, position: 'relative' }}>
248
+ {Array.from({ length: virtual.endIndex - virtual.startIndex + 1 }, (_, offset) => {
249
+ const index = virtual.startIndex + offset
250
+ return (
251
+ <div
252
+ aria-posinset={index + 1}
253
+ aria-setsize={1000}
254
+ className="interaction-virtual-row"
255
+ key={index}
256
+ role="listitem"
257
+ style={{ insetBlockStart: index * 36 }}
258
+ >
259
+ Record {String(index + 1).padStart(4, '0')}
260
+ </div>
261
+ )
262
+ })}
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ <p aria-live="polite" className="interaction-announcement"><strong>Result:</strong> {announcement}</p>
269
+ </section>
270
+ )
271
+ }
@@ -0,0 +1,7 @@
1
+ # Ceramic Interaction Lab
2
+
3
+ Use this runnable template while building Charts, Database, Calendar, or
4
+ Scheduler surfaces. It exercises the shared interaction foundation with
5
+ deterministic local data and no product-specific workflow.
6
+
7
+ Run at 320px and desktop widths, in LTR and RTL, with reduced motion enabled.
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en" dir="ltr" data-theme="utopia-default" data-color-mode="light">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="Ceramic shared interaction contract lab." />
7
+ <title>Ceramic Interaction Lab</title>
8
+ </head>
9
+ <body>
10
+ <a class="skip-link" href="#main-content">Skip to interaction lab</a>
11
+ <div id="root"></div>
12
+ <script type="module" src="./main.tsx"></script>
13
+ </body>
14
+ </html>