@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.
- package/README.md +23 -0
- package/bin/utopia-ds-mcp.mjs +9 -2
- package/bin/utopia-ds.mjs +106 -8
- package/data/blocks/activity-feed/ActivityFeedBlock.tsx +255 -0
- package/data/blocks/activity-feed/README.md +16 -0
- package/data/blocks/activity-feed/activity-feed-contract.ts +96 -0
- package/data/blocks/activity-feed/activity-feed-fixture.ts +22 -0
- package/data/blocks/activity-feed/activity-feed.css +154 -0
- package/data/blocks/agent-status/AgentStatusBlock.tsx +313 -0
- package/data/blocks/agent-status/README.md +15 -0
- package/data/blocks/agent-status/agent-status-contract.ts +133 -0
- package/data/blocks/agent-status/agent-status-fixture.ts +79 -0
- package/data/blocks/agent-status/agent-status.css +216 -0
- package/data/blocks/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
- package/data/blocks/analytics-dashboard/README.md +17 -0
- package/data/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
- package/data/blocks/calendar-application/CalendarApplicationBlocks.tsx +519 -0
- package/data/blocks/calendar-application/README.md +20 -0
- package/data/blocks/calendar-application/calendar-application.css +216 -0
- package/data/blocks/calendar-application/calendar-localization.ts +90 -0
- package/data/blocks/calendar-application/calendar-panel-contract.ts +72 -0
- package/data/blocks/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
- package/data/blocks/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
- package/data/blocks/dashboard-composer/README.md +16 -0
- package/data/blocks/dashboard-composer/RegistryDataBlocks.tsx +238 -0
- package/data/blocks/dashboard-composer/dashboard-composer.css +328 -0
- package/data/blocks/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/blocks/database-workspace/README.md +9 -0
- package/data/blocks/database-workspace/database-workspace.css +107 -0
- package/data/blocks/interaction-workbench/InteractionWorkbench.tsx +271 -0
- package/data/blocks/interaction-workbench/README.md +9 -0
- package/data/blocks/interaction-workbench/interaction-workbench.css +226 -0
- package/data/docs/calendar.md +164 -0
- package/data/docs/charts.md +95 -0
- package/data/docs/composition-registry.md +174 -0
- package/data/docs/dashboard-composer.md +94 -0
- package/data/docs/database.md +68 -0
- package/data/docs/examples/dashboard-layout.v1.json +65 -0
- package/data/docs/foundations.md +1 -1
- package/data/docs/interaction-foundation.md +109 -0
- package/data/docs/quick-start-ai.md +23 -1
- package/data/manifests/blocks.json +392 -0
- package/data/manifests/catalog.json +7 -4
- package/data/manifests/components.json +179 -0
- package/data/manifests/composition-registry.json +427 -0
- package/data/manifests/motion-profiles.json +1 -1
- package/data/manifests/patterns.json +113 -1
- package/data/manifests/templates.json +346 -0
- package/data/manifests/theme-renacore.json +142 -0
- package/data/manifests/themes.json +314 -28
- package/data/templates/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/templates/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
- package/data/templates/analytics-dashboard/README.md +6 -0
- package/data/templates/analytics-dashboard/analytics-dashboard.css +92 -0
- package/data/templates/analytics-dashboard/index.html +13 -0
- package/data/templates/analytics-dashboard/main.tsx +196 -0
- package/data/templates/analytics-dashboard/styles.css +120 -0
- package/data/templates/analytics-dashboard/template.manifest.json +17 -0
- package/data/templates/calendar-application/CalendarApplicationBlocks.tsx +519 -0
- package/data/templates/calendar-application/README.md +10 -0
- package/data/templates/calendar-application/calendar-application.css +216 -0
- package/data/templates/calendar-application/calendar-localization.ts +90 -0
- package/data/templates/calendar-application/index.html +14 -0
- package/data/templates/calendar-application/main.tsx +409 -0
- package/data/templates/calendar-application/styles.css +248 -0
- package/data/templates/calendar-application/template.manifest.json +27 -0
- package/data/templates/composition-registry-lab/README.md +9 -0
- package/data/templates/composition-registry-lab/index.html +14 -0
- package/data/templates/composition-registry-lab/main.tsx +217 -0
- package/data/templates/composition-registry-lab/sample-layout.json +63 -0
- package/data/templates/composition-registry-lab/styles.css +343 -0
- package/data/templates/composition-registry-lab/template.manifest.json +34 -0
- package/data/templates/dashboard-composer/ActivityFeedBlock.tsx +255 -0
- package/data/templates/dashboard-composer/AgentStatusBlock.tsx +313 -0
- package/data/templates/dashboard-composer/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/templates/dashboard-composer/AnalyticsMetricBlocks.tsx +87 -0
- package/data/templates/dashboard-composer/CalendarApplicationBlocks.tsx +519 -0
- package/data/templates/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
- package/data/templates/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
- package/data/templates/dashboard-composer/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/templates/dashboard-composer/README.md +53 -0
- package/data/templates/dashboard-composer/RegistryDataBlocks.tsx +238 -0
- package/data/templates/dashboard-composer/activity-feed-contract.ts +96 -0
- package/data/templates/dashboard-composer/activity-feed-fixture.ts +22 -0
- package/data/templates/dashboard-composer/activity-feed.css +154 -0
- package/data/templates/dashboard-composer/agent-status-contract.ts +133 -0
- package/data/templates/dashboard-composer/agent-status-fixture.ts +79 -0
- package/data/templates/dashboard-composer/agent-status-lab/index.html +19 -0
- package/data/templates/dashboard-composer/agent-status-lab/main.tsx +151 -0
- package/data/templates/dashboard-composer/agent-status-lab/styles.css +139 -0
- package/data/templates/dashboard-composer/agent-status.css +216 -0
- package/data/templates/dashboard-composer/analytics-dashboard.css +92 -0
- package/data/templates/dashboard-composer/calendar-application.css +216 -0
- package/data/templates/dashboard-composer/calendar-localization.ts +90 -0
- package/data/templates/dashboard-composer/calendar-panel-contract.ts +72 -0
- package/data/templates/dashboard-composer/dashboard-composer.css +328 -0
- package/data/templates/dashboard-composer/database-workspace.css +107 -0
- package/data/templates/dashboard-composer/index.html +14 -0
- package/data/templates/dashboard-composer/main.tsx +550 -0
- package/data/templates/dashboard-composer/sample-layout.json +147 -0
- package/data/templates/dashboard-composer/styles.css +198 -0
- package/data/templates/dashboard-composer/template.manifest.json +34 -0
- package/data/templates/dashboard-composer/visual-editor/PuckEditorSurface.tsx +132 -0
- package/data/templates/dashboard-composer/visual-editor/VisualEditorApp.tsx +343 -0
- package/data/templates/dashboard-composer/visual-editor/index.html +17 -0
- package/data/templates/dashboard-composer/visual-editor/main.tsx +95 -0
- package/data/templates/dashboard-composer/visual-editor/styles.css +310 -0
- package/data/templates/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/templates/database-workspace/README.md +5 -0
- package/data/templates/database-workspace/database-workspace.css +107 -0
- package/data/templates/database-workspace/index.html +14 -0
- package/data/templates/database-workspace/main.tsx +247 -0
- package/data/templates/database-workspace/styles.css +121 -0
- package/data/templates/database-workspace/template.manifest.json +19 -0
- package/data/templates/interaction-lab/InteractionWorkbench.tsx +271 -0
- package/data/templates/interaction-lab/README.md +7 -0
- package/data/templates/interaction-lab/index.html +14 -0
- package/data/templates/interaction-lab/interaction-workbench.css +226 -0
- package/data/templates/interaction-lab/main.tsx +104 -0
- package/data/templates/interaction-lab/styles.css +224 -0
- package/data/templates/interaction-lab/template.manifest.json +36 -0
- package/lib/api.mjs +72 -5
- package/lib/composition-runtime.mjs +382 -0
- package/package.json +4 -1
|
@@ -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,9 @@
|
|
|
1
|
+
# Interaction Workbench
|
|
2
|
+
|
|
3
|
+
This block is a test fixture and implementation reference for Ceramic's shared
|
|
4
|
+
interaction contract. Keep product data and business rules outside the block.
|
|
5
|
+
|
|
6
|
+
The example includes responsive logical grid navigation, range/select-all
|
|
7
|
+
behavior, typeahead, pointer drag, a focused-stage move toolbar for keyboard and
|
|
8
|
+
touch, resize, fixed-size virtualization with explicit list semantics, undo, an
|
|
9
|
+
ARIA live region, RTL, reduced motion, and a 320px layout.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
.interaction-workbench {
|
|
2
|
+
display: grid;
|
|
3
|
+
gap: var(--interaction-density-comfortable);
|
|
4
|
+
min-inline-size: 0;
|
|
5
|
+
padding: var(--space-4);
|
|
6
|
+
color: var(--foreground);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.interaction-workbench > header,
|
|
10
|
+
.interaction-panel-heading,
|
|
11
|
+
.interaction-resize {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: space-between;
|
|
15
|
+
gap: var(--space-3);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.interaction-workbench h2,
|
|
19
|
+
.interaction-workbench h3,
|
|
20
|
+
.interaction-workbench p {
|
|
21
|
+
margin: 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.interaction-supporting,
|
|
25
|
+
.interaction-instruction {
|
|
26
|
+
color: var(--muted-foreground);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.interaction-supporting {
|
|
30
|
+
margin-block-start: var(--space-1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.interaction-instruction {
|
|
34
|
+
max-inline-size: 62ch;
|
|
35
|
+
font-size: var(--font-size-small);
|
|
36
|
+
line-height: var(--line-height-body);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.interaction-step {
|
|
40
|
+
color: var(--muted-foreground);
|
|
41
|
+
font-size: var(--font-size-eyebrow);
|
|
42
|
+
font-weight: var(--font-weight-semibold);
|
|
43
|
+
letter-spacing: var(--tracking-eyebrow);
|
|
44
|
+
text-transform: uppercase;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.interaction-eyebrow {
|
|
48
|
+
color: var(--muted-foreground);
|
|
49
|
+
font-size: var(--font-size-eyebrow);
|
|
50
|
+
text-transform: uppercase;
|
|
51
|
+
letter-spacing: var(--tracking-eyebrow);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.interaction-history,
|
|
55
|
+
.interaction-current-actions > div {
|
|
56
|
+
display: flex;
|
|
57
|
+
gap: var(--space-1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.interaction-layout {
|
|
61
|
+
display: grid;
|
|
62
|
+
grid-template-columns: minmax(0, 1fr) minmax(16rem, .75fr);
|
|
63
|
+
gap: var(--space-4);
|
|
64
|
+
min-inline-size: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.interaction-panel {
|
|
68
|
+
display: grid;
|
|
69
|
+
align-content: start;
|
|
70
|
+
gap: var(--interaction-density-default);
|
|
71
|
+
min-inline-size: 0;
|
|
72
|
+
padding: var(--space-3);
|
|
73
|
+
border: var(--border-width-hairline) solid var(--border);
|
|
74
|
+
border-radius: var(--radius-surface);
|
|
75
|
+
background: var(--surface);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.interaction-panel > div:first-child h3 {
|
|
79
|
+
margin-block-start: var(--space-1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.interaction-grid {
|
|
83
|
+
display: grid;
|
|
84
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
85
|
+
gap: var(--interaction-density-compact);
|
|
86
|
+
margin: 0;
|
|
87
|
+
padding: 0;
|
|
88
|
+
list-style: none;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.interaction-item-shell {
|
|
92
|
+
min-inline-size: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.interaction-item {
|
|
96
|
+
border: var(--border-width-hairline) solid var(--border);
|
|
97
|
+
border-radius: var(--radius-control);
|
|
98
|
+
background: var(--background);
|
|
99
|
+
color: var(--foreground);
|
|
100
|
+
font: inherit;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.interaction-item {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: space-between;
|
|
107
|
+
gap: var(--space-2);
|
|
108
|
+
min-block-size: var(--button-height);
|
|
109
|
+
min-inline-size: 0;
|
|
110
|
+
padding-inline: var(--space-3);
|
|
111
|
+
text-align: start;
|
|
112
|
+
cursor: grab;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.interaction-item[aria-pressed='true'] {
|
|
116
|
+
border-color: var(--interaction-selection-border);
|
|
117
|
+
background: var(--interaction-selection-surface);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.interaction-item[draggable='true']:active {
|
|
121
|
+
border-color: var(--interaction-drag-border);
|
|
122
|
+
background: var(--interaction-drag-surface);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.interaction-item:focus-visible,
|
|
126
|
+
.interaction-virtual-scroll:focus-visible,
|
|
127
|
+
.interaction-resize input:focus-visible {
|
|
128
|
+
outline: var(--focus-ring-width) solid var(--interaction-focus);
|
|
129
|
+
outline-offset: var(--focus-ring-offset);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.interaction-current-actions {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: space-between;
|
|
136
|
+
gap: var(--space-3);
|
|
137
|
+
min-inline-size: 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.interaction-current-actions > span {
|
|
141
|
+
color: var(--muted-foreground);
|
|
142
|
+
font-size: var(--font-size-small);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.interaction-current-actions button {
|
|
146
|
+
min-block-size: var(--interaction-target-min);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.interaction-resize {
|
|
150
|
+
flex-wrap: wrap;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.interaction-resize input {
|
|
154
|
+
inline-size: 100%;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.interaction-virtual-shell {
|
|
158
|
+
max-inline-size: 100%;
|
|
159
|
+
min-inline-size: 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.interaction-virtual-scroll {
|
|
163
|
+
block-size: 180px;
|
|
164
|
+
max-inline-size: 100%;
|
|
165
|
+
overflow: auto;
|
|
166
|
+
border: var(--border-width-hairline) solid var(--border);
|
|
167
|
+
background: var(--background);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.interaction-virtual-row {
|
|
171
|
+
position: absolute;
|
|
172
|
+
inset-inline: 0;
|
|
173
|
+
display: flex;
|
|
174
|
+
align-items: center;
|
|
175
|
+
block-size: 36px;
|
|
176
|
+
padding-inline: var(--space-3);
|
|
177
|
+
border-block-end: var(--border-width-hairline) solid var(--border);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.interaction-announcement {
|
|
181
|
+
min-block-size: var(--button-height);
|
|
182
|
+
padding: var(--space-3);
|
|
183
|
+
border-inline-start: var(--border-width-strong) solid var(--interaction-selection-border);
|
|
184
|
+
background: var(--interaction-selection-surface);
|
|
185
|
+
color: var(--foreground);
|
|
186
|
+
font-size: var(--font-size-small);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@media (max-width: 44rem) {
|
|
190
|
+
.interaction-workbench > header,
|
|
191
|
+
.interaction-layout {
|
|
192
|
+
grid-template-columns: 1fr;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.interaction-workbench > header {
|
|
196
|
+
display: grid;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.interaction-history {
|
|
200
|
+
inline-size: 100%;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.interaction-current-actions {
|
|
204
|
+
align-items: stretch;
|
|
205
|
+
flex-direction: column;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.interaction-current-actions > div {
|
|
209
|
+
display: grid;
|
|
210
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.interaction-grid {
|
|
214
|
+
grid-template-columns: 1fr;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@media (prefers-reduced-motion: reduce) {
|
|
219
|
+
.interaction-workbench *,
|
|
220
|
+
.interaction-workbench *::before,
|
|
221
|
+
.interaction-workbench *::after {
|
|
222
|
+
scroll-behavior: auto;
|
|
223
|
+
transition-duration: 0ms;
|
|
224
|
+
animation-duration: 0ms;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Ceramic Calendar
|
|
2
|
+
|
|
3
|
+
Ceramic Calendar is a timezone-aware application scheduler for month, week,
|
|
4
|
+
day, and agenda workflows. It provides interaction, recurrence, conflict,
|
|
5
|
+
permission, and mutation contracts; the consuming product still owns
|
|
6
|
+
persistence, authorization, localization, and domain-specific scheduling rules.
|
|
7
|
+
|
|
8
|
+
The application layer is composed from Ceramic's Shadcn-derived primitives:
|
|
9
|
+
the sidebar uses the `react-day-picker` Calendar contract, toolbars use
|
|
10
|
+
`Button`, `IconButton`, and `ButtonGroup`, timezone selection uses the themed
|
|
11
|
+
Radix `Select`, and event editing uses Ceramic form controls. The scheduler
|
|
12
|
+
engine remains custom because Shadcn's Calendar is a date-selection primitive,
|
|
13
|
+
not a multi-resource event scheduler.
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import {
|
|
17
|
+
SchedulerRoot,
|
|
18
|
+
type SchedulerEvent,
|
|
19
|
+
type SchedulerMutationAdapter,
|
|
20
|
+
} from '@utopia-studio-design/design-system/Calendar'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Stable contracts
|
|
24
|
+
|
|
25
|
+
- Events are stored as ISO instants with stable IDs. Changing the display
|
|
26
|
+
timezone changes labels and placement without rewriting those instants.
|
|
27
|
+
- Calendar cells represent civil dates, not instants. Their `dateTime` values
|
|
28
|
+
are built from local year/month/day parts; never derive a cell date with
|
|
29
|
+
`toISOString().slice(0, 10)`, which can shift the day outside UTC.
|
|
30
|
+
- Recurrence supports daily and weekly series, bounded expansion, occurrence
|
|
31
|
+
identity, and explicit exceptions.
|
|
32
|
+
- `SchedulerMutationAdapter` commits create, update, move, resize, duplicate,
|
|
33
|
+
and delete mutations. Failed commits restore the previous event collection.
|
|
34
|
+
- `SchedulerPermissions` gates every mutation independently. Hidden or disabled
|
|
35
|
+
controls never replace server-side authorization.
|
|
36
|
+
- Availability and event overlap checks return explicit conflict reasons rather
|
|
37
|
+
than silently moving or truncating an event.
|
|
38
|
+
|
|
39
|
+
## Keyboard and accessibility
|
|
40
|
+
|
|
41
|
+
- Every event is a named button. Enter opens the event editor.
|
|
42
|
+
- `Alt+ArrowUp/ArrowDown` moves an event by 15 minutes.
|
|
43
|
+
- `Alt+Shift+ArrowUp/ArrowDown` resizes an event by 15 minutes.
|
|
44
|
+
- `Ctrl/Cmd+D` duplicates and Delete removes an event when permitted.
|
|
45
|
+
- Selecting an event also exposes equivalent pointer and touch actions.
|
|
46
|
+
- Month dates use one roving tab stop. Arrow keys move by day or week,
|
|
47
|
+
Home/End move to the logical week boundary, and RTL reverses horizontal
|
|
48
|
+
movement to match the visual grid.
|
|
49
|
+
- Month exposes a named ARIA grid with one column-header row, six week rows,
|
|
50
|
+
seven grid cells per week, and `aria-selected` on the active civil date.
|
|
51
|
+
- Week, day, and month views retain an accessible agenda alternative.
|
|
52
|
+
- The event editor traps focus, closes with Escape, and restores focus to its
|
|
53
|
+
trigger.
|
|
54
|
+
- The editor keeps incomplete date/time text as a local draft instead of
|
|
55
|
+
converting it on every keystroke. Required fields and `end > start` are
|
|
56
|
+
validated in the drawer, associated with their controls, and focus the first
|
|
57
|
+
invalid field without throwing a runtime error.
|
|
58
|
+
- `EventEditorDrawer.onSave` may be synchronous or return a Promise. While a
|
|
59
|
+
Promise is pending the form is busy and duplicate submission or dismissal is
|
|
60
|
+
blocked. A rejected save keeps the edited values, exposes a localized alert,
|
|
61
|
+
and focuses that alert so the user can retry or cancel.
|
|
62
|
+
- Unsaved drafts should pass `canDelete={false}`. Existing-event deletion
|
|
63
|
+
remains controlled through `onDelete`; persistence and confirmation policy
|
|
64
|
+
stay with the consuming product.
|
|
65
|
+
- `SchedulerRoot.onEventEdit` and `onEventCreate` receive the invoking
|
|
66
|
+
`HTMLElement` as an optional second argument. Store it in a ref and pass that
|
|
67
|
+
ref to `EventEditorDrawer.returnFocusRef`; relying only on
|
|
68
|
+
`document.activeElement` does not restore pointer focus reliably in Safari.
|
|
69
|
+
- Logical inline placement mirrors under RTL. Dates, times, and stored instants
|
|
70
|
+
keep their locale and timezone meaning.
|
|
71
|
+
- View buttons keep `labels.view` as their accessible and wide-layout name.
|
|
72
|
+
Supply `labels.compactView` when a localized mobile label needs a shorter,
|
|
73
|
+
semantically equivalent form.
|
|
74
|
+
- Month overflow counts keep `labels.more` as their accessible and wide-layout
|
|
75
|
+
name. Supply `labels.compactMore` for a localized narrow-cell form such as
|
|
76
|
+
`+٢`.
|
|
77
|
+
|
|
78
|
+
## Responsive behavior
|
|
79
|
+
|
|
80
|
+
When no explicit view is requested, the runnable application starts in day view
|
|
81
|
+
below 44rem and week view at wider sizes. An explicit `?view=week` remains week
|
|
82
|
+
view at every viewport. On narrow screens its seven days stay available inside
|
|
83
|
+
the scheduler's own horizontally scrolling surface; the selected date is
|
|
84
|
+
scrolled into view instead of hiding the other six days. Logical scroll
|
|
85
|
+
containment and proximity snapping keep physical LTR and RTL swipe gestures
|
|
86
|
+
inside the scheduler rather than widening or moving the document.
|
|
87
|
+
|
|
88
|
+
The date navigator remains one logical row when space permits, the four view
|
|
89
|
+
choices retain their full accessible names, and New Event becomes a touch-sized
|
|
90
|
+
icon action without losing that name. Month uses localized weekday headings,
|
|
91
|
+
a compact narrow label at mobile widths, selectable dates, and small event
|
|
92
|
+
indicators while retaining each event's accessible name. Month and week own
|
|
93
|
+
their overflow, and agenda provides the compact reading and touch alternative.
|
|
94
|
+
The document itself must never gain horizontal overflow.
|
|
95
|
+
|
|
96
|
+
## Locale, RTL, and calendar systems
|
|
97
|
+
|
|
98
|
+
Arabic is not one scheduling convention. Pass the actual regional locale and
|
|
99
|
+
let `getSchedulerLocaleWeekInfo(locale)` resolve the first day of the week and
|
|
100
|
+
weekend:
|
|
101
|
+
|
|
102
|
+
- `ar-SA`: Sunday start, Friday–Saturday weekend.
|
|
103
|
+
- `ar-EG`: Saturday start, Friday–Saturday weekend.
|
|
104
|
+
- `ar-AE`: Monday start, Saturday–Sunday weekend.
|
|
105
|
+
|
|
106
|
+
`SchedulerRoot` accepts `locale`, `direction`, `weekStartsOn`, `weekendDays`,
|
|
107
|
+
`numberingSystem`, `calendarSystem`, `secondaryCalendar`, and localized
|
|
108
|
+
`labels`. Gregorian dates remain the scheduling and persistence coordinate
|
|
109
|
+
system. A Hijri calendar such as `islamic-umalqura` can be shown as a secondary
|
|
110
|
+
date without changing event placement.
|
|
111
|
+
|
|
112
|
+
When `direction` is omitted, `SchedulerRoot` derives `ltr` or `rtl` from the
|
|
113
|
+
locale through `Intl.Locale` and a script fallback. Pass `direction` only when
|
|
114
|
+
the surrounding product deliberately overrides the locale direction.
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
const week = getSchedulerLocaleWeekInfo('ar-SA')
|
|
118
|
+
|
|
119
|
+
<SchedulerRoot
|
|
120
|
+
calendarSystem="gregory"
|
|
121
|
+
labels={arabicLabels}
|
|
122
|
+
locale="ar-SA"
|
|
123
|
+
secondaryCalendar="islamic-umalqura"
|
|
124
|
+
weekendDays={week.weekend}
|
|
125
|
+
weekStartsOn={week.firstDay}
|
|
126
|
+
/>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Do not infer Hijri usage from Arabic language alone. Products must choose the
|
|
130
|
+
calendar system explicitly. Ceramic wraps mixed-direction event titles in
|
|
131
|
+
`bdi` so Arabic chrome and user-authored content remain readable together.
|
|
132
|
+
|
|
133
|
+
## Copyable blocks and template
|
|
134
|
+
|
|
135
|
+
- `scheduler-shell`
|
|
136
|
+
- `calendar-sidebar`
|
|
137
|
+
- `event-editor-drawer`
|
|
138
|
+
- `agenda-panel`
|
|
139
|
+
- `timezone-selector`
|
|
140
|
+
- `template-calendar-application`
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npx utopia-ds template template-calendar-application --copy ./calendar-application
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The template presents a product-first calendar workspace with an embedded mini
|
|
147
|
+
calendar, segmented view controls, themed timezone selection, event agenda, and
|
|
148
|
+
editor drawer. All-day, overlapping, recurring, cross-timezone, and unavailable
|
|
149
|
+
events remain available. Loading, empty, error, rollback, conflict, and
|
|
150
|
+
read-only fixtures live in the collapsed `Test states` lab instead of the
|
|
151
|
+
primary product toolbar.
|
|
152
|
+
|
|
153
|
+
The template routes editor saves through the same
|
|
154
|
+
`SchedulerMutationAdapter` used by move and resize operations. The `Fail next
|
|
155
|
+
save` fixture therefore exercises both direct manipulation rollback and editor
|
|
156
|
+
draft recovery rather than a disconnected demo state.
|
|
157
|
+
|
|
158
|
+
## Performance boundary
|
|
159
|
+
|
|
160
|
+
- Import Calendar from its tree-shakeable package entry, not the root package.
|
|
161
|
+
- The Calendar package entry is capped at 45 KiB before gzip.
|
|
162
|
+
- The runnable calendar application asset is capped at 120 KiB before gzip and
|
|
163
|
+
35 KiB after gzip.
|
|
164
|
+
- Recurrence expansion is capped at 366 occurrences per source event.
|