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.
- package/BRAND.md +54 -3
- package/README.md +11 -4
- package/USAGE.md +1 -1
- package/astryx-dracula.d.ts +2 -2
- package/astryx-dracula.js +26 -4
- package/astryx-dracula.variants.d.ts +2 -2
- package/package.json +11 -11
- package/shared/auth-chrome-config.ts +0 -4
- package/shared/auth-chrome.tsx +2 -3
- package/shared/chaptered-doc-config.ts +3 -2
- package/shared/order-desk.tsx +52 -54
- package/shared/revenue-chart.tsx +1 -1
- package/shared/scene-castle.tsx +21 -13
- package/templates/ai-chat-landing.tsx +6 -9
- package/templates/ai-chat.tsx +5 -1
- package/templates/centered-hero.tsx +2 -2
- package/templates/classic-gallery.tsx +5 -0
- package/templates/contact-form.tsx +9 -0
- package/templates/dashboard-portfolio.tsx +17 -4
- package/templates/dashboard.tsx +5 -5
- package/templates/detail-page.tsx +20 -28
- package/templates/documentation-design.tsx +3 -0
- package/templates/documentation-technical.tsx +8 -5
- package/templates/documentation.tsx +13 -6
- package/templates/form-two-column.tsx +11 -2
- package/templates/form-wizard.template.mjs +15 -0
- package/templates/form-wizard.tsx +878 -0
- package/templates/gallery-hero.tsx +10 -4
- package/templates/ide.tsx +177 -96
- package/templates/incident-console.tsx +15 -5
- package/templates/kanban-board.tsx +46 -4
- package/templates/library.tsx +12 -3
- package/templates/login-split.tsx +3 -3
- package/templates/login-sso.tsx +9 -17
- package/templates/messaging-shell.tsx +109 -46
- package/templates/mixed-gallery.tsx +6 -0
- package/templates/payment-form.tsx +29 -28
- package/templates/product-detail.tsx +12 -3
- package/templates/product-gallery.tsx +8 -3
- package/templates/product-tour.tsx +11 -7
- package/templates/settings-dialog.tsx +5 -0
- package/templates/settings-sidebar.tsx +603 -584
- package/templates/settings.tsx +5 -2
- package/templates/shell-nav.tsx +23 -3
- package/templates/shell-side-nav.tsx +94 -81
- package/templates/shell-top-nav.tsx +98 -86
- package/templates/table-grouped.tsx +146 -132
- package/templates/{table-page-shoe-store-heatmap.template.mjs → table-page-shoe-store.template.mjs} +1 -1
- package/templates/{table-page-shoe-store-heatmap.tsx → table-page-shoe-store.tsx} +1 -1
- package/templates/table-page.tsx +8 -0
- package/templates/table-tree.template.mjs +16 -0
- package/templates/table-tree.tsx +707 -0
- package/templates/tech-report.tsx +8 -5
- package/templates/theme-showcase.tsx +18 -11
- package/templates/work-item-detail.template.mjs +8 -0
- package/templates/work-item-detail.tsx +1049 -0
- package/theme.css +38 -3
- package/theme.css.d.ts +2 -2
- 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
|
+
}
|