@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
package/README.md CHANGED
@@ -7,7 +7,13 @@ npm install -D @utopia-studio-design/design-system-cli
7
7
  npx utopia-ds init . --theme utopia-default --yes
8
8
  npx utopia-ds search "Arabic data table" --json
9
9
  npx utopia-ds component DataTable --json
10
+ npx utopia-ds registry --list --json
11
+ npx utopia-ds layout validate ./dashboard-layout.json --json
12
+ npx utopia-ds layout serialize ./dashboard-layout.json
13
+ npx utopia-ds block interaction-workbench --copy ./src/interaction-workbench
10
14
  npx utopia-ds template template-saas-solution-homepage --copy ./saas-website
15
+ npx utopia-ds template template-interaction-lab --copy ./interaction-lab
16
+ npx utopia-ds template template-dashboard-composer --copy ./dashboard-composer
11
17
  npx utopia-ds template template-saas-solution-homepage --theme dextrum --copy ./dextrum-website
12
18
  npx utopia-ds template validate ./my-community-template
13
19
  npx utopia-ds template submit ./my-community-template
@@ -17,3 +23,20 @@ npx utopia-ds mcp
17
23
  ```
18
24
 
19
25
  Programmatic consumers can import `@utopia-studio-design/design-system-cli/api`.
26
+ The Ceramic Composition Registry and versioned Layout JSON are the source of
27
+ truth for agent and Studio dashboard composition. The Dashboard Composer's
28
+ optional Puck adapter consumes this API, but it does not define Ceramic blocks
29
+ or styling.
30
+
31
+ To run the generated Dashboard Composer as a standalone project:
32
+
33
+ ```sh
34
+ cd dashboard-composer
35
+ npm install
36
+ npm run dev
37
+ ```
38
+
39
+ Use `npm run build` before deployment. The generated project pins a compatible
40
+ Ceramic package range and splits the optional Puck editor dependencies into
41
+ separate production chunks; the validated Layout JSON and registry renderer
42
+ remain usable without opening the visual editor.
@@ -1,13 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- capabilityManifest, getComponent, getDoc, getMotionProfile, getTemplate, getTheme,
4
- listComponents, listDocs, listMotionProfiles, listTemplates, listThemes, repositoryDoctor, search,
3
+ capabilityManifest, getBlock, getBlockDefinition, getComponent, getDoc, getMotionProfile, getTemplate, getTheme,
4
+ listBlockDefinitions, listBlocks, listComponents, listDocs, listMotionProfiles, listTemplates, listThemes,
5
+ repositoryDoctor, search, validateAgentMutation, validateLayout,
5
6
  } from '../lib/api.mjs'
6
7
 
7
8
  const tools = [
8
9
  ['search', 'Search components, docs, templates, themes, and motion profiles.', { query: { type: 'string' } }, ({ query }) => search(query)],
9
10
  ['list_components', 'List every Ceramic component contract.', {}, () => listComponents()],
10
11
  ['get_component', 'Get one component including imports, tokens, usage, and Arabic rules.', { name: { type: 'string' } }, ({ name }) => getComponent(name)],
12
+ ['list_block_definitions', 'List Ceramic registry definitions for agent and editor composition.', {}, () => listBlockDefinitions()],
13
+ ['get_block_definition', 'Get one Ceramic block definition including props, layout, states, and agent permissions.', { id: { type: 'string' } }, ({ id }) => getBlockDefinition(id)],
14
+ ['validate_layout', 'Validate a versioned Ceramic Layout JSON document without coercion.', { document: { type: 'object' } }, ({ document }) => validateLayout(document)],
15
+ ['validate_agent_layout_mutation', 'Validate an agent-authored layout change against locks and permissions.', { current: { type: 'object' }, next: { type: 'object' } }, ({ current, next }) => validateAgentMutation(current, next)],
16
+ ['list_blocks', 'List copyable Ceramic interaction blocks.', {}, () => listBlocks()],
17
+ ['get_block', 'Get a block contract and copyable bundle metadata.', { id: { type: 'string' } }, ({ id }) => getBlock(id)],
11
18
  ['list_templates', 'List Ceramic starter templates.', {}, () => listTemplates()],
12
19
  ['get_template', 'Get a template contract and sections.', { id: { type: 'string' } }, ({ id }) => getTemplate(id)],
13
20
  ['list_themes', 'List themes that implement the semantic contract.', {}, () => listThemes()],
package/bin/utopia-ds.mjs CHANGED
@@ -3,8 +3,9 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, wri
3
3
  import { dirname, join, resolve } from 'node:path'
4
4
  import { spawn } from 'node:child_process'
5
5
  import {
6
- capabilityManifest, envelope, getComponent, getDoc, getMotionProfile, getTemplate, getTheme,
7
- listComponents, listDocs, listMotionProfiles, listTemplates, listThemes, mcpLaunch, repositoryDoctor, search,
6
+ capabilityManifest, envelope, getBlock, getBlockDefinition, getComponent, getDoc, getMotionProfile, getTemplate, getTheme,
7
+ listBlockDefinitions, listBlocks, listComponents, listDocs, listMotionProfiles, listTemplates, listThemes, mcpLaunch,
8
+ repositoryDoctor, search, serializeLayout, validateLayout,
8
9
  } from '../lib/api.mjs'
9
10
  import { createTemplateSubmissionUrl, validateTemplateSubmission } from '../lib/template-submission.mjs'
10
11
  import { installCliTelemetry } from '../lib/telemetry.mjs'
@@ -16,7 +17,9 @@ const optionsWithValues = new Set(['--copy', '--theme'])
16
17
  const values = commandIndex >= 0 ? positionalValues(args.slice(commandIndex + 1)) : []
17
18
  const json = args.includes('--json')
18
19
  const dense = args.includes('--dense')
19
- const cliVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
20
+ const cliPackageMetadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
21
+ const cliVersion = cliPackageMetadata.version
22
+ const designSystemVersion = cliPackageMetadata.ceramic.designSystem
20
23
  let telemetryErrorCode
21
24
 
22
25
  installCliTelemetry({
@@ -71,6 +74,10 @@ Commands:
71
74
  init [directory] [--theme <id>] [--yes] Add Ceramic and agent instructions
72
75
  search <query> Search every design-system domain
73
76
  component <Name>|--list Inspect component contracts
77
+ registry <block-id>|--list Inspect agent-editable block definitions
78
+ layout validate <file> Validate versioned Ceramic Layout JSON
79
+ layout serialize <file> Print canonical Ceramic Layout JSON
80
+ block <id>|--list [--copy <directory>] Inspect or copy a composed interaction block
74
81
  template <id>|--list [--skeleton] Inspect starter structures
75
82
  template <id> --copy <directory> [--theme <id>]
76
83
  Generate a themed runnable project
@@ -247,10 +254,10 @@ function copyTemplateProject(entry) {
247
254
  const workspaceCli = resolve(process.cwd(), 'packages/design-system-cli')
248
255
  const designSystemDependency = existsSync(join(workspacePackage, 'package.json'))
249
256
  ? `file:${workspacePackage}`
250
- : '^0.3.0'
257
+ : designSystemVersion
251
258
  const cliDependency = existsSync(join(workspaceCli, 'package.json'))
252
259
  ? `file:${workspaceCli}`
253
- : '^0.3.0'
260
+ : `^${cliVersion}`
254
261
  write(join(target, 'package.json'), `${JSON.stringify({
255
262
  name: packageName,
256
263
  private: true,
@@ -263,16 +270,62 @@ function copyTemplateProject(entry) {
263
270
  'lucide-react': '^1.23.0',
264
271
  react: '^19.0.0',
265
272
  'react-dom': '^19.0.0',
273
+ ...(entry.packageDependencies ?? {}),
266
274
  },
267
275
  devDependencies: { '@utopia-studio-design/design-system-cli': cliDependency, '@vitejs/plugin-react': '^4.3.4', typescript: '^5.7.2', vite: '^6.0.7' },
268
276
  }, null, 2)}\n`)
269
277
  write(join(target, '.ceramic/config.json'), `${JSON.stringify({ apiVersion: 1, theme, motionProfile: getTheme(theme)?.motionProfile, motionAdapter: 'framer-motion', arabicFriendly: true, source: '@utopia-studio-design/design-system' }, null, 2)}\n`)
270
- write(join(target, 'vite.config.ts'), `import { resolve } from 'node:path'\nimport react from '@vitejs/plugin-react'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n plugins: [react()],\n build: {\n rollupOptions: {\n input: {\n home: resolve('index.html'),\n product: resolve('product/index.html'),\n agents: resolve('agents/index.html'),\n integrations: resolve('integrations/index.html'),\n integrationDetail: resolve('integrations/slack/index.html'),\n customers: resolve('customers/index.html'),\n customerStory: resolve('customers/aster-labs/index.html'),\n pricing: resolve('pricing/index.html'),\n changelog: resolve('changelog/index.html'),\n contactSales: resolve('contact-sales/index.html'),\n },\n },\n },\n})\n`)
278
+ const htmlEntries = []
279
+ const collectHtml = (directory, prefix = '') => {
280
+ for (const name of readdirSync(directory)) {
281
+ const path = join(directory, name)
282
+ const relative = prefix ? `${prefix}/${name}` : name
283
+ if (statSync(path).isDirectory()) collectHtml(path, relative)
284
+ else if (name.endsWith('.html')) htmlEntries.push(relative)
285
+ }
286
+ }
287
+ collectHtml(target)
288
+ const viteInputs = htmlEntries.map((path, index) => ` page${index + 1}: resolve('${path}'),`).join('\n')
289
+ write(join(target, 'vite.config.ts'), `import { resolve } from 'node:path'\nimport react from '@vitejs/plugin-react'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n plugins: [react()],\n build: {\n rollupOptions: {\n input: {\n${viteInputs}\n },\n output: {\n manualChunks(id) {\n if (id.includes('node_modules/@dnd-kit/')) return 'puck-dnd'\n if (id.includes('node_modules/@tanstack/react-virtual')) return 'puck-virtual'\n if (id.includes('node_modules/@radix-ui/')) return 'radix'\n if (\n id.includes('node_modules/clsx')\n || id.includes('node_modules/class-variance-authority')\n || id.includes('node_modules/tailwind-merge')\n || id.includes('node_modules/tiny-invariant')\n ) return 'ui-utils'\n if (id.includes('node_modules/react-dom') || id.includes('node_modules/react/')) return 'react'\n return undefined\n },\n },\n },\n },\n})\n`)
271
290
  const existingReadme = readFileSync(join(target, 'README.md'), 'utf8')
272
- write(join(target, 'README.md'), `# Generated Ceramic SaaS website\n\nActive theme: \`${theme}\`\n\n\`\`\`sh\nnpm install\nnpm run dev\n\`\`\`\n\nOpen \`http://localhost:5173/?seed=1974341818\`. All ten page entries share the same seed, theme, and locale state.\n\n${existingReadme}`)
291
+ write(join(target, 'README.md'), `# Generated ${entry.title}\n\nActive theme: \`${theme}\`\n\n\`\`\`sh\nnpm install\nnpm run dev\n\`\`\`\n\nOpen \`http://localhost:5173/\`.\n\n${existingReadme}`)
273
292
  output('template-copy-result', { ok: true, id: entry.id, target, theme, pages: entry.pages ?? [] }, (data) => `Generated ${data.id} with theme ${data.theme} in ${data.target}.\nNext: cd ${data.target} && npm install && npm run dev`)
274
293
  }
275
294
 
295
+ function copyBlock(entry) {
296
+ const requestedTarget = argAfter('--copy', entry.id)
297
+ if (!requestedTarget) return
298
+ const target = resolve(requestedTarget)
299
+ if (existsSync(target) && !args.includes('--force')) return fail(`Target already exists: ${target}. Pass --force to overwrite it.`, 'ERR_TARGET_EXISTS')
300
+ const source = resolve(dirname(new URL(import.meta.url).pathname), '..', 'data', entry.bundlePath)
301
+ if (!existsSync(source)) return fail(`Block bundle is missing for "${entry.id}".`, 'ERR_BLOCK_BUNDLE')
302
+ cpSync(source, target, { recursive: true, force: args.includes('--force') })
303
+ output('block-copy-result', { ok: true, id: entry.id, target }, (data) => `Copied ${data.id} to ${data.target}.`)
304
+ }
305
+
306
+ function readLayoutInput() {
307
+ const file = values[1]
308
+ if (!file) {
309
+ fail('Layout command requires a JSON file path.', 'ERR_LAYOUT_FILE')
310
+ return null
311
+ }
312
+ const path = resolve(file)
313
+ if (!existsSync(path)) {
314
+ fail(`Layout file not found: ${path}.`, 'ERR_LAYOUT_FILE')
315
+ return null
316
+ }
317
+ try {
318
+ return { path, document: JSON.parse(readFileSync(path, 'utf8')) }
319
+ } catch (cause) {
320
+ fail(
321
+ `Layout file is not valid JSON: ${path}.`,
322
+ 'ERR_LAYOUT_JSON',
323
+ [cause instanceof Error ? cause.message : 'Unknown JSON parse error'],
324
+ )
325
+ return null
326
+ }
327
+ }
328
+
276
329
  function validateCommunityTemplate({ submit = false } = {}) {
277
330
  const target = resolve(values[1] ?? process.cwd())
278
331
  const result = validateTemplateSubmission(target)
@@ -311,6 +364,51 @@ else if (command === 'search') {
311
364
  const name = values[0]
312
365
  if (!name || args.includes('--list')) output('component-list', listComponents(), (rows) => rows.map((item) => `${item.name}|${item.status}|${item.category}|${item.packageImport}`).join(dense ? '\n' : '\n- '))
313
366
  else { const item = getComponent(name); item ? output('component', item, formatComponent) : fail(`Unknown component "${name}".`, 'ERR_COMPONENT', search(name).slice(0, 5).map((result) => result.id)) }
367
+ } else if (command === 'registry') {
368
+ const name = values[0]
369
+ if (!name || args.includes('--list')) {
370
+ const definitions = listBlockDefinitions({ renderableOnly: args.includes('--renderable') })
371
+ output('block-definition-list', definitions, (rows) =>
372
+ rows.map((item) => `${item.id}|${item.availability}|${item.category}|${item.title}`).join('\n'))
373
+ } else {
374
+ const item = getBlockDefinition(name)
375
+ item ? output('block-definition', item) : fail(`Unknown block definition "${name}".`, 'ERR_BLOCK_DEFINITION')
376
+ }
377
+ } else if (command === 'layout') {
378
+ const action = values[0]
379
+ if (!['validate', 'serialize'].includes(action)) {
380
+ fail('Layout requires "validate" or "serialize".', 'ERR_LAYOUT_ACTION')
381
+ } else {
382
+ const input = readLayoutInput()
383
+ if (input && action === 'validate') {
384
+ const result = validateLayout(input.document)
385
+ output('layout-validation-result', { ...result, path: input.path })
386
+ if (!result.ok) process.exitCode = 1
387
+ } else if (input) {
388
+ try {
389
+ const serialized = serializeLayout(input.document)
390
+ if (json) output('layout-serialization-result', { ok: true, path: input.path, serialized })
391
+ else process.stdout.write(serialized)
392
+ } catch (cause) {
393
+ fail(
394
+ 'Ceramic layout validation failed.',
395
+ 'ERR_LAYOUT_INVALID',
396
+ cause instanceof Error && Array.isArray(cause.issues)
397
+ ? cause.issues.map((issue) => `${issue.code}: ${issue.path} ${issue.message}`)
398
+ : [],
399
+ )
400
+ }
401
+ }
402
+ }
403
+ } else if (command === 'block') {
404
+ const name = values[0]
405
+ if (!name || args.includes('--list')) output('block-list', listBlocks(), (rows) => rows.map((item) => `${item.id}|${item.category}|${item.title}|${item.purpose}`).join('\n'))
406
+ else {
407
+ const item = getBlock(name)
408
+ if (!item) fail(`Unknown block "${name}".`, 'ERR_BLOCK')
409
+ else if (args.includes('--copy')) copyBlock(item)
410
+ else output('block', item)
411
+ }
314
412
  } else if (command === 'template') {
315
413
  const name = values[0]
316
414
  if (name === 'validate') validateCommunityTemplate()
@@ -336,6 +434,6 @@ else if (command === 'search') {
336
434
  if (!topic || args.includes('--list')) output('docs-list', listDocs(), (rows) => rows.join('\n'))
337
435
  else { const item = getDoc(topic); item ? output('docs', item, (doc) => dense ? doc.content.replace(/\n{2,}/g, '\n').trim() : doc.content) : fail(`Unknown docs topic "${topic}".`, 'ERR_DOCS', listDocs()) }
338
436
  } else if (command === 'doctor') {
339
- const result = repositoryDoctor(); output('doctor-result', result, (data) => data.ok ? `Ceramic doctor passed. ${data.checks.components} components, ${data.checks.templates} templates, ${data.checks.themes} themes, ${data.checks.motionProfiles} motion profiles, MCP ready.` : `Ceramic doctor failed: ${data.missing.join(', ')}`); if (!result.ok) process.exitCode = 1
437
+ const result = repositoryDoctor(); output('doctor-result', result, (data) => data.ok ? `Ceramic doctor passed. ${data.checks.components} components, ${data.checks.blocks} blocks, ${data.checks.templates} templates, ${data.checks.themes} themes, ${data.checks.motionProfiles} motion profiles, MCP ready.` : `Ceramic doctor failed: ${data.missing.join(', ')}`); if (!result.ok) process.exitCode = 1
340
438
  } else if (command === 'mcp') runMcp()
341
439
  else fail(`Unknown command "${command}".`, 'ERR_COMMAND', capabilityManifest().commands.map((item) => item.name))
@@ -0,0 +1,255 @@
1
+ import * as React from 'react'
2
+ import { Badge } from '@utopia-studio-design/design-system/Badge'
3
+ import { Button } from '@utopia-studio-design/design-system/Button'
4
+ import { StatusDot } from '@utopia-studio-design/design-system/DataDisplay'
5
+ import './activity-feed.css'
6
+
7
+ export type ActivityFeedDensity = 'compact' | 'default' | 'comfortable'
8
+ export type ActivityFeedGrouping = 'none' | 'day' | 'source'
9
+ export type ActivityFeedState = 'loading' | 'empty' | 'error' | 'ready'
10
+ export type ActivityFeedTone = 'neutral' | 'info' | 'positive' | 'warning' | 'critical'
11
+
12
+ export interface ActivityFeedItem {
13
+ id: string
14
+ label: string
15
+ source?: string
16
+ timestamp: string
17
+ tone?: ActivityFeedTone
18
+ }
19
+
20
+ export interface ActivityFeedLabels {
21
+ end: string
22
+ emptyDescription: string
23
+ emptyTitle: string
24
+ errorDescription: string
25
+ errorTitle: string
26
+ eyebrow: string
27
+ invalidTime: string
28
+ loadMore: string
29
+ loading: string
30
+ otherSource: string
31
+ retry: string
32
+ title: string
33
+ }
34
+
35
+ export interface ActivityFeedBlockProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
36
+ density?: ActivityFeedDensity
37
+ direction?: 'ltr' | 'rtl'
38
+ groupBy?: ActivityFeedGrouping
39
+ items?: ActivityFeedItem[]
40
+ labels: ActivityFeedLabels
41
+ locale?: string
42
+ onRetry?: () => void
43
+ pageSize?: number
44
+ showSources?: boolean
45
+ state?: ActivityFeedState
46
+ timeZone?: string
47
+ title?: string
48
+ }
49
+
50
+ interface ActivityGroup {
51
+ id: string
52
+ label?: string
53
+ items: ActivityFeedItem[]
54
+ }
55
+
56
+ function safeDate(timestamp: string) {
57
+ const date = new Date(timestamp)
58
+ return Number.isNaN(date.valueOf()) ? null : date
59
+ }
60
+
61
+ function makeFormatter(locale: string, timeZone: string, options: Intl.DateTimeFormatOptions) {
62
+ try {
63
+ return new Intl.DateTimeFormat(locale, { ...options, timeZone })
64
+ } catch {
65
+ return new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' })
66
+ }
67
+ }
68
+
69
+ function formatNumber(locale: string, value: number) {
70
+ try {
71
+ return new Intl.NumberFormat(locale).format(value)
72
+ } catch {
73
+ return String(value)
74
+ }
75
+ }
76
+
77
+ function groupItems(
78
+ items: ActivityFeedItem[],
79
+ groupBy: ActivityFeedGrouping,
80
+ locale: string,
81
+ timeZone: string,
82
+ otherSource: string,
83
+ ): ActivityGroup[] {
84
+ if (groupBy === 'none') return [{ id: 'all', items }]
85
+
86
+ const dayFormatter = makeFormatter(locale, timeZone, {
87
+ dateStyle: 'full',
88
+ })
89
+ const groups = new Map<string, ActivityGroup>()
90
+ items.forEach((item) => {
91
+ const date = safeDate(item.timestamp)
92
+ const label = groupBy === 'source'
93
+ ? item.source || otherSource
94
+ : date ? dayFormatter.format(date) : otherSource
95
+ const id = `${groupBy}:${label}`
96
+ const group = groups.get(id) ?? { id, label, items: [] }
97
+ group.items.push(item)
98
+ groups.set(id, group)
99
+ })
100
+ return [...groups.values()]
101
+ }
102
+
103
+ function ActivityItems({
104
+ items,
105
+ labels,
106
+ locale,
107
+ showSources,
108
+ timeZone,
109
+ }: {
110
+ items: ActivityFeedItem[]
111
+ labels: ActivityFeedLabels
112
+ locale: string
113
+ showSources: boolean
114
+ timeZone: string
115
+ }) {
116
+ const dateTimeFormatter = makeFormatter(locale, timeZone, {
117
+ dateStyle: 'medium',
118
+ timeStyle: 'short',
119
+ })
120
+ const timeFormatter = makeFormatter(locale, timeZone, {
121
+ hour: 'numeric',
122
+ minute: '2-digit',
123
+ })
124
+
125
+ return (
126
+ <ol className="ceramic-activity-items">
127
+ {items.map((item) => {
128
+ const date = safeDate(item.timestamp)
129
+ const accessibleTime = date ? dateTimeFormatter.format(date) : labels.invalidTime
130
+ const visibleTime = date ? timeFormatter.format(date) : labels.invalidTime
131
+ return (
132
+ <li key={item.id}>
133
+ <StatusDot
134
+ aria-hidden="true"
135
+ className="ceramic-activity-marker"
136
+ data-tone={item.tone ?? 'neutral'}
137
+ />
138
+ <div className="ceramic-activity-copy">
139
+ <p>{item.label}</p>
140
+ <div>
141
+ <time dateTime={date ? date.toISOString() : undefined} title={accessibleTime}>
142
+ {visibleTime}
143
+ </time>
144
+ {showSources && item.source ? <Badge variant="outline">{item.source}</Badge> : null}
145
+ </div>
146
+ </div>
147
+ </li>
148
+ )
149
+ })}
150
+ </ol>
151
+ )
152
+ }
153
+
154
+ export function ActivityFeedBlock({
155
+ density = 'default',
156
+ direction,
157
+ groupBy = 'day',
158
+ items = [],
159
+ labels,
160
+ locale = 'en-US',
161
+ onRetry,
162
+ pageSize = 4,
163
+ showSources = true,
164
+ state: stateProp,
165
+ timeZone = 'UTC',
166
+ title,
167
+ ...props
168
+ }: ActivityFeedBlockProps) {
169
+ const titleId = React.useId()
170
+ const [visibleCount, setVisibleCount] = React.useState(pageSize)
171
+ const state = stateProp ?? (items.length ? 'ready' : 'empty')
172
+ const visibleItems = items.slice(0, visibleCount)
173
+ const groups = groupItems(visibleItems, groupBy, locale, timeZone, labels.otherSource)
174
+ const hasMore = visibleCount < items.length
175
+ const visibleCountLabel = formatNumber(locale, visibleItems.length)
176
+ const totalCountLabel = formatNumber(locale, items.length)
177
+
178
+ React.useEffect(() => {
179
+ setVisibleCount(pageSize)
180
+ }, [items.length, pageSize])
181
+
182
+ return (
183
+ <section
184
+ aria-busy={state === 'loading'}
185
+ aria-labelledby={titleId}
186
+ className="ceramic-activity-feed"
187
+ data-density={density}
188
+ data-state={state}
189
+ dir={direction}
190
+ lang={locale}
191
+ {...props}
192
+ >
193
+ <header>
194
+ <div>
195
+ <span>{labels.eyebrow}</span>
196
+ <h2 id={titleId}>{title ?? labels.title}</h2>
197
+ </div>
198
+ {state === 'ready' ? <Badge variant="outline">{visibleCountLabel}/{totalCountLabel}</Badge> : null}
199
+ </header>
200
+
201
+ {state === 'loading' ? (
202
+ <div className="ceramic-activity-state" role="status">{labels.loading}</div>
203
+ ) : null}
204
+ {state === 'error' ? (
205
+ <div className="ceramic-activity-state" role="alert">
206
+ <strong>{labels.errorTitle}</strong>
207
+ <span>{labels.errorDescription}</span>
208
+ {onRetry ? <Button size="sm" variant="outline" onClick={onRetry}>{labels.retry}</Button> : null}
209
+ </div>
210
+ ) : null}
211
+ {state === 'empty' ? (
212
+ <div className="ceramic-activity-state" role="status">
213
+ <strong>{labels.emptyTitle}</strong>
214
+ <span>{labels.emptyDescription}</span>
215
+ </div>
216
+ ) : null}
217
+ {state === 'ready' ? (
218
+ <div className="ceramic-activity-groups">
219
+ {groups.map((group, groupIndex) => {
220
+ const groupTitleId = `${titleId}-group-${groupIndex + 1}`
221
+ return (
222
+ <section aria-labelledby={group.label ? groupTitleId : undefined} key={group.id}>
223
+ {group.label ? <h3 id={groupTitleId}>{group.label}</h3> : null}
224
+ <ActivityItems
225
+ items={group.items}
226
+ labels={labels}
227
+ locale={locale}
228
+ showSources={showSources}
229
+ timeZone={timeZone}
230
+ />
231
+ </section>
232
+ )
233
+ })}
234
+ </div>
235
+ ) : null}
236
+
237
+ {state === 'ready' ? (
238
+ <footer>
239
+ {hasMore ? (
240
+ <Button
241
+ size="sm"
242
+ variant="outline"
243
+ onClick={() => setVisibleCount((count) => Math.min(items.length, count + pageSize))}
244
+ >
245
+ {labels.loadMore}
246
+ </Button>
247
+ ) : <span>{labels.end}</span>}
248
+ </footer>
249
+ ) : null}
250
+ <output aria-live="polite" className="uds-visually-hidden">
251
+ {state === 'ready' ? `${visibleCountLabel} / ${totalCountLabel}` : ''}
252
+ </output>
253
+ </section>
254
+ )
255
+ }
@@ -0,0 +1,16 @@
1
+ # Activity Feed block
2
+
3
+ `ActivityFeedBlock` is a Ceramic composition block for chronological,
4
+ actor-independent product activity. The product owns activity data and retry
5
+ behavior; the Composition Registry owns density, grouping, locale, direction,
6
+ timezone, source visibility, and page size.
7
+
8
+ Use `resolveActivityFeedConfiguration` in every renderer. `direction="auto"`
9
+ derives text direction from the locale while `direction="inherit"` follows the
10
+ dashboard preview. Activity timestamps are formatted with the registered locale
11
+ and IANA timezone. Raw colors, spacing, classes, and tokens are not part of the
12
+ Layout JSON contract.
13
+
14
+ The block supports `loading`, `empty`, `error`, and `ready` states. Local fixture
15
+ pagination reveals `pageSize` items at a time; production adapters may replace
16
+ the supplied `items` when a backend page is fetched.
@@ -0,0 +1,96 @@
1
+ import { getLocaleTextDirection } from '@utopia-studio-design/design-system/Utilities'
2
+ import type {
3
+ ActivityFeedBlockProps,
4
+ ActivityFeedLabels,
5
+ } from './ActivityFeedBlock'
6
+
7
+ export const activityFeedLocaleOptions = ['en-US', 'ar-SA', 'ar-EG', 'ar-AE'] as const
8
+
9
+ export type ActivityFeedLocale = typeof activityFeedLocaleOptions[number]
10
+ export type ActivityFeedDirection = 'inherit' | 'auto' | 'ltr' | 'rtl'
11
+
12
+ export interface ActivityFeedConfiguration {
13
+ density: NonNullable<ActivityFeedBlockProps['density']>
14
+ direction?: 'ltr' | 'rtl'
15
+ groupBy: NonNullable<ActivityFeedBlockProps['groupBy']>
16
+ labels: ActivityFeedLabels
17
+ locale: ActivityFeedLocale
18
+ pageSize: number
19
+ showSources: boolean
20
+ timeZone: string
21
+ }
22
+
23
+ export interface ActivityFeedContext {
24
+ direction: 'ltr' | 'rtl'
25
+ }
26
+
27
+ const directionModes: ActivityFeedDirection[] = ['inherit', 'auto', 'ltr', 'rtl']
28
+ const densities: ActivityFeedConfiguration['density'][] = ['compact', 'default', 'comfortable']
29
+ const groupings: ActivityFeedConfiguration['groupBy'][] = ['none', 'day', 'source']
30
+
31
+ const englishLabels: ActivityFeedLabels = {
32
+ end: 'You have reached the end of the activity.',
33
+ emptyDescription: 'New updates will appear here when work changes.',
34
+ emptyTitle: 'No activity yet',
35
+ errorDescription: 'Reconnect the activity source and try again.',
36
+ errorTitle: 'Activity unavailable',
37
+ eyebrow: 'Activity',
38
+ invalidTime: 'Time unavailable',
39
+ loadMore: 'Load more',
40
+ loading: 'Loading activity…',
41
+ otherSource: 'Other',
42
+ retry: 'Try again',
43
+ title: 'Recent activity',
44
+ }
45
+
46
+ const arabicLabels: ActivityFeedLabels = {
47
+ end: 'وصلت إلى نهاية سجل النشاط.',
48
+ emptyDescription: 'ستظهر التحديثات الجديدة هنا عند تغيّر العمل.',
49
+ emptyTitle: 'لا يوجد نشاط بعد',
50
+ errorDescription: 'أعد الاتصال بمصدر النشاط ثم حاول مرة أخرى.',
51
+ errorTitle: 'النشاط غير متاح',
52
+ eyebrow: 'النشاط',
53
+ invalidTime: 'الوقت غير متاح',
54
+ loadMore: 'تحميل المزيد',
55
+ loading: 'جارٍ تحميل النشاط…',
56
+ otherSource: 'أخرى',
57
+ retry: 'حاول مرة أخرى',
58
+ title: 'النشاط الأخير',
59
+ }
60
+
61
+ export function getActivityFeedLabels(locale: string): ActivityFeedLabels {
62
+ return getLocaleTextDirection(locale) === 'rtl' ? arabicLabels : englishLabels
63
+ }
64
+
65
+ export function resolveActivityFeedConfiguration(
66
+ props: Record<string, unknown>,
67
+ context: ActivityFeedContext,
68
+ ): ActivityFeedConfiguration {
69
+ const locale = activityFeedLocaleOptions.includes(props.locale as ActivityFeedLocale)
70
+ ? props.locale as ActivityFeedLocale
71
+ : 'en-US'
72
+ const directionMode = directionModes.includes(props.direction as ActivityFeedDirection)
73
+ ? props.direction as ActivityFeedDirection
74
+ : 'inherit'
75
+ const direction = directionMode === 'inherit'
76
+ ? context.direction
77
+ : directionMode === 'auto' ? getLocaleTextDirection(locale) : directionMode
78
+ const pageSize = Number.isInteger(props.pageSize)
79
+ ? Math.min(20, Math.max(2, Number(props.pageSize)))
80
+ : 4
81
+
82
+ return {
83
+ density: densities.includes(props.density as ActivityFeedConfiguration['density'])
84
+ ? props.density as ActivityFeedConfiguration['density']
85
+ : 'default',
86
+ direction,
87
+ groupBy: groupings.includes(props.groupBy as ActivityFeedConfiguration['groupBy'])
88
+ ? props.groupBy as ActivityFeedConfiguration['groupBy']
89
+ : 'day',
90
+ labels: getActivityFeedLabels(locale),
91
+ locale,
92
+ pageSize,
93
+ showSources: props.showSources !== false,
94
+ timeZone: typeof props.timeZone === 'string' && props.timeZone ? props.timeZone : 'UTC',
95
+ }
96
+ }
@@ -0,0 +1,22 @@
1
+ import type { ActivityFeedItem } from './ActivityFeedBlock'
2
+
3
+ const activityItems: Record<'en' | 'ar', ActivityFeedItem[]> = {
4
+ en: [
5
+ { id: 'activity-1', label: 'Calendar block moved after the task list', source: 'Studio', timestamp: '2026-03-09T00:20:00.000Z', tone: 'info' },
6
+ { id: 'activity-2', label: 'Release QA completed without blockers', source: 'Mina', timestamp: '2026-03-09T01:10:00.000Z', tone: 'positive' },
7
+ { id: 'activity-3', label: 'Layout JSON published to this workspace', source: 'Agent', timestamp: '2026-03-09T02:30:00.000Z', tone: 'positive' },
8
+ { id: 'activity-4', label: 'Design-system package data synchronized', source: 'CLI', timestamp: '2026-03-08T06:40:00.000Z', tone: 'neutral' },
9
+ { id: 'activity-5', label: 'Arabic calendar review requested', source: 'Studio', timestamp: '2026-03-08T08:15:00.000Z', tone: 'warning' },
10
+ ],
11
+ ar: [
12
+ { id: 'activity-1', label: 'نُقلت كتلة التقويم بعد قائمة المهام', source: 'الاستوديو', timestamp: '2026-03-09T00:20:00.000Z', tone: 'info' },
13
+ { id: 'activity-2', label: 'اكتمل فحص الإصدار من دون عوائق', source: 'مينا', timestamp: '2026-03-09T01:10:00.000Z', tone: 'positive' },
14
+ { id: 'activity-3', label: 'نُشر ملف تخطيط JSON في مساحة العمل', source: 'الوكيل', timestamp: '2026-03-09T02:30:00.000Z', tone: 'positive' },
15
+ { id: 'activity-4', label: 'تمت مزامنة بيانات حزمة نظام التصميم', source: 'CLI', timestamp: '2026-03-08T06:40:00.000Z', tone: 'neutral' },
16
+ { id: 'activity-5', label: 'طُلبت مراجعة التقويم العربي', source: 'الاستوديو', timestamp: '2026-03-08T08:15:00.000Z', tone: 'warning' },
17
+ ],
18
+ }
19
+
20
+ export function getActivityFeedFixture(locale: string) {
21
+ return activityItems[locale.startsWith('ar') ? 'ar' : 'en']
22
+ }