@utopia-studio-design/design-system-cli 0.4.1 → 0.6.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 (126) 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/theme-vyapti.json +125 -0
  52. package/data/manifests/themes.json +400 -28
  53. package/data/templates/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
  54. package/data/templates/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
  55. package/data/templates/analytics-dashboard/README.md +6 -0
  56. package/data/templates/analytics-dashboard/analytics-dashboard.css +92 -0
  57. package/data/templates/analytics-dashboard/index.html +13 -0
  58. package/data/templates/analytics-dashboard/main.tsx +196 -0
  59. package/data/templates/analytics-dashboard/styles.css +120 -0
  60. package/data/templates/analytics-dashboard/template.manifest.json +17 -0
  61. package/data/templates/calendar-application/CalendarApplicationBlocks.tsx +519 -0
  62. package/data/templates/calendar-application/README.md +10 -0
  63. package/data/templates/calendar-application/calendar-application.css +216 -0
  64. package/data/templates/calendar-application/calendar-localization.ts +90 -0
  65. package/data/templates/calendar-application/index.html +14 -0
  66. package/data/templates/calendar-application/main.tsx +409 -0
  67. package/data/templates/calendar-application/styles.css +248 -0
  68. package/data/templates/calendar-application/template.manifest.json +27 -0
  69. package/data/templates/composition-registry-lab/README.md +9 -0
  70. package/data/templates/composition-registry-lab/index.html +14 -0
  71. package/data/templates/composition-registry-lab/main.tsx +217 -0
  72. package/data/templates/composition-registry-lab/sample-layout.json +63 -0
  73. package/data/templates/composition-registry-lab/styles.css +343 -0
  74. package/data/templates/composition-registry-lab/template.manifest.json +34 -0
  75. package/data/templates/dashboard-composer/ActivityFeedBlock.tsx +255 -0
  76. package/data/templates/dashboard-composer/AgentStatusBlock.tsx +313 -0
  77. package/data/templates/dashboard-composer/AnalyticsDashboardBlocks.tsx +107 -0
  78. package/data/templates/dashboard-composer/AnalyticsMetricBlocks.tsx +87 -0
  79. package/data/templates/dashboard-composer/CalendarApplicationBlocks.tsx +519 -0
  80. package/data/templates/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
  81. package/data/templates/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
  82. package/data/templates/dashboard-composer/DatabaseWorkspaceBlocks.tsx +240 -0
  83. package/data/templates/dashboard-composer/README.md +53 -0
  84. package/data/templates/dashboard-composer/RegistryDataBlocks.tsx +238 -0
  85. package/data/templates/dashboard-composer/activity-feed-contract.ts +96 -0
  86. package/data/templates/dashboard-composer/activity-feed-fixture.ts +22 -0
  87. package/data/templates/dashboard-composer/activity-feed.css +154 -0
  88. package/data/templates/dashboard-composer/agent-status-contract.ts +133 -0
  89. package/data/templates/dashboard-composer/agent-status-fixture.ts +79 -0
  90. package/data/templates/dashboard-composer/agent-status-lab/index.html +19 -0
  91. package/data/templates/dashboard-composer/agent-status-lab/main.tsx +151 -0
  92. package/data/templates/dashboard-composer/agent-status-lab/styles.css +139 -0
  93. package/data/templates/dashboard-composer/agent-status.css +216 -0
  94. package/data/templates/dashboard-composer/analytics-dashboard.css +92 -0
  95. package/data/templates/dashboard-composer/calendar-application.css +216 -0
  96. package/data/templates/dashboard-composer/calendar-localization.ts +90 -0
  97. package/data/templates/dashboard-composer/calendar-panel-contract.ts +72 -0
  98. package/data/templates/dashboard-composer/dashboard-composer.css +328 -0
  99. package/data/templates/dashboard-composer/database-workspace.css +107 -0
  100. package/data/templates/dashboard-composer/index.html +14 -0
  101. package/data/templates/dashboard-composer/main.tsx +550 -0
  102. package/data/templates/dashboard-composer/sample-layout.json +147 -0
  103. package/data/templates/dashboard-composer/styles.css +198 -0
  104. package/data/templates/dashboard-composer/template.manifest.json +34 -0
  105. package/data/templates/dashboard-composer/visual-editor/PuckEditorSurface.tsx +132 -0
  106. package/data/templates/dashboard-composer/visual-editor/VisualEditorApp.tsx +343 -0
  107. package/data/templates/dashboard-composer/visual-editor/index.html +17 -0
  108. package/data/templates/dashboard-composer/visual-editor/main.tsx +95 -0
  109. package/data/templates/dashboard-composer/visual-editor/styles.css +310 -0
  110. package/data/templates/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
  111. package/data/templates/database-workspace/README.md +5 -0
  112. package/data/templates/database-workspace/database-workspace.css +107 -0
  113. package/data/templates/database-workspace/index.html +14 -0
  114. package/data/templates/database-workspace/main.tsx +247 -0
  115. package/data/templates/database-workspace/styles.css +121 -0
  116. package/data/templates/database-workspace/template.manifest.json +19 -0
  117. package/data/templates/interaction-lab/InteractionWorkbench.tsx +271 -0
  118. package/data/templates/interaction-lab/README.md +7 -0
  119. package/data/templates/interaction-lab/index.html +14 -0
  120. package/data/templates/interaction-lab/interaction-workbench.css +226 -0
  121. package/data/templates/interaction-lab/main.tsx +104 -0
  122. package/data/templates/interaction-lab/styles.css +224 -0
  123. package/data/templates/interaction-lab/template.manifest.json +36 -0
  124. package/lib/api.mjs +72 -5
  125. package/lib/composition-runtime.mjs +382 -0
  126. 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.