astryx-dracula 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/BRAND.md +54 -3
  2. package/README.md +11 -4
  3. package/USAGE.md +1 -1
  4. package/astryx-dracula.d.ts +2 -2
  5. package/astryx-dracula.js +26 -4
  6. package/astryx-dracula.variants.d.ts +2 -2
  7. package/package.json +11 -11
  8. package/shared/auth-chrome-config.ts +0 -4
  9. package/shared/auth-chrome.tsx +2 -3
  10. package/shared/chaptered-doc-config.ts +3 -2
  11. package/shared/order-desk.tsx +52 -54
  12. package/shared/revenue-chart.tsx +1 -1
  13. package/shared/scene-castle.tsx +21 -13
  14. package/templates/ai-chat-landing.tsx +6 -9
  15. package/templates/ai-chat.tsx +5 -1
  16. package/templates/centered-hero.tsx +2 -2
  17. package/templates/classic-gallery.tsx +5 -0
  18. package/templates/contact-form.tsx +9 -0
  19. package/templates/dashboard-portfolio.tsx +17 -4
  20. package/templates/dashboard.tsx +5 -5
  21. package/templates/detail-page.tsx +20 -28
  22. package/templates/documentation-design.tsx +3 -0
  23. package/templates/documentation-technical.tsx +8 -5
  24. package/templates/documentation.tsx +13 -6
  25. package/templates/form-two-column.tsx +11 -2
  26. package/templates/form-wizard.template.mjs +15 -0
  27. package/templates/form-wizard.tsx +878 -0
  28. package/templates/gallery-hero.tsx +10 -4
  29. package/templates/ide.tsx +177 -96
  30. package/templates/incident-console.tsx +15 -5
  31. package/templates/kanban-board.tsx +46 -4
  32. package/templates/library.tsx +12 -3
  33. package/templates/login-split.tsx +3 -3
  34. package/templates/login-sso.tsx +9 -17
  35. package/templates/messaging-shell.tsx +109 -46
  36. package/templates/mixed-gallery.tsx +6 -0
  37. package/templates/payment-form.tsx +29 -28
  38. package/templates/product-detail.tsx +12 -3
  39. package/templates/product-gallery.tsx +8 -3
  40. package/templates/product-tour.tsx +11 -7
  41. package/templates/settings-dialog.tsx +5 -0
  42. package/templates/settings-sidebar.tsx +603 -584
  43. package/templates/settings.tsx +5 -2
  44. package/templates/shell-nav.tsx +23 -3
  45. package/templates/shell-side-nav.tsx +94 -81
  46. package/templates/shell-top-nav.tsx +98 -86
  47. package/templates/table-grouped.tsx +146 -132
  48. package/templates/{table-page-shoe-store-heatmap.template.mjs → table-page-shoe-store.template.mjs} +1 -1
  49. package/templates/{table-page-shoe-store-heatmap.tsx → table-page-shoe-store.tsx} +1 -1
  50. package/templates/table-page.tsx +8 -0
  51. package/templates/table-tree.template.mjs +16 -0
  52. package/templates/table-tree.tsx +707 -0
  53. package/templates/tech-report.tsx +8 -5
  54. package/templates/theme-showcase.tsx +18 -11
  55. package/templates/work-item-detail.template.mjs +8 -0
  56. package/templates/work-item-detail.tsx +1049 -0
  57. package/theme.css +38 -3
  58. package/theme.css.d.ts +2 -2
  59. package/tokens.css +41 -4
@@ -0,0 +1,1049 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ // XLE (canonical structure of the tree below; the CLI had no XLE validator
3
+ // after `astryx layout` was removed in 0.7.0, so this is a reading aid and a
4
+ // human drift check — update it with the file, never alone):
5
+ // L[h=fill] > (LH[divider p=6] > V[g=3] > (H[j=between a=start g=4] > (V[g=2] > (H[g=2 a=center wrap] > (Lk"All tasks" + Bu + Tx"PLT-247"[t=supporting] + Bu + Tx"Platform / Onboarding"[t=supporting]) + Hd"Refresh onboarding flow for workspaces"[level=1] + (H[g=2 a=center wrap] > SD"In progress" + Tx"In progress"[t=supporting] + Bu + Bd"High" + Bu + Tx"2 of 5 subtasks"[t=supporting] + Bu + Tx"2 comments"[t=supporting])) + (H[g=1] > (B.secondary"Watch task" + IB"Show task properties")))) + (LC[p=0] > V[g=0] > (S.transparent[p=6] > V[g=4] > (Hd"Description"[level=2] + (V[g=2 mw=720] > Tx*2) + (Hd"Success criteria"[level=3] + UL[disc compact] > LI*3))) + D + (S.transparent[p=6] > V[g=4] > ((H[j=between a=center] > Hd"Subtasks"[level=2] + B.secondary"Add subtask") + (H[g=3 a=center] > Tx"2 of 5 complete"[t=supporting] + (SI[fill] > ProgressBar)) + (UL > CheckboxListItem*5) + (V[g=2] > (TI"Subtask title" + (H[j=end g=2] > (B.ghost"Cancel" + B.primary"Add subtask")))))) + D + (S.transparent[p=6] > V[g=4] > (H[j=between a=center] > (Hd"Attachments"[level=2] + Tx"4 files and links"[t=supporting])) + (V[g=1] > CC*4)) + D + (S.transparent[p=6] > V[g=4] > (H[j=between a=center] > (Hd"Comments and activity"[level=2] + Tx"2 comments · 3 updates"[t=supporting]) + B.secondary"Add comment") + (V[g=5] > (H[g=3 a=start] > (Av + (SI[fill] > V[g=1] > ((H[g=2 a=center] > (Tx"Priya Shah" + SI[fill] + TS)) + (C.muted[p=3] > Tx)))))*5) + (V[g=2] > (TA"Write a comment…" + (H[j=end g=2] > (B.ghost"Cancel" + B.primary"Comment"))))))) + (LP[w=340 p=4 divider label="Task properties"] > V[g=6] > (V[g=4] > (Hd"Properties"[level=3] + (SE"Status" + SE"Priority" + SE"Assignee" + (Tokenizer"Labels" > Tk*3))) + (V[g=4] > (Hd"Details"[level=3] + ML > MLI*6)) + (V[g=4] > (Hd"Watchers"[level=3] + AvatarGroup > Av*6 + Tx"6 watching"[t=supporting])) + (V[g=4] > (Hd"Linked items"[level=3] + CC*2)))) ;; Drawer"Task properties"[side=end w=340] > (DrawerHeader"Task properties" + LC[p=4] > V[g=6] > (V[g=4] > Hd"Properties"[level=3] + (SE*3 + Tokenizer"Labels")) + (V[g=4] > Hd"Details"[level=3] + ML > MLI*6) + (V[g=4] > Hd"Watchers"[level=3] + AvatarGroup > Av*6) + (V[g=4] > Hd"Linked items"[level=3] + CC*2))
6
+
7
+ import { Avatar } from "@astryxdesign/core/Avatar";
8
+ import { AvatarGroup } from "@astryxdesign/core/AvatarGroup";
9
+ import { Badge, type BadgeVariant } from "@astryxdesign/core/Badge";
10
+ import { Button } from "@astryxdesign/core/Button";
11
+ import { Card } from "@astryxdesign/core/Card";
12
+ import { Center } from "@astryxdesign/core/Center";
13
+ import { CheckboxListItem } from "@astryxdesign/core/CheckboxList";
14
+ import { ClickableCard } from "@astryxdesign/core/ClickableCard";
15
+ import { Divider } from "@astryxdesign/core/Divider";
16
+ import { Drawer, DrawerHeader } from "@astryxdesign/core/Drawer";
17
+ import { useMediaQuery } from "@astryxdesign/core/hooks";
18
+ import { Icon } from "@astryxdesign/core/Icon";
19
+ import { IconButton } from "@astryxdesign/core/IconButton";
20
+ import {
21
+ HStack,
22
+ Layout,
23
+ LayoutContent,
24
+ LayoutHeader,
25
+ LayoutPanel,
26
+ StackItem,
27
+ VStack,
28
+ } from "@astryxdesign/core/Layout";
29
+ import { Link } from "@astryxdesign/core/Link";
30
+ import { List, ListItem } from "@astryxdesign/core/List";
31
+ import { MetadataList, MetadataListItem } from "@astryxdesign/core/MetadataList";
32
+ import { ProgressBar } from "@astryxdesign/core/ProgressBar";
33
+ import { Section } from "@astryxdesign/core/Section";
34
+ import { Selector } from "@astryxdesign/core/Selector";
35
+ import { StatusDot } from "@astryxdesign/core/StatusDot";
36
+ import { Heading, Text } from "@astryxdesign/core/Text";
37
+ import { TextArea } from "@astryxdesign/core/TextArea";
38
+ import { TextInput } from "@astryxdesign/core/TextInput";
39
+ import { Timestamp } from "@astryxdesign/core/Timestamp";
40
+ import { Token } from "@astryxdesign/core/Token";
41
+ import { Tokenizer } from "@astryxdesign/core/Tokenizer";
42
+ import type { SearchableItem, SearchSource } from "@astryxdesign/core/Typeahead";
43
+ import {
44
+ ArrowLeft,
45
+ ExternalLink,
46
+ Eye,
47
+ EyeOff,
48
+ FileImage,
49
+ FileText,
50
+ Info,
51
+ Link as LinkIcon,
52
+ } from "lucide-react";
53
+ import {
54
+ type ComponentType,
55
+ type CSSProperties,
56
+ type ReactNode,
57
+ type SVGProps,
58
+ useRef,
59
+ useState,
60
+ } from "react";
61
+
62
+ // Definite ancestor for `height="fill"`: 100dvh, not minHeight: "100%" (a
63
+ // percentage min-height against an indefinite block computes to 0 — CSS 2.1
64
+ // §10.5). Templates ship standalone, so the page owns this, not the host.
65
+ const pageStyle: CSSProperties = { height: "100dvh" };
66
+
67
+ // The showcase's own route stands in for the task list and the related-record
68
+ // routes a real app would own. It is a destination, never an action.
69
+ const SELF_HASH = "#/templates/work-item-detail";
70
+
71
+ // The attachment tile is a recessed well, not a raised chip: muted is #21222C
72
+ // on the kit and cards already sit a tier up. Only dimension props and tokens
73
+ // here — no raw px or hex in a style object.
74
+ const attachmentTileStyle: CSSProperties = {
75
+ flexShrink: 0,
76
+ backgroundColor: "var(--color-background-muted)",
77
+ borderRadius: "var(--radius-element)",
78
+ };
79
+
80
+ // ─── Work item identity ─────────────────────────────────────────────────────
81
+ const RECORD_REF = "PLT-247";
82
+ const RECORD_TITLE = "Refresh onboarding flow for workspaces";
83
+ const RECORD_PROJECT = "Platform / Onboarding";
84
+ const CURRENT_USER = "Vlad Dracul";
85
+ const REPORTER = "Sofia Alvarez";
86
+ const START_DATE = "2026-03-02";
87
+ const DUE_DATE = "2026-03-18";
88
+ const SPRINT = "Sprint 24 · Mar 2 – Mar 15";
89
+ const TEAM = "Platform · Growth pod";
90
+
91
+ const DESCRIPTION_PARAGRAPHS = [
92
+ "Reduce the drop-off between account creation and first meaningful action in a new workspace. Recent cohorts activate at 42 percent by day seven, and interview evidence points to an empty starting state and a long welcome sequence as the two largest contributors.",
93
+ "Ship a revised flow that shortens the sequence, seeds every new workspace with a sample project, and measures activation as the percentage of workspaces that reach three completed actions in the first three days.",
94
+ ];
95
+
96
+ const SUCCESS_CRITERIA = [
97
+ "Day-seven activation rises from 42 to 55 percent for new workspaces in the variant.",
98
+ "Median time to first completed action drops below eight minutes.",
99
+ "No regression in day-thirty retention.",
100
+ ];
101
+
102
+ // ─── Editable fields ────────────────────────────────────────────────────────
103
+ /** Shape shared by every Selector option list on this page. */
104
+ type Option = { value: string; label: string; icon?: ReactNode };
105
+
106
+ // In progress is the kit's `info` dot (cyan) and pulses; the brand forbids the
107
+ // purple `accent` dot as a status, which stock Astryx uses for this state.
108
+ const STATUS_OPTIONS: Option[] = [
109
+ { value: "todo", label: "To do", icon: <StatusDot variant="neutral" label="To do" /> },
110
+ {
111
+ value: "in_progress",
112
+ label: "In progress",
113
+ icon: <StatusDot variant="info" isPulsing label="In progress" />,
114
+ },
115
+ {
116
+ value: "in_review",
117
+ label: "In review",
118
+ icon: <StatusDot variant="warning" label="In review" />,
119
+ },
120
+ { value: "done", label: "Done", icon: <StatusDot variant="success" label="Done" /> },
121
+ ];
122
+
123
+ const ASSIGNEE_OPTIONS: Option[] = [
124
+ { value: "priya-shah", label: "Priya Shah", icon: <Avatar name="Priya Shah" size="xsm" /> },
125
+ { value: "marcus-chen", label: "Marcus Chen", icon: <Avatar name="Marcus Chen" size="xsm" /> },
126
+ {
127
+ value: "sofia-alvarez",
128
+ label: "Sofia Alvarez",
129
+ icon: <Avatar name="Sofia Alvarez" size="xsm" />,
130
+ },
131
+ ];
132
+
133
+ // The badge colour rides along with the option so the list stays the single
134
+ // source of truth; a separate switch would have to be kept in step by hand.
135
+ // Red/orange read as urgency, yellow as a tag; a priority is a ranking, so
136
+ // this follows kanban-board.tsx's mapping rather than the status vocabulary.
137
+ const PRIORITY_OPTIONS: (Option & { badge: BadgeVariant })[] = [
138
+ { value: "high", label: "High", badge: "red" },
139
+ { value: "mid", label: "Medium", badge: "yellow" },
140
+ { value: "low", label: "Low", badge: "neutral" },
141
+ ];
142
+
143
+ type LabelOption = SearchableItem;
144
+
145
+ const LABEL_OPTIONS: LabelOption[] = [
146
+ { id: "onboarding", label: "Onboarding" },
147
+ { id: "q1-goal", label: "Q1 goal" },
148
+ { id: "growth", label: "Growth" },
149
+ ];
150
+
151
+ const LABEL_SEARCH_SOURCE: SearchSource<LabelOption> = {
152
+ search: (query) =>
153
+ LABEL_OPTIONS.filter((option) => option.label.toLowerCase().includes(query.toLowerCase())),
154
+ bootstrap: () => LABEL_OPTIONS,
155
+ };
156
+
157
+ /** Every editable field on the work item, held as one unit of page state. */
158
+ type WorkItemFields = {
159
+ status: string;
160
+ assignee: string;
161
+ priority: string;
162
+ labels: LabelOption[];
163
+ };
164
+
165
+ /**
166
+ * The options list defines the valid values, so an unrecognised one falls back
167
+ * to the first entry rather than leaving the slot empty.
168
+ */
169
+ function optionFor<T extends Option>(options: T[], value: string): T {
170
+ return options.find((option) => option.value === value) ?? options[0];
171
+ }
172
+
173
+ function PriorityBadge({ value }: { value: string }) {
174
+ const { label, badge } = optionFor(PRIORITY_OPTIONS, value);
175
+ return <Badge variant={badge} label={label} />;
176
+ }
177
+
178
+ // ─── Record data ────────────────────────────────────────────────────────────
179
+ type Subtask = {
180
+ id: number;
181
+ title: string;
182
+ isDone: boolean;
183
+ assignee: string;
184
+ due?: string;
185
+ };
186
+
187
+ const SUBTASKS: Subtask[] = [
188
+ {
189
+ id: 1,
190
+ title: "Audit current onboarding flow and note friction points",
191
+ isDone: true,
192
+ assignee: "Priya Shah",
193
+ due: "2026-03-04",
194
+ },
195
+ {
196
+ id: 2,
197
+ title: "Interview five recent sign-ups about their first-run experience",
198
+ isDone: true,
199
+ assignee: "Marcus Chen",
200
+ due: "2026-03-06",
201
+ },
202
+ {
203
+ id: 3,
204
+ title: "Draft revised welcome sequence with three variants",
205
+ isDone: false,
206
+ assignee: "Priya Shah",
207
+ due: "2026-03-11",
208
+ },
209
+ {
210
+ id: 4,
211
+ title: "Review copy with content design and legal",
212
+ isDone: false,
213
+ assignee: "Sofia Alvarez",
214
+ due: "2026-03-13",
215
+ },
216
+ {
217
+ id: 5,
218
+ title: "Ship variant A to 10% of new accounts and measure activation",
219
+ isDone: false,
220
+ assignee: "Marcus Chen",
221
+ due: "2026-03-18",
222
+ },
223
+ ];
224
+
225
+ type Attachment = {
226
+ id: string;
227
+ kind: "link" | "image" | "file";
228
+ title: string;
229
+ subtitle: string;
230
+ href: string;
231
+ };
232
+
233
+ // Reserved example.com hosts: well-formed destinations, so the card is an
234
+ // honest link even though the record is fixture data.
235
+ const ATTACHMENTS: Attachment[] = [
236
+ {
237
+ id: "audit-notes",
238
+ kind: "link",
239
+ title: "Onboarding audit notes",
240
+ subtitle: "docs.example.com",
241
+ href: "https://docs.example.com/onboarding-audit",
242
+ },
243
+ {
244
+ id: "funnel-dashboard",
245
+ kind: "link",
246
+ title: "Sign-up funnel dashboard",
247
+ subtitle: "analytics.example.com",
248
+ href: "https://analytics.example.com/signup-funnel",
249
+ },
250
+ {
251
+ id: "welcome-screen",
252
+ kind: "image",
253
+ title: "welcome-screen-v3.png",
254
+ subtitle: "1.4 MB · PNG image",
255
+ href: "https://assets.example.com/welcome-screen-v3.png",
256
+ },
257
+ {
258
+ id: "transcripts",
259
+ kind: "file",
260
+ title: "Interview transcripts.pdf",
261
+ subtitle: "820 KB · PDF document",
262
+ href: "https://assets.example.com/interview-transcripts.pdf",
263
+ },
264
+ ];
265
+
266
+ type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
267
+
268
+ const ATTACHMENT_ICONS: Record<Attachment["kind"], IconComponent> = {
269
+ link: LinkIcon,
270
+ image: FileImage,
271
+ file: FileText,
272
+ };
273
+
274
+ const WATCHERS = [
275
+ "Priya Shah",
276
+ "Marcus Chen",
277
+ "Sofia Alvarez",
278
+ "Jordan Blake",
279
+ "Lin Wei",
280
+ "Aisha Okafor",
281
+ ];
282
+
283
+ const LINKED_ITEMS = [
284
+ { id: "PLT-238", title: "Track activation rate by acquisition channel" },
285
+ { id: "PLT-241", title: "Welcome email A/B test — copy variants" },
286
+ ];
287
+
288
+ type ActivityEntry = {
289
+ id: number;
290
+ kind: "comment" | "event";
291
+ author: string;
292
+ time: string;
293
+ body: string;
294
+ };
295
+
296
+ const ACTIVITY: ActivityEntry[] = [
297
+ {
298
+ id: 1,
299
+ kind: "event",
300
+ author: "Sofia Alvarez",
301
+ time: "2026-03-02T14:12:00Z",
302
+ body: "created this task",
303
+ },
304
+ {
305
+ id: 2,
306
+ kind: "comment",
307
+ author: "Priya Shah",
308
+ time: "2026-03-03T09:41:00Z",
309
+ body: "Finished the audit — three biggest friction points are the account form length, the empty first-run dashboard, and the tour tooltip cluster on step two. Full notes in the linked doc.",
310
+ },
311
+ {
312
+ id: 3,
313
+ kind: "event",
314
+ author: "Marcus Chen",
315
+ time: "2026-03-03T15:20:00Z",
316
+ body: "changed status from To do to In progress",
317
+ },
318
+ {
319
+ id: 4,
320
+ kind: "comment",
321
+ author: "Marcus Chen",
322
+ time: "2026-03-04T10:05:00Z",
323
+ body: "Interviews wrapped. A consistent theme: people expect a personalized starting workspace and get an empty one. Suggest we scope a template step into variant A.",
324
+ },
325
+ {
326
+ id: 5,
327
+ kind: "event",
328
+ author: "Sofia Alvarez",
329
+ time: "2026-03-04T16:48:00Z",
330
+ body: "assigned this task to Priya Shah",
331
+ },
332
+ ];
333
+
334
+ // ─── Shared pieces ──────────────────────────────────────────────────────────
335
+ // A Section escapes its container's padding by design — it cancels
336
+ // --container-padding-* with negative margins so fills can run edge to edge.
337
+ // This value is therefore the real inset of the main column.
338
+ const SECTION_PADDING = 6;
339
+
340
+ function SectionHeader({
341
+ title,
342
+ level = 2,
343
+ meta,
344
+ action,
345
+ }: {
346
+ title: string;
347
+ level?: 2 | 3;
348
+ meta?: ReactNode;
349
+ action?: ReactNode;
350
+ }) {
351
+ return (
352
+ <HStack gap={3} vAlign="center" hAlign="between" wrap="wrap">
353
+ <VStack gap={1}>
354
+ <Heading level={level}>{title}</Heading>
355
+ {meta}
356
+ </VStack>
357
+ {action}
358
+ </HStack>
359
+ );
360
+ }
361
+
362
+ // A separator inside one metadata line. It is aria-hidden: the pauses it
363
+ // draws are visual grouping, and a screen reader already hears the fields as
364
+ // separate text nodes.
365
+ function Bullet() {
366
+ return (
367
+ <Text type="supporting" color="secondary" aria-hidden="true">
368
+ {"・"}
369
+ </Text>
370
+ );
371
+ }
372
+
373
+ function PersonRow({ name }: { name: string }) {
374
+ return (
375
+ <HStack gap={2} vAlign="center">
376
+ <Avatar name={name} size="xsm" />
377
+ <Text type="body">{name}</Text>
378
+ </HStack>
379
+ );
380
+ }
381
+
382
+ // ─── Page header ────────────────────────────────────────────────────────────
383
+ function PageHeader({
384
+ fields,
385
+ doneCount,
386
+ subtaskCount,
387
+ commentCount,
388
+ isWatching,
389
+ onToggleWatch,
390
+ isNarrow,
391
+ onOpenProperties,
392
+ }: {
393
+ fields: WorkItemFields;
394
+ doneCount: number;
395
+ subtaskCount: number;
396
+ commentCount: number;
397
+ isWatching: boolean;
398
+ onToggleWatch: () => void;
399
+ isNarrow: boolean;
400
+ onOpenProperties: () => void;
401
+ }) {
402
+ const status = optionFor(STATUS_OPTIONS, fields.status);
403
+
404
+ return (
405
+ <LayoutHeader hasDivider padding={6}>
406
+ <HStack gap={4} vAlign="start" hAlign="between" wrap="wrap">
407
+ <VStack gap={2}>
408
+ <HStack gap={1} vAlign="center" wrap="wrap">
409
+ <Link href={SELF_HASH} type="supporting" color="secondary">
410
+ <HStack gap={1} vAlign="center">
411
+ <Icon icon={ArrowLeft} size="sm" color="inherit" />
412
+ All tasks
413
+ </HStack>
414
+ </Link>
415
+ <Bullet />
416
+ <Text type="supporting" color="secondary">
417
+ {RECORD_REF}
418
+ </Text>
419
+ <Bullet />
420
+ <Text type="supporting" color="secondary">
421
+ {RECORD_PROJECT}
422
+ </Text>
423
+ </HStack>
424
+ <Heading level={1} maxLines={2}>
425
+ {RECORD_TITLE}
426
+ </Heading>
427
+ {/* Every number on this line is counted from state: the status
428
+ and priority are the live field values, and the two tallies
429
+ read the arrays the sections below render. */}
430
+ <HStack gap={2} vAlign="center" wrap="wrap">
431
+ {status.icon}
432
+ <Text type="supporting" color="secondary">
433
+ {status.label}
434
+ </Text>
435
+ <Bullet />
436
+ <PriorityBadge value={fields.priority} />
437
+ <Bullet />
438
+ <Text type="supporting" color="secondary" hasTabularNumbers>
439
+ {doneCount} of {subtaskCount} subtasks
440
+ </Text>
441
+ <Bullet />
442
+ <Text type="supporting" color="secondary" hasTabularNumbers>
443
+ {commentCount} comments
444
+ </Text>
445
+ </HStack>
446
+ </VStack>
447
+ <HStack gap={2} vAlign="center">
448
+ {/* The label names the action rather than the state, so the
449
+ control stays an action; the watcher group in the rail is
450
+ the state it changes. */}
451
+ <Button
452
+ label={isWatching ? "Stop watching" : "Watch task"}
453
+ variant="secondary"
454
+ icon={<Icon icon={isWatching ? EyeOff : Eye} size="sm" />}
455
+ onClick={onToggleWatch}
456
+ />
457
+ {/* The rail is always visible on wide screens, so the discoverable
458
+ way to reach it only exists where it would otherwise vanish. */}
459
+ {isNarrow && (
460
+ <IconButton
461
+ label="Show task properties"
462
+ variant="secondary"
463
+ icon={<Icon icon={Info} size="sm" />}
464
+ onClick={onOpenProperties}
465
+ />
466
+ )}
467
+ </HStack>
468
+ </HStack>
469
+ </LayoutHeader>
470
+ );
471
+ }
472
+
473
+ // ─── Description ────────────────────────────────────────────────────────────
474
+ function DescriptionSection() {
475
+ return (
476
+ <Section variant="transparent" padding={SECTION_PADDING}>
477
+ <VStack gap={4}>
478
+ <SectionHeader title="Description" />
479
+ {/* Reading copy gets a measure; row-dense sections do not. 720 at the
480
+ 14px body role keeps a line near 90 characters in the mono face. */}
481
+ <VStack gap={2} maxWidth={720}>
482
+ {DESCRIPTION_PARAGRAPHS.map((paragraph) => (
483
+ <Text key={paragraph} type="body">
484
+ {paragraph}
485
+ </Text>
486
+ ))}
487
+ </VStack>
488
+ <VStack gap={2}>
489
+ <Heading level={3}>Success criteria</Heading>
490
+ <List listStyle="disc" density="compact">
491
+ {SUCCESS_CRITERIA.map((criterion) => (
492
+ // A string label is single-line truncated by ListItem's own
493
+ // rule (ListItem.tsx label docs); a node is not, and a
494
+ // truncated success criterion is a criterion you cannot read.
495
+ <ListItem
496
+ key={criterion}
497
+ label={
498
+ <Text type="body" textWrap="wrap">
499
+ {criterion}
500
+ </Text>
501
+ }
502
+ />
503
+ ))}
504
+ </List>
505
+ </VStack>
506
+ </VStack>
507
+ </Section>
508
+ );
509
+ }
510
+
511
+ // ─── Subtasks ───────────────────────────────────────────────────────────────
512
+ function SubtasksSection({
513
+ subtasks,
514
+ doneCount,
515
+ onSetDone,
516
+ onAdd,
517
+ }: {
518
+ subtasks: Subtask[];
519
+ doneCount: number;
520
+ onSetDone: (id: number, isDone: boolean) => void;
521
+ onAdd: (title: string) => void;
522
+ }) {
523
+ const [isAdding, setAdding] = useState(false);
524
+ const [draft, setDraft] = useState("");
525
+
526
+ const progress = subtasks.length === 0 ? 0 : (doneCount / subtasks.length) * 100;
527
+
528
+ const openComposer = () => {
529
+ setDraft("");
530
+ setAdding(true);
531
+ };
532
+
533
+ const submit = () => {
534
+ const title = draft.trim();
535
+ if (title.length === 0) {
536
+ return;
537
+ }
538
+ onAdd(title);
539
+ // Clearing rather than closing: adding a checklist usually means adding
540
+ // several rows, and the field keeps focus for the next one.
541
+ setDraft("");
542
+ };
543
+
544
+ const cancel = () => {
545
+ setDraft("");
546
+ setAdding(false);
547
+ };
548
+
549
+ return (
550
+ <Section variant="transparent" padding={SECTION_PADDING}>
551
+ <VStack gap={4}>
552
+ <SectionHeader
553
+ title="Subtasks"
554
+ // While the composer is open the field's own Add button is the
555
+ // action; leaving a second, inert trigger beside it would be a
556
+ // dead control.
557
+ action={
558
+ isAdding ? undefined : (
559
+ <Button label="Add subtask" variant="secondary" onClick={openComposer} />
560
+ )
561
+ }
562
+ />
563
+ <HStack gap={3} vAlign="center">
564
+ <Text type="supporting" color="secondary" hasTabularNumbers>
565
+ {doneCount} of {subtasks.length} complete
566
+ </Text>
567
+ <StackItem size="fill">
568
+ <ProgressBar
569
+ label="Subtask progress"
570
+ isLabelHidden
571
+ value={progress}
572
+ variant="success"
573
+ />
574
+ </StackItem>
575
+ </HStack>
576
+ <List>
577
+ {subtasks.map((task) => (
578
+ <CheckboxListItem
579
+ key={task.id}
580
+ // The label wraps; first-line keeps the checkbox and the
581
+ // due date level with it rather than with the middle of
582
+ // a three-line row. A node is required for that: a plain
583
+ // string label is single-line truncated by CheckboxListItem.
584
+ align="first-line"
585
+ label={
586
+ <Text type="body" textWrap="wrap">
587
+ {task.title}
588
+ </Text>
589
+ }
590
+ isChecked={task.isDone}
591
+ onCheck={(isDone) => onSetDone(task.id, isDone)}
592
+ endContent={
593
+ <HStack gap={2} vAlign="center">
594
+ {task.due && (
595
+ <Timestamp value={task.due} format="date" type="supporting" color="secondary" />
596
+ )}
597
+ <Avatar name={task.assignee} size="sm" />
598
+ </HStack>
599
+ }
600
+ />
601
+ ))}
602
+ </List>
603
+ {isAdding && (
604
+ <VStack gap={2}>
605
+ <TextInput
606
+ label="Subtask title"
607
+ isLabelHidden
608
+ placeholder="Describe the next step"
609
+ value={draft}
610
+ onChange={setDraft}
611
+ onEnter={submit}
612
+ hasAutoFocus
613
+ />
614
+ <HStack gap={2} hAlign="end">
615
+ <Button label="Cancel" variant="ghost" onClick={cancel} />
616
+ <Button
617
+ label="Add subtask"
618
+ variant="primary"
619
+ isDisabled={draft.trim().length === 0}
620
+ onClick={submit}
621
+ />
622
+ </HStack>
623
+ </VStack>
624
+ )}
625
+ </VStack>
626
+ </Section>
627
+ );
628
+ }
629
+
630
+ // ─── Attachments ────────────────────────────────────────────────────────────
631
+ function AttachmentsSection() {
632
+ return (
633
+ <Section variant="transparent" padding={SECTION_PADDING}>
634
+ <VStack gap={4}>
635
+ <SectionHeader
636
+ title="Attachments"
637
+ meta={
638
+ <Text type="supporting" color="secondary" hasTabularNumbers>
639
+ {ATTACHMENTS.length} files and links
640
+ </Text>
641
+ }
642
+ />
643
+ <VStack gap={1}>
644
+ {ATTACHMENTS.map((attachment) => {
645
+ const AttachmentIcon = ATTACHMENT_ICONS[attachment.kind];
646
+ return (
647
+ <ClickableCard
648
+ key={attachment.id}
649
+ label={`Open ${attachment.title}`}
650
+ href={attachment.href}
651
+ target="_blank"
652
+ padding={3}
653
+ >
654
+ <HStack gap={3} vAlign="center">
655
+ <Center width={40} height={40} style={attachmentTileStyle}>
656
+ <Icon icon={AttachmentIcon} size="lg" color="secondary" />
657
+ </Center>
658
+ <StackItem size="fill">
659
+ <VStack gap={0}>
660
+ <Text type="body" weight="semibold">
661
+ {attachment.title}
662
+ </Text>
663
+ <Text type="supporting" color="secondary">
664
+ {attachment.subtitle}
665
+ </Text>
666
+ </VStack>
667
+ </StackItem>
668
+ {/* A destination hint, not a control: the card itself is
669
+ the link, so a second focus stop here would repeat it. */}
670
+ <Icon icon={ExternalLink} size="sm" color="secondary" />
671
+ </HStack>
672
+ </ClickableCard>
673
+ );
674
+ })}
675
+ </VStack>
676
+ </VStack>
677
+ </Section>
678
+ );
679
+ }
680
+
681
+ // ─── Activity ───────────────────────────────────────────────────────────────
682
+ function ActivitySection({
683
+ activity,
684
+ commentCount,
685
+ onPost,
686
+ }: {
687
+ activity: ActivityEntry[];
688
+ commentCount: number;
689
+ onPost: (body: string) => void;
690
+ }) {
691
+ const commentFieldRef = useRef<HTMLTextAreaElement>(null);
692
+ const [draft, setDraft] = useState("");
693
+
694
+ const eventCount = activity.length - commentCount;
695
+
696
+ const focusCommentField = () => {
697
+ const field = commentFieldRef.current;
698
+ if (!field) {
699
+ return;
700
+ }
701
+ // Scroll first, then take focus without a second jump competing with it.
702
+ field.scrollIntoView({ behavior: "smooth", block: "center" });
703
+ field.focus({ preventScroll: true });
704
+ };
705
+
706
+ const submit = () => {
707
+ const body = draft.trim();
708
+ if (body.length === 0) {
709
+ return;
710
+ }
711
+ onPost(body);
712
+ setDraft("");
713
+ };
714
+
715
+ return (
716
+ <Section variant="transparent" padding={SECTION_PADDING}>
717
+ <VStack gap={6}>
718
+ <SectionHeader
719
+ title="Comments and activity"
720
+ meta={
721
+ <Text type="supporting" color="secondary" hasTabularNumbers>
722
+ {commentCount} comments · {eventCount} updates
723
+ </Text>
724
+ }
725
+ action={<Button label="Add comment" variant="secondary" onClick={focusCommentField} />}
726
+ />
727
+ <VStack gap={5}>
728
+ {activity.map((entry) => {
729
+ const isComment = entry.kind === "comment";
730
+ return (
731
+ <HStack key={entry.id} gap={3} vAlign="start">
732
+ <Avatar name={entry.author} size="sm" />
733
+ <StackItem size="fill">
734
+ <VStack gap={1}>
735
+ <HStack gap={2} vAlign="center">
736
+ <Text type="body" weight="semibold">
737
+ {entry.author}
738
+ </Text>
739
+ <StackItem size="fill" />
740
+ <Timestamp
741
+ value={entry.time}
742
+ format="relative"
743
+ type="supporting"
744
+ color="secondary"
745
+ />
746
+ </HStack>
747
+ {/* A comment gets a bubble; an event is one line of
748
+ history and would make the feed read as a list of
749
+ messages if it got one too. */}
750
+ {isComment ? (
751
+ <Card variant="muted" padding={3}>
752
+ <Text type="body">{entry.body}</Text>
753
+ </Card>
754
+ ) : (
755
+ <Text type="supporting" color="secondary">
756
+ {entry.body}
757
+ </Text>
758
+ )}
759
+ </VStack>
760
+ </StackItem>
761
+ </HStack>
762
+ );
763
+ })}
764
+ </VStack>
765
+ <VStack gap={2}>
766
+ <TextArea
767
+ ref={commentFieldRef}
768
+ width="100%"
769
+ label="Add a comment"
770
+ isLabelHidden
771
+ placeholder="Write a comment…"
772
+ value={draft}
773
+ onChange={setDraft}
774
+ rows={4}
775
+ />
776
+ <HStack gap={2} hAlign="end">
777
+ <Button
778
+ label="Cancel"
779
+ variant="ghost"
780
+ isDisabled={draft.length === 0}
781
+ onClick={() => setDraft("")}
782
+ />
783
+ <Button
784
+ label="Comment"
785
+ variant="primary"
786
+ isDisabled={draft.trim().length === 0}
787
+ onClick={submit}
788
+ />
789
+ </HStack>
790
+ </VStack>
791
+ </VStack>
792
+ </Section>
793
+ );
794
+ }
795
+
796
+ // ─── Properties rail ────────────────────────────────────────────────────────
797
+ // Rendered in the LayoutPanel on wide screens and in the Drawer below 1024px.
798
+ // It owns the record's editable fields, so it is one component with two hosts
799
+ // rather than two copies that drift.
800
+ function PropertiesPanel({
801
+ fields,
802
+ onFieldsChange,
803
+ watchers,
804
+ }: {
805
+ fields: WorkItemFields;
806
+ onFieldsChange: (patch: Partial<WorkItemFields>) => void;
807
+ watchers: string[];
808
+ }) {
809
+ return (
810
+ <VStack gap={6}>
811
+ <VStack gap={4}>
812
+ <SectionHeader title="Properties" level={3} />
813
+ <Selector
814
+ label="Status"
815
+ width="100%"
816
+ value={fields.status}
817
+ onChange={(status) => onFieldsChange({ status })}
818
+ options={STATUS_OPTIONS}
819
+ />
820
+ <Selector
821
+ label="Priority"
822
+ width="100%"
823
+ value={fields.priority}
824
+ onChange={(priority) => onFieldsChange({ priority })}
825
+ options={PRIORITY_OPTIONS}
826
+ renderValue={(option) => <PriorityBadge value={option.value} />}
827
+ renderOption={(option) => <PriorityBadge value={option.value} />}
828
+ />
829
+ <Selector
830
+ label="Assignee"
831
+ width="100%"
832
+ hasSearch
833
+ searchPlaceholder="Search people…"
834
+ value={fields.assignee}
835
+ onChange={(assignee) => onFieldsChange({ assignee })}
836
+ options={ASSIGNEE_OPTIONS}
837
+ />
838
+ <Tokenizer
839
+ label="Labels"
840
+ width="100%"
841
+ hasEntriesOnFocus
842
+ placeholder="Add a label"
843
+ searchSource={LABEL_SEARCH_SOURCE}
844
+ value={fields.labels}
845
+ onChange={(labels) => onFieldsChange({ labels })}
846
+ renderToken={(label, onRemove) => (
847
+ <Token size="sm" color="yellow" label={label.label} onRemove={onRemove} />
848
+ )}
849
+ />
850
+ </VStack>
851
+
852
+ <VStack gap={4}>
853
+ <SectionHeader title="Details" level={3} />
854
+ <MetadataList>
855
+ <MetadataListItem label="Reporter">
856
+ <PersonRow name={REPORTER} />
857
+ </MetadataListItem>
858
+ <MetadataListItem label="Start date">
859
+ <Timestamp value={START_DATE} format="date" type="body" color="primary" />
860
+ </MetadataListItem>
861
+ <MetadataListItem label="Due date">
862
+ <Timestamp value={DUE_DATE} format="date" type="body" color="primary" />
863
+ </MetadataListItem>
864
+ <MetadataListItem label="Sprint">
865
+ <Text type="body">{SPRINT}</Text>
866
+ </MetadataListItem>
867
+ <MetadataListItem label="Parent">
868
+ {/* Navigation inside a metadata row: the link is a
869
+ destination, so it carries no underline of its own. */}
870
+ <Link href={SELF_HASH}>PLT-192 · Activation initiative</Link>
871
+ </MetadataListItem>
872
+ <MetadataListItem label="Team">
873
+ <Text type="body">{TEAM}</Text>
874
+ </MetadataListItem>
875
+ </MetadataList>
876
+ </VStack>
877
+
878
+ <VStack gap={4}>
879
+ <SectionHeader
880
+ title="Watchers"
881
+ level={3}
882
+ meta={
883
+ <Text type="supporting" color="secondary" hasTabularNumbers>
884
+ {watchers.length} watching
885
+ </Text>
886
+ }
887
+ />
888
+ <AvatarGroup size="sm">
889
+ {watchers.map((name) => (
890
+ <Avatar key={name} name={name} />
891
+ ))}
892
+ </AvatarGroup>
893
+ </VStack>
894
+
895
+ <VStack gap={4}>
896
+ <SectionHeader title="Linked items" level={3} />
897
+ <VStack gap={1}>
898
+ {LINKED_ITEMS.map((item) => (
899
+ <ClickableCard
900
+ key={item.id}
901
+ label={`Open ${item.id}: ${item.title}`}
902
+ href={SELF_HASH}
903
+ padding={3}
904
+ >
905
+ <VStack gap={0}>
906
+ <Text type="supporting" color="secondary">
907
+ {item.id}
908
+ </Text>
909
+ <Text type="body" weight="semibold">
910
+ {item.title}
911
+ </Text>
912
+ </VStack>
913
+ </ClickableCard>
914
+ ))}
915
+ </VStack>
916
+ </VStack>
917
+ </VStack>
918
+ );
919
+ }
920
+
921
+ // ─── Page ───────────────────────────────────────────────────────────────────
922
+ export default function WorkItemDetail() {
923
+ const [fields, setFields] = useState<WorkItemFields>({
924
+ status: "in_progress",
925
+ assignee: "priya-shah",
926
+ priority: "high",
927
+ labels: LABEL_OPTIONS,
928
+ });
929
+ const [subtasks, setSubtasks] = useState(SUBTASKS);
930
+ const [activity, setActivity] = useState(ACTIVITY);
931
+ const [isWatching, setWatching] = useState(false);
932
+ // The rail is docked at desktop width and becomes a Drawer below 1024px, so
933
+ // the drawer keeps its own flag and only exists on narrow viewports.
934
+ const [isPropertiesOpen, setPropertiesOpen] = useState(false);
935
+ const isNarrow = useMediaQuery("(max-width: 1024px)");
936
+
937
+ // One derivation each, read by every place that prints the number: the
938
+ // header summary, the subtask progress row and the section metas.
939
+ const doneCount = subtasks.filter((task) => task.isDone).length;
940
+ const commentCount = activity.filter((entry) => entry.kind === "comment").length;
941
+ const watchers = isWatching ? [CURRENT_USER, ...WATCHERS] : WATCHERS;
942
+
943
+ const updateFields = (patch: Partial<WorkItemFields>) =>
944
+ setFields((previous) => ({ ...previous, ...patch }));
945
+
946
+ const setSubtaskDone = (id: number, isDone: boolean) =>
947
+ setSubtasks((previous) =>
948
+ previous.map((task) => (task.id === id ? { ...task, isDone } : task)),
949
+ );
950
+
951
+ const addSubtask = (title: string) =>
952
+ setSubtasks((previous) => {
953
+ const nextId = previous.reduce((max, task) => Math.max(max, task.id), 0) + 1;
954
+ // No due date: the new row is unscheduled until someone sets one, and
955
+ // the row renders without the date rather than inventing a deadline.
956
+ return [...previous, { id: nextId, title, isDone: false, assignee: CURRENT_USER }];
957
+ });
958
+
959
+ const postComment = (body: string) =>
960
+ setActivity((previous) => {
961
+ const nextId = previous.reduce((max, entry) => Math.max(max, entry.id), 0) + 1;
962
+ return [
963
+ ...previous,
964
+ {
965
+ id: nextId,
966
+ kind: "comment",
967
+ author: CURRENT_USER,
968
+ time: new Date().toISOString(),
969
+ body,
970
+ },
971
+ ];
972
+ });
973
+
974
+ return (
975
+ <>
976
+ <Layout
977
+ style={pageStyle}
978
+ height="fill"
979
+ header={
980
+ <PageHeader
981
+ fields={fields}
982
+ doneCount={doneCount}
983
+ subtaskCount={subtasks.length}
984
+ commentCount={commentCount}
985
+ isWatching={isWatching}
986
+ onToggleWatch={() => setWatching((previous) => !previous)}
987
+ isNarrow={isNarrow}
988
+ onOpenProperties={() => setPropertiesOpen(true)}
989
+ />
990
+ }
991
+ content={
992
+ <LayoutContent role="main" padding={0}>
993
+ <VStack gap={0}>
994
+ <DescriptionSection />
995
+ <Divider />
996
+ <SubtasksSection
997
+ subtasks={subtasks}
998
+ doneCount={doneCount}
999
+ onSetDone={setSubtaskDone}
1000
+ onAdd={addSubtask}
1001
+ />
1002
+ <Divider />
1003
+ <AttachmentsSection />
1004
+ <Divider />
1005
+ <ActivitySection
1006
+ activity={activity}
1007
+ commentCount={commentCount}
1008
+ onPost={postComment}
1009
+ />
1010
+ </VStack>
1011
+ </LayoutContent>
1012
+ }
1013
+ end={
1014
+ isNarrow ? undefined : (
1015
+ <LayoutPanel
1016
+ width={340}
1017
+ padding={4}
1018
+ hasDivider
1019
+ role="complementary"
1020
+ label="Task properties"
1021
+ >
1022
+ <PropertiesPanel fields={fields} onFieldsChange={updateFields} watchers={watchers} />
1023
+ </LayoutPanel>
1024
+ )
1025
+ }
1026
+ />
1027
+ {/* Narrow viewport: the rail's content is a Drawer rather than a column
1028
+ that would squeeze the record. Every editable field therefore stays
1029
+ reachable from the header's properties button. */}
1030
+ <Drawer
1031
+ side="end"
1032
+ label="Task properties"
1033
+ isOpen={isNarrow && isPropertiesOpen}
1034
+ onOpenChange={setPropertiesOpen}
1035
+ width={340}
1036
+ >
1037
+ <Layout
1038
+ height="fill"
1039
+ header={<DrawerHeader title="Task properties" onOpenChange={setPropertiesOpen} />}
1040
+ content={
1041
+ <LayoutContent padding={4}>
1042
+ <PropertiesPanel fields={fields} onFieldsChange={updateFields} watchers={watchers} />
1043
+ </LayoutContent>
1044
+ }
1045
+ />
1046
+ </Drawer>
1047
+ </>
1048
+ );
1049
+ }