@utopia-studio-design/design-system-cli 0.8.1 → 0.8.3

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 (239) hide show
  1. package/bin/utopia-ds-mcp.mjs +15 -4
  2. package/bin/utopia-ds.mjs +16 -3
  3. package/data/blocks/semantic-pattern-parts/AuthParts.tsx +48 -0
  4. package/data/blocks/semantic-pattern-parts/ChoiceCards.tsx +7 -0
  5. package/data/blocks/semantic-pattern-parts/ListFilters.tsx +9 -0
  6. package/data/blocks/semantic-pattern-parts/OutcomeNotice.tsx +7 -0
  7. package/data/blocks/semantic-pattern-parts/PatternParts.tsx +7 -0
  8. package/data/blocks/semantic-pattern-parts/ProductParts.tsx +38 -0
  9. package/data/blocks/semantic-pattern-parts/ProgressSteps.tsx +6 -0
  10. package/data/blocks/semantic-pattern-parts/ReviewList.tsx +7 -0
  11. package/data/blocks/semantic-pattern-parts/ValidatedField.tsx +8 -0
  12. package/data/blocks/semantic-pattern-parts/WorkflowParts.tsx +339 -0
  13. package/data/blocks/semantic-pattern-parts/auth-parts.css +27 -0
  14. package/data/blocks/semantic-pattern-parts/pattern-parts.css +25 -0
  15. package/data/blocks/semantic-pattern-parts/product-parts.css +25 -0
  16. package/data/blocks/semantic-pattern-parts/types.ts +3 -0
  17. package/data/blocks/semantic-pattern-parts/workflow-parts.css +118 -0
  18. package/data/blocks/ux-pattern-examples/AccessExamples.tsx +46 -0
  19. package/data/blocks/ux-pattern-examples/AccountExamples.tsx +133 -0
  20. package/data/blocks/ux-pattern-examples/OnboardingExample.tsx +15 -0
  21. package/data/blocks/ux-pattern-examples/ProductExamples.tsx +79 -0
  22. package/data/blocks/ux-pattern-examples/ReviewExample.tsx +14 -0
  23. package/data/blocks/ux-pattern-examples/SignupExample.tsx +22 -0
  24. package/data/blocks/ux-pattern-examples/WorkflowExample.tsx +1761 -0
  25. package/data/blocks/ux-pattern-examples/WorkflowExamples.tsx +190 -0
  26. package/data/blocks/ux-pattern-examples/copy.ts +6 -0
  27. package/data/blocks/ux-pattern-examples/ux-examples.css +18 -0
  28. package/data/blocks/ux-pattern-examples/workflow-copy.ts +76 -0
  29. package/data/blocks/ux-pattern-examples/workflow-definitions.ts +1291 -0
  30. package/data/docs/ui-parts-qa.md +172 -0
  31. package/data/docs/ui-parts.md +105 -0
  32. package/data/docs/ux-pattern-adaptation-qa.md +38 -0
  33. package/data/docs/ux-pattern-adaptation.md +41 -0
  34. package/data/docs/ux-pattern-migration.md +111 -0
  35. package/data/docs/ux-pattern-publish-2026-09-13.md +31 -0
  36. package/data/docs/ux-pattern-release-2026-09-13.md +26 -0
  37. package/data/docs/ux-source-evidence/account-auth.json +41 -0
  38. package/data/docs/ux-source-evidence/account-forgot-password.json +36 -0
  39. package/data/docs/ux-source-evidence/account-invite.json +63 -0
  40. package/data/docs/ux-source-evidence/account-locked.json +63 -0
  41. package/data/docs/ux-source-evidence/account-login.json +28 -0
  42. package/data/docs/ux-source-evidence/account-mfa.json +62 -0
  43. package/data/docs/ux-source-evidence/account-onboarding-credentials.json +52 -0
  44. package/data/docs/ux-source-evidence/account-onboarding-legal.json +56 -0
  45. package/data/docs/ux-source-evidence/account-onboarding-orientation.json +66 -0
  46. package/data/docs/ux-source-evidence/account-onboarding-profile.json +50 -0
  47. package/data/docs/ux-source-evidence/account-onboarding.json +69 -0
  48. package/data/docs/ux-source-evidence/account-reset-password.json +27 -0
  49. package/data/docs/ux-source-evidence/account-session-expired.json +62 -0
  50. package/data/docs/ux-source-evidence/account-signup.json +33 -0
  51. package/data/docs/ux-source-evidence/account-verify-email.json +34 -0
  52. package/data/docs/ux-source-evidence/account-welcome.json +41 -0
  53. package/data/docs/ux-source-evidence/admin-analytics.json +54 -0
  54. package/data/docs/ux-source-evidence/admin-audit.json +31 -0
  55. package/data/docs/ux-source-evidence/admin-broadcasts-new.json +36 -0
  56. package/data/docs/ux-source-evidence/admin-broadcasts.json +41 -0
  57. package/data/docs/ux-source-evidence/admin-closures.json +52 -0
  58. package/data/docs/ux-source-evidence/admin-devices.json +51 -0
  59. package/data/docs/ux-source-evidence/admin-escalations.json +35 -0
  60. package/data/docs/ux-source-evidence/admin-incidents.json +54 -0
  61. package/data/docs/ux-source-evidence/admin-loading.json +17 -0
  62. package/data/docs/ux-source-evidence/admin-onboarding-detail.json +80 -0
  63. package/data/docs/ux-source-evidence/admin-onboarding.json +62 -0
  64. package/data/docs/ux-source-evidence/admin-payments.json +45 -0
  65. package/data/docs/ux-source-evidence/admin-providers.json +38 -0
  66. package/data/docs/ux-source-evidence/admin-settings.json +18 -0
  67. package/data/docs/ux-source-evidence/admin-subscriptions-detail-allocate.json +42 -0
  68. package/data/docs/ux-source-evidence/admin-subscriptions-detail.json +48 -0
  69. package/data/docs/ux-source-evidence/admin-subscriptions-new.json +43 -0
  70. package/data/docs/ux-source-evidence/admin-subscriptions.json +41 -0
  71. package/data/docs/ux-source-evidence/admin-transactions.json +41 -0
  72. package/data/docs/ux-source-evidence/error.json +30 -0
  73. package/data/docs/ux-source-evidence/loading.json +19 -0
  74. package/data/docs/ux-source-evidence/marketing-blog.json +48 -0
  75. package/data/docs/ux-source-evidence/marketing-contact.json +36 -0
  76. package/data/docs/ux-source-evidence/marketing.json +64 -0
  77. package/data/docs/ux-source-evidence/not-found.json +28 -0
  78. package/data/docs/ux-source-evidence/portal-loading.json +17 -0
  79. package/data/docs/ux-source-evidence/portal-mentor-messages.json +31 -0
  80. package/data/docs/ux-source-evidence/portal-mentor-profile.json +23 -0
  81. package/data/docs/ux-source-evidence/portal-mentor-requests-detail.json +54 -0
  82. package/data/docs/ux-source-evidence/portal-mentor-sessions-detail-assess.json +42 -0
  83. package/data/docs/ux-source-evidence/portal-mentor-settings.json +18 -0
  84. package/data/docs/ux-source-evidence/portal-mentor.json +38 -0
  85. package/data/docs/ux-source-evidence/portal-sessions-detail-live.json +48 -0
  86. package/data/docs/ux-source-evidence/portal-sponsor-billing.json +28 -0
  87. package/data/docs/ux-source-evidence/portal-sponsor-profile.json +22 -0
  88. package/data/docs/ux-source-evidence/portal-sponsor-sessions.json +28 -0
  89. package/data/docs/ux-source-evidence/portal-sponsor-settings.json +18 -0
  90. package/data/docs/ux-source-evidence/portal-sponsor-trainees.json +36 -0
  91. package/data/docs/ux-source-evidence/portal-sponsor.json +52 -0
  92. package/data/docs/ux-source-evidence/portal-trainee-competency.json +42 -0
  93. package/data/docs/ux-source-evidence/portal-trainee-credits.json +40 -0
  94. package/data/docs/ux-source-evidence/portal-trainee-mentors.json +48 -0
  95. package/data/docs/ux-source-evidence/portal-trainee-messages.json +32 -0
  96. package/data/docs/ux-source-evidence/portal-trainee-notifications.json +35 -0
  97. package/data/docs/ux-source-evidence/portal-trainee-profile.json +26 -0
  98. package/data/docs/ux-source-evidence/portal-trainee-request.json +54 -0
  99. package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +36 -0
  100. package/data/docs/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +42 -0
  101. package/data/docs/ux-source-evidence/portal-trainee-sessions-detail.json +55 -0
  102. package/data/docs/ux-source-evidence/portal-trainee-sessions.json +36 -0
  103. package/data/docs/ux-source-evidence/portal-trainee-settings-notifications.json +38 -0
  104. package/data/docs/ux-source-evidence/portal-trainee-settings.json +18 -0
  105. package/data/docs/ux-source-evidence/portal-trainee.json +44 -0
  106. package/data/docs/ux-source-evidence/portal-tv-detail.json +39 -0
  107. package/data/docs/ux-source-evidence/portal-tv.json +44 -0
  108. package/data/docs/ux-source-evidence/shared-admin-admin-shell.json +35 -0
  109. package/data/docs/ux-source-evidence/shared-auth-auth-primitives.json +105 -0
  110. package/data/docs/ux-source-evidence/shared-auth-auth-shell.json +25 -0
  111. package/data/docs/ux-source-evidence/shared-auth-onboarding-primitives.json +52 -0
  112. package/data/docs/ux-source-evidence/shared-auth-onboarding-shell.json +28 -0
  113. package/data/docs/ux-source-evidence/shared-auth-status-indicator.json +41 -0
  114. package/data/docs/ux-source-evidence/shared-portal-empty-state.json +24 -0
  115. package/data/docs/ux-source-evidence/shared-portal-loading-skeleton.json +27 -0
  116. package/data/docs/ux-source-evidence/shared-portal-notifications-dropdown.json +39 -0
  117. package/data/docs/ux-source-evidence/shared-portal-portal-shell.json +36 -0
  118. package/data/docs/ux-source-evidence/shared-portal-profile-form.json +32 -0
  119. package/data/docs/ux-source-evidence/shared-portal-settings-view.json +81 -0
  120. package/data/docs/ux-source-evidence/shared-portal-usage-sparkline.json +18 -0
  121. package/data/docs/ux-source-evidence/shared-portal-user-menu.json +34 -0
  122. package/data/manifests/blocks.json +230 -21
  123. package/data/manifests/patterns.json +330 -0
  124. package/data/manifests/ui-patterns.json +1029 -0
  125. package/data/manifests/ux-examples.json +2177 -0
  126. package/data/manifests/ux-patterns.json +1135 -0
  127. package/data/manifests/ux-source-inventory.json +4669 -0
  128. package/data/ui-parts/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
  129. package/data/ui-parts/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
  130. package/data/ui-parts/blocks/semantic-pattern-parts/AuthParts.tsx +48 -0
  131. package/data/ui-parts/blocks/semantic-pattern-parts/ChoiceCards.tsx +7 -0
  132. package/data/ui-parts/blocks/semantic-pattern-parts/ListFilters.tsx +9 -0
  133. package/data/ui-parts/blocks/semantic-pattern-parts/OutcomeNotice.tsx +7 -0
  134. package/data/ui-parts/blocks/semantic-pattern-parts/ProductParts.tsx +38 -0
  135. package/data/ui-parts/blocks/semantic-pattern-parts/ProgressSteps.tsx +6 -0
  136. package/data/ui-parts/blocks/semantic-pattern-parts/ReviewList.tsx +7 -0
  137. package/data/ui-parts/blocks/semantic-pattern-parts/ValidatedField.tsx +8 -0
  138. package/data/ui-parts/blocks/semantic-pattern-parts/WorkflowParts.tsx +339 -0
  139. package/data/ui-parts/blocks/semantic-pattern-parts/auth-parts.css +27 -0
  140. package/data/ui-parts/blocks/semantic-pattern-parts/pattern-parts.css +25 -0
  141. package/data/ui-parts/blocks/semantic-pattern-parts/product-parts.css +25 -0
  142. package/data/ui-parts/blocks/semantic-pattern-parts/types.ts +3 -0
  143. package/data/ui-parts/blocks/semantic-pattern-parts/workflow-parts.css +118 -0
  144. package/data/ui-parts/blocks/ux-pattern-examples/AccessExamples.tsx +46 -0
  145. package/data/ui-parts/blocks/ux-pattern-examples/AccountExamples.tsx +133 -0
  146. package/data/ui-parts/blocks/ux-pattern-examples/ProductExamples.tsx +79 -0
  147. package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExample.tsx +1761 -0
  148. package/data/ui-parts/blocks/ux-pattern-examples/WorkflowExamples.tsx +190 -0
  149. package/data/ui-parts/blocks/ux-pattern-examples/copy.ts +6 -0
  150. package/data/ui-parts/blocks/ux-pattern-examples/ux-examples.css +18 -0
  151. package/data/ui-parts/blocks/ux-pattern-examples/workflow-copy.ts +76 -0
  152. package/data/ui-parts/blocks/ux-pattern-examples/workflow-definitions.ts +1291 -0
  153. package/data/ux-source-evidence/account-auth.json +41 -0
  154. package/data/ux-source-evidence/account-forgot-password.json +36 -0
  155. package/data/ux-source-evidence/account-invite.json +63 -0
  156. package/data/ux-source-evidence/account-locked.json +63 -0
  157. package/data/ux-source-evidence/account-login.json +28 -0
  158. package/data/ux-source-evidence/account-mfa.json +62 -0
  159. package/data/ux-source-evidence/account-onboarding-credentials.json +52 -0
  160. package/data/ux-source-evidence/account-onboarding-legal.json +56 -0
  161. package/data/ux-source-evidence/account-onboarding-orientation.json +66 -0
  162. package/data/ux-source-evidence/account-onboarding-profile.json +50 -0
  163. package/data/ux-source-evidence/account-onboarding.json +69 -0
  164. package/data/ux-source-evidence/account-reset-password.json +27 -0
  165. package/data/ux-source-evidence/account-session-expired.json +62 -0
  166. package/data/ux-source-evidence/account-signup.json +33 -0
  167. package/data/ux-source-evidence/account-verify-email.json +34 -0
  168. package/data/ux-source-evidence/account-welcome.json +41 -0
  169. package/data/ux-source-evidence/admin-analytics.json +54 -0
  170. package/data/ux-source-evidence/admin-audit.json +31 -0
  171. package/data/ux-source-evidence/admin-broadcasts-new.json +36 -0
  172. package/data/ux-source-evidence/admin-broadcasts.json +41 -0
  173. package/data/ux-source-evidence/admin-closures.json +52 -0
  174. package/data/ux-source-evidence/admin-devices.json +51 -0
  175. package/data/ux-source-evidence/admin-escalations.json +35 -0
  176. package/data/ux-source-evidence/admin-incidents.json +54 -0
  177. package/data/ux-source-evidence/admin-loading.json +17 -0
  178. package/data/ux-source-evidence/admin-onboarding-detail.json +80 -0
  179. package/data/ux-source-evidence/admin-onboarding.json +62 -0
  180. package/data/ux-source-evidence/admin-payments.json +45 -0
  181. package/data/ux-source-evidence/admin-providers.json +38 -0
  182. package/data/ux-source-evidence/admin-settings.json +18 -0
  183. package/data/ux-source-evidence/admin-subscriptions-detail-allocate.json +42 -0
  184. package/data/ux-source-evidence/admin-subscriptions-detail.json +48 -0
  185. package/data/ux-source-evidence/admin-subscriptions-new.json +43 -0
  186. package/data/ux-source-evidence/admin-subscriptions.json +41 -0
  187. package/data/ux-source-evidence/admin-transactions.json +41 -0
  188. package/data/ux-source-evidence/error.json +30 -0
  189. package/data/ux-source-evidence/loading.json +19 -0
  190. package/data/ux-source-evidence/marketing-blog.json +48 -0
  191. package/data/ux-source-evidence/marketing-contact.json +36 -0
  192. package/data/ux-source-evidence/marketing.json +64 -0
  193. package/data/ux-source-evidence/not-found.json +28 -0
  194. package/data/ux-source-evidence/portal-loading.json +17 -0
  195. package/data/ux-source-evidence/portal-mentor-messages.json +31 -0
  196. package/data/ux-source-evidence/portal-mentor-profile.json +23 -0
  197. package/data/ux-source-evidence/portal-mentor-requests-detail.json +54 -0
  198. package/data/ux-source-evidence/portal-mentor-sessions-detail-assess.json +42 -0
  199. package/data/ux-source-evidence/portal-mentor-settings.json +18 -0
  200. package/data/ux-source-evidence/portal-mentor.json +38 -0
  201. package/data/ux-source-evidence/portal-sessions-detail-live.json +48 -0
  202. package/data/ux-source-evidence/portal-sponsor-billing.json +28 -0
  203. package/data/ux-source-evidence/portal-sponsor-profile.json +22 -0
  204. package/data/ux-source-evidence/portal-sponsor-sessions.json +28 -0
  205. package/data/ux-source-evidence/portal-sponsor-settings.json +18 -0
  206. package/data/ux-source-evidence/portal-sponsor-trainees.json +36 -0
  207. package/data/ux-source-evidence/portal-sponsor.json +52 -0
  208. package/data/ux-source-evidence/portal-trainee-competency.json +42 -0
  209. package/data/ux-source-evidence/portal-trainee-credits.json +40 -0
  210. package/data/ux-source-evidence/portal-trainee-mentors.json +48 -0
  211. package/data/ux-source-evidence/portal-trainee-messages.json +32 -0
  212. package/data/ux-source-evidence/portal-trainee-notifications.json +35 -0
  213. package/data/ux-source-evidence/portal-trainee-profile.json +26 -0
  214. package/data/ux-source-evidence/portal-trainee-request.json +54 -0
  215. package/data/ux-source-evidence/portal-trainee-sessions-detail-feedback.json +36 -0
  216. package/data/ux-source-evidence/portal-trainee-sessions-detail-precheck.json +42 -0
  217. package/data/ux-source-evidence/portal-trainee-sessions-detail.json +55 -0
  218. package/data/ux-source-evidence/portal-trainee-sessions.json +36 -0
  219. package/data/ux-source-evidence/portal-trainee-settings-notifications.json +38 -0
  220. package/data/ux-source-evidence/portal-trainee-settings.json +18 -0
  221. package/data/ux-source-evidence/portal-trainee.json +44 -0
  222. package/data/ux-source-evidence/portal-tv-detail.json +39 -0
  223. package/data/ux-source-evidence/portal-tv.json +44 -0
  224. package/data/ux-source-evidence/shared-admin-admin-shell.json +35 -0
  225. package/data/ux-source-evidence/shared-auth-auth-primitives.json +105 -0
  226. package/data/ux-source-evidence/shared-auth-auth-shell.json +25 -0
  227. package/data/ux-source-evidence/shared-auth-onboarding-primitives.json +52 -0
  228. package/data/ux-source-evidence/shared-auth-onboarding-shell.json +28 -0
  229. package/data/ux-source-evidence/shared-auth-status-indicator.json +41 -0
  230. package/data/ux-source-evidence/shared-portal-empty-state.json +24 -0
  231. package/data/ux-source-evidence/shared-portal-loading-skeleton.json +27 -0
  232. package/data/ux-source-evidence/shared-portal-notifications-dropdown.json +39 -0
  233. package/data/ux-source-evidence/shared-portal-portal-shell.json +36 -0
  234. package/data/ux-source-evidence/shared-portal-profile-form.json +32 -0
  235. package/data/ux-source-evidence/shared-portal-settings-view.json +81 -0
  236. package/data/ux-source-evidence/shared-portal-usage-sparkline.json +18 -0
  237. package/data/ux-source-evidence/shared-portal-user-menu.json +34 -0
  238. package/lib/api.mjs +230 -4
  239. package/package.json +2 -2
@@ -0,0 +1,87 @@
1
+ import * as React from 'react'
2
+ import { Badge } from '@utopia-studio-design/design-system/Badge'
3
+ import type { ChartState } from '@utopia-studio-design/design-system/Charts'
4
+ import './analytics-dashboard.css'
5
+
6
+ export interface MetricDefinition {
7
+ change?: number
8
+ changeLabel?: string
9
+ format?: 'number' | 'percent' | 'currency'
10
+ id: string
11
+ label: string
12
+ value: number
13
+ }
14
+
15
+ export interface KPIGridProps extends React.HTMLAttributes<HTMLDivElement> {
16
+ currency?: string
17
+ density?: 'compact' | 'default' | 'comfortable'
18
+ locale?: string
19
+ metrics: MetricDefinition[]
20
+ state?: ChartState
21
+ }
22
+
23
+ export function KPIGrid({
24
+ className,
25
+ currency = 'USD',
26
+ density = 'default',
27
+ locale,
28
+ metrics,
29
+ state = metrics.length === 0 ? 'empty' : 'ready',
30
+ ...props
31
+ }: KPIGridProps) {
32
+ const formatMetric = (metric: MetricDefinition) => {
33
+ if (metric.format === 'percent') {
34
+ return new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 }).format(metric.value)
35
+ }
36
+ if (metric.format === 'currency') {
37
+ return new Intl.NumberFormat(locale, {
38
+ style: 'currency',
39
+ currency,
40
+ notation: 'compact',
41
+ maximumFractionDigits: 1,
42
+ }).format(metric.value)
43
+ }
44
+ return new Intl.NumberFormat(locale, { notation: 'compact', maximumFractionDigits: 1 }).format(metric.value)
45
+ }
46
+
47
+ return (
48
+ <section
49
+ aria-busy={state === 'loading'}
50
+ aria-label="Key performance indicators"
51
+ className={['ceramic-kpi-grid', className].filter(Boolean).join(' ')}
52
+ data-density={density}
53
+ data-state={state}
54
+ {...props}
55
+ >
56
+ {state === 'loading' ? (
57
+ <>
58
+ {[0, 1, 2].map((item) => <span aria-hidden="true" className="ceramic-kpi-skeleton" key={item} />)}
59
+ <span className="uds-visually-hidden" role="status">Loading metrics…</span>
60
+ </>
61
+ ) : null}
62
+ {state === 'empty' ? (
63
+ <div className="ceramic-kpi-state" role="status">
64
+ <strong>No metrics available</strong>
65
+ <span>Choose another period or data source.</span>
66
+ </div>
67
+ ) : null}
68
+ {state === 'error' ? (
69
+ <div className="ceramic-kpi-state" role="alert">
70
+ <strong>Metrics unavailable</strong>
71
+ <span>Reconnect the data source and try again.</span>
72
+ </div>
73
+ ) : null}
74
+ {state === 'ready' || state === 'partial' ? metrics.map((metric) => (
75
+ <article className="ceramic-kpi" key={metric.id}>
76
+ <span>{metric.label}</span>
77
+ <strong>{formatMetric(metric)}</strong>
78
+ {metric.change !== undefined ? (
79
+ <Badge variant="outline">
80
+ {metric.change > 0 ? '+' : ''}{metric.change.toFixed(1)}% {metric.changeLabel ?? 'vs previous'}
81
+ </Badge>
82
+ ) : <span className="ceramic-kpi-unavailable">No comparison</span>}
83
+ </article>
84
+ )) : null}
85
+ </section>
86
+ )
87
+ }
@@ -0,0 +1,92 @@
1
+ .ceramic-kpi-grid {
2
+ display: grid;
3
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
4
+ gap: var(--interaction-density-default);
5
+ min-inline-size: 0;
6
+ }
7
+
8
+ .ceramic-kpi-grid[data-density='compact'] {
9
+ gap: var(--interaction-density-compact);
10
+ }
11
+
12
+ .ceramic-kpi-grid[data-density='comfortable'] {
13
+ gap: var(--interaction-density-comfortable);
14
+ }
15
+
16
+ .ceramic-kpi {
17
+ display: grid;
18
+ align-content: start;
19
+ gap: var(--space-2);
20
+ min-inline-size: 0;
21
+ padding: var(--space-4);
22
+ border: var(--border-width-hairline) solid var(--border);
23
+ background: var(--surface);
24
+ }
25
+
26
+ .ceramic-kpi > span:first-child,
27
+ .ceramic-kpi-unavailable {
28
+ color: var(--muted-foreground);
29
+ font-size: var(--font-size-small);
30
+ }
31
+
32
+ .ceramic-kpi > strong {
33
+ font-size: var(--font-size-marketing-h3);
34
+ font-weight: var(--font-weight-medium);
35
+ line-height: var(--line-height-tight);
36
+ }
37
+
38
+ .ceramic-kpi .uds-badge {
39
+ inline-size: fit-content;
40
+ }
41
+
42
+ .ceramic-kpi-state {
43
+ display: grid;
44
+ grid-column: 1 / -1;
45
+ justify-items: center;
46
+ gap: var(--space-2);
47
+ min-block-size: 10rem;
48
+ padding: var(--space-6);
49
+ border: var(--border-width-hairline) dashed var(--border);
50
+ color: var(--muted-foreground);
51
+ text-align: center;
52
+ }
53
+
54
+ .ceramic-kpi-state strong {
55
+ color: var(--foreground);
56
+ }
57
+
58
+ .ceramic-kpi-skeleton {
59
+ min-block-size: 9rem;
60
+ background: var(--surface-strong);
61
+ }
62
+
63
+ .ceramic-analytics-header {
64
+ display: flex;
65
+ align-items: end;
66
+ justify-content: space-between;
67
+ gap: var(--space-6);
68
+ padding-block: var(--space-6);
69
+ border-block-end: var(--border-width-hairline) solid var(--border);
70
+ }
71
+
72
+ .ceramic-analytics-header > div {
73
+ display: grid;
74
+ gap: var(--space-1);
75
+ }
76
+
77
+ .ceramic-analytics-header :is(h1, p) {
78
+ margin: 0;
79
+ }
80
+
81
+ .ceramic-analytics-header > div > span,
82
+ .ceramic-analytics-header p {
83
+ color: var(--muted-foreground);
84
+ font-size: var(--font-size-small);
85
+ }
86
+
87
+ @media (max-width: 44rem) {
88
+ .ceramic-analytics-header {
89
+ align-items: stretch;
90
+ flex-direction: column;
91
+ }
92
+ }
@@ -0,0 +1,48 @@
1
+ import { useId, useRef, useState, type ReactNode } from 'react'
2
+ import { Button, TextInput } from '@utopia-studio-design/design-system'
3
+ import { Eye, EyeOff } from 'lucide-react'
4
+ import './auth-parts.css'
5
+
6
+ /** Host-owned policy and labels: this meter is not a server password policy. */
7
+ export function PasswordField({ label, value, onChange, showLabel, hideLabel, rules = [], error, hint }: {
8
+ label: string; value: string; onChange: (value: string) => void; showLabel: string; hideLabel: string;
9
+ rules?: { id: string; label: string; passed: boolean }[]; error?: string; hint?: string;
10
+ }) {
11
+ const id = useId(), [shown, setShown] = useState(false)
12
+ return <div className="auth-part-field" data-slot="password-field">
13
+ <label htmlFor={id}>{label}</label>
14
+ <div className="auth-part-password"><TextInput id={id} type={shown ? 'text' : 'password'} value={value} onChange={e => onChange(e.target.value)} autoComplete="off" aria-invalid={Boolean(error)} aria-describedby={error || hint ? id + '-help' : undefined} /><Button type="button" variant="ghost" size="icon" onClick={() => setShown(v => !v)} aria-label={shown ? hideLabel : showLabel} aria-pressed={shown}>{shown ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}</Button></div>
15
+ {error || hint ? <p id={id + '-help'} data-error={Boolean(error)}>{error || hint}</p> : null}
16
+ {value && rules.length ? <ul className="auth-part-rules">{rules.map(rule => <li key={rule.id} data-passed={rule.passed}><span aria-hidden="true">{rule.passed ? '✓' : '○'}</span> {rule.label}</li>)}</ul> : null}
17
+ </div>
18
+ }
19
+
20
+ /** Six slots, paste, backspace and arrow movement; verification stays with host. */
21
+ export function VerificationCode({ label, digitLabel, value, onChange, error, disabled = false }: {
22
+ label: string; digitLabel: (index: number) => string; value: string; onChange: (value: string) => void; error?: string; disabled?: boolean;
23
+ }) {
24
+ const inputs = useRef<(HTMLInputElement | null)[]>([]), id = useId()
25
+ const update = (index: number, incoming: string) => {
26
+ const digits = incoming.replace(/\D/g, '').slice(0, 6 - index)
27
+ const slots = value.padEnd(6, ' ').split('')
28
+ slots.splice(index, Math.max(1, digits.length), ...(digits || ' ').split(''))
29
+ onChange(slots.slice(0, 6).join('').trimEnd())
30
+ if (digits) inputs.current[Math.min(index + digits.length, 5)]?.focus()
31
+ }
32
+ return <fieldset className="auth-part-code" data-slot="verification-code" aria-describedby={error ? id : undefined}>
33
+ <legend>{label}</legend><div dir="ltr">{Array.from({ length: 6 }, (_, index) => <TextInput key={index} ref={el => { inputs.current[index] = el }} aria-label={digitLabel(index + 1)} aria-invalid={Boolean(error)} aria-describedby={error ? id : undefined} inputMode="numeric" autoComplete={index === 0 ? 'one-time-code' : 'off'} value={value[index] ?? ''} disabled={disabled}
34
+ onChange={e => update(index, e.target.value)} onFocus={e => e.currentTarget.select()} onPaste={e => { e.preventDefault(); update(index, e.clipboardData.getData('text')) }}
35
+ onKeyDown={e => { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); inputs.current[Math.max(0, Math.min(5, index + (e.key === 'ArrowLeft' ? -1 : 1)))]?.focus() } else if (e.key === 'Backspace' && !value[index] && index) { e.preventDefault(); onChange(value.slice(0, index - 1)); inputs.current[index - 1]?.focus() } }} />)}</div>
36
+ {error ? <p id={id} role="alert">{error}</p> : null}
37
+ </fieldset>
38
+ }
39
+
40
+ export function ProviderButtons({ providers, onSelect, pending, label }: {
41
+ providers: { id: string; label: string }[]; onSelect: (id: string) => void; pending?: string; label: string;
42
+ }) {
43
+ return <div className="auth-part-providers" data-slot="provider-sign-in" role="group" aria-label={label}>{providers.map(provider => <Button key={provider.id} type="button" variant="outline" disabled={Boolean(pending)} aria-busy={pending === provider.id} onClick={() => onSelect(provider.id)}>{provider.label}</Button>)}</div>
44
+ }
45
+
46
+ export function AuthFrame({ title, subtitle, footer, children, locale = 'en' }: { title: string; subtitle?: string; footer: string; children: ReactNode; locale?: string }) {
47
+ return <section className="ux-example ux-example--account" lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}><div className="ux-example__center"><div className="auth-part-frame"><header><h3>{title}</h3>{subtitle ? <p>{subtitle}</p> : null}</header>{children}</div></div><footer>{footer}</footer></section>
48
+ }
@@ -0,0 +1,7 @@
1
+ import { Button } from '@utopia-studio-design/design-system'
2
+ import type { PatternOption } from './types'
3
+ import './pattern-parts.css'
4
+
5
+ export function ChoiceCards({ label, options, value, onChange }: { label: string; options: PatternOption[]; value: string; onChange: (id: string) => void }) {
6
+ return <fieldset className="pattern-parts pattern-choices" data-slot="pattern-choices"><legend>{label}</legend><div>{options.map(option => <Button key={option.id} type="button" variant="outline" aria-pressed={value === option.id} onClick={() => onChange(option.id)}><strong>{option.label}</strong>{option.description ? <span>{option.description}</span> : null}</Button>)}</div></fieldset>
7
+ }
@@ -0,0 +1,9 @@
1
+ import { useId } from 'react'
2
+ import { Button, TextInput, NativeSelect } from '@utopia-studio-design/design-system'
3
+ import type { PatternOption } from './types'
4
+ import './pattern-parts.css'
5
+
6
+ export function ListFilters({ label, query, onQueryChange, queryLabel, status, onStatusChange, statusLabel, options, resetLabel }: { label: string; query: string; onQueryChange: (query: string) => void; queryLabel: string; status: string; onStatusChange: (status: string) => void; statusLabel: string; options: PatternOption[]; resetLabel: string }) {
7
+ const id = useId()
8
+ return <section className="pattern-parts pattern-filters" data-slot="pattern-filters" aria-label={label}><div><label htmlFor={`${id}-query`}>{queryLabel}</label><TextInput id={`${id}-query`} type="search" value={query} onChange={event => onQueryChange(event.target.value)} /></div><div><label htmlFor={`${id}-status`}>{statusLabel}</label><NativeSelect id={`${id}-status`} value={status} onChange={event => onStatusChange(event.target.value)}>{options.map(option => <option value={option.id} key={option.id}>{option.label}</option>)}</NativeSelect></div><Button type="button" variant="outline" onClick={() => { onQueryChange(''); onStatusChange(options[0]?.id ?? '') }}>{resetLabel}</Button></section>
9
+ }
@@ -0,0 +1,7 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Button } from '@utopia-studio-design/design-system'
3
+ import './pattern-parts.css'
4
+
5
+ export function OutcomeNotice({ title, children, actionLabel, onAction, tone = 'info' }: { title: string; children: ReactNode; actionLabel: string; onAction: () => void; tone?: 'info' | 'success' | 'warning' }) {
6
+ return <section className="pattern-parts pattern-result" data-slot="pattern-result" data-tone={tone}><div role="status"><strong>{title}</strong><p>{children}</p></div><Button type="button" variant="outline" onClick={onAction}>{actionLabel}</Button></section>
7
+ }
@@ -0,0 +1,38 @@
1
+ import { Button, NativeSelect } from '@utopia-studio-design/design-system'
2
+ import './product-parts.css'
3
+
4
+ /** Rows represent destinations; the host supplies navigation, not this part. */
5
+ export function ActivityList({ items, emptyLabel, unreadLabel, onOpen }: {
6
+ items: { id: string; title: string; body: string; time: string; unread?: boolean }[];
7
+ emptyLabel: string; unreadLabel: string; onOpen: (id: string) => void;
8
+ }) {
9
+ if (!items.length) return <p className="product-card" role="status">{emptyLabel}</p>
10
+ return <ul className="product-list" data-slot="activity-list">{items.map(item => <li key={item.id}>
11
+ <button className="product-row" data-slot="activity-row" data-unread={Boolean(item.unread)} onClick={() => onOpen(item.id)} type="button">
12
+ <span className="product-row__content"><strong>{item.title}</strong><span>{item.body}</span></span>
13
+ <span className="product-row__meta"><span>{item.time}</span>{item.unread ? <strong>{unreadLabel}</strong> : null}</span>
14
+ </button>
15
+ </li>)}</ul>
16
+ }
17
+
18
+ /** Selected channels and mandatory-delivery policy belong to the host. */
19
+ export function ChannelPreferences({ items, options, requiredLabel, onChange }: {
20
+ items: { id: string; title: string; description: string; channel: string; disallowed?: string[] }[];
21
+ options: { id: string; label: string }[]; requiredLabel: string; onChange: (id: string, channel: string) => void;
22
+ }) {
23
+ return <div className="product-list" data-slot="channel-preferences">{items.map(item => <div className="product-setting" key={item.id}>
24
+ <div><strong>{item.title}</strong><p>{item.description}</p>{item.disallowed?.length ? <small>{requiredLabel}</small> : null}</div>
25
+ <NativeSelect aria-label={item.title} value={item.channel} onChange={e => onChange(item.id, e.target.value)}>{options.map(option => <option key={option.id} value={option.id} disabled={item.disallowed?.includes(option.id)}>{option.label}</option>)}</NativeSelect>
26
+ </div>)}</div>
27
+ }
28
+
29
+ export function DirectoryCards({ items, emptyLabel, profileLabel, actionLabel, onProfile, onAction }: {
30
+ items: { id: string; name: string; role: string; organization: string; languages: string; expertise: string; availability: string; stats: string }[];
31
+ emptyLabel: string; profileLabel: string; actionLabel: string; onProfile: (id: string) => void; onAction: (id: string) => void;
32
+ }) {
33
+ if (!items.length) return <p className="product-card" role="status">{emptyLabel}</p>
34
+ return <ul className="product-grid" data-slot="directory-cards">{items.map(item => <li className="product-card" data-slot="directory-card" key={item.id}>
35
+ <header><strong>{item.name}</strong><p>{item.role}</p></header><span>{item.availability}</span><p>{item.stats}</p><p>{item.organization} · {item.languages}</p><p>{item.expertise}</p>
36
+ <div className="product-actions"><Button onClick={() => onAction(item.id)}>{actionLabel}</Button><Button variant="outline" onClick={() => onProfile(item.id)}>{profileLabel}</Button></div>
37
+ </li>)}</ul>
38
+ }
@@ -0,0 +1,6 @@
1
+ import { Button } from '@utopia-studio-design/design-system'
2
+ import './pattern-parts.css'
3
+
4
+ export function ProgressSteps({ label, steps, onSelect }: { label: string; steps: { id: string; label: string; status: 'done' | 'current' | 'locked'; statusLabel: string }[]; onSelect: (id: string) => void }) {
5
+ return <nav className="pattern-parts pattern-steps" data-slot="pattern-steps" aria-label={label}><ol>{steps.map((step, index) => <li key={step.id}><Button variant={step.status === 'current' ? 'default' : 'outline'} disabled={step.status === 'locked'} aria-current={step.status === 'current' ? 'step' : undefined} onClick={() => onSelect(step.id)}><span aria-hidden="true">{step.status === 'done' ? '✓' : index + 1}</span><strong>{step.label}</strong><span>{step.statusLabel}</span></Button></li>)}</ol></nav>
6
+ }
@@ -0,0 +1,7 @@
1
+ import { Button, Badge, DataTable, DataTableShell, DataTableHeader, DataTableBody, DataTableRow, DataTableHead, DataTableCell } from '@utopia-studio-design/design-system'
2
+ import type { ReviewItem } from './types'
3
+ import './pattern-parts.css'
4
+
5
+ export function ReviewList({ label, rows, columns, state = 'ready', message, retryLabel, onRetry, onReview, reviewLabel }: { label: string; rows: ReviewItem[]; columns: [string, string, string, string]; state?: 'loading' | 'error' | 'ready'; message: string; retryLabel: string; onRetry: () => void; onReview: (id: string) => void; reviewLabel: (row: ReviewItem) => string }) {
6
+ return <section className="pattern-parts pattern-review" data-slot="pattern-review" aria-label={label} aria-busy={state === 'loading'}>{state !== 'ready' ? <div className="pattern-result"><p role={state === 'error' ? 'alert' : 'status'}>{message}</p>{state === 'error' ? <Button variant="outline" onClick={onRetry}>{retryLabel}</Button> : null}</div> : <DataTableShell><DataTable aria-label={label}><DataTableHeader><DataTableRow>{columns.map(column => <DataTableHead key={column}>{column}</DataTableHead>)}</DataTableRow></DataTableHeader><DataTableBody>{rows.map(row => <DataTableRow key={row.id}><DataTableCell><strong>{row.title}</strong>{row.description ? <p>{row.description}</p> : null}</DataTableCell><DataTableCell><Badge variant="secondary">{row.status}</Badge></DataTableCell><DataTableCell>{row.assignee}</DataTableCell><DataTableCell><Button variant="outline" size="sm" onClick={() => onReview(row.id)}>{reviewLabel(row)}</Button></DataTableCell></DataTableRow>)}{!rows.length ? <DataTableRow><DataTableCell colSpan={4}><p role="status">{message}</p></DataTableCell></DataTableRow> : null}</DataTableBody></DataTable></DataTableShell>}</section>
7
+ }
@@ -0,0 +1,8 @@
1
+ import { useId } from 'react'
2
+ import { TextInput } from '@utopia-studio-design/design-system'
3
+ import './pattern-parts.css'
4
+
5
+ export function ValidatedField({ label, value, onChange, hint, error, type = 'text', required = false }: { label: string; value: string; onChange: (value: string) => void; hint?: string; error?: string; type?: 'text' | 'email'; required?: boolean }) {
6
+ const id = useId()
7
+ return <div className="pattern-parts pattern-field" data-slot="pattern-field"><label htmlFor={id}>{label}</label><TextInput id={id} type={type} value={value} onChange={event => onChange(event.target.value)} required={required} aria-invalid={Boolean(error)} aria-describedby={error || hint ? `${id}-help` : undefined} />{error || hint ? <p id={`${id}-help`} data-error={Boolean(error)}>{error || hint}</p> : null}</div>
8
+ }
@@ -0,0 +1,339 @@
1
+ import { useId } from "react";
2
+ import {
3
+ Button,
4
+ TextInput,
5
+ TextArea,
6
+ NativeSelect,
7
+ } from "@utopia-studio-design/design-system";
8
+ import "./workflow-parts.css";
9
+ import "./product-parts.css";
10
+
11
+ export type WorkflowField = {
12
+ id: string;
13
+ label: string;
14
+ type?:
15
+ | "text"
16
+ | "email"
17
+ | "date"
18
+ | "time"
19
+ | "number"
20
+ | "url"
21
+ | "textarea"
22
+ | "select"
23
+ | "multi-select"
24
+ | "check"
25
+ | "file";
26
+ options?: string[];
27
+ required?: boolean;
28
+ minLength?: number;
29
+ min?: number;
30
+ max?: number;
31
+ accept?: string;
32
+ hint?: string;
33
+ };
34
+
35
+ /** Pure field rendering. Validation, uploaded file handling and persistence belong to the host. */
36
+ export function WorkflowFields({
37
+ fields,
38
+ values,
39
+ errors = {},
40
+ onChange,
41
+ onFile,
42
+ }: {
43
+ fields: WorkflowField[];
44
+ values: Record<string, string>;
45
+ errors?: Record<string, string>;
46
+ onChange: (id: string, value: string) => void;
47
+ onFile?: (id: string, file: File | undefined) => void;
48
+ }) {
49
+ const prefix = useId();
50
+ return (
51
+ <div className="workflow-fields" data-slot="workflow-fields">
52
+ {fields.map((field) => {
53
+ const id = `${prefix}-${field.id}`,
54
+ error = errors[field.id];
55
+ const shared = {
56
+ id,
57
+ required: field.required,
58
+ "aria-invalid": Boolean(error),
59
+ "aria-describedby": `${id}-help`,
60
+ value: values[field.id] ?? "",
61
+ onChange: (
62
+ e: React.ChangeEvent<
63
+ HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
64
+ >
65
+ ) => onChange(field.id, e.target.value),
66
+ };
67
+ if (field.type === 'multi-select') {
68
+ const selected = (values[field.id] ?? '').split('\n').filter(Boolean)
69
+ return <fieldset className="product-card" data-slot="workflow-multi-select" key={field.id} aria-describedby={`${id}-help`} aria-invalid={Boolean(error)}>
70
+ <legend>{field.label}{field.required ? ' *' : ''}</legend>
71
+ <div className="workflow-fields">{field.options?.map(option => <label className="ux-example__check" key={option}><input type="checkbox" checked={selected.includes(option)} onChange={e => onChange(field.id, (e.target.checked ? [...selected, option] : selected.filter(v=>v!==option)).join('\n'))} />{option}</label>)}</div>
72
+ <small id={`${id}-help`} data-error={Boolean(error)}>{error || field.hint}</small>
73
+ </fieldset>
74
+ }
75
+ return (
76
+ <div className="workflow-field" key={field.id}>
77
+ <label htmlFor={id}>
78
+ {field.label}
79
+ {field.required ? " *" : ""}
80
+ </label>
81
+ {field.type === "check" ? (
82
+ <input
83
+ id={id}
84
+ type="checkbox"
85
+ checked={values[field.id] === "true"}
86
+ required={field.required}
87
+ aria-invalid={Boolean(error)}
88
+ aria-describedby={`${id}-help`}
89
+ onChange={(e) => onChange(field.id, String(e.target.checked))}
90
+ />
91
+ ) : field.type === "file" ? (
92
+ <input
93
+ id={id}
94
+ type="file"
95
+ accept={field.accept}
96
+ aria-invalid={Boolean(error)}
97
+ aria-describedby={`${id}-help`}
98
+ onChange={(e) => onFile?.(field.id, e.target.files?.[0])}
99
+ />
100
+ ) : field.type === "textarea" ? (
101
+ <TextArea {...shared} minLength={field.minLength} rows={4} />
102
+ ) : field.type === "select" ? (
103
+ <NativeSelect {...shared}>
104
+ <option value="">—</option>
105
+ {field.options?.map((option) => (
106
+ <option key={option}>{option}</option>
107
+ ))}
108
+ </NativeSelect>
109
+ ) : (
110
+ <TextInput
111
+ {...shared}
112
+ type={field.type ?? "text"}
113
+ minLength={field.minLength}
114
+ min={field.min}
115
+ max={field.max}
116
+ />
117
+ )}
118
+ <small id={`${id}-help`} data-error={Boolean(error)}>
119
+ {error ||
120
+ field.hint ||
121
+ (field.type === "file" ? values[field.id] : "")}
122
+ </small>
123
+ </div>
124
+ );
125
+ })}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ /** Table is independently scroll-owned. The host supplies filtered rows and exact actions. */
131
+ export function RecordTable({
132
+ caption,
133
+ columns,
134
+ rows,
135
+ emptyLabel,
136
+ actionLabel,
137
+ onOpen,
138
+ }: {
139
+ caption: string;
140
+ columns: string[];
141
+ rows: { id: string; cells: string[] }[];
142
+ emptyLabel: string;
143
+ actionLabel: string;
144
+ onOpen?: (id: string) => void;
145
+ }) {
146
+ return (
147
+ <div
148
+ className="workflow-table-scroll"
149
+ data-slot="record-table"
150
+ tabIndex={0}
151
+ role="region"
152
+ aria-label={caption}
153
+ >
154
+ <table>
155
+ <caption>{caption}</caption>
156
+ <thead>
157
+ <tr>
158
+ {columns.map((column, i) => (
159
+ <th scope="col" key={i}>
160
+ {column}
161
+ </th>
162
+ ))}
163
+ {onOpen ? <th scope="col">{actionLabel}</th> : null}
164
+ </tr>
165
+ </thead>
166
+ <tbody>
167
+ {rows.length ? (
168
+ rows.map((row) => (
169
+ <tr key={row.id}>
170
+ {row.cells.map((cell, i) => (
171
+ <td key={i}>{cell}</td>
172
+ ))}
173
+ {onOpen ? (
174
+ <td>
175
+ <Button
176
+ size="sm"
177
+ variant="outline"
178
+ onClick={() => onOpen(row.id)}
179
+ aria-label={`${actionLabel}: ${row.cells[0]}`}
180
+ >
181
+ {actionLabel}
182
+ </Button>
183
+ </td>
184
+ ) : null}
185
+ </tr>
186
+ ))
187
+ ) : (
188
+ <tr>
189
+ <td colSpan={columns.length + (onOpen ? 1 : 0)}>{emptyLabel}</td>
190
+ </tr>
191
+ )}
192
+ </tbody>
193
+ </table>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ /** Gate states are supplied by the host; a preview cannot verify identity or hardware. */
199
+ export function StepChecklist({
200
+ items,
201
+ passLabel,
202
+ failLabel,
203
+ onChange,
204
+ }: {
205
+ items: {
206
+ id: string;
207
+ label: string;
208
+ description: string;
209
+ state: string;
210
+ disabled?: boolean;
211
+ }[];
212
+ passLabel: string;
213
+ failLabel: string;
214
+ onChange: (id: string, state: "pass" | "fail") => void;
215
+ }) {
216
+ return (
217
+ <ol className="workflow-checklist" data-slot="step-checklist">
218
+ {items.map((item) => (
219
+ <li className="product-card" key={item.id} data-state={item.state}>
220
+ <strong>{item.label}</strong>
221
+ <p>{item.description}</p>
222
+ <div className="product-actions">
223
+ <Button
224
+ disabled={item.disabled}
225
+ aria-pressed={item.state === "pass"}
226
+ onClick={() => onChange(item.id, "pass")}
227
+ >
228
+ {passLabel}
229
+ </Button>
230
+ <Button
231
+ variant="outline"
232
+ disabled={item.disabled}
233
+ aria-pressed={item.state === "fail"}
234
+ onClick={() => onChange(item.id, "fail")}
235
+ >
236
+ {failLabel}
237
+ </Button>
238
+ </div>
239
+ </li>
240
+ ))}
241
+ </ol>
242
+ );
243
+ }
244
+
245
+ export function StatStrip({
246
+ items,
247
+ }: {
248
+ items: { label: string; value: string; detail?: string }[];
249
+ }) {
250
+ return (
251
+ <dl className="workflow-stats" data-slot="stat-strip">
252
+ {items.map((item) => (
253
+ <div className="product-card" key={item.label}>
254
+ <dt>{item.label}</dt>
255
+ <dd>{item.value}</dd>
256
+ {item.detail ? <small>{item.detail}</small> : null}
257
+ </div>
258
+ ))}
259
+ </dl>
260
+ );
261
+ }
262
+
263
+ export function RatingFields({
264
+ labels,
265
+ values,
266
+ onChange,
267
+ }: {
268
+ labels: string[];
269
+ values: number[];
270
+ onChange: (index: number, value: number) => void;
271
+ }) {
272
+ return (
273
+ <div className="workflow-fields" data-slot="rating-fields">
274
+ {labels.map((label, index) => (
275
+ <fieldset className="product-card" key={label}>
276
+ <legend>{label}</legend>
277
+ <div className="product-actions">
278
+ {[1, 2, 3, 4, 5].map((value) => (
279
+ <Button
280
+ key={value}
281
+ variant={values[index] === value ? "default" : "outline"}
282
+ aria-pressed={values[index] === value}
283
+ onClick={() => onChange(index, value)}
284
+ aria-label={`${label}: ${value} / 5`}
285
+ >
286
+ {value}
287
+ </Button>
288
+ ))}
289
+ </div>
290
+ </fieldset>
291
+ ))}
292
+ </div>
293
+ );
294
+ }
295
+
296
+ export function TrendSummary({
297
+ title,
298
+ items,
299
+ }: {
300
+ title: string;
301
+ items: { label: string; value: number; max: number }[];
302
+ }) {
303
+ return (
304
+ <section className="product-card" data-slot="trend-summary">
305
+ <h4>{title}</h4>
306
+ {items.map((item) => (
307
+ <label className="workflow-field" key={item.label}>
308
+ <span>
309
+ {item.label} · {item.value} / {item.max}
310
+ </span>
311
+ <meter min={0} max={item.max} value={item.value} />
312
+ </label>
313
+ ))}
314
+ </section>
315
+ );
316
+ }
317
+
318
+ /** No camera, microphone, network or recording is started by this visual stage. */
319
+ export function MediaStage({
320
+ label,
321
+ status,
322
+ children,
323
+ }: {
324
+ label: string;
325
+ status: string;
326
+ children?: React.ReactNode;
327
+ }) {
328
+ return (
329
+ <section
330
+ className="workflow-media"
331
+ data-slot="media-stage"
332
+ aria-label={label}
333
+ >
334
+ <h4>{label}</h4>
335
+ <p role="status">{status}</p>
336
+ {children}
337
+ </section>
338
+ );
339
+ }