bedrock-flows 0.7.1

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 (85) hide show
  1. package/auth-schema.sql +8 -0
  2. package/bin/bedrock-flows.mjs +127 -0
  3. package/lib/setup.mjs +262 -0
  4. package/package.json +11 -0
  5. package/template/.storybook/main.js +46 -0
  6. package/template/.storybook/manager-head.html +963 -0
  7. package/template/.storybook/preview-head.html +35 -0
  8. package/template/.storybook/preview.js +23 -0
  9. package/template/CHANGELOG.md +236 -0
  10. package/template/README.md +26 -0
  11. package/template/apps/dashboard/index.html +15 -0
  12. package/template/apps/dashboard/package.json +22 -0
  13. package/template/apps/dashboard/src/App.module.css +1318 -0
  14. package/template/apps/dashboard/src/App.tsx +2716 -0
  15. package/template/apps/dashboard/src/auth-client.ts +17 -0
  16. package/template/apps/dashboard/src/changelog.tsx +92 -0
  17. package/template/apps/dashboard/src/index.css +86 -0
  18. package/template/apps/dashboard/src/main.tsx +15 -0
  19. package/template/apps/dashboard/src/theme.ts +31 -0
  20. package/template/apps/dashboard/src/vite-env.d.ts +4 -0
  21. package/template/apps/dashboard/vite.config.ts +48 -0
  22. package/template/apps/worker/.dev.vars.example +50 -0
  23. package/template/apps/worker/package.json +19 -0
  24. package/template/apps/worker/src/index.ts +295 -0
  25. package/template/apps/worker/tsconfig.json +11 -0
  26. package/template/apps/worker/wrangler.jsonc +29 -0
  27. package/template/bedrock.config.ts +16 -0
  28. package/template/design-system/README.md +97 -0
  29. package/template/design-system/starter-v1/components/button/component.css +42 -0
  30. package/template/design-system/starter-v1/components/button/danger.html +21 -0
  31. package/template/design-system/starter-v1/components/button/default.html +21 -0
  32. package/template/design-system/starter-v1/components/button/disabled.html +21 -0
  33. package/template/design-system/starter-v1/components/button/ghost.html +21 -0
  34. package/template/design-system/starter-v1/components/button/macro.njk +14 -0
  35. package/template/design-system/starter-v1/components/button/primary.html +21 -0
  36. package/template/design-system/starter-v1/components/button/variants.json +30 -0
  37. package/template/design-system/starter-v1/ds.json +3 -0
  38. package/template/design-system/starter-v1/global.css +52 -0
  39. package/template/design-system/starter-v1/style.css +107 -0
  40. package/template/gitignore +8 -0
  41. package/template/package.json +41 -0
  42. package/template/prototypes/F-001-hello/1-welcome.njk +30 -0
  43. package/template/prototypes/F-001-hello/2-form.njk +46 -0
  44. package/template/prototypes/F-001-hello/3-done.njk +29 -0
  45. package/template/prototypes/F-001-hello/meta.json +6 -0
  46. package/template/prototypes/_shared/_auth-gate.njk +54 -0
  47. package/template/prototypes/_shared/delivery.njk +43 -0
  48. package/template/prototypes/_shared/layout.njk +15 -0
  49. package/template/prototypes/_shared/screen.njk +1818 -0
  50. package/template/prototypes/_shared/wireflow.njk +4731 -0
  51. package/template/public/auth-gate.css +150 -0
  52. package/template/public/bedrock/color-inspector.js +284 -0
  53. package/template/public/bedrock/component-overlay.js +219 -0
  54. package/template/public/bedrock/data/bedrock-config.js +45 -0
  55. package/template/public/bedrock/font-size-overlay.js +590 -0
  56. package/template/public/bedrock/grid-overlay.js +379 -0
  57. package/template/public/bedrock/prototype-navigation.js +974 -0
  58. package/template/public/cmdk.js +146 -0
  59. package/template/public/ds-xray.css +112 -0
  60. package/template/public/ds-xray.js +271 -0
  61. package/template/public/favicon.svg +4 -0
  62. package/template/public/icons/bolt-fill.svg +3 -0
  63. package/template/public/icons/bolt.svg +3 -0
  64. package/template/public/icons/caret-down-fill.svg +3 -0
  65. package/template/public/icons/check-double.svg +4 -0
  66. package/template/public/icons/check.svg +3 -0
  67. package/template/public/icons/chevron-left.svg +3 -0
  68. package/template/public/icons/chevron-right.svg +3 -0
  69. package/template/public/icons/circle-info.svg +6 -0
  70. package/template/public/icons/grid.svg +6 -0
  71. package/template/public/icons/message-square-1.svg +3 -0
  72. package/template/public/icons/message-square.svg +3 -0
  73. package/template/public/icons/messages.svg +4 -0
  74. package/template/public/icons/options-horizontal.svg +5 -0
  75. package/template/public/icons/swatches.svg +6 -0
  76. package/template/public/icons/workflow.svg +6 -0
  77. package/template/public/lightbox.js +87 -0
  78. package/template/public/proto-chrome.css +596 -0
  79. package/template/public/screen-comments.css +723 -0
  80. package/template/public/wireflow-client.js +26 -0
  81. package/template/scripts/build-storybooks.mjs +8 -0
  82. package/template/scripts/dev-setup.mjs +15 -0
  83. package/template/scripts/generate-stories.mjs +12 -0
  84. package/template/scripts/generate-variants.mjs +22 -0
  85. package/template/tsconfig.base.json +19 -0
@@ -0,0 +1,2716 @@
1
+ import { useEffect, useRef, useState, type ReactNode, type CSSProperties, type FormEvent } from 'react'
2
+ import styles from './App.module.css'
3
+ import { APP_VERSION, ChangelogView } from './changelog'
4
+
5
+ // Custom icon set (public/icons/*.svg) inlined so they inherit
6
+ // currentColor. One source of truth for every chrome glyph — nav,
7
+ // the About "i" badge, and the comments count. viewBox 24, 1.5px
8
+ // stroke; `black` from the source SVGs becomes `currentColor`.
9
+ const ICON_PATHS: Record<string, ReactNode> = {
10
+ workflow: (
11
+ <>
12
+ <path d="M6.25 10C8.59721 10 10.5 8.76878 10.5 7.25C10.5 5.73122 8.59721 4.5 6.25 4.5C3.90279 4.5 2 5.73122 2 7.25C2 8.76878 3.90279 10 6.25 10Z" />
13
+ <path d="M12 19H8C7 19 5 18.4 5 16C5 13.6 7 13 8 13H17C18 13 20 12.4 20 10C20 7.6 18 7 17 7H13" />
14
+ <path d="M11 21.4142L13.7071 18.7071L11 16" />
15
+ <path d="M21 16H17C16.4477 16 16 16.4477 16 17V21C16 21.5523 16.4477 22 17 22H21C21.5523 22 22 21.5523 22 21V17C22 16.4477 21.5523 16 21 16Z" fill="currentColor" stroke="none" />
16
+ </>
17
+ ),
18
+ grid: (
19
+ <>
20
+ <path d="M19 4H15C14.4477 4 14 4.44772 14 5V9C14 9.55228 14.4477 10 15 10H19C19.5523 10 20 9.55228 20 9V5C20 4.44772 19.5523 4 19 4Z" />
21
+ <path d="M19 14H15C14.4477 14 14 14.4477 14 15V19C14 19.5523 14.4477 20 15 20H19C19.5523 20 20 19.5523 20 19V15C20 14.4477 19.5523 14 19 14Z" />
22
+ <path d="M9 4H5C4.44772 4 4 4.44772 4 5V9C4 9.55228 4.44772 10 5 10H9C9.55228 10 10 9.55228 10 9V5C10 4.44772 9.55228 4 9 4Z" />
23
+ <path d="M9 14H5C4.44772 14 4 14.4477 4 15V19C4 19.5523 4.44772 20 5 20H9C9.55228 20 10 19.5523 10 19V15C10 14.4477 9.55228 14 9 14Z" />
24
+ </>
25
+ ),
26
+ swatches: (
27
+ <>
28
+ <path d="M3 7C3 5.11438 3 4.17157 3.58579 3.58579C4.17157 3 5.11438 3 7 3C8.88562 3 9.82843 3 10.4142 3.58579C11 4.17157 11 5.11438 11 7V12V17C11 18.8856 11 19.8284 10.4142 20.4142C9.82843 21 8.88562 21 7 21C5.11438 21 4.17157 21 3.58579 20.4142C3 19.8284 3 18.8856 3 17V12V7Z" />
29
+ <path d="M11 7.49994L12.6716 5.82837C14.0049 4.49503 14.6716 3.82837 15.5 3.82837C16.3284 3.82837 16.9951 4.49503 18.3284 5.82837L19.1716 6.67151C20.5049 8.00485 21.1716 8.67151 21.1716 9.49994C21.1716 10.3283 20.5049 10.995 19.1716 12.3283L11 20.4999" />
30
+ <path d="M7 21H17C18.8856 21 19.8284 21 20.4142 20.4142C21 19.8284 21 18.8856 21 17V15.5C21 15.0353 21 14.803 20.9616 14.6098C20.8038 13.8164 20.1836 13.1962 19.3902 13.0384C19.197 13 18.9647 13 18.5 13" />
31
+ <path d="M7 16.9915L7.00849 17" />
32
+ </>
33
+ ),
34
+ circleInfo: (
35
+ <>
36
+ <path d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" />
37
+ <path d="M11.9999 16.7122V10.6733" strokeLinecap="round" />
38
+ <path d="M12.0029 7.32996H12.0149" strokeLinecap="round" />
39
+ </>
40
+ ),
41
+ messageSquare: (
42
+ <path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" strokeLinecap="round" strokeLinejoin="round" />
43
+ ),
44
+ caretDown: (
45
+ <path d="M15.9325 10C16.3564 10 16.588 10.4944 16.3166 10.8201L12.3841 15.5391C12.1842 15.7789 11.8158 15.7789 11.6159 15.5391L7.68341 10.8201C7.41202 10.4944 7.6436 10 8.06752 10H15.9325Z" fill="currentColor" stroke="none" />
46
+ ),
47
+ archive: (
48
+ <>
49
+ <rect width="20" height="5" x="2" y="3" rx="1" strokeLinecap="round" strokeLinejoin="round" />
50
+ <path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" strokeLinecap="round" strokeLinejoin="round" />
51
+ <path d="M10 12h4" strokeLinecap="round" strokeLinejoin="round" />
52
+ </>
53
+ ),
54
+ users: (
55
+ <>
56
+ <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" strokeLinecap="round" strokeLinejoin="round" />
57
+ <circle cx="9" cy="7" r="4" />
58
+ <path d="M22 21v-2a4 4 0 0 0-3-3.87" strokeLinecap="round" strokeLinejoin="round" />
59
+ <path d="M16 3.13a4 4 0 0 1 0 7.75" strokeLinecap="round" strokeLinejoin="round" />
60
+ </>
61
+ ),
62
+ // The official Figma mark (provided by Johan) — single evenodd path, filled
63
+ // in currentColor (overrides the set's stroke default).
64
+ figma: (
65
+ <path
66
+ fillRule="evenodd"
67
+ clipRule="evenodd"
68
+ d="M15.5421 2L12.9419 2L11.5712 2L8.97095 2C7.91873 2 6.90902 2.41618 6.1641 3.15792C5.41908 3.89975 5 4.90652 5 5.95691L5 6.64226L5.06 6.64226C5.20042 7.43794 5.58318 8.17746 6.1641 8.7559C6.34117 8.93222 6.53321 9.09014 6.73722 9.22844C6.53321 9.36675 6.34117 9.52467 6.1641 9.70098C5.41908 10.4428 5 11.4496 5 12.5L5 13.1853L5.06 13.1853C5.20042 13.981 5.58318 14.7205 6.1641 15.299C6.34117 15.4753 6.53321 15.6332 6.73722 15.7715C6.53321 15.9098 6.34117 16.0677 6.1641 16.2441C5.41908 16.9859 5 17.9927 5 19.043L5 19.7284L5.06 19.7284C5.20042 20.5241 5.58318 21.2636 6.1641 21.842C6.90902 22.5838 7.91873 23 8.97094 23C10.0232 23 11.0329 22.5838 11.7778 21.842C12.5228 21.1002 12.9419 20.0934 12.9419 19.043L12.9419 16.4569L12.9419 15.4906C13.6616 16.112 14.5842 16.4569 15.5421 16.4569C16.5943 16.4569 17.604 16.0407 18.349 15.299C19.094 14.5571 19.5131 13.5504 19.5131 12.5C19.5131 11.4496 19.094 10.4428 18.349 9.70099C18.1719 9.52467 17.9798 9.36675 17.7758 9.22844C17.9798 9.09014 18.1719 8.93222 18.349 8.7559C19.094 8.01406 19.5131 7.00729 19.5131 5.95691C19.5131 4.90652 19.094 3.89975 18.349 3.15792C17.604 2.41618 16.5943 2 15.5421 2ZM12.9419 12.5109C12.9448 13.192 13.2177 13.845 13.7024 14.3277C14.1898 14.813 14.8515 15.0862 15.5421 15.0862C16.2327 15.0862 16.8944 14.813 17.3818 14.3277C17.8691 13.8424 18.1424 13.1849 18.1424 12.5C18.1424 11.815 17.8691 11.1575 17.3818 10.6723C16.8971 10.1897 16.2402 9.91687 15.5538 9.9138L15.5421 9.91382L15.527 9.91382C14.8419 9.91776 14.1863 10.1905 13.7024 10.6723C13.2177 11.1549 12.9448 11.808 12.9419 12.489L12.9419 12.5109ZM11.5712 8.54307L11.5712 3.3707L8.97095 3.3707C8.28037 3.3707 7.61867 3.64389 7.13126 4.12922C6.64394 4.61446 6.3707 5.27195 6.3707 5.95691C6.3707 6.64186 6.64394 7.29936 7.13126 7.78459C7.61592 8.26718 8.27289 8.54002 8.95923 8.54309C8.96314 8.54308 8.96704 8.54307 8.97094 8.54307L11.5712 8.54307ZM8.95904 16.4569C8.96301 16.4569 8.96698 16.4569 8.97094 16.4569L11.5712 16.4569L11.5712 19.043C11.5712 19.728 11.2979 20.3855 10.8106 20.8707C10.3232 21.3561 9.66152 21.6293 8.97094 21.6293C8.28037 21.6293 7.61867 21.3561 7.13126 20.8707C6.64394 20.3855 6.3707 19.728 6.3707 19.043C6.3707 18.3581 6.64394 17.7006 7.13126 17.2154C7.61587 16.7328 8.27277 16.46 8.95904 16.4569ZM11.5712 15.0861L8.97094 15.0861C8.96698 15.0861 8.96301 15.0861 8.95904 15.0862C8.27277 15.083 7.61587 14.8102 7.13126 14.3277C6.64394 13.8424 6.3707 13.1849 6.3707 12.5C6.3707 11.815 6.64394 11.1575 7.13126 10.6723C7.61592 10.1897 8.27289 9.91687 8.95923 9.9138C8.96314 9.91381 8.96704 9.91382 8.97095 9.91382L11.5712 9.91382L11.5712 9.91382L11.5712 12.4865C11.5712 12.491 11.5712 12.4955 11.5712 12.5C11.5712 12.5045 11.5712 12.5089 11.5712 12.5134L11.5712 15.0861ZM15.5421 3.3707L12.9419 3.3707L12.9419 8.54311L15.5235 8.54311L15.5421 8.54307L15.5538 8.54309C16.2402 8.54002 16.8971 8.26718 17.3818 7.78459C17.8691 7.29936 18.1424 6.64186 18.1424 5.95691C18.1424 5.27195 17.8691 4.61446 17.3818 4.12922C16.8944 3.64389 16.2327 3.3707 15.5421 3.3707Z"
69
+ fill="currentColor"
70
+ stroke="none"
71
+ />
72
+ ),
73
+ }
74
+
75
+ function Icon({
76
+ name,
77
+ size = 18,
78
+ className,
79
+ }: {
80
+ name: keyof typeof ICON_PATHS
81
+ size?: number
82
+ className?: string
83
+ }) {
84
+ return (
85
+ <svg
86
+ width={size}
87
+ height={size}
88
+ viewBox="0 0 24 24"
89
+ fill="none"
90
+ stroke="currentColor"
91
+ strokeWidth={1.5}
92
+ aria-hidden="true"
93
+ className={className}
94
+ >
95
+ {ICON_PATHS[name]}
96
+ </svg>
97
+ )
98
+ }
99
+
100
+ // Minimal contenteditable rich-text editor. Spec is stored as HTML so
101
+ // inline comments can later be anchored to real DOM nodes (a wrapped
102
+ // selection span) rather than fragile markdown markers. Deliberately
103
+ // dependency-free — matches the repo's lean ethos; swap for Tiptap if
104
+ // richer editing is needed.
105
+ function RichEditor({
106
+ value,
107
+ onChange,
108
+ }: {
109
+ value: string
110
+ onChange: (html: string) => void
111
+ }) {
112
+ const ref = useRef<HTMLDivElement>(null)
113
+
114
+ // Seed once / when an external reset clears it; don't fight the caret
115
+ // by re-writing innerHTML on every keystroke.
116
+ useEffect(() => {
117
+ const el = ref.current
118
+ if (el && el.innerHTML !== value && document.activeElement !== el) {
119
+ el.innerHTML = value
120
+ }
121
+ }, [value])
122
+
123
+ const exec = (cmd: string, arg?: string) => {
124
+ document.execCommand(cmd, false, arg)
125
+ ref.current?.focus()
126
+ if (ref.current) onChange(ref.current.innerHTML)
127
+ }
128
+
129
+ const Btn = ({ cmd, arg, label }: { cmd: string; arg?: string; label: string }) => (
130
+ <button
131
+ type="button"
132
+ className={styles.rteBtn}
133
+ onMouseDown={(e) => e.preventDefault()}
134
+ onClick={() => (cmd === 'createLink' ? exec(cmd, prompt('Link URL') || undefined) : exec(cmd, arg))}
135
+ title={label}
136
+ >
137
+ {label}
138
+ </button>
139
+ )
140
+
141
+ return (
142
+ <div className={styles.rte}>
143
+ <div className={styles.rteToolbar}>
144
+ <Btn cmd="bold" label="B" />
145
+ <Btn cmd="italic" label="I" />
146
+ <Btn cmd="formatBlock" arg="<h2>" label="H2" />
147
+ <Btn cmd="formatBlock" arg="<h3>" label="H3" />
148
+ <Btn cmd="formatBlock" arg="<p>" label="P" />
149
+ <Btn cmd="insertUnorderedList" label="• List" />
150
+ <Btn cmd="insertOrderedList" label="1. List" />
151
+ <Btn cmd="createLink" label="Link" />
152
+ </div>
153
+ <div
154
+ ref={ref}
155
+ className={styles.rteSurface}
156
+ contentEditable
157
+ suppressContentEditableWarning
158
+ onInput={() => ref.current && onChange(ref.current.innerHTML)}
159
+ data-placeholder="Spec — the job, who, solution, workflow, edge cases…"
160
+ />
161
+ </div>
162
+ )
163
+ }
164
+ import { authClient, useSession, signIn, signOut, signUp } from './auth-client'
165
+ import { type Theme, getTheme, setTheme as persistTheme } from './theme'
166
+
167
+ // Two-letter initials fallback when the user has no profile image. Splits
168
+ // on whitespace; first letter of first two tokens, uppercased.
169
+ function initialsFor(name?: string | null, email?: string | null): string {
170
+ const source = (name || email || '').trim()
171
+ if (!source) return '?'
172
+ const tokens = source.split(/[\s@.]+/).filter(Boolean)
173
+ const a = tokens[0]?.[0] || ''
174
+ const b = tokens[1]?.[0] || ''
175
+ return (a + b).toUpperCase() || source[0]?.toUpperCase() || '?'
176
+ }
177
+
178
+ function UserPill({
179
+ user,
180
+ onSignOut,
181
+ }: {
182
+ user: { name?: string | null; email?: string | null; image?: string | null }
183
+ onSignOut: () => void
184
+ }) {
185
+ const [open, setOpen] = useState(false)
186
+ const [theme, setThemeState] = useState<Theme>(() => getTheme())
187
+
188
+ const changeTheme = (t: Theme) => {
189
+ persistTheme(t)
190
+ setThemeState(t)
191
+ }
192
+
193
+ // Outside-click closes the menu. Bound only while open so we don't pay
194
+ // for the listener at rest.
195
+ useEffect(() => {
196
+ if (!open) return
197
+ const onDown = (e: MouseEvent) => {
198
+ const t = e.target as HTMLElement | null
199
+ if (!t?.closest(`.${styles.userMenu}`)) setOpen(false)
200
+ }
201
+ document.addEventListener('mousedown', onDown)
202
+ return () => document.removeEventListener('mousedown', onDown)
203
+ }, [open])
204
+
205
+ return (
206
+ <div className={styles.userMenu}>
207
+ <button
208
+ type="button"
209
+ className={styles.userPill}
210
+ onClick={() => setOpen((o) => !o)}
211
+ aria-haspopup="menu"
212
+ aria-expanded={open}
213
+ >
214
+ {user.image ? (
215
+ <img className={styles.userAvatar} src={user.image} alt="" referrerPolicy="no-referrer" />
216
+ ) : (
217
+ <span className={styles.userAvatarFallback}>{initialsFor(user.name, user.email)}</span>
218
+ )}
219
+ <span className={styles.userName}>{user.name || user.email}</span>
220
+ <Icon name="caretDown" size={16} className={`${styles.userCaret} ${open ? styles.userCaretOpen : ''}`} />
221
+ </button>
222
+ {open && (
223
+ <div className={styles.userPanel} role="menu">
224
+ <div className={styles.userPanelMeta}>{user.email}</div>
225
+ <div className={styles.userPanelLabel}>Appearance</div>
226
+ <div className={styles.themeToggle} role="group" aria-label="Appearance">
227
+ {(['system', 'light', 'dark'] as const).map((t) => (
228
+ <button
229
+ key={t}
230
+ type="button"
231
+ className={`${styles.themeOption} ${theme === t ? styles.themeOptionActive : ''}`}
232
+ aria-pressed={theme === t}
233
+ onClick={() => changeTheme(t)}
234
+ >
235
+ {t === 'system' ? 'System' : t === 'light' ? 'Light' : 'Dark'}
236
+ </button>
237
+ ))}
238
+ </div>
239
+ <button
240
+ type="button"
241
+ className={styles.userPanelItem}
242
+ onClick={() => {
243
+ setOpen(false)
244
+ onSignOut()
245
+ }}
246
+ >
247
+ Sign out
248
+ </button>
249
+ </div>
250
+ )}
251
+ </div>
252
+ )
253
+ }
254
+
255
+ function AuthGate() {
256
+ const [mode, setMode] = useState<'signin' | 'signup'>('signin')
257
+ const [step, setStep] = useState<'credentials' | 'verify'>('credentials')
258
+ const [name, setName] = useState('')
259
+ const [email, setEmail] = useState('')
260
+ const [password, setPassword] = useState('')
261
+ const [otp, setOtp] = useState('')
262
+ const [error, setError] = useState<string | null>(null)
263
+ const [notice, setNotice] = useState<string | null>(null)
264
+ const [busy, setBusy] = useState(false)
265
+ const [invited, setInvited] = useState(false)
266
+ const [googleEnabled, setGoogleEnabled] = useState(false)
267
+
268
+ // Only show providers that are actually configured (hide Google when a project
269
+ // is provisioned without it — otherwise the button 404s).
270
+ useEffect(() => {
271
+ fetch('/__auth-config')
272
+ .then((r) => (r.ok ? r.json() : null))
273
+ .then((d) => setGoogleEnabled(Boolean(d?.google)))
274
+ .catch(() => {})
275
+ }, [])
276
+
277
+ // Arrived via an invite link (?invite=<token>) → resolve it to the invited
278
+ // email, prefill + lock it, and switch to the create-account form.
279
+ useEffect(() => {
280
+ const t = new URLSearchParams(window.location.search).get('invite')
281
+ if (!t) return
282
+ fetch(`/api/invite?token=${encodeURIComponent(t)}`)
283
+ .then((r) => (r.ok ? r.json() : null))
284
+ .then((d) => {
285
+ if (d?.email) {
286
+ setEmail(d.email)
287
+ setMode('signup')
288
+ setInvited(true)
289
+ }
290
+ })
291
+ .catch(() => {})
292
+ }, [])
293
+
294
+ const sendCode = () =>
295
+ authClient.emailOtp.sendVerificationOtp({ email: email.trim(), type: 'email-verification' })
296
+
297
+ const submit = async (e: FormEvent) => {
298
+ e.preventDefault()
299
+ setError(null)
300
+ setNotice(null)
301
+ setBusy(true)
302
+ try {
303
+ if (mode === 'signup' || invited) {
304
+ const { error } = await signUp.email({
305
+ email: email.trim(),
306
+ password,
307
+ name: name.trim() || email.split('@')[0],
308
+ })
309
+ if (error) throw new Error(error.message || 'Sign up failed')
310
+ // Account created (unverified); a code was emailed on sign-up.
311
+ setStep('verify')
312
+ setNotice(`We emailed a 6-digit code to ${email.trim()}.`)
313
+ } else {
314
+ const { error } = await signIn.email({ email: email.trim(), password })
315
+ if (error) {
316
+ const unverified =
317
+ (error as { code?: string }).code === 'EMAIL_NOT_VERIFIED' ||
318
+ /verif/i.test(error.message || '')
319
+ if (unverified) {
320
+ await sendCode()
321
+ setStep('verify')
322
+ setNotice(`Your email isn't verified yet — we sent a code to ${email.trim()}.`)
323
+ return
324
+ }
325
+ throw new Error(error.message || 'Sign in failed')
326
+ }
327
+ }
328
+ } catch (err) {
329
+ setError(err instanceof Error ? err.message : 'Something went wrong')
330
+ } finally {
331
+ setBusy(false)
332
+ }
333
+ }
334
+
335
+ const verify = async (e: FormEvent) => {
336
+ e.preventDefault()
337
+ setError(null)
338
+ setBusy(true)
339
+ try {
340
+ const { error } = await authClient.emailOtp.verifyEmail({ email: email.trim(), otp: otp.trim() })
341
+ if (error) throw new Error(error.message || 'Invalid or expired code')
342
+ // Verified → establish the session with the password they just set.
343
+ const { error: signinErr } = await signIn.email({ email: email.trim(), password })
344
+ if (signinErr) throw new Error('Verified — now sign in with your password.')
345
+ setPassword('')
346
+ } catch (err) {
347
+ setError(err instanceof Error ? err.message : 'Something went wrong')
348
+ } finally {
349
+ setBusy(false)
350
+ }
351
+ }
352
+
353
+ const resend = async () => {
354
+ setError(null)
355
+ try {
356
+ await sendCode()
357
+ setNotice(`New code sent to ${email.trim()}.`)
358
+ } catch {
359
+ setError('Could not resend the code.')
360
+ }
361
+ }
362
+
363
+ const google = async () => {
364
+ setError(null)
365
+ try {
366
+ await signIn.social({ provider: 'google', callbackURL: window.location.href })
367
+ } catch (err) {
368
+ setError(err instanceof Error ? err.message : 'Google sign-in failed')
369
+ }
370
+ }
371
+
372
+ if (step === 'verify') {
373
+ return (
374
+ <div className={'bf-auth-gate'}>
375
+ <div className={'bf-auth-gate__card'}>
376
+ <h2 className={'bf-auth-gate__title'}>Check your email</h2>
377
+ <p className={'bf-auth-gate__lede'}>Enter the 6-digit code we sent to {email.trim()}.</p>
378
+ {/* Errors surface as an alert banner at the top of the card. */}
379
+ {error && <p className={'bf-auth-error'} role="alert">{error}</p>}
380
+ <form className={'bf-auth-form'} onSubmit={verify}>
381
+ <label className={'bf-auth-form__field'}>
382
+ <span className={'bf-auth-form__label'}>Verification code</span>
383
+ <input
384
+ type="text"
385
+ inputMode="numeric"
386
+ autoComplete="one-time-code"
387
+ placeholder="123456"
388
+ value={otp}
389
+ onChange={(e) => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
390
+ maxLength={6}
391
+ required
392
+ />
393
+ </label>
394
+ <button type="submit" className={'bf-auth-form__submit'} disabled={busy || otp.length < 6}>
395
+ {busy ? 'Verifying…' : 'Verify & continue'}
396
+ </button>
397
+ <button type="button" className={'bf-auth-form__tab'} onClick={resend} disabled={busy}>
398
+ Resend code
399
+ </button>
400
+ {notice && <p className={'bf-auth-gate__lede'}>{notice}</p>}
401
+ </form>
402
+ </div>
403
+ </div>
404
+ )
405
+ }
406
+
407
+ const creating = mode === 'signup' || invited
408
+ return (
409
+ <div className={'bf-auth-gate'}>
410
+ <div className={'bf-auth-gate__card'}>
411
+ <h2 className={'bf-auth-gate__title'}>{invited ? 'Create your account' : 'Sign in to continue'}</h2>
412
+ <p className={'bf-auth-gate__lede'}>
413
+ {invited
414
+ ? "You've been invited — set a password to create your account."
415
+ : 'This prototype review tool is restricted to invited reviewers.'}
416
+ </p>
417
+ {/* Errors surface as an alert banner at the top of the card. */}
418
+ {error && <p className={'bf-auth-error'} role="alert">{error}</p>}
419
+ <form className={'bf-auth-form'} onSubmit={submit}>
420
+ {!invited && (
421
+ <div className={'bf-auth-form__tabs'}>
422
+ <button
423
+ type="button"
424
+ className={`bf-auth-form__tab ${mode === 'signin' ? 'is-active' : ''}`}
425
+ onClick={() => setMode('signin')}
426
+ >
427
+ Sign in
428
+ </button>
429
+ <button
430
+ type="button"
431
+ className={`bf-auth-form__tab ${mode === 'signup' ? 'is-active' : ''}`}
432
+ onClick={() => setMode('signup')}
433
+ >
434
+ Sign up
435
+ </button>
436
+ </div>
437
+ )}
438
+ {googleEnabled && (
439
+ <>
440
+ <button type="button" className={'bf-auth-form__google'} onClick={google}>
441
+ <svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
442
+ <path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.71v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.61z" />
443
+ <path fill="#34A853" d="M9 18c2.43 0 4.47-.81 5.96-2.18l-2.92-2.26c-.81.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.32A9 9 0 0 0 9 18z" />
444
+ <path fill="#FBBC05" d="M3.97 10.71A5.4 5.4 0 0 1 3.69 9c0-.59.1-1.17.28-1.71V4.96H.96A9 9 0 0 0 0 9c0 1.45.35 2.83.96 4.04l3.01-2.33z" />
445
+ <path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 9 0 9 9 0 0 0 .96 4.96l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58z" />
446
+ </svg>
447
+ Continue with Google
448
+ </button>
449
+ <div className={'bf-auth-form__divider'}><span>or</span></div>
450
+ </>
451
+ )}
452
+ {creating && (
453
+ <label className={'bf-auth-form__field'}>
454
+ <span className={'bf-auth-form__label'}>Name</span>
455
+ <input
456
+ type="text"
457
+ placeholder="Your name"
458
+ value={name}
459
+ onChange={(e) => setName(e.target.value)}
460
+ autoComplete="name"
461
+ maxLength={40}
462
+ />
463
+ </label>
464
+ )}
465
+ <label className={'bf-auth-form__field'}>
466
+ <span className={'bf-auth-form__label'}>Email</span>
467
+ <input
468
+ type="email"
469
+ placeholder="you@example.com"
470
+ value={email}
471
+ onChange={(e) => setEmail(e.target.value)}
472
+ autoComplete="email"
473
+ readOnly={invited}
474
+ required
475
+ />
476
+ </label>
477
+ <label className={'bf-auth-form__field'}>
478
+ <span className={'bf-auth-form__label'}>Password</span>
479
+ <input
480
+ type="password"
481
+ value={password}
482
+ onChange={(e) => setPassword(e.target.value)}
483
+ autoComplete={creating ? 'new-password' : 'current-password'}
484
+ minLength={8}
485
+ required
486
+ />
487
+ </label>
488
+ <button type="submit" className={'bf-auth-form__submit'} disabled={busy}>
489
+ {creating ? 'Create account' : 'Sign in'}
490
+ </button>
491
+ {notice && <p className={'bf-auth-gate__lede'}>{notice}</p>}
492
+ </form>
493
+ </div>
494
+ </div>
495
+ )
496
+ }
497
+
498
+ type Prototype = {
499
+ slug: string
500
+ id: string
501
+ name: string
502
+ /** 'wireflow' (default) or 'delivery' (open-ended front-end / page tree). */
503
+ kind?: string
504
+ dsVersion: string
505
+ briefUrl?: string
506
+ wireflowUrl?: string
507
+ figmaUrl?: string
508
+ status?: string
509
+ // ISO date the spec.md was last pulled in from its Google Doc.
510
+ specUpdatedAt?: string
511
+ // Superseded flows: kept for reference, shown in a separate list.
512
+ archived?: boolean
513
+ archivedNote?: string
514
+ // Versioning (see scripts/new-version.mjs). `version` is 1 implicitly on
515
+ // the original; new versions are 2, 3, … `supersedes` is the old id this
516
+ // version replaced; `supersededBy` is the new id that replaced this one.
517
+ version?: number
518
+ supersedes?: string
519
+ supersededBy?: string
520
+ }
521
+
522
+ // In production the dashboard is same-origin with the worker ('').
523
+ //
524
+ // Local dev: `npm run dev` sets VITE_LOCAL_QUEUE=1, so comment traffic goes
525
+ // SAME-ORIGIN through the vite proxy → the local `npx wrangler dev` worker
526
+ // (WF_COMMENTS bound to the REAL KV via `remote: true`, plus the localhost
527
+ // auth-bypass). Reads AND writes work against the same data prod sees — run
528
+ // `npx wrangler dev` alongside `npm run dev`. (Without the local worker,
529
+ // comment fetches fail; that's the trade for working local writes. Writing
530
+ // cross-origin to prod instead 401s: the prod cookie is SameSite=Lax so it
531
+ // isn't sent, and the bypass only fires for a localhost-host request.)
532
+ // Injected at build time from bedrock.config.ts `modules.commenting.prodOrigin`
533
+ // (vite define) — per-project, so platform code stays deployment-agnostic.
534
+ // Empty → same-origin only (fine when the dashboard is served by the worker).
535
+ const COMMENTS_PROD_ORIGIN = __BEDROCK_COMMENTS_PROD_ORIGIN__
536
+ function commentsBase(): string {
537
+ if (typeof window === 'undefined') return ''
538
+ // Local dev (npm run dev) → same-origin (vite proxy → local worker).
539
+ if ((import.meta.env as Record<string, string | undefined>).VITE_LOCAL_QUEUE) return ''
540
+ return /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname)
541
+ ? COMMENTS_PROD_ORIGIN
542
+ : ''
543
+ }
544
+
545
+ // DS comment thread id: `DS-<version>-<component>`, sanitized to the
546
+ // KV-key charset [A-Za-z0-9_-] (the worker enforces this regex). One
547
+ // thread per component (its Docs page). MUST match the scheme the
548
+ // Storybook commenting bar uses (.storybook/manager-head.html) so the
549
+ // same KV key is read/written from both surfaces.
550
+ function dsThreadId(version: string, component: string): string {
551
+ const clean = (s: string) => String(s || '').replace(/[^A-Za-z0-9_-]/g, '-')
552
+ return `DS-${clean(version)}-${clean(component)}`
553
+ }
554
+
555
+ type DsComponent = {
556
+ name: string
557
+ bucket: string
558
+ variants: string[]
559
+ // Storybook docs story id (kebab of the title); follows a storyTitle override
560
+ // so OS/-grouped components resolve to `os-…`, not `components-…`. May be
561
+ // absent on older manifests — callers fall back to `<bucket>-<name>`.
562
+ docId?: string
563
+ }
564
+
565
+ type DsVersion = {
566
+ version: string
567
+ components: string[]
568
+ // Optional richer detail (bucket + variant filenames). Older /__ds.json
569
+ // payloads won't have it; the queue degrades gracefully when absent.
570
+ componentDetails?: DsComponent[]
571
+ }
572
+
573
+ type Tab = 'flows' | 'deliveries' | 'ds' | 'drafts' | 'queue' | 'archived' | 'users' | 'changelog'
574
+
575
+ type DraftFeature = {
576
+ id: string
577
+ fId?: string
578
+ name: string
579
+ briefUrl?: string
580
+ spec: string
581
+ author?: string
582
+ authorEmail?: string
583
+ createdAt?: string
584
+ }
585
+
586
+ // Who may submit draft feature specs. Mirrors the worker's allowlist;
587
+ // the server is the real gate — this just hides the form for others.
588
+ const SPEC_AUTHORS = ['johan@obra.studio', 'tyler@ziptility.com']
589
+
590
+ const STATUS_TONES: Record<string, string> = {
591
+ 'To do': styles.statusTodo,
592
+ 'To validate': styles.statusValidate,
593
+ 'Work in progress': styles.statusWip,
594
+ 'Ready for design': styles.statusReady,
595
+ 'Ready for engineering': styles.statusReady,
596
+ 'Waiting for spec change': styles.statusTodo,
597
+ }
598
+
599
+ // URL hash → active tab. Letting the dashboard be deep-linkable means
600
+ // "open the design systems list" or "open the queue" can be a real URL.
601
+ // We mirror the active tab back into the hash so a copied link reproduces
602
+ // the view.
603
+ function readTabFromHash(): Tab {
604
+ const h = (typeof window !== 'undefined' ? window.location.hash : '').replace(/^#/, '')
605
+ if (h === 'ds' || h === 'flows' || h === 'deliveries' || h === 'queue' || h === 'archived' || h === 'users' || h === 'changelog') return h as Tab
606
+ return 'flows'
607
+ }
608
+
609
+ // ⌘K command palette: type a component name → open its Storybook docs (also
610
+ // covers flows + the nav tabs). Opens in a new tab so you keep your place.
611
+ function CommandPalette({
612
+ dsVersions,
613
+ items,
614
+ onNavTab,
615
+ onClose,
616
+ }: {
617
+ dsVersions: DsVersion[] | null
618
+ items: Prototype[] | null
619
+ onNavTab: (t: Tab) => void
620
+ onClose: () => void
621
+ }) {
622
+ const [query, setQuery] = useState('')
623
+ const [active, setActive] = useState(0)
624
+ const inputRef = useRef<HTMLInputElement>(null)
625
+ useEffect(() => { inputRef.current?.focus() }, [])
626
+
627
+ type Cmd = { id: string; label: string; sub: string; run: () => void }
628
+ const all: Cmd[] = []
629
+ for (const v of dsVersions || []) {
630
+ const detailOf = new Map((v.componentDetails || []).map((d) => [d.name, d]))
631
+ for (const c of v.components) {
632
+ const d = detailOf.get(c)
633
+ const docId = d?.docId || `${d?.bucket || 'components'}-${c}`
634
+ const href = `/ds/${v.version}/storybook/?path=/docs/${docId}--docs`
635
+ all.push({ id: `c:${v.version}:${c}`, label: c, sub: `Component · ${v.version}`, run: () => window.open(href, '_blank', 'noopener') })
636
+ }
637
+ }
638
+ for (const p of (items || []).filter((x) => !x.archived)) {
639
+ all.push({ id: `f:${p.id}`, label: p.name, sub: `Flow · ${p.id}`, run: () => window.open(`/p/${p.id}/`, '_blank', 'noopener') })
640
+ }
641
+ ;([['flows', 'Flows'], ['ds', 'Design systems'], ['queue', 'Resolution queue'], ['changelog', 'Changelog']] as [Tab, string][])
642
+ .forEach(([t, l]) => all.push({ id: `nav:${t}`, label: l, sub: 'Go to tab', run: () => onNavTab(t) }))
643
+
644
+ const q = query.trim().toLowerCase()
645
+ const results = q ? all.filter((c) => (c.label + ' ' + c.sub).toLowerCase().includes(q)) : all.slice(0, 60)
646
+ const activeIdx = Math.min(active, Math.max(0, results.length - 1))
647
+ const run = (c?: Cmd) => { if (c) { c.run(); onClose() } }
648
+
649
+ return (
650
+ <div
651
+ onClick={onClose}
652
+ style={{ position: 'fixed', inset: 0, zIndex: 2000, background: 'rgba(15,23,42,0.45)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '12vh' }}
653
+ >
654
+ <div
655
+ onClick={(e) => e.stopPropagation()}
656
+ role="dialog"
657
+ aria-label="Command menu"
658
+ style={{ width: 'min(560px, 92vw)', background: 'var(--dash-surface)', border: '1px solid var(--dash-border-strong)', borderRadius: 12, boxShadow: '0 24px 64px rgba(0,0,0,0.4)', overflow: 'hidden' }}
659
+ >
660
+ <input
661
+ ref={inputRef}
662
+ value={query}
663
+ onChange={(e) => { setQuery(e.target.value); setActive(0) }}
664
+ onKeyDown={(e) => {
665
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, results.length - 1)) }
666
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)) }
667
+ else if (e.key === 'Enter') { e.preventDefault(); run(results[activeIdx]) }
668
+ else if (e.key === 'Escape') { e.preventDefault(); onClose() }
669
+ }}
670
+ placeholder="Search components, flows…"
671
+ style={{ width: '100%', boxSizing: 'border-box', border: 'none', borderBottom: '1px solid var(--dash-border)', background: 'transparent', color: 'var(--dash-text)', fontSize: 15, padding: '14px 16px', outline: 'none', fontFamily: 'inherit' }}
672
+ />
673
+ <div style={{ maxHeight: '52vh', overflowY: 'auto', padding: '6px 0' }}>
674
+ {results.length === 0 && <div style={{ padding: '16px', fontSize: 13, color: 'var(--dash-text-muted)' }}>No matches.</div>}
675
+ {results.map((c, i) => (
676
+ <button
677
+ key={c.id}
678
+ type="button"
679
+ onMouseEnter={() => setActive(i)}
680
+ onClick={() => run(c)}
681
+ style={{ display: 'flex', alignItems: 'baseline', gap: 10, width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer', padding: '9px 16px', fontFamily: 'inherit', background: i === activeIdx ? 'var(--dash-surface-alt)' : 'transparent', color: 'var(--dash-text)' }}
682
+ >
683
+ <span style={{ fontSize: 14, fontWeight: 500 }}>{c.label}</span>
684
+ <span style={{ fontSize: 12, color: 'var(--dash-text-muted)' }}>{c.sub}</span>
685
+ </button>
686
+ ))}
687
+ </div>
688
+ <div style={{ padding: '8px 16px', borderTop: '1px solid var(--dash-border)', fontSize: 11, color: 'var(--dash-text-muted)' }}>
689
+ ↑↓ navigate · ↵ open in Storybook · esc close
690
+ </div>
691
+ </div>
692
+ </div>
693
+ )
694
+ }
695
+
696
+ // ── Users admin (admin-only) ─────────────────────────────────────────────────
697
+ // Self-hosted user management on the better-auth `admin` plugin — reads/writes
698
+ // THIS deployment's own auth DB. No hosted dashboard / cloud project needed.
699
+ type AdminUser = {
700
+ id: string
701
+ email: string
702
+ name?: string
703
+ role?: string | null
704
+ banned?: boolean | null
705
+ }
706
+
707
+ const uaBtn: CSSProperties = {
708
+ padding: '4px 10px',
709
+ fontSize: 13,
710
+ borderRadius: 6,
711
+ border: '1px solid rgba(127,127,127,0.35)',
712
+ background: 'transparent',
713
+ color: 'inherit',
714
+ cursor: 'pointer',
715
+ }
716
+ const uaInput: CSSProperties = {
717
+ padding: '6px 10px',
718
+ fontSize: 13,
719
+ borderRadius: 6,
720
+ border: '1px solid rgba(127,127,127,0.35)',
721
+ background: 'transparent',
722
+ color: 'inherit',
723
+ }
724
+
725
+ function UsersAdmin({ currentUserId }: { currentUserId?: string }) {
726
+ const [users, setUsers] = useState<AdminUser[] | null>(null)
727
+ const [error, setError] = useState<string | null>(null)
728
+ const [busyId, setBusyId] = useState<string | null>(null)
729
+ const [inviteEmail, setInviteEmail] = useState('')
730
+ const [inviting, setInviting] = useState(false)
731
+ const [notice, setNotice] = useState<string | null>(null)
732
+
733
+ const load = async () => {
734
+ setError(null)
735
+ const res = await authClient.admin.listUsers({
736
+ query: { limit: 200, sortBy: 'createdAt', sortDirection: 'desc' },
737
+ })
738
+ if (res.error) {
739
+ setError(res.error.message || 'Failed to load users')
740
+ return
741
+ }
742
+ setUsers((res.data?.users as AdminUser[]) || [])
743
+ }
744
+ useEffect(() => {
745
+ load()
746
+ }, [])
747
+
748
+ const act = async (
749
+ id: string,
750
+ fn: () => Promise<{ error?: { message?: string } | null }>,
751
+ ) => {
752
+ setBusyId(id)
753
+ setError(null)
754
+ const res = await fn()
755
+ setBusyId(null)
756
+ if (res?.error) {
757
+ setError(res.error.message || 'Action failed')
758
+ return
759
+ }
760
+ await load()
761
+ }
762
+
763
+ // Role semantics: 'user' = invited reviewer; 'manager' = gives feedback that
764
+ // a designer must approve before agents act; 'designer' = actionable comments
765
+ // + approves manager feedback; 'admin' = designer powers + user management.
766
+ const ROLES = ['user', 'manager', 'designer', 'admin'] as const
767
+ const setRole = (u: AdminUser, role: string) =>
768
+ act(u.id, () => authClient.admin.setRole({ userId: u.id, role: role as 'user' | 'admin' }))
769
+ const toggleBan = (u: AdminUser) =>
770
+ act(u.id, () =>
771
+ u.banned
772
+ ? authClient.admin.unbanUser({ userId: u.id })
773
+ : authClient.admin.banUser({ userId: u.id }),
774
+ )
775
+ const remove = (u: AdminUser) => {
776
+ if (!window.confirm(`Delete ${u.email}? This cannot be undone.`)) return
777
+ act(u.id, () => authClient.admin.removeUser({ userId: u.id }))
778
+ }
779
+
780
+ // Email an invite — the person follows the link and creates their OWN account
781
+ // (no admin-set password). The invite also authorizes their email to sign up.
782
+ const sendInvite = async (e: FormEvent) => {
783
+ e.preventDefault()
784
+ setInviting(true)
785
+ setError(null)
786
+ setNotice(null)
787
+ const email = inviteEmail.trim().toLowerCase()
788
+ const res = await fetch('/api/invite', {
789
+ method: 'POST',
790
+ headers: { 'Content-Type': 'application/json' },
791
+ credentials: 'include',
792
+ body: JSON.stringify({ email }),
793
+ })
794
+ const data = (await res.json().catch(() => ({}))) as { error?: string }
795
+ setInviting(false)
796
+ if (!res.ok) {
797
+ setError(data.error || 'Failed to send invite')
798
+ return
799
+ }
800
+ setNotice(`Invite sent to ${email}. They'll get an email to create their account.`)
801
+ setInviteEmail('')
802
+ }
803
+
804
+ if (error && !users) return <p className={styles.error}>{error}</p>
805
+ if (!users) return <p className={styles.muted}>Loading users…</p>
806
+
807
+ return (
808
+ <section style={{ maxWidth: 880 }}>
809
+ {error && <p className={styles.error}>{error}</p>}
810
+ {notice && <p className={styles.muted}>{notice}</p>}
811
+ <form onSubmit={sendInvite} style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', margin: '4px 0 18px' }}>
812
+ <input required type="email" placeholder="invite someone by email…" value={inviteEmail}
813
+ onChange={(e) => setInviteEmail(e.target.value)} style={{ ...uaInput, minWidth: 260 }} />
814
+ <button type="submit" disabled={inviting} style={uaBtn}>{inviting ? 'Sending…' : 'Send invite'}</button>
815
+ </form>
816
+ <p className={styles.muted}>
817
+ {users.length} user{users.length === 1 ? '' : 's'}
818
+ </p>
819
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
820
+ <thead>
821
+ <tr style={{ textAlign: 'left', opacity: 0.6 }}>
822
+ <th style={{ padding: 8 }}>User</th>
823
+ <th style={{ padding: 8 }}>Role</th>
824
+ <th style={{ padding: 8 }}>Status</th>
825
+ <th style={{ padding: 8, textAlign: 'right' }}>Actions</th>
826
+ </tr>
827
+ </thead>
828
+ <tbody>
829
+ {users.map((u) => {
830
+ const self = u.id === currentUserId
831
+ const busy = busyId === u.id
832
+ return (
833
+ <tr key={u.id} style={{ borderTop: '1px solid rgba(127,127,127,0.2)' }}>
834
+ <td style={{ padding: 8 }}>
835
+ <div style={{ fontWeight: 600 }}>
836
+ {u.name || '—'}
837
+ {self && ' (you)'}
838
+ </div>
839
+ <div style={{ opacity: 0.65 }}>{u.email}</div>
840
+ </td>
841
+ <td style={{ padding: 8 }}>
842
+ <select
843
+ value={u.role || 'user'}
844
+ disabled={busy || self}
845
+ onChange={(e) => setRole(u, e.target.value)}
846
+ style={{ ...uaInput, padding: '4px 6px', cursor: self ? 'default' : 'pointer' }}
847
+ title="user = reviewer · manager = comments need designer approval · designer = actionable comments + approves managers · admin = designer + user management"
848
+ >
849
+ {ROLES.map((r) => (
850
+ <option key={r} value={r}>{r}</option>
851
+ ))}
852
+ </select>
853
+ </td>
854
+ <td style={{ padding: 8 }}>{u.banned ? 'Banned' : 'Active'}</td>
855
+ <td style={{ padding: 8, textAlign: 'right', whiteSpace: 'nowrap' }}>
856
+ {' '}
857
+ <button type="button" disabled={busy || self} onClick={() => toggleBan(u)} style={uaBtn}>
858
+ {u.banned ? 'Unban' : 'Ban'}
859
+ </button>{' '}
860
+ <button
861
+ type="button"
862
+ disabled={busy || self}
863
+ onClick={() => remove(u)}
864
+ style={{ ...uaBtn, color: '#c0392b' }}
865
+ >
866
+ Delete
867
+ </button>
868
+ </td>
869
+ </tr>
870
+ )
871
+ })}
872
+ </tbody>
873
+ </table>
874
+ </section>
875
+ )
876
+ }
877
+
878
+ function App() {
879
+ const session = useSession()
880
+ const user = session.data?.user
881
+ const sessionPending = session.isPending
882
+
883
+ const [items, setItems] = useState<Prototype[] | null>(null)
884
+ const [dsVersions, setDsVersions] = useState<DsVersion[] | null>(null)
885
+ const [error, setError] = useState<string | null>(null)
886
+ const [tab, setTab] = useState<Tab>(readTabFromHash())
887
+ // Mobile-only: the sidebar is an off-canvas drawer toggled from the
888
+ // top bar. Desktop keeps it always visible (CSS handles the switch).
889
+ const [sidebarOpen, setSidebarOpen] = useState(false)
890
+ // Desktop sidebar collapse (shadcn-style) — toggled from the module top bar,
891
+ // persisted across sessions.
892
+ const [collapsed, setCollapsed] = useState(() => {
893
+ try { return localStorage.getItem('bf-sidebar-collapsed') === '1' } catch { return false }
894
+ })
895
+ const toggleCollapsed = () =>
896
+ setCollapsed((c) => {
897
+ const next = !c
898
+ try { localStorage.setItem('bf-sidebar-collapsed', next ? '1' : '0') } catch { /* ignore */ }
899
+ return next
900
+ })
901
+ // Comment counts populate after the manifest loads — one fetch per
902
+ // prototype against the deployed worker. Falls back to a sparse map
903
+ // so rows render immediately and counts pop in as they arrive.
904
+ const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
905
+ // Per-prototype count of comments awaiting human review
906
+ // (designed-by-agent + needs-human) — summed for the queue nav badge.
907
+ const [reviewCounts, setReviewCounts] = useState<Record<string, number>>({})
908
+ const [drafts, setDrafts] = useState<DraftFeature[] | null>(null)
909
+
910
+ const canAuthorSpecs = SPEC_AUTHORS.includes((user?.email || '').toLowerCase())
911
+ const loadDrafts = () => {
912
+ // Same-origin: draft writes are session-auth'd (cookies only flow
913
+ // same-origin) and the route lives on the worker, not the prod
914
+ // comment proxy. Use `wrangler dev` or a deploy to exercise it.
915
+ fetch(`/__draft-features.json`, { cache: 'no-store', credentials: 'include' })
916
+ .then((r) => (r.ok ? r.json() : []))
917
+ .then((d) => setDrafts(Array.isArray(d) ? d : []))
918
+ .catch(() => setDrafts([]))
919
+ }
920
+
921
+ // Keep tab and URL hash in sync — both ways. Clicking a tab pushes the
922
+ // hash so the back button respects it; arriving from a copied link with
923
+ // a hash selects the right tab.
924
+ useEffect(() => {
925
+ const onHash = () => setTab(readTabFromHash())
926
+ window.addEventListener('hashchange', onHash)
927
+ return () => window.removeEventListener('hashchange', onHash)
928
+ }, [])
929
+ const goToTab = (next: Tab) => {
930
+ setTab(next)
931
+ if (window.location.hash !== `#${next}`) {
932
+ // pushState keeps the back button working without scrolling.
933
+ history.pushState(null, '', `#${next}`)
934
+ }
935
+ }
936
+ // ⌘K / Ctrl+K command palette — jump to a DS component's Storybook docs (or
937
+ // a flow / tab) by name.
938
+ const [paletteOpen, setPaletteOpen] = useState(false)
939
+ useEffect(() => {
940
+ const onKey = (e: KeyboardEvent) => {
941
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
942
+ e.preventDefault()
943
+ setPaletteOpen((o) => !o)
944
+ }
945
+ }
946
+ window.addEventListener('keydown', onKey)
947
+ return () => window.removeEventListener('keydown', onKey)
948
+ }, [])
949
+
950
+ // Server-side content gate bounces anonymous /p|/ds navigations here
951
+ // with ?next= — once a session exists, continue to the page they wanted.
952
+ useEffect(() => {
953
+ if (!user) return
954
+ const next = new URLSearchParams(window.location.search).get('next')
955
+ if (next && next.startsWith('/') && !next.startsWith('//')) window.location.replace(next)
956
+ }, [user?.id])
957
+
958
+ useEffect(() => {
959
+ // The manifests sit behind the worker's content gate (401 when signed
960
+ // out), so fetch only once a session exists — and refetch when sign-in
961
+ // completes, since the AuthGate doesn't reload the page.
962
+ if (!user) return
963
+ // Cache-bust the build manifests — they're plain static assets that
964
+ // Cloudflare edge-caches, so without a unique URL the dashboard can
965
+ // show a stale flow list for a while after a deploy.
966
+ const fresh = (path: string) => `${path}?t=${Date.now()}`
967
+ fetch(fresh('/__prototypes.json'), { cache: 'no-store', credentials: 'include' })
968
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
969
+ .then(setItems)
970
+ .catch((e) => setError(String(e)))
971
+ fetch(fresh('/__ds.json'), { cache: 'no-store', credentials: 'include' })
972
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
973
+ .then(setDsVersions)
974
+ .catch(() => setDsVersions([]))
975
+ loadDrafts()
976
+ }, [user?.id])
977
+
978
+ // Fan-out comment counts once the manifest has loaded. Each row fetches
979
+ // independently so a slow/failed prototype doesn't block the others.
980
+ useEffect(() => {
981
+ if (!items) return
982
+ const base = commentsBase()
983
+ let cancelled = false
984
+ for (const p of items) {
985
+ fetch(`${base}/__wf-comments/${p.id}.json`, {
986
+ cache: 'no-store',
987
+ credentials: 'include',
988
+ })
989
+ .then((r) => (r.ok ? r.json() : []))
990
+ .then((arr) => {
991
+ if (cancelled) return
992
+ // Show the actionable number: open (unresolved) top-level comments.
993
+ // Replies (parentId set) are messages, not actionable items.
994
+ const list = Array.isArray(arr) ? (arr as { resolved?: boolean; status?: string; parentId?: string }[]) : []
995
+ const n = list.filter((c) => !c?.parentId && !c?.resolved).length
996
+ setCommentCounts((prev) => (prev[p.id] === n ? prev : { ...prev, [p.id]: n }))
997
+ // Comments awaiting human review — feeds the queue nav badge.
998
+ // Includes manager comments waiting on a designer ('pending-review').
999
+ const reviewN = list.filter((c) => !c?.parentId && inReviewQueue(c)).length
1000
+ setReviewCounts((prev) => (prev[p.id] === reviewN ? prev : { ...prev, [p.id]: reviewN }))
1001
+ })
1002
+ .catch(() => { /* leave undefined → renders as — */ })
1003
+ }
1004
+ return () => { cancelled = true }
1005
+ }, [items])
1006
+
1007
+ // Fan-out review counts for DS component threads too, so the queue nav
1008
+ // badge reflects DS comments awaiting human review alongside prototype
1009
+ // ones. Keyed by the DS thread id (`DS-<version>-<component>`), which
1010
+ // can't collide with a prototype's F-ID.
1011
+ useEffect(() => {
1012
+ if (!dsVersions) return
1013
+ const base = commentsBase()
1014
+ let cancelled = false
1015
+ for (const v of dsVersions) {
1016
+ for (const c of v.components) {
1017
+ const dsId = dsThreadId(v.version, c)
1018
+ fetch(`${base}/__wf-comments/${dsId}.json`, {
1019
+ cache: 'no-store',
1020
+ credentials: 'include',
1021
+ })
1022
+ .then((r) => (r.ok ? r.json() : []))
1023
+ .then((arr) => {
1024
+ if (cancelled) return
1025
+ const list = Array.isArray(arr) ? (arr as { status?: string; parentId?: string }[]) : []
1026
+ const reviewN = list.filter((x) => !x?.parentId && inReviewQueue(x)).length
1027
+ setReviewCounts((prev) => (prev[dsId] === reviewN ? prev : { ...prev, [dsId]: reviewN }))
1028
+ })
1029
+ .catch(() => { /* leave undefined */ })
1030
+ }
1031
+ }
1032
+ return () => { cancelled = true }
1033
+ }, [dsVersions])
1034
+
1035
+ // Auth gate is the same as on wireflow pages — block all dashboard
1036
+ // interaction until a session exists. While the session call is in
1037
+ // flight, render nothing (avoids a flash of the gate).
1038
+ if (sessionPending) return <div className={styles.page} />
1039
+ if (!user) return <AuthGate />
1040
+
1041
+ // Mobile top bar (≤860px): toggle on the left, current-tab title in
1042
+ // the middle — replaces the earlier floating-FAB toggle so users have
1043
+ // a clear sense of where they are.
1044
+ // Total comments awaiting human review — the queue nav badge.
1045
+ const queuePending = Object.values(reviewCounts).reduce((a, b) => a + b, 0)
1046
+ // Let the queue push a fresh per-flow review count up so the nav badge
1047
+ // updates the instant a comment is approved/reopened (no reload).
1048
+ const setReviewCount = (protoId: string, n: number) =>
1049
+ setReviewCounts((prev) => (prev[protoId] === n ? prev : { ...prev, [protoId]: n }))
1050
+ // Archived flows (superseded — e.g. F-008, split into F-008a/F-008b)
1051
+ // are pulled out of the main table into their own list below it.
1052
+ const activeItems = (items || []).filter((p) => !p.archived && p.kind !== 'delivery')
1053
+ const deliveryItems = (items || []).filter((p) => !p.archived && p.kind === 'delivery')
1054
+ const archivedItems = (items || []).filter((p) => p.archived)
1055
+ // Count versions per flow family so the main list can flag flows that
1056
+ // have history. The family is the id with any trailing `-vN` stripped
1057
+ // (F-001, F-001-v2, F-001-v3 all share base "F-001"). Counts include
1058
+ // both active and archived members.
1059
+ const versionCount: Record<string, number> = {}
1060
+ for (const p of items || []) {
1061
+ const base = (p.id || '').replace(/-v\d+$/i, '')
1062
+ versionCount[base] = (versionCount[base] || 0) + 1
1063
+ }
1064
+ const familyOf = (id: string) => (id || '').replace(/-v\d+$/i, '')
1065
+ const TAB_LABEL: Record<Tab, string> = {
1066
+ flows: 'Active wireflows',
1067
+ deliveries: 'Front-end deliveries',
1068
+ ds: 'Design systems',
1069
+ drafts: 'Drafts',
1070
+ queue: 'Agent designs',
1071
+ archived: 'Archived wireflows',
1072
+ users: 'Users',
1073
+ changelog: 'Changelog',
1074
+ }
1075
+ const isAdmin = (user as { role?: string } | undefined)?.role === 'admin'
1076
+ const navButton = (
1077
+ key: Tab,
1078
+ label: string,
1079
+ count: number | null,
1080
+ icon: keyof typeof ICON_PATHS,
1081
+ ) => (
1082
+ <button
1083
+ key={key}
1084
+ type="button"
1085
+ aria-current={tab === key ? 'page' : undefined}
1086
+ className={`${styles.navBtn} ${tab === key ? styles.navBtnActive : ''}`}
1087
+ onClick={() => {
1088
+ goToTab(key)
1089
+ setSidebarOpen(false)
1090
+ }}
1091
+ >
1092
+ <Icon name={icon} size={17} className={styles.navIcon} />
1093
+ <span className={styles.navLabel}>{label}</span>
1094
+ {count !== null && <span className={styles.navBadge}>{count}</span>}
1095
+ </button>
1096
+ )
1097
+ return (
1098
+ <div className={`${styles.page} ${collapsed ? styles.pageCollapsed : ''}`}>
1099
+ {paletteOpen && (
1100
+ <CommandPalette
1101
+ dsVersions={dsVersions}
1102
+ items={items}
1103
+ onNavTab={(t) => { goToTab(t); setPaletteOpen(false) }}
1104
+ onClose={() => setPaletteOpen(false)}
1105
+ />
1106
+ )}
1107
+ <header className={styles.mobileTopBar} role="banner">
1108
+ <button
1109
+ type="button"
1110
+ className={styles.sidebarToggle}
1111
+ aria-label={sidebarOpen ? 'Collapse navigation' : 'Expand navigation'}
1112
+ aria-expanded={sidebarOpen}
1113
+ onClick={() => setSidebarOpen((o) => !o)}
1114
+ >
1115
+ {sidebarOpen ? (
1116
+ /* Close (X) when the full-screen sidebar is open */
1117
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1118
+ <path d="M18 6 6 18" />
1119
+ <path d="m6 6 12 12" />
1120
+ </svg>
1121
+ ) : (
1122
+ /* shadcn PanelLeft icon */
1123
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1124
+ <rect width="18" height="18" x="3" y="3" rx="2" />
1125
+ <path d="M9 3v18" />
1126
+ </svg>
1127
+ )}
1128
+ </button>
1129
+ <h1 className={styles.mobileTopBarTitle}>{TAB_LABEL[tab]}</h1>
1130
+ </header>
1131
+ {sidebarOpen && (
1132
+ <div
1133
+ className={styles.scrim}
1134
+ onClick={() => setSidebarOpen(false)}
1135
+ aria-hidden="true"
1136
+ />
1137
+ )}
1138
+ <nav
1139
+ className={`${styles.sidebar} ${sidebarOpen ? styles.sidebarOpen : ''}`}
1140
+ aria-label="Sections"
1141
+ >
1142
+ <div className={styles.sidebarBrand}>
1143
+ <h1 className={styles.title}>
1144
+ Bedrock Flows
1145
+ <button
1146
+ type="button"
1147
+ className={styles.titleInfo}
1148
+ aria-label="About this app"
1149
+ >
1150
+ <Icon name="circleInfo" size={16} className={styles.titleInfoIcon} />
1151
+ <span className={styles.titleInfoPanel} role="tooltip">
1152
+ This app displays wireflows based on the{' '}
1153
+ <a
1154
+ href="https://docs.google.com/spreadsheets/d/1r81pxEMhjo9H1UWUjjZggSLVHayzxvLCeWDeqq3HStg/edit?gid=345393954#gid=345393954"
1155
+ target="_blank"
1156
+ rel="noreferrer"
1157
+ >
1158
+ feature pipeline sheet
1159
+ </a>
1160
+ . Google Docs content is duplicated to the Spec page of every
1161
+ wireflow. Then, the designer prompts a flow, usually starting
1162
+ from Figma designs, but eventually iterating fully in their
1163
+ LLM of choice.
1164
+ </span>
1165
+ </button>
1166
+ </h1>
1167
+ {/* Running release — the latest CHANGELOG.md heading. Click → changelog. */}
1168
+ <button
1169
+ type="button"
1170
+ className={`${styles.versionPill} ${tab === 'changelog' ? styles.versionPillActive : ''}`}
1171
+ title="What changed in each release"
1172
+ onClick={() => { goToTab('changelog'); setSidebarOpen(false) }}
1173
+ >
1174
+ v{APP_VERSION}
1175
+ </button>
1176
+ </div>
1177
+ <div className={styles.navSectionLabel}>Wireflows</div>
1178
+ {navButton('flows', 'Active', items ? activeItems.length : null, 'workflow')}
1179
+ {navButton('archived', 'Archived', items && archivedItems.length > 0 ? archivedItems.length : null, 'archive')}
1180
+ <div className={styles.navSep} />
1181
+ <div className={styles.navSectionLabel}>Front-end deliveries</div>
1182
+ {navButton('deliveries', 'Deliveries', items ? deliveryItems.length : null, 'workflow')}
1183
+ <div className={styles.navSep} />
1184
+ {navButton('queue', 'Agent designs', queuePending > 0 ? queuePending : null, 'messageSquare')}
1185
+ <div className={styles.navSep} />
1186
+ {navButton('ds', 'Design systems', dsVersions ? dsVersions.length : null, 'swatches')}
1187
+ {isAdmin && <div className={styles.navSep} />}
1188
+ {isAdmin && navButton('users', 'Users', null, 'users')}
1189
+ <div className={styles.sidebarUser}>
1190
+ <UserPill
1191
+ user={user}
1192
+ onSignOut={() => signOut().then(() => authClient.getSession())}
1193
+ />
1194
+ </div>
1195
+ </nav>
1196
+ <div className={styles.contentCol}>
1197
+ <header className={styles.pageTopbar}>
1198
+ <button
1199
+ type="button"
1200
+ className={styles.topbarToggle}
1201
+ onClick={toggleCollapsed}
1202
+ aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
1203
+ title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
1204
+ >
1205
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
1206
+ <rect width="18" height="18" x="3" y="3" rx="2" />
1207
+ <path d="M9 3v18" />
1208
+ </svg>
1209
+ </button>
1210
+ <h1 className={styles.pageTopbarTitle}>{TAB_LABEL[tab]}</h1>
1211
+ {tab === 'queue' && (
1212
+ <button type="button" className={styles.titleInfo} aria-label="How the resolution queue works">
1213
+ <Icon name="circleInfo" size={16} className={styles.titleInfoIcon} />
1214
+ <span className={styles.titleInfoPanel} role="tooltip">
1215
+ Comments an agent has addressed (<strong>designed by agent</strong>),
1216
+ awaiting your sign-off. Check the live screen under each comment —
1217
+ approve if the fix makes sense, reopen it if not, or archive it to
1218
+ clear it from the queue when it no longer matters.
1219
+ {' '}<strong>Manager comments</strong> also land here first: approve
1220
+ them (optionally adding context) to hand them to the agent.
1221
+ </span>
1222
+ </button>
1223
+ )}
1224
+ </header>
1225
+ <main className={styles.main}>
1226
+
1227
+ {error && <p className={styles.error}>{error}</p>}
1228
+ {!items && !error && tab !== 'users' && tab !== 'changelog' && <p className={styles.muted}>Loading…</p>}
1229
+
1230
+ {tab === 'users' && isAdmin && <UsersAdmin currentUserId={user?.id} />}
1231
+
1232
+ {tab === 'changelog' && <ChangelogView />}
1233
+
1234
+ {tab === 'ds' && dsVersions && dsVersions.length > 0 && (
1235
+ <section className={styles.dsSection}>
1236
+ <ul className={styles.dsList}>
1237
+ {dsVersions.map((v) => (
1238
+ <li
1239
+ key={v.version}
1240
+ className={`${styles.dsItem} ${styles.dsItemClickable}`}
1241
+ onClick={(e) => {
1242
+ if (!(e.target as HTMLElement).closest('a'))
1243
+ window.location.href = `/ds/${v.version}/storybook/`
1244
+ }}
1245
+ >
1246
+ <div>
1247
+ <a className={styles.dsVersion} href={`/ds/${v.version}/storybook/`}>
1248
+ {v.version}
1249
+ </a>
1250
+ <span className={styles.dsCount}>
1251
+ {v.components.length}{' '}
1252
+ {v.components.length === 1 ? 'component' : 'components'}
1253
+ </span>
1254
+ </div>
1255
+ <div className={styles.dsComponents}>
1256
+ {v.components.map((c) => (
1257
+ <span key={c} className={styles.dsChip}>
1258
+ {c}
1259
+ </span>
1260
+ ))}
1261
+ </div>
1262
+ <a
1263
+ className={styles.dsLink}
1264
+ href={`/ds/${v.version}/storybook/`}
1265
+ >
1266
+ Open storybook →
1267
+ </a>
1268
+ </li>
1269
+ ))}
1270
+ </ul>
1271
+ </section>
1272
+ )}
1273
+
1274
+ {tab === 'deliveries' && items && (
1275
+ deliveryItems.length === 0 ? (
1276
+ <p className={styles.muted}>
1277
+ No deliveries yet. Add one under <code>prototypes/&lt;slug&gt;/</code> with{' '}
1278
+ <code>meta.json</code> containing <code>"kind": "delivery"</code> — it renders a
1279
+ page tree of every page instead of a wireflow.
1280
+ </p>
1281
+ ) : (
1282
+ <table className={styles.table}>
1283
+ <thead>
1284
+ <tr>
1285
+ <th className={styles.colId}>ID</th>
1286
+ <th>Name</th>
1287
+ <th className={styles.colStatus}>Flavor</th>
1288
+ <th className={styles.colRefs}>Page tree</th>
1289
+ </tr>
1290
+ </thead>
1291
+ <tbody>
1292
+ {deliveryItems.map((p) => (
1293
+ <tr key={p.id}>
1294
+ <td className={styles.colId}>{p.id}</td>
1295
+ <td>{p.name}</td>
1296
+ <td className={styles.colStatus}>{(p as { flavor?: string }).flavor || 'nunjucks'}</td>
1297
+ <td className={styles.colRefs}>
1298
+ <a href={`/p/${p.id}/`} target="_blank" rel="noreferrer">Open page tree ↗</a>
1299
+ </td>
1300
+ </tr>
1301
+ ))}
1302
+ </tbody>
1303
+ </table>
1304
+ )
1305
+ )}
1306
+
1307
+ {tab === 'flows' && items && items.length === 0 && (
1308
+ <p className={styles.muted}>
1309
+ No prototypes yet. Add one under <code>prototypes/&lt;slug&gt;/</code> with{' '}
1310
+ <code>meta.json</code> and <code>index.njk</code>.
1311
+ </p>
1312
+ )}
1313
+
1314
+ {tab === 'flows' && items && items.length > 0 && (
1315
+ <table className={styles.table}>
1316
+ <thead>
1317
+ <tr>
1318
+ <th className={styles.colId}>F-ID</th>
1319
+ <th>Feature Name</th>
1320
+ <th className={styles.colStatus}>Status</th>
1321
+ <th className={styles.colStatus}>Spec updated</th>
1322
+ <th className={styles.colRefs}>
1323
+ References
1324
+ <span
1325
+ className={styles.titleInfo}
1326
+ tabIndex={0}
1327
+ aria-label="About the References column"
1328
+ >
1329
+ <Icon name="circleInfo" size={16} className={styles.titleInfoIcon} />
1330
+ <span className={styles.titleInfoPanel} role="tooltip">
1331
+ What this flow is built from: the <strong>design system</strong>{' '}
1332
+ it renders against (documented under the Design systems tab)
1333
+ and a <strong>Figma file</strong> when there's one to embed.
1334
+ </span>
1335
+ </span>
1336
+ </th>
1337
+ <th className={styles.colComments}>Comments</th>
1338
+ <th className={styles.colLinks}>Links</th>
1339
+ </tr>
1340
+ </thead>
1341
+ <tbody>
1342
+ {activeItems.map((p) => (
1343
+ <tr key={p.slug}>
1344
+ <td className={styles.mono} data-label="F-ID">
1345
+ {p.id}
1346
+ {versionCount[familyOf(p.id)] > 1 && (
1347
+ <span
1348
+ className={styles.versionBadge}
1349
+ title={`Version ${p.version ?? 1} of ${versionCount[familyOf(p.id)]}`}
1350
+ >
1351
+ v{p.version ?? 1}
1352
+ </span>
1353
+ )}
1354
+ </td>
1355
+ <td className={styles.featureNameCell} data-label="Feature">
1356
+ <a className={styles.featureLink} href={`/p/${p.id}/`}>
1357
+ {p.name}
1358
+ </a>
1359
+ {p.supersedes && (
1360
+ <span className={styles.supersedesLine}>
1361
+ supersedes{' '}
1362
+ <a href={`/p/${p.supersedes}/`}>{p.supersedes}</a>
1363
+ {versionCount[familyOf(p.id)] > 1 &&
1364
+ ` · ${versionCount[familyOf(p.id)]} versions`}
1365
+ </span>
1366
+ )}
1367
+ </td>
1368
+ <td data-label="Status">
1369
+ {p.status && (
1370
+ <span
1371
+ className={`${styles.statusBadge} ${STATUS_TONES[p.status] || ''}`}
1372
+ >
1373
+ {p.status}
1374
+ </span>
1375
+ )}
1376
+ </td>
1377
+ <td className={styles.mono} data-label="Spec updated">
1378
+ {p.specUpdatedAt
1379
+ ? p.specUpdatedAt
1380
+ : <span className={styles.tsetEmpty}>—</span>}
1381
+ </td>
1382
+ <td className={styles.mono} data-label="References">
1383
+ <div className={styles.refsCell}>
1384
+ <a
1385
+ className={`${styles.dsCell} ${styles.refRow}`}
1386
+ href={`/ds/${p.dsVersion}/storybook/`}
1387
+ title={`Open ${p.dsVersion} storybook`}
1388
+ >
1389
+ <Icon name="swatches" size={14} className={styles.refIcon} />
1390
+ {p.dsVersion}
1391
+ </a>
1392
+ {p.figmaUrl ? (
1393
+ <a
1394
+ className={`${styles.featureLink} ${styles.refRow}`}
1395
+ href={`/p/${p.id}/figma`}
1396
+ title="Open Figma wireframe view"
1397
+ >
1398
+ <Icon name="figma" size={14} className={styles.refIcon} />
1399
+ figma
1400
+ </a>
1401
+ ) : (
1402
+ <span className={`${styles.tsetEmpty} ${styles.refRow}`}>
1403
+ <Icon name="figma" size={14} className={styles.refIcon} />
1404
+
1405
+ </span>
1406
+ )}
1407
+ </div>
1408
+ </td>
1409
+ <td data-label="Comments">
1410
+ {(() => {
1411
+ const n = commentCounts[p.id]
1412
+ if (n === undefined) return <span className={styles.tsetEmpty}>·</span>
1413
+ if (n === 0) return <span className={styles.tsetEmpty}>—</span>
1414
+ return (
1415
+ <a
1416
+ href={`/p/${p.id}/?comments=1`}
1417
+ className={styles.commentCount}
1418
+ title={`${n} ${n === 1 ? 'comment' : 'comments'} — open wireflow with comments`}
1419
+ >
1420
+ <Icon name="messageSquare" size={12} />
1421
+ {n}
1422
+ </a>
1423
+ )
1424
+ })()}
1425
+ </td>
1426
+ <td className={styles.links} data-label="Links">
1427
+ {p.briefUrl && (
1428
+ <a href={p.briefUrl} target="_blank" rel="noreferrer">
1429
+ brief
1430
+ </a>
1431
+ )}
1432
+ {p.wireflowUrl && (
1433
+ <a href={p.wireflowUrl} target="_blank" rel="noreferrer">
1434
+ wireflow
1435
+ </a>
1436
+ )}
1437
+ </td>
1438
+ </tr>
1439
+ ))}
1440
+ </tbody>
1441
+ </table>
1442
+ )}
1443
+
1444
+ {tab === 'archived' && items && archivedItems.length === 0 && (
1445
+ <p className={styles.muted}>No archived flows.</p>
1446
+ )}
1447
+
1448
+ {tab === 'archived' && archivedItems.length > 0 && (
1449
+ <table className={styles.table}>
1450
+ <thead>
1451
+ <tr>
1452
+ <th className={styles.colId}>F-ID</th>
1453
+ <th>Feature Name</th>
1454
+ <th>Replaced by</th>
1455
+ </tr>
1456
+ </thead>
1457
+ <tbody>
1458
+ {archivedItems.map((p) => {
1459
+ const next = p.supersededBy ? (items || []).find((q) => q.id === p.supersededBy) : null
1460
+ return (
1461
+ <tr key={p.slug}>
1462
+ <td className={styles.mono} data-label="F-ID">{p.id}</td>
1463
+ <td className={styles.featureNameCell} data-label="Feature">
1464
+ <a className={styles.featureLink} href={`/p/${p.id}/`}>{p.name}</a>
1465
+ </td>
1466
+ <td data-label="Replaced by">
1467
+ {p.supersededBy ? (
1468
+ <a className={styles.replacedByLink} href={`/p/${p.supersededBy}/`}>
1469
+ {next ? next.name : p.supersededBy}
1470
+ </a>
1471
+ ) : p.archivedNote ? (
1472
+ <span className={styles.muted}>{p.archivedNote}</span>
1473
+ ) : (
1474
+ <span className={styles.muted}>—</span>
1475
+ )}
1476
+ </td>
1477
+ </tr>
1478
+ )
1479
+ })}
1480
+ </tbody>
1481
+ </table>
1482
+ )}
1483
+
1484
+ {tab === 'drafts' && (
1485
+ <DraftsPanel
1486
+ drafts={drafts}
1487
+ canAuthor={canAuthorSpecs}
1488
+ onSubmitted={loadDrafts}
1489
+ suggestedFId={(() => {
1490
+ const nums: number[] = []
1491
+ for (const p of items || []) {
1492
+ const m = /F-0*(\d+)/i.exec(p.id || '')
1493
+ if (m) nums.push(Number(m[1]))
1494
+ }
1495
+ for (const d of drafts || []) {
1496
+ const m = /F-0*(\d+)/i.exec(d.fId || '')
1497
+ if (m) nums.push(Number(m[1]))
1498
+ }
1499
+ const next = (nums.length ? Math.max(...nums) : 0) + 1
1500
+ return 'F-' + String(next).padStart(3, '0')
1501
+ })()}
1502
+ />
1503
+ )}
1504
+
1505
+ {tab === 'queue' && <QueueView items={items} dsVersions={dsVersions} user={user} onReviewCount={setReviewCount} />}
1506
+ </main>
1507
+ </div>
1508
+ </div>
1509
+ )
1510
+ }
1511
+
1512
+ // ── Resolution queue ──────────────────────────────────────────────────
1513
+ // Comments move (pending-review →) open → designed-by-agent → approved
1514
+ // (+ needs-human). The queue surfaces everything an agent has addressed so a
1515
+ // human can check the live screen and sign it off, anything the agent flagged
1516
+ // as needing a human, and manager comments waiting on a designer's approval
1517
+ // (managers only give comments — a designer interprets/approves before the
1518
+ // agentic loop may act).
1519
+
1520
+ // Statuses that land in the Agent designs queue: agent output awaiting a
1521
+ // human verdict, agent-flagged questions, and manager comments awaiting a
1522
+ // designer's approval before they become actionable.
1523
+ const QUEUE_REVIEW_STATUSES = ['designed-by-agent', 'needs-human', 'pending-review']
1524
+ const inReviewQueue = (c: { status?: string } | null | undefined) =>
1525
+ QUEUE_REVIEW_STATUSES.includes(c?.status || '')
1526
+
1527
+ type QComment = {
1528
+ id: string
1529
+ text?: string
1530
+ author?: string
1531
+ userId?: string
1532
+ screenId?: string
1533
+ status?: string
1534
+ statusBy?: string
1535
+ statusAt?: string
1536
+ resolved?: boolean
1537
+ createdAt?: string
1538
+ parentId?: string
1539
+ editedAt?: string
1540
+ // DS-component comments may pin to a single Storybook story (id like
1541
+ // `components-top-bar--two-line-title`) so the queue previews only that story.
1542
+ storyId?: string
1543
+ storyName?: string
1544
+ agentResponse?: string
1545
+ agentResponseBy?: string
1546
+ agentResponseAt?: string
1547
+ // Designer context added when approving a manager comment ('pending-review'
1548
+ // → 'open') — the designer's interpretation of what the agent should do.
1549
+ designerNote?: string
1550
+ designerNoteBy?: string
1551
+ designerNoteAt?: string
1552
+ // Image attachment ids — served by GET /__wf-comments/<flow>/attachments/<id>.
1553
+ attachments?: string[]
1554
+ // Side notes are reference-only — agents skip them.
1555
+ kind?: string
1556
+ // Viewport the comment was made against (preview frame size and named
1557
+ // breakpoint, or window size at 'full'). The queue re-renders its screen
1558
+ // preview at this resolution so the reviewer sees what the commenter saw.
1559
+ // Older comments lack it; the preview falls back to defaults.
1560
+ viewport?: { w?: number; h?: number; bp?: string }
1561
+ // Element the comment is pinned to — a CSS path scoped to the screen's
1562
+ // content root plus a human-readable label (set by the screen panel's
1563
+ // pick mode). Display-only here; re-resolving happens on the screen.
1564
+ anchor?: { selector?: string; label?: string }
1565
+ }
1566
+
1567
+ // Same FNV-1a 4-letter code used everywhere else a comment is referenced.
1568
+ function qCommentCode(id: string): string {
1569
+ let h = 2166136261 >>> 0
1570
+ const s = String(id || '')
1571
+ for (let i = 0; i < s.length; i++) {
1572
+ h ^= s.charCodeAt(i)
1573
+ h = Math.imul(h, 16777619) >>> 0
1574
+ }
1575
+ let out = ''
1576
+ for (let i = 0; i < 4; i++) {
1577
+ out += String.fromCharCode(65 + (h % 26))
1578
+ h = Math.floor(h / 26) + 131
1579
+ }
1580
+ return out
1581
+ }
1582
+
1583
+ function qFmtDate(iso?: string): string {
1584
+ if (!iso) return ''
1585
+ try {
1586
+ return new Date(iso).toLocaleDateString(undefined, {
1587
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
1588
+ })
1589
+ } catch { return iso }
1590
+ }
1591
+
1592
+ // Coarse "how long ago" for a comment's age — makes a stale comment obvious
1593
+ // at a glance ("3 weeks ago") without the reader parsing an absolute date.
1594
+ function qRelAge(iso?: string): string {
1595
+ if (!iso) return ''
1596
+ const then = new Date(iso).getTime()
1597
+ if (Number.isNaN(then)) return ''
1598
+ const secs = Math.max(0, (Date.now() - then) / 1000)
1599
+ const day = 86400
1600
+ if (secs < 3600) { const m = Math.round(secs / 60); return m <= 1 ? 'just now' : `${m} min ago` }
1601
+ if (secs < day) { const h = Math.round(secs / 3600); return `${h} hour${h === 1 ? '' : 's'} ago` }
1602
+ if (secs < day * 14) { const d = Math.round(secs / day); return `${d} day${d === 1 ? '' : 's'} ago` }
1603
+ if (secs < day * 60) { const w = Math.round(secs / (day * 7)); return `${w} week${w === 1 ? '' : 's'} ago` }
1604
+ const mo = Math.round(secs / (day * 30)); return `${mo} month${mo === 1 ? '' : 's'} ago`
1605
+ }
1606
+
1607
+ // A scaled, lazy iframe of the live screen so the reviewer can eyeball
1608
+ // the fix. On load it measures the real content (same-origin) so the whole
1609
+ // screen shows — not a fixed 800px crop. Desktop screens are ~1280 wide and
1610
+ // scale to the card width; mobile screens render a 402×874 phone frame
1611
+ // (`is-phone-frame` on <html>), which we show at up to 1:1, centered, with
1612
+ // its full height so nothing (e.g. a bottom FAB) gets clipped.
1613
+ function ScreenPreview({ url, flowHref, viewport }: { url: string; flowHref?: string; viewport?: { w?: number; h?: number; bp?: string } }) {
1614
+ const wrapRef = useRef<HTMLDivElement>(null)
1615
+ const frameRef = useRef<HTMLIFrameElement>(null)
1616
+ const [cardW, setCardW] = useState(0)
1617
+ // Natural content size of the iframe; corrected on load. When the comment
1618
+ // recorded the viewport it was made against, render at that width so the
1619
+ // reviewer sees the same breakpoint the commenter saw.
1620
+ const vpW = viewport?.w && viewport.w > 0 ? Math.round(viewport.w) : undefined
1621
+ // When the height was recorded too, render the exact commented viewport
1622
+ // (w×h) instead of the full page height — that's what the reviewer saw:
1623
+ // fixed dialogs center like on a real phone, the bottom drawer pins, and a
1624
+ // dialog-state card doesn't become a 1300px-tall preview of dimmed page.
1625
+ const vpH = vpW && viewport?.h && viewport.h > 0 ? Math.round(viewport.h) : undefined
1626
+ const [dims, setDims] = useState({ w: vpW ?? 1280, h: vpH ?? viewport?.h ?? 800 })
1627
+ // True when the screen 404s — e.g. a comment whose screen was removed in a
1628
+ // later flow rebuild. We show a note instead of a blank iframe.
1629
+ const [missing, setMissing] = useState(false)
1630
+ useEffect(() => {
1631
+ const el = wrapRef.current
1632
+ if (!el) return
1633
+ const fit = () => setCardW(el.clientWidth || 0)
1634
+ fit()
1635
+ const ro = new ResizeObserver(fit)
1636
+ ro.observe(el)
1637
+ return () => ro.disconnect()
1638
+ }, [])
1639
+ useEffect(() => {
1640
+ let cancelled = false
1641
+ fetch(url, { headers: { Accept: 'application/octet-stream' } })
1642
+ .then((r) => { if (!cancelled && !r.ok) setMissing(true) })
1643
+ .catch(() => {})
1644
+ return () => { cancelled = true }
1645
+ }, [url])
1646
+ const measure = () => {
1647
+ try {
1648
+ const doc = frameRef.current?.contentDocument
1649
+ if (!doc || !doc.body) return
1650
+ if (vpW && vpH) {
1651
+ setDims({ w: vpW, h: vpH }) // exact commented viewport — no measuring
1652
+ } else if (vpW) {
1653
+ // Width-only record (older comments): height is measured.
1654
+ const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight, 400)
1655
+ setDims({ w: vpW, h: Math.min(h, 4000) })
1656
+ } else if (doc.documentElement.classList.contains('is-phone-frame')) {
1657
+ const b = doc.body
1658
+ setDims({ w: b.offsetWidth || 402, h: b.offsetHeight || 874 })
1659
+ } else {
1660
+ const h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight, 600)
1661
+ // Cap high enough for tall Storybook docs pages (e.g. the icon grid).
1662
+ setDims({ w: 1280, h: Math.min(h, 4000) })
1663
+ }
1664
+ } catch { /* cross-origin — keep defaults */ }
1665
+ }
1666
+ const onLoad = () => {
1667
+ measure()
1668
+ // Storybook docs render asynchronously after the iframe's load event, so a
1669
+ // one-shot measure catches an empty page. Re-measure on a few ticks so the
1670
+ // frame grows to fit the rendered stories.
1671
+ ;[250, 700, 1500, 3000].forEach((t) => window.setTimeout(measure, t))
1672
+ }
1673
+ // Never upscale past 1:1 (keeps the phone crisp); center horizontally.
1674
+ const scale = cardW ? Math.min(1, cardW / dims.w) : 0.5
1675
+ const offsetX = Math.max(0, (cardW - dims.w * scale) / 2)
1676
+ if (missing) {
1677
+ return (
1678
+ <div style={{
1679
+ padding: '24px 16px', fontSize: 13, lineHeight: 1.5, textAlign: 'center',
1680
+ color: 'var(--dash-text-muted)',
1681
+ borderTop: '1px solid var(--dash-border)', borderBottom: '1px solid var(--dash-border)',
1682
+ background: 'var(--dash-surface-alt)',
1683
+ }}>
1684
+ This screen no longer exists (the flow was restructured since the comment).
1685
+ If the fix was design-system-level, verify it in the design system instead.
1686
+ {flowHref && (
1687
+ <div style={{ marginTop: 8 }}>
1688
+ <a href={flowHref} target="_blank" rel="noreferrer" style={{ color: 'var(--dash-link, #2563eb)', textDecoration: 'none', fontWeight: 600 }}>
1689
+ Open the flow ↗
1690
+ </a>
1691
+ </div>
1692
+ )}
1693
+ </div>
1694
+ )
1695
+ }
1696
+ return (
1697
+ <>
1698
+ {vpW && (
1699
+ <div style={{
1700
+ padding: '4px 16px', fontSize: 11, color: 'var(--dash-text-muted)',
1701
+ borderTop: '1px solid var(--dash-border)', background: 'var(--dash-surface-alt)',
1702
+ }}>
1703
+ Commented at {vpW}{viewport?.h ? `×${Math.round(viewport.h)}` : ''}{viewport?.bp ? ` · ${viewport.bp}` : ''} — previewing {vpH ? 'that viewport' : 'at that width'}
1704
+ </div>
1705
+ )}
1706
+ <div
1707
+ ref={wrapRef}
1708
+ style={{
1709
+ width: '100%', overflow: 'hidden', height: dims.h * scale,
1710
+ background: 'var(--dash-surface-alt)',
1711
+ borderTop: '1px solid var(--dash-border)',
1712
+ borderBottom: '1px solid var(--dash-border)',
1713
+ }}
1714
+ >
1715
+ <iframe
1716
+ ref={frameRef}
1717
+ src={url}
1718
+ loading="lazy"
1719
+ title="Screen preview"
1720
+ onLoad={onLoad}
1721
+ style={{
1722
+ width: dims.w, height: dims.h, border: 0,
1723
+ transformOrigin: 'top left',
1724
+ transform: `translateX(${offsetX}px) scale(${scale})`,
1725
+ }}
1726
+ />
1727
+ </div>
1728
+ </>
1729
+ )
1730
+ }
1731
+
1732
+ // Render comment text with http(s) URLs as clickable links. Returns an array
1733
+ // of strings + anchor elements (React escapes the strings, so it's XSS-safe;
1734
+ // only http/https match, so javascript: URLs can't slip through).
1735
+ // A short, friendly label for a long URL (full URL stays the href + hover
1736
+ // title). e.g. a giant Figma link → "figma.com · 14-410".
1737
+ function shortLinkLabel(url: string): string {
1738
+ try {
1739
+ const u = new URL(url)
1740
+ const host = u.hostname.replace(/^www\./, '')
1741
+ if (host.includes('figma.com')) {
1742
+ const node = u.searchParams.get('node-id')
1743
+ return node ? `figma.com · ${node}` : 'figma.com'
1744
+ }
1745
+ let tail = (u.pathname || '').replace(/\/+$/, '')
1746
+ if (tail.length > 22) tail = tail.slice(0, 22) + '…'
1747
+ return host + tail
1748
+ } catch { return url.length > 42 ? url.slice(0, 42) + '…' : url }
1749
+ }
1750
+
1751
+ function linkifyComment(text: string) {
1752
+ if (!text) return text
1753
+ return text.split(/(https?:\/\/[^\s]+)/g).map((part, i) => {
1754
+ if (!/^https?:\/\//.test(part)) return part
1755
+ let url = part, tail = ''
1756
+ const trail = part.match(/[).,;:!?\]]+$/)
1757
+ if (trail) { tail = trail[0]; url = part.slice(0, -tail.length) }
1758
+ return (
1759
+ <span key={i}>
1760
+ <a href={url} title={url} target="_blank" rel="noreferrer noopener" style={{ color: '#2563eb', textDecoration: 'underline', wordBreak: 'break-word' }}>{shortLinkLabel(url)}</a>
1761
+ {tail}
1762
+ </span>
1763
+ )
1764
+ })
1765
+ }
1766
+
1767
+ function QueueView({
1768
+ items,
1769
+ dsVersions,
1770
+ user,
1771
+ onReviewCount,
1772
+ }: {
1773
+ items: Prototype[] | null
1774
+ dsVersions: DsVersion[] | null
1775
+ user: { id?: string | null; name?: string | null; email?: string | null; image?: string | null }
1776
+ onReviewCount?: (protoId: string, n: number) => void
1777
+ }) {
1778
+ // Both prototype threads and DS component threads land in the same
1779
+ // `flows` map, keyed by their KV id. DS threads additionally carry a
1780
+ // `preview` URL (a same-origin standalone variant page to iframe) so a
1781
+ // card can show the component the comment is about. Prototype threads
1782
+ // leave `preview` undefined and fall back to the per-screen iframe.
1783
+ const [flows, setFlows] = useState<Record<string, { name: string; comments: QComment[]; preview?: string }>>({})
1784
+ const [loading, setLoading] = useState(true)
1785
+ // Cards mid-exit (key → 'approved' | 'archived') play a collapse animation
1786
+ // before they leave the list, so the action feels completed rather than an
1787
+ // instant swap. Approve flashes green "✓ Approved"; Archive is a quiet slate
1788
+ // "Archived". The toast surfaces running progress / offers Undo.
1789
+ const [exiting, setExiting] = useState<Record<string, 'approved' | 'archived'>>({})
1790
+ const [toast, setToast] = useState<{ mode: 'approved' | 'archived'; reviewed: number; left: number } | null>(null)
1791
+ // Which card (key `protoId:id`) has its "Reopen with a note" composer open,
1792
+ // and the in-progress note. The note posts as a reply on the original
1793
+ // comment so the agent sees the original + its implementation + why it's
1794
+ // being sent back, all in one thread.
1795
+ const [reopenFor, setReopenFor] = useState<string | null>(null)
1796
+ const [reopenNote, setReopenNote] = useState('')
1797
+ // Which pending manager comment has its "Approve for agent" composer open,
1798
+ // and the designer-context note in progress.
1799
+ const [approveFor, setApproveFor] = useState<string | null>(null)
1800
+ const [approveNote, setApproveNote] = useState('')
1801
+ // Images staged on the reopen note — uploaded on submit, attached to the
1802
+ // reply so the agent gets the visual evidence with the send-back.
1803
+ const [reopenAtts, setReopenAtts] = useState<{ blob: Blob; url: string }[]>([])
1804
+ const sessionApprovedRef = useRef(0)
1805
+ const toastTimer = useRef<number | null>(null)
1806
+ // The most recent queue action, so the toast can offer an Undo. Holds the
1807
+ // comment's prior state to restore, plus the mode (so undo only decrements
1808
+ // the reviewed counter for approvals, not archives).
1809
+ const lastApprovedRef = useRef<{ protoId: string; id: string; prev: QComment; mode: 'approved' | 'archived' } | null>(null)
1810
+
1811
+ // Prototype lookup so a queue card can warn when its comment sits on a flow
1812
+ // that's since been superseded by a newer version (the comment may be stale).
1813
+ const protoById = new Map((items || []).map((p) => [p.id, p]))
1814
+
1815
+ useEffect(() => {
1816
+ if (!items) return
1817
+ let cancelled = false
1818
+ const base = commentsBase()
1819
+ setLoading(true)
1820
+
1821
+ // Prototype threads — keyed by F-ID, no preview override (falls back
1822
+ // to the per-screen iframe).
1823
+ const protoFetches = items.map(async (p) => {
1824
+ try {
1825
+ const r = await fetch(`${base}/__wf-comments/${p.id}.json`, {
1826
+ cache: 'no-store', credentials: 'include',
1827
+ })
1828
+ if (!r.ok) return null
1829
+ const arr = await r.json()
1830
+ return Array.isArray(arr)
1831
+ ? { id: p.id, name: p.name, comments: arr as QComment[], preview: undefined as string | undefined }
1832
+ : null
1833
+ } catch { return null }
1834
+ })
1835
+
1836
+ // DS component threads — keyed by `DS-<version>-<component>`. Preview
1837
+ // iframes the component's standalone variant page (default.html, or the
1838
+ // first available variant). Same-origin relative URL so cookies flow.
1839
+ const dsFetches: Promise<{ id: string; name: string; comments: QComment[]; preview?: string } | null>[] = []
1840
+ for (const v of dsVersions || []) {
1841
+ const detailByName = new Map((v.componentDetails || []).map((d) => [d.name, d]))
1842
+ for (const c of v.components) {
1843
+ const dsId = dsThreadId(v.version, c)
1844
+ const detail = detailByName.get(c)
1845
+ const bucket = detail?.bucket || 'components'
1846
+ // Embed the component's Storybook DOCS page (all its stories stacked)
1847
+ // so the reviewer sees the real examples — including the specific one a
1848
+ // comment is about — not just one standalone variant. iframe.html
1849
+ // renders the preview without the manager chrome (so the DS commenting
1850
+ // bar doesn't recurse). docId follows a storyTitle override (OS/ → os-…);
1851
+ // fall back to `<bucket>-<component>` for older manifests.
1852
+ const docId = detail?.docId || `${bucket}-${c}`
1853
+ const preview = `/ds/${v.version}/storybook/iframe.html?viewMode=docs&id=${docId}--docs`
1854
+ dsFetches.push(
1855
+ (async () => {
1856
+ try {
1857
+ const r = await fetch(`${base}/__wf-comments/${dsId}.json`, {
1858
+ cache: 'no-store', credentials: 'include',
1859
+ })
1860
+ if (!r.ok) return null
1861
+ const arr = await r.json()
1862
+ return Array.isArray(arr)
1863
+ ? { id: dsId, name: `${c} · ${v.version}`, comments: arr as QComment[], preview }
1864
+ : null
1865
+ } catch { return null }
1866
+ })(),
1867
+ )
1868
+ }
1869
+ }
1870
+
1871
+ Promise.all([...protoFetches, ...dsFetches]).then((results) => {
1872
+ if (cancelled) return
1873
+ const next: Record<string, { name: string; comments: QComment[]; preview?: string }> = {}
1874
+ for (const x of results) if (x) next[x.id] = { name: x.name, comments: x.comments, preview: x.preview }
1875
+ setFlows(next)
1876
+ setLoading(false)
1877
+ })
1878
+ return () => { cancelled = true }
1879
+ }, [items, dsVersions])
1880
+
1881
+ // `isDs` + `preview` distinguish DS-component threads (id starts `DS-`,
1882
+ // preview is a standalone variant page) from prototype threads (preview
1883
+ // is undefined → per-screen iframe).
1884
+ type Row = { protoId: string; protoName: string; c: QComment; isDs: boolean; preview?: string }
1885
+ const designed: Row[] = []
1886
+ const needsHuman: Row[] = []
1887
+ const pendingReview: Row[] = []
1888
+ for (const [pid, f] of Object.entries(flows)) {
1889
+ const isDs = pid.startsWith('DS-')
1890
+ for (const c of f.comments) {
1891
+ const row: Row = { protoId: pid, protoName: f.name, c, isDs, preview: f.preview }
1892
+ if (c.status === 'designed-by-agent') designed.push(row)
1893
+ else if (c.status === 'needs-human') needsHuman.push(row)
1894
+ else if (c.status === 'pending-review' && !c.parentId) pendingReview.push(row)
1895
+ }
1896
+ }
1897
+ const byNewest = (a: Row, b: Row) =>
1898
+ String(b.c.statusAt || '').localeCompare(String(a.c.statusAt || ''))
1899
+ designed.sort(byNewest)
1900
+ needsHuman.sort(byNewest)
1901
+ // Manager comments have no statusAt until a designer touches them — sort by
1902
+ // when they were posted instead.
1903
+ pendingReview.sort((a, b) => String(b.c.createdAt || '').localeCompare(String(a.c.createdAt || '')))
1904
+
1905
+ // Approve (verdict: change is good) or Archive (no verdict: clear it from the
1906
+ // queue without claiming the change was accepted — e.g. a comment on a since-
1907
+ // superseded flow). Both are terminal + persist immediately, play the collapse
1908
+ // animation, then drop the card and fire a toast offering Undo. Only Approve
1909
+ // counts toward the "reviewed" progress tally.
1910
+ function finishCard(protoId: string, c: QComment, mode: 'approved' | 'archived') {
1911
+ const key = protoId + ':' + c.id
1912
+ if (exiting[key]) return
1913
+ const f = flows[protoId]
1914
+ if (!f) return
1915
+ const idx = f.comments.findIndex((x) => x.id === c.id)
1916
+ if (idx < 0) return
1917
+ const now = new Date().toISOString()
1918
+ const by = user.name || user.email || 'Reviewer'
1919
+ const prev = f.comments[idx]
1920
+ lastApprovedRef.current = { protoId, id: c.id, prev, mode }
1921
+ // `resolved` mirrors any terminal state so other surfaces' !resolved
1922
+ // counters (screen badge, wireflow markers) drop it without a reload.
1923
+ f.comments[idx] = { ...f.comments[idx], status: mode, resolved: true, statusAt: now, statusBy: by }
1924
+ setExiting((p) => ({ ...p, [key]: mode }))
1925
+ const base = commentsBase()
1926
+ fetch(`${base}/__wf-comments/${protoId}.json`, {
1927
+ method: 'PUT',
1928
+ headers: { 'Content-Type': 'application/json' },
1929
+ credentials: 'include',
1930
+ body: JSON.stringify(f.comments),
1931
+ })
1932
+ .then((r) => { if (!r.ok) throw new Error('save failed (' + r.status + ')') })
1933
+ .catch((err) => alert('Could not save: ' + (err instanceof Error ? err.message : String(err))))
1934
+ onReviewCount?.(protoId, f.comments.filter((x) => !x.parentId && inReviewQueue(x)).length)
1935
+ window.setTimeout(() => {
1936
+ if (mode === 'approved') sessionApprovedRef.current += 1
1937
+ const left = Object.values(flows).reduce(
1938
+ (n, fl) => n + fl.comments.filter((x) => x.status === 'designed-by-agent').length, 0,
1939
+ )
1940
+ setToast({ mode, reviewed: sessionApprovedRef.current, left })
1941
+ if (toastTimer.current) window.clearTimeout(toastTimer.current)
1942
+ // Longer than the bare progress toast — leaves a window to hit Undo.
1943
+ toastTimer.current = window.setTimeout(() => setToast(null), 6000)
1944
+ setFlows({ ...flows, [protoId]: { ...f } })
1945
+ setExiting((p) => { const n = { ...p }; delete n[key]; return n })
1946
+ }, 520)
1947
+ }
1948
+ const approve = (protoId: string, c: QComment) => finishCard(protoId, c, 'approved')
1949
+ const archive = (protoId: string, c: QComment) => finishCard(protoId, c, 'archived')
1950
+
1951
+ // Revert the most recent approval — restores the comment to its prior
1952
+ // status, re-persists, and brings the card back into the list. Wired to the
1953
+ // toast's Undo so a mis-click is recoverable.
1954
+ function undoLast() {
1955
+ const last = lastApprovedRef.current
1956
+ if (!last) return
1957
+ const f = flows[last.protoId]
1958
+ if (!f) return
1959
+ const idx = f.comments.findIndex((x) => x.id === last.id)
1960
+ if (idx < 0) return
1961
+ f.comments[idx] = last.prev
1962
+ if (toastTimer.current) window.clearTimeout(toastTimer.current)
1963
+ setToast(null)
1964
+ // Only approvals bumped the reviewed tally — don't decrement for an archive.
1965
+ if (last.mode === 'approved') sessionApprovedRef.current = Math.max(0, sessionApprovedRef.current - 1)
1966
+ lastApprovedRef.current = null
1967
+ const base = commentsBase()
1968
+ fetch(`${base}/__wf-comments/${last.protoId}.json`, {
1969
+ method: 'PUT',
1970
+ headers: { 'Content-Type': 'application/json' },
1971
+ credentials: 'include',
1972
+ body: JSON.stringify(f.comments),
1973
+ })
1974
+ .then((r) => { if (!r.ok) throw new Error('save failed (' + r.status + ')') })
1975
+ .catch((err) => alert('Could not undo: ' + (err instanceof Error ? err.message : String(err))))
1976
+ onReviewCount?.(last.protoId, f.comments.filter((x) => !x.parentId && inReviewQueue(x)).length)
1977
+ setFlows({ ...flows, [last.protoId]: { ...f } })
1978
+ }
1979
+
1980
+ // Stage images for the reopen note: downscale to a 4K-class edge (detail is
1981
+ // the point of design evidence), keep originals when already within bounds.
1982
+ async function stageReopenImages(files: File[]) {
1983
+ const MAX = 10 * 1024 * 1024
1984
+ const staged: { blob: Blob; url: string }[] = []
1985
+ for (const f of files) {
1986
+ if (!f.type.startsWith('image/')) continue
1987
+ try {
1988
+ const img = await new Promise<HTMLImageElement>((res, rej) => {
1989
+ const u = URL.createObjectURL(f)
1990
+ const i = new Image()
1991
+ i.onload = () => { URL.revokeObjectURL(u); res(i) }
1992
+ i.onerror = () => { URL.revokeObjectURL(u); rej(new Error('unreadable')) }
1993
+ i.src = u
1994
+ })
1995
+ const long = Math.max(img.naturalWidth, img.naturalHeight) || 1
1996
+ const scale = Math.min(1, 4096 / long)
1997
+ let blob: Blob
1998
+ if (scale === 1 && f.size <= MAX) {
1999
+ blob = f
2000
+ } else {
2001
+ const canvas = document.createElement('canvas')
2002
+ canvas.width = Math.max(1, Math.round(img.naturalWidth * scale))
2003
+ canvas.height = Math.max(1, Math.round(img.naturalHeight * scale))
2004
+ canvas.getContext('2d')!.drawImage(img, 0, 0, canvas.width, canvas.height)
2005
+ blob = await new Promise<Blob>((res, rej) =>
2006
+ canvas.toBlob((b) => (b ? res(b) : rej(new Error('encode failed'))), 'image/jpeg', 0.85))
2007
+ if (blob.size > MAX) continue
2008
+ }
2009
+ staged.push({ blob, url: URL.createObjectURL(blob) })
2010
+ } catch { /* skip unreadable file */ }
2011
+ }
2012
+ if (staged.length) setReopenAtts((prev) => [...prev, ...staged])
2013
+ }
2014
+
2015
+ async function uploadReopenAttachments(protoId: string): Promise<string[]> {
2016
+ const ids: string[] = []
2017
+ for (const att of reopenAtts) {
2018
+ const r = await fetch(`${commentsBase()}/__wf-comments/${protoId}/attachments`, {
2019
+ method: 'POST',
2020
+ headers: { 'Content-Type': att.blob.type },
2021
+ body: att.blob,
2022
+ credentials: 'include',
2023
+ })
2024
+ if (!r.ok) throw new Error('attachment upload failed (' + r.status + ')')
2025
+ const data = await r.json()
2026
+ ids.push(data.id)
2027
+ }
2028
+ return ids
2029
+ }
2030
+
2031
+ // Reopen, optionally attaching a note. The note posts as a reply on the
2032
+ // original comment (so the agent gets the full thread: original ask →
2033
+ // implementation → why it's coming back), then the comment returns to
2034
+ // `open` so it re-enters the prototype's open list for another pass.
2035
+ async function reopenWithNote(protoId: string, c: QComment, note: string) {
2036
+ const f = flows[protoId]
2037
+ if (!f) return
2038
+ const idx = f.comments.findIndex((x) => x.id === c.id)
2039
+ if (idx < 0) return
2040
+ const now = new Date().toISOString()
2041
+ const by = user.name || user.email || 'Reviewer'
2042
+ let attIds: string[] = []
2043
+ if (reopenAtts.length) {
2044
+ try {
2045
+ attIds = await uploadReopenAttachments(protoId)
2046
+ } catch (err) {
2047
+ alert('Could not upload attachment: ' + (err instanceof Error ? err.message : String(err)))
2048
+ return // keep the composer state intact
2049
+ }
2050
+ }
2051
+ if (note || attIds.length) {
2052
+ const id = (crypto.randomUUID && crypto.randomUUID()) || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
2053
+ const reply: QComment & { parentId: string } = { id, text: note, author: by, userId: user.id || undefined, createdAt: now, parentId: c.id }
2054
+ if (attIds.length) reply.attachments = attIds
2055
+ f.comments.push(reply)
2056
+ }
2057
+ f.comments[idx] = { ...f.comments[idx], status: 'open', resolved: false, statusAt: now, statusBy: by }
2058
+ setReopenFor(null)
2059
+ setReopenNote('')
2060
+ reopenAtts.forEach((a) => URL.revokeObjectURL(a.url))
2061
+ setReopenAtts([])
2062
+ const base = commentsBase()
2063
+ try {
2064
+ const r = await fetch(`${base}/__wf-comments/${protoId}.json`, {
2065
+ method: 'PUT',
2066
+ headers: { 'Content-Type': 'application/json' },
2067
+ credentials: 'include',
2068
+ body: JSON.stringify(f.comments),
2069
+ })
2070
+ if (!r.ok) throw new Error('save failed (' + r.status + ')')
2071
+ setFlows({ ...flows, [protoId]: { ...f } })
2072
+ onReviewCount?.(protoId, f.comments.filter((x) => !x.parentId && inReviewQueue(x)).length)
2073
+ } catch (err) {
2074
+ alert('Could not save: ' + (err instanceof Error ? err.message : String(err)))
2075
+ }
2076
+ }
2077
+
2078
+ // Approve a manager comment for the agent queue: 'pending-review' → 'open',
2079
+ // optionally recording the designer's interpretation as designerNote so the
2080
+ // agent gets "what to do with this" alongside the manager's words.
2081
+ async function approveForAgent(protoId: string, c: QComment, note: string) {
2082
+ const f = flows[protoId]
2083
+ if (!f) return
2084
+ const idx = f.comments.findIndex((x) => x.id === c.id)
2085
+ if (idx < 0) return
2086
+ const now = new Date().toISOString()
2087
+ const by = user.name || user.email || 'Designer'
2088
+ f.comments[idx] = {
2089
+ ...f.comments[idx],
2090
+ status: 'open',
2091
+ resolved: false,
2092
+ statusAt: now,
2093
+ statusBy: by,
2094
+ ...(note ? { designerNote: note, designerNoteBy: by, designerNoteAt: now } : {}),
2095
+ }
2096
+ setApproveFor(null)
2097
+ setApproveNote('')
2098
+ const base = commentsBase()
2099
+ try {
2100
+ const r = await fetch(`${base}/__wf-comments/${protoId}.json`, {
2101
+ method: 'PUT',
2102
+ headers: { 'Content-Type': 'application/json' },
2103
+ credentials: 'include',
2104
+ body: JSON.stringify(f.comments),
2105
+ })
2106
+ if (!r.ok) throw new Error('save failed (' + r.status + ')')
2107
+ setFlows({ ...flows, [protoId]: { ...f } })
2108
+ onReviewCount?.(protoId, f.comments.filter((x) => !x.parentId && inReviewQueue(x)).length)
2109
+ } catch (err) {
2110
+ alert('Could not save: ' + (err instanceof Error ? err.message : String(err)))
2111
+ }
2112
+ }
2113
+
2114
+ const card = (row: Row, kind: 'designed' | 'needs-human' | 'pending-review') => {
2115
+ const { protoId, protoName, c, isDs, preview } = row
2116
+ const key = protoId + ':' + c.id
2117
+ const isExiting = !!exiting[key]
2118
+ // Stale-flow warning: this comment lives on a prototype that's since been
2119
+ // superseded by a newer version, so it may no longer reflect the live flow.
2120
+ const proto = isDs ? undefined : protoById.get(protoId)
2121
+ const supersededBy = proto?.supersededBy
2122
+ // Replies on this comment (read-only here) so a reviewer sees the thread.
2123
+ const replies = (flows[protoId]?.comments || [])
2124
+ .filter((x) => x.parentId === c.id)
2125
+ .sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')))
2126
+ // For DS rows, derive the storybook Docs URL from the standalone
2127
+ // docs-iframe preview (`/ds/<version>/storybook/iframe.html?…&id=<docId>`).
2128
+ // The manager Docs URL reuses that version + doc id.
2129
+ // DS version, from the thread's docs-iframe preview URL.
2130
+ const dsVer = isDs && preview ? /^\/ds\/([^/]+)\//.exec(preview)?.[1] : undefined
2131
+ // If the comment pins a specific story, preview ONLY that story; otherwise
2132
+ // the component's whole Docs page (the thread-level `preview`).
2133
+ const dsPreview = isDs
2134
+ ? (c.storyId && dsVer ? `/ds/${dsVer}/storybook/iframe.html?viewMode=story&id=${c.storyId}` : preview)
2135
+ : preview
2136
+ let dsStorybookUrl: string | undefined
2137
+ if (isDs && c.storyId && dsVer) {
2138
+ dsStorybookUrl = `/ds/${dsVer}/storybook/?path=/story/${c.storyId}`
2139
+ } else if (isDs && preview) {
2140
+ const m = /^\/ds\/([^/]+)\/storybook\/iframe\.html\?.*\bid=([^&]+)/.exec(preview)
2141
+ if (m) {
2142
+ const [, ver, docId] = m
2143
+ dsStorybookUrl = `/ds/${ver}/storybook/?path=/docs/${docId}`
2144
+ }
2145
+ }
2146
+ const openHref = isDs ? (dsStorybookUrl || dsPreview || `/p/${protoId}/`) : (c.screenId ? `/p/${protoId}/${c.screenId}` : `/p/${protoId}/`)
2147
+ return (
2148
+ <div
2149
+ key={key}
2150
+ style={{
2151
+ background: 'var(--dash-surface)',
2152
+ border: '1px solid ' + (isExiting ? '#16a34a' : 'var(--dash-border)'),
2153
+ borderRadius: 12, overflow: 'hidden', position: 'relative',
2154
+ transition: 'opacity .4s ease, max-height .5s ease, margin-bottom .5s ease, transform .4s ease',
2155
+ maxHeight: isExiting ? 0 : 6000,
2156
+ marginBottom: isExiting ? 0 : 16,
2157
+ opacity: isExiting ? 0 : 1,
2158
+ transform: isExiting ? 'scale(0.985)' : 'none',
2159
+ }}
2160
+ >
2161
+ {isExiting && (
2162
+ <div style={{
2163
+ position: 'absolute', inset: 0, zIndex: 5, display: 'flex',
2164
+ alignItems: 'center', justifyContent: 'center',
2165
+ background: exiting[key] === 'archived' ? 'rgba(100,116,139,0.12)' : 'rgba(22,163,74,0.12)',
2166
+ }}>
2167
+ <span style={{
2168
+ display: 'inline-flex', alignItems: 'center', gap: 8,
2169
+ background: exiting[key] === 'archived' ? '#64748b' : '#16a34a',
2170
+ color: '#fff', fontWeight: 700, fontSize: 15, padding: '8px 18px', borderRadius: 999,
2171
+ boxShadow: exiting[key] === 'archived' ? '0 6px 16px rgba(100,116,139,0.45)' : '0 6px 16px rgba(22,163,74,0.45)',
2172
+ }}>{exiting[key] === 'archived' ? 'Archived' : 'Approved'}</span>
2173
+ </div>
2174
+ )}
2175
+ <div style={{ padding: '14px 16px 0' }}>
2176
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', fontSize: 12, color: 'var(--dash-text-muted)', marginBottom: 8 }}>
2177
+ {!isDs && (
2178
+ <span style={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 11, fontWeight: 700, letterSpacing: '.3px', color: 'var(--dash-text)', background: 'var(--dash-surface-alt)', borderRadius: 5, padding: '2px 7px' }}>
2179
+ {protoId}
2180
+ </span>
2181
+ )}
2182
+ <span style={{ fontWeight: 600, color: 'var(--dash-text)' }}>{protoName}</span>
2183
+ {isDs && (
2184
+ <span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '.4px', textTransform: 'uppercase', color: '#fff', background: '#7c3aed', borderRadius: 5, padding: '2px 6px' }}>
2185
+ Design system
2186
+ </span>
2187
+ )}
2188
+ <span style={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 11, background: 'var(--dash-surface-alt)', borderRadius: 5, padding: '2px 7px' }}>
2189
+ {isDs ? (c.storyName || (c.storyId ? c.storyId.split('--').pop()?.replace(/-/g, ' ') : 'Component')) : (c.screenId || 'Flow-level')}
2190
+ </span>
2191
+ <span style={{ fontFamily: 'ui-monospace, Menlo, monospace', fontSize: 11, fontWeight: 700, letterSpacing: '.5px' }}>
2192
+ {qCommentCode(c.id)}
2193
+ </span>
2194
+ {(c.anchor?.label || c.anchor?.selector) && (
2195
+ <span
2196
+ title={c.anchor.selector || ''}
2197
+ style={{ display: 'inline-flex', alignItems: 'center', gap: 4, maxWidth: 220, fontSize: 11, color: 'var(--dash-text-muted)', border: '1px solid var(--dash-border)', borderRadius: 999, padding: '1px 8px' }}
2198
+ >
2199
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ flexShrink: 0 }}>
2200
+ <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
2201
+ <circle cx="12" cy="10" r="3" />
2202
+ </svg>
2203
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.anchor.label || c.anchor.selector}</span>
2204
+ </span>
2205
+ )}
2206
+ {c.createdAt && (
2207
+ <span title={qFmtDate(c.createdAt)}>· {qRelAge(c.createdAt)}</span>
2208
+ )}
2209
+ </div>
2210
+ </div>
2211
+ {supersededBy && (
2212
+ <div style={{
2213
+ margin: '0 16px 12px', padding: '8px 12px',
2214
+ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
2215
+ background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8,
2216
+ fontSize: 12.5, lineHeight: 1.4, color: '#92400e',
2217
+ }}>
2218
+ <span style={{ fontSize: 14 }}>⚠️</span>
2219
+ <span>
2220
+ <strong>Newer version exists.</strong> {protoId} was superseded by{' '}
2221
+ <a href={`/p/${supersededBy}/`} target="_blank" rel="noreferrer" style={{ color: '#92400e', fontWeight: 700 }}>{supersededBy}</a>
2222
+ {' '}— this comment may be stale. Consider <strong>Archive</strong> rather than Approve.
2223
+ </span>
2224
+ </div>
2225
+ )}
2226
+ <div style={{ fontSize: 14, lineHeight: 1.5, padding: '0 16px 12px', whiteSpace: 'pre-wrap', color: 'var(--dash-text)' }}>
2227
+ {linkifyComment(c.text || '')}
2228
+ </div>
2229
+ {Array.isArray(c.attachments) && c.attachments.length > 0 && (
2230
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, padding: '0 16px 12px' }}>
2231
+ {c.attachments.map((aid) => (
2232
+ <a
2233
+ key={aid}
2234
+ href={`${commentsBase()}/__wf-comments/${protoId}/attachments/${encodeURIComponent(aid)}`}
2235
+ target="_blank"
2236
+ rel="noreferrer"
2237
+ onClick={(e) => {
2238
+ // Shared harness lightbox when present; plain link fallback.
2239
+ const lb = (window as unknown as { bfLightbox?: { open: (u: string, a?: string, caption?: string) => void } }).bfLightbox
2240
+ if (!lb) return
2241
+ e.preventDefault()
2242
+ lb.open(e.currentTarget.href, 'Comment attachment', c.text || '')
2243
+ }}>
2244
+ <img
2245
+ src={`${commentsBase()}/__wf-comments/${protoId}/attachments/${encodeURIComponent(aid)}`}
2246
+ loading="lazy"
2247
+ alt="Comment attachment"
2248
+ style={{ display: 'block', maxHeight: 120, maxWidth: '100%', borderRadius: 6, border: '1px solid var(--dash-border)' }}
2249
+ />
2250
+ </a>
2251
+ ))}
2252
+ </div>
2253
+ )}
2254
+ <div style={{ fontSize: 12, color: 'var(--dash-text-muted)', padding: '0 16px 14px' }}>
2255
+ {(c.author ? c.author + ' commented · ' : '')}
2256
+ {kind === 'designed'
2257
+ ? 'addressed by agent'
2258
+ : kind === 'pending-review'
2259
+ ? 'manager comment — awaiting your approval'
2260
+ : 'flagged for a human'}
2261
+ {c.statusBy ? ' · ' + c.statusBy : ''}
2262
+ {c.statusAt ? ' · ' + qFmtDate(c.statusAt) : ''}
2263
+ </div>
2264
+ {c.designerNote ? (
2265
+ <div style={{
2266
+ margin: '0 16px 14px', padding: '10px 12px',
2267
+ borderLeft: '3px solid var(--dash-border-strong)', borderRadius: '0 6px 6px 0',
2268
+ background: 'var(--dash-surface-alt)', fontSize: 13, lineHeight: 1.5,
2269
+ color: 'var(--dash-text)', whiteSpace: 'pre-wrap',
2270
+ }}>
2271
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--dash-text-muted)', marginBottom: 4 }}>
2272
+ ↳ Designer context{c.designerNoteBy ? ' · ' + c.designerNoteBy : ''}
2273
+ </div>
2274
+ {linkifyComment(c.designerNote)}
2275
+ </div>
2276
+ ) : null}
2277
+ {c.agentResponse ? (
2278
+ <div style={{
2279
+ margin: '0 16px 14px', padding: '10px 12px',
2280
+ borderLeft: '3px solid #16a34a', borderRadius: '0 6px 6px 0',
2281
+ background: 'var(--dash-surface-alt)', fontSize: 13, lineHeight: 1.5,
2282
+ color: 'var(--dash-text)', whiteSpace: 'pre-wrap',
2283
+ }}>
2284
+ <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--dash-text-muted)', marginBottom: 4 }}>
2285
+ ↳ Agent response{c.agentResponseBy ? ' · ' + c.agentResponseBy : ''}
2286
+ </div>
2287
+ {linkifyComment(c.agentResponse)}
2288
+ </div>
2289
+ ) : null}
2290
+ {replies.length > 0 && (
2291
+ <div style={{
2292
+ margin: '0 16px 14px', paddingLeft: 10,
2293
+ borderLeft: '2px solid var(--dash-border)',
2294
+ display: 'flex', flexDirection: 'column', gap: 8,
2295
+ }}>
2296
+ {replies.map((r) => (
2297
+ <div key={r.id} style={{ fontSize: 13, lineHeight: 1.5, color: 'var(--dash-text)' }}>
2298
+ <div style={{ whiteSpace: 'pre-wrap' }}>{linkifyComment(r.text || '')}</div>
2299
+ <div style={{ fontSize: 11, color: 'var(--dash-text-muted)', marginTop: 2 }}>
2300
+ {(r.author ? r.author + ' · ' : '') + qFmtDate(r.createdAt)}
2301
+ </div>
2302
+ </div>
2303
+ ))}
2304
+ </div>
2305
+ )}
2306
+ {/* Preview/links use SAME-ORIGIN relative paths (not commentsBase):
2307
+ on localhost they render the local prototype page; on the deployed
2308
+ dashboard they hit the authenticated same-origin worker. Using the
2309
+ prod base from localhost loaded a cross-origin iframe whose cookie
2310
+ is blocked as third-party → the sign-in gate showed instead. */}
2311
+ {isDs
2312
+ ? (dsPreview
2313
+ ? <ScreenPreview url={dsPreview} />
2314
+ : <a href={openHref} target="_blank" rel="noreferrer" style={{ display: 'block', padding: '16px', fontSize: 13, color: 'var(--dash-text-muted)', textAlign: 'center', borderTop: '1px solid var(--dash-border)', textDecoration: 'none' }}>Design-system component comment. Open in Storybook ↗</a>)
2315
+ : c.screenId
2316
+ ? <ScreenPreview url={`/p/${protoId}/${c.screenId}`} flowHref={`/p/${protoId}/`} viewport={c.viewport} />
2317
+ : <a href={`/p/${protoId}/`} target="_blank" rel="noreferrer" style={{ display: 'block', padding: '16px', fontSize: 13, color: 'var(--dash-text-muted)', textAlign: 'center', borderTop: '1px solid var(--dash-border)', textDecoration: 'none' }}>Flow-level comment — not tied to a screen. Open the {protoId} flow ↗</a>}
2318
+ <div style={{ display: 'flex', gap: 8, padding: '12px 16px', alignItems: 'center' }}>
2319
+ {kind === 'pending-review' ? (
2320
+ // Approving a manager comment isn't terminal — it promotes the
2321
+ // comment to 'open' so the agentic loop picks it up, optionally
2322
+ // with the designer's interpretation attached.
2323
+ <button
2324
+ type="button"
2325
+ onClick={() => {
2326
+ setApproveNote('')
2327
+ setApproveFor(approveFor === key ? null : key)
2328
+ }}
2329
+ aria-expanded={approveFor === key}
2330
+ style={{ fontFamily: 'inherit', fontSize: 13, fontWeight: 600, padding: '8px 14px', borderRadius: 7, border: '1px solid #16a34a', background: approveFor === key ? '#15803d' : '#16a34a', color: '#fff', cursor: 'pointer' }}
2331
+ >
2332
+ ✓ Approve for agent…
2333
+ </button>
2334
+ ) : (
2335
+ <button
2336
+ type="button"
2337
+ onClick={() => approve(protoId, c)}
2338
+ disabled={isExiting}
2339
+ style={{ fontFamily: 'inherit', fontSize: 13, fontWeight: 600, padding: '8px 14px', borderRadius: 7, border: '1px solid #16a34a', background: '#16a34a', color: '#fff', cursor: isExiting ? 'default' : 'pointer', opacity: isExiting ? 0.7 : 1 }}
2340
+ >
2341
+ ✓ Approve
2342
+ </button>
2343
+ )}
2344
+ <a
2345
+ href={openHref}
2346
+ target="_blank"
2347
+ rel="noreferrer"
2348
+ style={{ fontSize: 12, color: 'var(--dash-text-muted)' }}
2349
+ >
2350
+ {isDs ? 'Open in Storybook ↗' : (c.screenId ? 'Open full screen ↗' : 'Open flow ↗')}
2351
+ </a>
2352
+ <span style={{ flex: 1 }} />
2353
+ <button
2354
+ type="button"
2355
+ onClick={() => archive(protoId, c)}
2356
+ disabled={isExiting}
2357
+ title="Clears this from your queue without approving. The prototype is unchanged — use this when the comment no longer matters (e.g. it sits on a superseded flow)."
2358
+ style={{ fontFamily: 'inherit', fontSize: 13, fontWeight: 500, padding: '8px 14px', borderRadius: 7, border: 'none', background: 'transparent', color: 'var(--dash-text-muted)', cursor: isExiting ? 'default' : 'pointer' }}
2359
+ >
2360
+ Archive
2361
+ </button>
2362
+ {kind !== 'pending-review' && (
2363
+ <button
2364
+ type="button"
2365
+ onClick={() => {
2366
+ setReopenNote('')
2367
+ setReopenFor(reopenFor === key ? null : key)
2368
+ }}
2369
+ aria-expanded={reopenFor === key}
2370
+ style={{ fontFamily: 'inherit', fontSize: 13, fontWeight: 500, padding: '8px 14px', borderRadius: 7, border: '1px solid var(--dash-border-strong)', background: reopenFor === key ? 'var(--dash-surface-alt, var(--dash-surface))' : 'var(--dash-surface)', color: 'var(--dash-text)', cursor: 'pointer' }}
2371
+ >
2372
+ Reopen…
2373
+ </button>
2374
+ )}
2375
+ </div>
2376
+ {approveFor === key && (
2377
+ <div style={{ padding: '0 16px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
2378
+ <textarea
2379
+ autoFocus
2380
+ value={approveNote}
2381
+ onChange={(e) => setApproveNote(e.target.value)}
2382
+ onKeyDown={(e) => {
2383
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); approveForAgent(protoId, c, approveNote.trim()) }
2384
+ if (e.key === 'Escape') { e.preventDefault(); setApproveFor(null) }
2385
+ }}
2386
+ placeholder="Add context for the agent (optional) — your interpretation of what should be done with this feedback. Leave empty to approve as-is."
2387
+ rows={3}
2388
+ style={{ width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 13, lineHeight: 1.5, padding: '8px 10px', borderRadius: 7, border: '1px solid var(--dash-border-strong)', background: 'var(--dash-surface)', color: 'var(--dash-text)', resize: 'vertical' }}
2389
+ />
2390
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
2391
+ <button
2392
+ type="button"
2393
+ onClick={() => { setApproveFor(null); setApproveNote('') }}
2394
+ style={{ fontFamily: 'inherit', fontSize: 13, fontWeight: 500, padding: '8px 14px', borderRadius: 7, border: '1px solid var(--dash-border)', background: 'transparent', color: 'var(--dash-text-muted)', cursor: 'pointer' }}
2395
+ >
2396
+ Cancel
2397
+ </button>
2398
+ <button
2399
+ type="button"
2400
+ onClick={() => approveForAgent(protoId, c, approveNote.trim())}
2401
+ style={{ fontFamily: 'inherit', fontSize: 13, fontWeight: 600, padding: '8px 14px', borderRadius: 7, border: '1px solid #16a34a', background: '#16a34a', color: '#fff', cursor: 'pointer' }}
2402
+ >
2403
+ {approveNote.trim() ? 'Approve with context' : 'Approve as-is'}
2404
+ </button>
2405
+ </div>
2406
+ </div>
2407
+ )}
2408
+ {reopenFor === key && (
2409
+ <div style={{ padding: '0 16px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
2410
+ <textarea
2411
+ autoFocus
2412
+ value={reopenNote}
2413
+ onChange={(e) => setReopenNote(e.target.value)}
2414
+ onKeyDown={(e) => {
2415
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); reopenWithNote(protoId, c, reopenNote.trim()) }
2416
+ if (e.key === 'Escape') { e.preventDefault(); setReopenFor(null) }
2417
+ }}
2418
+ onPaste={(e) => {
2419
+ const files = Array.from(e.clipboardData?.items || [])
2420
+ .filter((it) => it.kind === 'file' && it.type.startsWith('image/'))
2421
+ .map((it) => it.getAsFile())
2422
+ .filter((f): f is File => !!f)
2423
+ if (!files.length) return
2424
+ e.preventDefault()
2425
+ stageReopenImages(files)
2426
+ }}
2427
+ placeholder="What's still off? This is added as a reply on the comment (the agent sees the original + implementation for context). Leave empty to just reopen."
2428
+ rows={3}
2429
+ style={{ width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 13, lineHeight: 1.5, padding: '8px 10px', borderRadius: 7, border: '1px solid var(--dash-border-strong)', background: 'var(--dash-surface)', color: 'var(--dash-text)', resize: 'vertical' }}
2430
+ />
2431
+ {reopenAtts.length > 0 && (
2432
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
2433
+ {reopenAtts.map((att, i) => (
2434
+ <span key={att.url} style={{ position: 'relative', display: 'inline-block' }}>
2435
+ <img src={att.url} alt="Staged attachment" style={{ display: 'block', maxHeight: 72, borderRadius: 6, border: '1px solid var(--dash-border)' }} />
2436
+ <button
2437
+ type="button"
2438
+ aria-label="Remove image"
2439
+ onClick={() => {
2440
+ URL.revokeObjectURL(att.url)
2441
+ setReopenAtts(reopenAtts.filter((_, j) => j !== i))
2442
+ }}
2443
+ style={{ position: 'absolute', top: -6, right: -6, width: 18, height: 18, borderRadius: 999, border: '1px solid var(--dash-border-strong)', background: 'var(--dash-surface)', color: 'var(--dash-text)', fontSize: 11, lineHeight: 1, cursor: 'pointer', padding: 0 }}
2444
+ >
2445
+ ×
2446
+ </button>
2447
+ </span>
2448
+ ))}
2449
+ </div>
2450
+ )}
2451
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
2452
+ <label style={{ marginRight: 'auto', fontSize: 12, color: 'var(--dash-text-muted)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
2453
+ <input
2454
+ type="file"
2455
+ accept="image/*"
2456
+ multiple
2457
+ style={{ display: 'none' }}
2458
+ onChange={(e) => {
2459
+ const files = Array.from(e.currentTarget.files || [])
2460
+ e.currentTarget.value = ''
2461
+ stageReopenImages(files)
2462
+ }}
2463
+ />
2464
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
2465
+ Attach image (or paste)
2466
+ </label>
2467
+ <button
2468
+ type="button"
2469
+ onClick={() => {
2470
+ setReopenFor(null); setReopenNote('')
2471
+ reopenAtts.forEach((a) => URL.revokeObjectURL(a.url)); setReopenAtts([])
2472
+ }}
2473
+ style={{ fontFamily: 'inherit', fontSize: 13, fontWeight: 500, padding: '8px 14px', borderRadius: 7, border: '1px solid var(--dash-border)', background: 'transparent', color: 'var(--dash-text-muted)', cursor: 'pointer' }}
2474
+ >
2475
+ Cancel
2476
+ </button>
2477
+ <button
2478
+ type="button"
2479
+ onClick={() => reopenWithNote(protoId, c, reopenNote.trim())}
2480
+ style={{ fontFamily: 'inherit', fontSize: 13, fontWeight: 600, padding: '8px 14px', borderRadius: 7, border: '1px solid var(--dash-border-strong)', background: 'var(--dash-text)', color: 'var(--dash-bg, #fff)', cursor: 'pointer' }}
2481
+ >
2482
+ {reopenNote.trim() || reopenAtts.length ? 'Reopen with note' : 'Reopen'}
2483
+ </button>
2484
+ </div>
2485
+ </div>
2486
+ )}
2487
+ </div>
2488
+ )
2489
+ }
2490
+
2491
+ return (
2492
+ <div>
2493
+ {loading && (
2494
+ <div className={styles.loadingRow}>
2495
+ <span className={styles.spinner} role="status" aria-label="Loading" />
2496
+ Loading the Agent Designs queue…
2497
+ </div>
2498
+ )}
2499
+ {!loading && designed.length === 0 && needsHuman.length === 0 && pendingReview.length === 0 && (
2500
+ <div className={styles.emptyState}>
2501
+ <Icon name="messageSquare" size={28} className={styles.emptyStateIcon} />
2502
+ <p style={{ margin: 0 }}>Queue clear — nothing is waiting for review.</p>
2503
+ <p style={{ margin: 0, maxWidth: 460 }}>
2504
+ Tip: every comment has a 4-letter code. To send an agent to work on
2505
+ one, prompt it with that code — e.g.{' '}
2506
+ <code style={{
2507
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
2508
+ background: 'var(--dash-surface)',
2509
+ border: '1px solid var(--dash-border)',
2510
+ borderRadius: 4, padding: '1px 5px', fontSize: 12,
2511
+ }}>Please fix QSAE for me</code>{' '}
2512
+ when running your agent locally. Addressed comments show up here for review.
2513
+ </p>
2514
+ </div>
2515
+ )}
2516
+ {pendingReview.length > 0 && (
2517
+ <>
2518
+ <h2 style={{ fontSize: 14, fontWeight: 600, margin: '0 0 12px' }}>
2519
+ Manager comments awaiting approval · {pendingReview.length}
2520
+ </h2>
2521
+ {pendingReview.map((row) => card(row, 'pending-review'))}
2522
+ </>
2523
+ )}
2524
+ {designed.length > 0 && (
2525
+ <>
2526
+ <h2 style={{ fontSize: 14, fontWeight: 600, margin: pendingReview.length > 0 ? '24px 0 12px' : '0 0 12px' }}>
2527
+ Designed by agent · {designed.length}
2528
+ </h2>
2529
+ {designed.map((row) => card(row, 'designed'))}
2530
+ </>
2531
+ )}
2532
+ {needsHuman.length > 0 && (
2533
+ <>
2534
+ <h2 style={{ fontSize: 14, fontWeight: 600, margin: '24px 0 12px' }}>
2535
+ Needs a human · {needsHuman.length}
2536
+ </h2>
2537
+ {needsHuman.map((row) => card(row, 'needs-human'))}
2538
+ </>
2539
+ )}
2540
+ {toast && (
2541
+ <div
2542
+ role="status"
2543
+ style={{
2544
+ position: 'fixed', bottom: 24, right: 24, zIndex: 1000,
2545
+ display: 'flex', alignItems: 'center', gap: 10,
2546
+ background: toast.mode === 'archived' ? '#475569' : '#16a34a', color: '#fff', padding: '12px 16px',
2547
+ borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
2548
+ fontSize: 14, fontWeight: 600,
2549
+ }}
2550
+ >
2551
+ <span>
2552
+ {toast.mode === 'archived'
2553
+ ? 'Archived · cleared from queue'
2554
+ : `Approved · ${toast.reviewed} reviewed${toast.left > 0 ? ` · ${toast.left} to go` : ' · queue clear'}`}
2555
+ </span>
2556
+ <button
2557
+ type="button"
2558
+ onClick={undoLast}
2559
+ style={{
2560
+ fontFamily: 'inherit', fontSize: 13, fontWeight: 700,
2561
+ marginLeft: 4, padding: '5px 12px', borderRadius: 7,
2562
+ border: '1px solid rgba(255,255,255,0.7)', background: 'transparent',
2563
+ color: '#fff', cursor: 'pointer',
2564
+ }}
2565
+ >
2566
+ Undo
2567
+ </button>
2568
+ </div>
2569
+ )}
2570
+ </div>
2571
+ )
2572
+ }
2573
+
2574
+ function DraftsPanel({
2575
+ drafts,
2576
+ canAuthor,
2577
+ onSubmitted,
2578
+ suggestedFId,
2579
+ }: {
2580
+ drafts: DraftFeature[] | null
2581
+ canAuthor: boolean
2582
+ onSubmitted: () => void
2583
+ suggestedFId: string
2584
+ }) {
2585
+ const [fId, setFId] = useState('')
2586
+ // Prefill with the next free F-ID once the prototype list has loaded
2587
+ // (only if the author hasn't typed their own yet).
2588
+ useEffect(() => {
2589
+ setFId((cur) => (cur ? cur : suggestedFId))
2590
+ }, [suggestedFId])
2591
+ const [name, setName] = useState('')
2592
+ const [briefUrl, setBriefUrl] = useState('')
2593
+ const [spec, setSpec] = useState('')
2594
+ const [busy, setBusy] = useState(false)
2595
+ const [err, setErr] = useState<string | null>(null)
2596
+ const [composing, setComposing] = useState(false)
2597
+
2598
+ const submit = async (e: React.FormEvent) => {
2599
+ e.preventDefault()
2600
+ setErr(null)
2601
+ setBusy(true)
2602
+ try {
2603
+ const r = await fetch(`/__draft-features`, {
2604
+ method: 'POST',
2605
+ headers: { 'Content-Type': 'application/json' },
2606
+ credentials: 'include',
2607
+ body: JSON.stringify({ fId: fId.trim(), name: name.trim(), briefUrl: briefUrl.trim(), spec: spec.trim() }),
2608
+ })
2609
+ const data = await r.json().catch(() => ({}))
2610
+ if (!r.ok) throw new Error(data.error || `Failed (${r.status})`)
2611
+ setFId(''); setName(''); setBriefUrl(''); setSpec('')
2612
+ setComposing(false)
2613
+ onSubmitted()
2614
+ } catch (e2) {
2615
+ setErr(e2 instanceof Error ? e2.message : 'Something went wrong')
2616
+ } finally {
2617
+ setBusy(false)
2618
+ }
2619
+ }
2620
+
2621
+ return (
2622
+ <section className={styles.dsSection}>
2623
+ {canAuthor ? (
2624
+ <button
2625
+ type="button"
2626
+ className={styles.draftSubmit}
2627
+ style={{ marginBottom: 20 }}
2628
+ onClick={() => setComposing(true)}
2629
+ >
2630
+ + New feature spec
2631
+ </button>
2632
+ ) : (
2633
+ <p className={styles.muted}>
2634
+ Draft feature specs are submitted by the spec authors. You can review submitted drafts below.
2635
+ </p>
2636
+ )}
2637
+
2638
+ {composing && (
2639
+ <div className={styles.draftOverlay} role="dialog" aria-modal="true">
2640
+ <form className={styles.draftFull} onSubmit={submit}>
2641
+ <header className={styles.draftFullHead}>
2642
+ <input
2643
+ className={styles.draftFullFid}
2644
+ placeholder="F-ID"
2645
+ value={fId}
2646
+ onChange={(e) => setFId(e.target.value)}
2647
+ maxLength={16}
2648
+ />
2649
+ <input
2650
+ className={styles.draftFullName}
2651
+ placeholder="Feature name"
2652
+ value={name}
2653
+ onChange={(e) => setName(e.target.value)}
2654
+ required
2655
+ />
2656
+ <div className={styles.draftFullActions}>
2657
+ <button
2658
+ type="button"
2659
+ className={styles.draftCancel}
2660
+ onClick={() => setComposing(false)}
2661
+ >
2662
+ Cancel
2663
+ </button>
2664
+ <button className={styles.draftSubmit} type="submit" disabled={busy}>
2665
+ {busy ? 'Submitting…' : 'Submit draft'}
2666
+ </button>
2667
+ </div>
2668
+ </header>
2669
+ <input
2670
+ className={styles.draftInput}
2671
+ placeholder="Brief URL (optional)"
2672
+ value={briefUrl}
2673
+ onChange={(e) => setBriefUrl(e.target.value)}
2674
+ />
2675
+ {err && <p className={styles.error}>{err}</p>}
2676
+ <div className={styles.draftFullEditor}>
2677
+ <RichEditor value={spec} onChange={setSpec} />
2678
+ </div>
2679
+ </form>
2680
+ </div>
2681
+ )}
2682
+
2683
+ {drafts && drafts.length === 0 && (
2684
+ <p className={styles.muted}>No draft features yet.</p>
2685
+ )}
2686
+ {drafts && drafts.length > 0 && (
2687
+ <ul className={styles.dsList}>
2688
+ {drafts.map((d) => (
2689
+ <li key={d.id} className={styles.dsItem}>
2690
+ <div>
2691
+ <span className={styles.dsVersion}>
2692
+ {d.fId ? `${d.fId} · ` : ''}{d.name}
2693
+ </span>
2694
+ <span className={styles.dsCount}>
2695
+ {d.author || d.authorEmail}
2696
+ {d.createdAt ? ` · ${new Date(d.createdAt).toLocaleDateString()}` : ''}
2697
+ </span>
2698
+ </div>
2699
+ <div
2700
+ className={styles.draftSpec}
2701
+ dangerouslySetInnerHTML={{ __html: d.spec }}
2702
+ />
2703
+ {d.briefUrl && (
2704
+ <a className={styles.dsLink} href={d.briefUrl} target="_blank" rel="noreferrer">
2705
+ Open brief →
2706
+ </a>
2707
+ )}
2708
+ </li>
2709
+ ))}
2710
+ </ul>
2711
+ )}
2712
+ </section>
2713
+ )
2714
+ }
2715
+
2716
+ export default App