astryx-dracula 0.5.0 → 0.6.2

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 (129) hide show
  1. package/BRAND.md +3 -3
  2. package/README.md +7 -4
  3. package/USAGE.md +49 -49
  4. package/astryx-dracula.d.ts +2 -2
  5. package/astryx-dracula.js +25 -9
  6. package/astryx-dracula.variants.d.ts +2 -2
  7. package/astryx.integration.mjs +1 -1
  8. package/icons.mjs +21 -17
  9. package/icons.tsx +69 -62
  10. package/package.json +125 -120
  11. package/shared/auth-chrome-config.ts +6 -6
  12. package/shared/auth-chrome.tsx +143 -144
  13. package/shared/auth-copy.ts +14 -14
  14. package/shared/chaptered-doc-config.ts +48 -51
  15. package/shared/chaptered-doc.tsx +267 -289
  16. package/shared/chart-hues.ts +25 -10
  17. package/shared/chart-labels.tsx +31 -30
  18. package/shared/chart-legend.tsx +55 -56
  19. package/shared/chart-panel-style.ts +3 -3
  20. package/shared/data-bar.tsx +97 -102
  21. package/shared/format-money.ts +1 -1
  22. package/shared/gallery-image.ts +6 -6
  23. package/shared/login-demo.ts +1 -1
  24. package/shared/metric-card.tsx +101 -105
  25. package/shared/metric-delta.tsx +28 -27
  26. package/shared/order-desk.tsx +205 -216
  27. package/shared/revenue-chart.tsx +145 -165
  28. package/shared/scene-castle.tsx +206 -291
  29. package/shared/scene-frame.tsx +33 -32
  30. package/shared/scene-hues.ts +17 -17
  31. package/shared/scene-tile.tsx +88 -92
  32. package/shared/settings-data.ts +119 -125
  33. package/shared/settings-rows.tsx +122 -159
  34. package/shared/sparkline.tsx +75 -88
  35. package/shared/sso-icons.tsx +10 -22
  36. package/shared/stars.tsx +16 -25
  37. package/templates/ai-chat-landing.template.mjs +5 -4
  38. package/templates/ai-chat-landing.tsx +473 -460
  39. package/templates/ai-chat.template.mjs +5 -4
  40. package/templates/ai-chat.tsx +543 -612
  41. package/templates/blank.template.mjs +5 -4
  42. package/templates/blank.tsx +18 -14
  43. package/templates/centered-hero.template.mjs +5 -4
  44. package/templates/centered-hero.tsx +55 -68
  45. package/templates/classic-gallery.template.mjs +5 -4
  46. package/templates/classic-gallery.tsx +97 -104
  47. package/templates/contact-form.template.mjs +5 -4
  48. package/templates/contact-form.tsx +247 -282
  49. package/templates/dashboard-portfolio.template.mjs +5 -4
  50. package/templates/dashboard-portfolio.tsx +717 -548
  51. package/templates/dashboard.template.mjs +5 -4
  52. package/templates/dashboard.tsx +596 -567
  53. package/templates/detail-page.template.mjs +5 -4
  54. package/templates/detail-page.tsx +546 -603
  55. package/templates/documentation-design.template.mjs +5 -4
  56. package/templates/documentation-design.tsx +231 -243
  57. package/templates/documentation-technical.template.mjs +5 -4
  58. package/templates/documentation-technical.tsx +236 -239
  59. package/templates/documentation.template.mjs +5 -4
  60. package/templates/documentation.tsx +267 -279
  61. package/templates/editor.template.mjs +5 -4
  62. package/templates/editor.tsx +844 -877
  63. package/templates/file-explorer.template.mjs +5 -4
  64. package/templates/file-explorer.tsx +566 -590
  65. package/templates/form-two-column.template.mjs +5 -4
  66. package/templates/form-two-column.tsx +195 -210
  67. package/templates/gallery-hero.template.mjs +5 -4
  68. package/templates/gallery-hero.tsx +93 -114
  69. package/templates/ide.template.mjs +5 -4
  70. package/templates/ide.tsx +407 -453
  71. package/templates/incident-console.template.mjs +5 -4
  72. package/templates/incident-console.tsx +593 -575
  73. package/templates/kanban-board.template.mjs +5 -4
  74. package/templates/kanban-board.tsx +665 -683
  75. package/templates/library.template.mjs +5 -4
  76. package/templates/library.tsx +409 -430
  77. package/templates/login-card.template.mjs +5 -4
  78. package/templates/login-card.tsx +63 -72
  79. package/templates/login-split.template.mjs +5 -4
  80. package/templates/login-split.tsx +192 -193
  81. package/templates/login-sso.template.mjs +5 -4
  82. package/templates/login-sso.tsx +257 -258
  83. package/templates/login.template.mjs +5 -4
  84. package/templates/login.tsx +53 -49
  85. package/templates/messaging-shell.template.mjs +5 -4
  86. package/templates/messaging-shell.tsx +556 -574
  87. package/templates/mixed-gallery.template.mjs +5 -4
  88. package/templates/mixed-gallery.tsx +60 -58
  89. package/templates/payment-form.template.mjs +5 -4
  90. package/templates/payment-form.tsx +882 -991
  91. package/templates/product-detail.template.mjs +5 -4
  92. package/templates/product-detail.tsx +261 -272
  93. package/templates/product-gallery.template.mjs +5 -4
  94. package/templates/product-gallery.tsx +126 -137
  95. package/templates/product-tour.template.mjs +5 -4
  96. package/templates/product-tour.tsx +741 -773
  97. package/templates/settings-dialog.template.mjs +5 -4
  98. package/templates/settings-dialog.tsx +688 -717
  99. package/templates/settings-sidebar.template.mjs +5 -4
  100. package/templates/settings-sidebar.tsx +762 -807
  101. package/templates/settings.template.mjs +5 -4
  102. package/templates/settings.tsx +289 -304
  103. package/templates/shell-nav.template.mjs +5 -4
  104. package/templates/shell-nav.tsx +212 -219
  105. package/templates/shell-side-nav.template.mjs +5 -4
  106. package/templates/shell-side-nav.tsx +135 -157
  107. package/templates/shell-top-nav.template.mjs +5 -4
  108. package/templates/shell-top-nav.tsx +182 -203
  109. package/templates/side-gallery.template.mjs +5 -4
  110. package/templates/side-gallery.tsx +85 -90
  111. package/templates/table-grouped.template.mjs +5 -4
  112. package/templates/table-grouped.tsx +1000 -1053
  113. package/templates/table-page-chart.template.mjs +5 -4
  114. package/templates/table-page-chart.tsx +400 -398
  115. package/templates/table-page-heatmap-status.template.mjs +5 -4
  116. package/templates/table-page-heatmap-status.tsx +483 -512
  117. package/templates/table-page-shoe-store-heatmap.template.mjs +5 -4
  118. package/templates/table-page-shoe-store-heatmap.tsx +748 -746
  119. package/templates/table-page.template.mjs +5 -4
  120. package/templates/table-page.tsx +396 -405
  121. package/templates/table.template.mjs +5 -4
  122. package/templates/table.tsx +92 -93
  123. package/templates/tech-report.template.mjs +5 -4
  124. package/templates/tech-report.tsx +1316 -1304
  125. package/templates/theme-showcase.template.mjs +5 -4
  126. package/templates/theme-showcase.tsx +871 -951
  127. package/theme.css +35 -77
  128. package/theme.css.d.ts +9 -0
  129. package/tokens.css +373 -380
@@ -1,249 +1,237 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
- // XLE (canonical structure, validated with `bunx astryx layout check`):
3
- // S > L > (LH[divider] > Tbar > (Hd"Sprint Board"[level=1] + Bd.neutral"8") + (H[g=2] > SE + D.strong + IB"Sort" + IB"Filter" + IB"Search" + B.primary"Add task")) + (LC[p=0] > G[c={min:280,max:4} g=4] > (C.muted[p=0] > L > (LH[divider] > H[j=between a=center] > (H[g=2 a=center] > SD + Hd"To-do"[level=2] + IB) + Tx) + (LC[p=3] > V[g=2] > (C[p=3] > V[g=2] > (H[j=between] > (H[g=2] > Tk + Bd) + MM) + (V[g=1] > Hd[level=3] + Tx) + Tx)*2))*4)
4
-
5
- import {
6
- useEffect,
7
- useRef,
8
- useState,
9
- type PointerEvent as ReactPointerEvent,
10
- type KeyboardEvent as ReactKeyboardEvent,
11
- type ReactNode,
12
- } from 'react';
13
-
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
+ // S[h=100dvh] > (L[h=fill] > (LH[divider p=6] > Tbar[g=2] > (Hd"Sprint Board"[level=1 t=display-2] + Bd.neutral"8") + (H[g=2 wrap] > SE"Sprint" + D.strong + (H[g=1 a=center wrap] > IB"Sort" + IB"Filter" + IB"Search") + B.primary"Add task")) + (LC[p=0 !scroll] > G[c={min:280,max:4} g=4] > (C.muted[p=0] > L[h=fill] > (LH[divider p=3] > H[j=between a=center] > (H[g=1 a=center] > SD + Hd"To-do"[level=2] + (Po > IB)) + Tx"2") + (LC[p=3] > V[g=2] > (C[p=3] > V[g=2] > (H[j=between] > (H[g=2] > Tk + Bd) + MM) + (V[g=1] > Hd[level=3] + Tx) + Tx)*2)) + (C.muted[p=0] > L[h=fill] > (LH[divider p=3] > H[j=between a=center] > (H[g=1 a=center] > SD + Hd"In progress"[level=2] + (Po > IB)) + Tx"2") + (LC[p=3] > V[g=2] > (C[p=3] > V[g=2] > (H[j=between] > (H[g=2] > Tk + Bd) + MM) + (V[g=1] > Hd[level=3] + Tx) + Tx)*2)) + (C.muted[p=0] > L[h=fill] > (LH[divider p=3] > H[j=between a=center] > (H[g=1 a=center] > SD + Hd"In review"[level=2] + (Po > IB)) + Tx"1") + (LC[p=3] > V[g=2] > (C[p=3] > V[g=2] > (H[j=between] > (H[g=2] > Tk + Bd) + MM) + (V[g=1] > Hd[level=3] + Tx) + Tx))) + (C.muted[p=0] > L[h=fill] > (LH[divider p=3] > H[j=between a=center] > (H[g=1 a=center] > SD + Hd"Done"[level=2] + (Po > IB)) + Tx"3") + (LC[p=3] > V[g=2] > (C[p=3] > V[g=2] > (H[j=between] > (H[g=2] > Tk + Bd) + MM) + (V[g=1] > Hd[level=3] + Tx) + Tx)*3))) + (C[p=3] > V[g=2] > (H[j=between] > (H[g=2] > Tk + Bd) + MM) + (V[g=1] > Hd[level=3] + Tx) + Tx)
6
+
7
+ import { Badge } from "@astryxdesign/core/Badge";
8
+ import { Button } from "@astryxdesign/core/Button";
9
+ import { Card } from "@astryxdesign/core/Card";
10
+ import { Divider } from "@astryxdesign/core/Divider";
11
+ import { EmptyState } from "@astryxdesign/core/EmptyState";
12
+ import { Grid } from "@astryxdesign/core/Grid";
13
+ import { Icon } from "@astryxdesign/core/Icon";
14
+ import { IconButton } from "@astryxdesign/core/IconButton";
15
+ import { HStack, Layout, LayoutContent, LayoutHeader, VStack } from "@astryxdesign/core/Layout";
16
+ import { MoreMenu } from "@astryxdesign/core/MoreMenu";
17
+ import { Popover } from "@astryxdesign/core/Popover";
18
+ import { Section } from "@astryxdesign/core/Section";
19
+ import { Selector } from "@astryxdesign/core/Selector";
20
+ import { StatusDot } from "@astryxdesign/core/StatusDot";
21
+ import { Heading, Text } from "@astryxdesign/core/Text";
22
+ import { Token } from "@astryxdesign/core/Token";
23
+ import { Toolbar } from "@astryxdesign/core/Toolbar";
14
24
  import {
15
- Layout,
16
- LayoutHeader,
17
- LayoutContent,
18
- HStack,
19
- VStack,
20
- } from '@astryxdesign/core/Layout';
21
- import {Text, Heading} from '@astryxdesign/core/Text';
22
- import {Card} from '@astryxdesign/core/Card';
23
- import {Badge} from '@astryxdesign/core/Badge';
24
- import {Token} from '@astryxdesign/core/Token';
25
- import {Button} from '@astryxdesign/core/Button';
26
- import {IconButton} from '@astryxdesign/core/IconButton';
27
- import {Icon} from '@astryxdesign/core/Icon';
28
- import {StatusDot} from '@astryxdesign/core/StatusDot';
29
- import {EmptyState} from '@astryxdesign/core/EmptyState';
30
- import {MoreMenu} from '@astryxdesign/core/MoreMenu';
31
- import {Selector} from '@astryxdesign/core/Selector';
32
- import {Popover} from '@astryxdesign/core/Popover';
33
- import {Divider} from '@astryxdesign/core/Divider';
34
- import {Toolbar} from '@astryxdesign/core/Toolbar';
35
- import {Section} from '@astryxdesign/core/Section';
36
- import {Grid} from '@astryxdesign/core/Grid';
37
-
25
+ ArrowUpDown,
26
+ CircleCheck,
27
+ ClipboardCheck,
28
+ Filter,
29
+ Inbox,
30
+ Info,
31
+ Plus,
32
+ RotateCw,
33
+ Search,
34
+ } from "lucide-react";
38
35
  import {
39
- Plus,
40
- Search,
41
- ArrowUpDown,
42
- Filter,
43
- RotateCw,
44
- CircleCheck,
45
- Inbox,
46
- Info,
47
- ClipboardCheck,
48
- } from 'lucide-react';
36
+ type KeyboardEvent as ReactKeyboardEvent,
37
+ type ReactNode,
38
+ type PointerEvent as ReactPointerEvent,
39
+ useEffect,
40
+ useRef,
41
+ useState,
42
+ } from "react";
49
43
 
50
44
  // ============= TYPES =============
51
45
 
52
- type ColumnId = 'todo' | 'in-progress' | 'in-review' | 'done';
53
- type Priority = 'high' | 'medium' | 'low';
46
+ type ColumnId = "todo" | "in-progress" | "in-review" | "done";
47
+ type Priority = "high" | "medium" | "low";
54
48
 
55
49
  interface WorkItem {
56
- id: string;
57
- column: ColumnId;
58
- ref: string;
59
- priority: Priority;
60
- title: string;
61
- description: string;
62
- lastEdited: string;
63
- dueDate: string;
50
+ id: string;
51
+ column: ColumnId;
52
+ ref: string;
53
+ priority: Priority;
54
+ title: string;
55
+ description: string;
56
+ lastEdited: string;
57
+ dueDate: string;
64
58
  }
65
59
 
66
60
  interface ColumnMeta {
67
- id: ColumnId;
68
- title: string;
69
- variant: 'neutral' | 'info' | 'warning' | 'success';
70
- tooltip: string;
71
- emptyTitle: string;
72
- emptyDescription: string;
73
- emptyIcon: typeof Inbox;
61
+ id: ColumnId;
62
+ title: string;
63
+ variant: "neutral" | "info" | "warning" | "success";
64
+ tooltip: string;
65
+ emptyTitle: string;
66
+ emptyDescription: string;
67
+ emptyIcon: typeof Inbox;
74
68
  }
75
69
 
76
70
  // Where a dragged card will land: a column and an insertion index within it
77
71
  // (measured against the cards remaining after the dragged card is removed).
78
72
  interface DropTarget {
79
- column: ColumnId;
80
- index: number;
73
+ column: ColumnId;
74
+ index: number;
81
75
  }
82
76
 
83
77
  // Live state of an in-progress pointer drag. Coordinates are in viewport space.
84
78
  interface DragState {
85
- id: string;
86
- width: number;
87
- height: number;
88
- offsetX: number;
89
- offsetY: number;
90
- pointerX: number;
91
- pointerY: number;
92
- target: DropTarget | null;
79
+ id: string;
80
+ width: number;
81
+ height: number;
82
+ offsetX: number;
83
+ offsetY: number;
84
+ pointerX: number;
85
+ pointerY: number;
86
+ target: DropTarget | null;
93
87
  }
94
88
 
95
89
  // ============= DATA =============
96
90
 
97
91
  const COLUMNS: ColumnMeta[] = [
98
- {
99
- id: 'todo',
100
- title: 'To-do',
101
- variant: 'neutral',
102
- tooltip: 'Palette tasks queued for this sprint, waiting to be picked up.',
103
- emptyTitle: 'To-do is empty',
104
- emptyDescription: 'Tasks pulled into this sprint appear here.',
105
- emptyIcon: Inbox,
106
- },
107
- {
108
- id: 'in-progress',
109
- title: 'In progress',
110
- variant: 'info',
111
- tooltip: 'Theme work currently on the easel.',
112
- emptyTitle: 'Nothing in progress',
113
- emptyDescription: 'Tasks being worked on appear here.',
114
- emptyIcon: RotateCw,
115
- },
116
- {
117
- id: 'in-review',
118
- title: 'In review',
119
- variant: 'info',
120
- tooltip: 'Shades waiting for your contrast review.',
121
- emptyTitle: 'Nothing in review',
122
- emptyDescription: 'Tasks awaiting your review appear here.',
123
- emptyIcon: ClipboardCheck,
124
- },
125
- {
126
- id: 'done',
127
- title: 'Done',
128
- variant: 'success',
129
- tooltip: 'Shipped shades, sealed after dark.',
130
- emptyTitle: 'Nothing done yet',
131
- emptyDescription: 'Completed tasks appear here.',
132
- emptyIcon: CircleCheck,
133
- },
92
+ {
93
+ id: "todo",
94
+ title: "To-do",
95
+ variant: "neutral",
96
+ tooltip: "Palette tasks queued for this sprint, waiting to be picked up.",
97
+ emptyTitle: "To-do is empty",
98
+ emptyDescription: "Tasks pulled into this sprint appear here.",
99
+ emptyIcon: Inbox,
100
+ },
101
+ {
102
+ id: "in-progress",
103
+ title: "In progress",
104
+ variant: "info",
105
+ tooltip: "Theme work currently on the easel.",
106
+ emptyTitle: "Nothing in progress",
107
+ emptyDescription: "Tasks being worked on appear here.",
108
+ emptyIcon: RotateCw,
109
+ },
110
+ {
111
+ id: "in-review",
112
+ title: "In review",
113
+ variant: "info",
114
+ tooltip: "Shades waiting for your contrast review.",
115
+ emptyTitle: "Nothing in review",
116
+ emptyDescription: "Tasks awaiting your review appear here.",
117
+ emptyIcon: ClipboardCheck,
118
+ },
119
+ {
120
+ id: "done",
121
+ title: "Done",
122
+ variant: "success",
123
+ tooltip: "Shipped shades, sealed after dark.",
124
+ emptyTitle: "Nothing done yet",
125
+ emptyDescription: "Completed tasks appear here.",
126
+ emptyIcon: CircleCheck,
127
+ },
134
128
  ];
135
129
 
136
- const PRIORITY_META: Record<
137
- Priority,
138
- {label: string; variant: 'red' | 'yellow' | 'neutral'}
139
- > = {
140
- high: {label: 'High', variant: 'red'},
141
- medium: {label: 'Medium', variant: 'yellow'},
142
- low: {label: 'Low', variant: 'neutral'}, // neutral: low priority is not info
130
+ const PRIORITY_META: Record<Priority, { label: string; variant: "red" | "yellow" | "neutral" }> = {
131
+ high: { label: "High", variant: "red" },
132
+ medium: { label: "Medium", variant: "yellow" },
133
+ low: { label: "Low", variant: "neutral" }, // neutral: low priority is not info
143
134
  };
144
135
 
145
136
  function groupByColumn(items: WorkItem[]): Record<ColumnId, WorkItem[]> {
146
- const map: Record<ColumnId, WorkItem[]> = {
147
- todo: [],
148
- 'in-progress': [],
149
- 'in-review': [],
150
- done: [],
151
- };
152
- for (const item of items) {
153
- map[item.column].push(item);
154
- }
155
- return map;
137
+ const map: Record<ColumnId, WorkItem[]> = {
138
+ todo: [],
139
+ "in-progress": [],
140
+ "in-review": [],
141
+ done: [],
142
+ };
143
+ for (const item of items) {
144
+ map[item.column].push(item);
145
+ }
146
+ return map;
156
147
  }
157
148
 
158
149
  const INITIAL_ITEMS: WorkItem[] = [
159
- {
160
- id: 't1',
161
- column: 'todo',
162
- ref: 'Task 4821',
163
- priority: 'low',
164
- title: 'Draft the palette rollout brief',
165
- description:
166
- 'Write a short brief outlining goals, scope, and contrast criteria for the Dracula rollout.',
167
- lastEdited: '2h ago',
168
- dueDate: 'Jul 8',
169
- },
170
- {
171
- id: 't2',
172
- column: 'todo',
173
- ref: 'Task 4842',
174
- priority: 'low',
175
- title: 'Collect feedback from night-shift reviewers',
176
- description:
177
- 'Gather input from key reviewers and summarize the main themes for the next review.',
178
- lastEdited: '1d ago',
179
- dueDate: 'Jul 11',
180
- },
181
- {
182
- id: 'p1',
183
- column: 'in-progress',
184
- ref: 'Task 4825',
185
- priority: 'high',
186
- title: 'Design the midnight landing page',
187
- description:
188
- 'Create a first-pass layout in pure Dracula and share it for early feedback after dark.',
189
- lastEdited: '18m ago',
190
- dueDate: 'Jul 3',
191
- },
192
- {
193
- id: 'p2',
194
- column: 'in-progress',
195
- ref: 'Task 4833',
196
- priority: 'medium',
197
- title: 'Set up the theme workspace',
198
- description:
199
- 'Configure the shared workspace and invite the coven so everyone has access.',
200
- lastEdited: '5m ago',
201
- dueDate: 'Jul 4',
202
- },
203
- {
204
- id: 'v1',
205
- column: 'in-review',
206
- ref: 'Task 4831',
207
- priority: 'medium',
208
- title: 'Review the moonlight contrast pass',
209
- description:
210
- 'Check the new palette against text and border contrast gates before it ships.',
211
- lastEdited: '2h ago',
212
- dueDate: 'Jul 5',
213
- },
214
- {
215
- id: 'r1',
216
- column: 'done',
217
- ref: 'Task 4788',
218
- priority: 'low',
219
- title: 'Publish the weekly changelog',
220
- description:
221
- 'Summarize progress, blockers, and next steps in a short update for the night shift.',
222
- lastEdited: 'Yesterday',
223
- dueDate: 'Jul 1',
224
- },
225
- {
226
- id: 'r2',
227
- column: 'done',
228
- ref: 'Task 4789',
229
- priority: 'high',
230
- title: 'Prepare the midnight demo walkthrough',
231
- description:
232
- 'Put together a candlelit walkthrough covering the main haunts for the demo.',
233
- lastEdited: '3d ago',
234
- dueDate: 'Jun 30',
235
- },
236
- {
237
- id: 'r3',
238
- column: 'done',
239
- ref: 'Task 4790',
240
- priority: 'medium',
241
- title: 'Review and merge open pull requests',
242
- description:
243
- 'Go through the pending bloodlines, leave comments, and merge the ones that are ready.',
244
- lastEdited: '4d ago',
245
- dueDate: 'Jun 28',
246
- },
150
+ {
151
+ id: "t1",
152
+ column: "todo",
153
+ ref: "Task 4821",
154
+ priority: "low",
155
+ title: "Draft the palette rollout brief",
156
+ description:
157
+ "Write a short brief outlining goals, scope, and contrast criteria for the Dracula rollout.",
158
+ lastEdited: "2h ago",
159
+ dueDate: "Jul 8",
160
+ },
161
+ {
162
+ id: "t2",
163
+ column: "todo",
164
+ ref: "Task 4842",
165
+ priority: "low",
166
+ title: "Collect feedback from night-shift reviewers",
167
+ description:
168
+ "Gather input from key reviewers and summarize the main themes for the next review.",
169
+ lastEdited: "1d ago",
170
+ dueDate: "Jul 11",
171
+ },
172
+ {
173
+ id: "p1",
174
+ column: "in-progress",
175
+ ref: "Task 4825",
176
+ priority: "high",
177
+ title: "Design the midnight landing page",
178
+ description:
179
+ "Create a first-pass layout in pure Dracula and share it for early feedback after dark.",
180
+ lastEdited: "18m ago",
181
+ dueDate: "Jul 3",
182
+ },
183
+ {
184
+ id: "p2",
185
+ column: "in-progress",
186
+ ref: "Task 4833",
187
+ priority: "medium",
188
+ title: "Set up the theme workspace",
189
+ description: "Configure the shared workspace and invite the coven so everyone has access.",
190
+ lastEdited: "5m ago",
191
+ dueDate: "Jul 4",
192
+ },
193
+ {
194
+ id: "v1",
195
+ column: "in-review",
196
+ ref: "Task 4831",
197
+ priority: "medium",
198
+ title: "Review the moonlight contrast pass",
199
+ description: "Check the new palette against text and border contrast gates before it ships.",
200
+ lastEdited: "2h ago",
201
+ dueDate: "Jul 5",
202
+ },
203
+ {
204
+ id: "r1",
205
+ column: "done",
206
+ ref: "Task 4788",
207
+ priority: "low",
208
+ title: "Publish the weekly changelog",
209
+ description:
210
+ "Summarize progress, blockers, and next steps in a short update for the night shift.",
211
+ lastEdited: "Yesterday",
212
+ dueDate: "Jul 1",
213
+ },
214
+ {
215
+ id: "r2",
216
+ column: "done",
217
+ ref: "Task 4789",
218
+ priority: "high",
219
+ title: "Prepare the midnight demo walkthrough",
220
+ description: "Put together a candlelit walkthrough covering the main haunts for the demo.",
221
+ lastEdited: "3d ago",
222
+ dueDate: "Jun 30",
223
+ },
224
+ {
225
+ id: "r3",
226
+ column: "done",
227
+ ref: "Task 4790",
228
+ priority: "medium",
229
+ title: "Review and merge open pull requests",
230
+ description:
231
+ "Go through the pending bloodlines, leave comments, and merge the ones that are ready.",
232
+ lastEdited: "4d ago",
233
+ dueDate: "Jun 28",
234
+ },
247
235
  ];
248
236
 
249
237
  // Pointer travel (px) before a press is promoted to a drag, so taps and clicks
@@ -262,495 +250,489 @@ const DRAG_CLONE_LAYER = 100;
262
250
  // Shared card contents, rendered both in the column list and inside the
263
251
  // floating drag clone so the two stay pixel-identical.
264
252
  function BoardCardBody({
265
- item,
266
- onMove,
253
+ item,
254
+ onMove,
267
255
  }: {
268
- item: WorkItem;
269
- onMove: (id: string, to: ColumnId) => void;
256
+ item: WorkItem;
257
+ onMove: (id: string, to: ColumnId) => void;
270
258
  }) {
271
- const priority = PRIORITY_META[item.priority];
272
- const moveTargets = COLUMNS.filter(c => c.id !== item.column).map(c => ({
273
- label: `Move to ${c.title}`,
274
- onClick: () => onMove(item.id, c.id),
275
- }));
276
-
277
- return (
278
- <VStack gap={2}>
279
- <HStack hAlign="between" vAlign="start">
280
- <HStack gap={2} vAlign="center" wrap="wrap">
281
- <Token label={item.ref} size="sm" />
282
- <Badge label={priority.label} variant={priority.variant} />
283
- </HStack>
284
- <MoreMenu
285
- label="Work item actions"
286
- size="sm"
287
- items={[{label: 'Open'}, {label: 'Assign to me'}, {type: 'divider'}, ...moveTargets]}
288
- />
289
- </HStack>
290
-
291
- <VStack gap={1}>
292
- <Heading level={3}>{item.title}</Heading>
293
- <Text type="body" color="secondary" maxLines={2}>
294
- {item.description}
295
- </Text>
296
- </VStack>
297
-
298
- <Text type="supporting" color="secondary">
299
- Edited {item.lastEdited} · Due {item.dueDate}
300
- </Text>
301
- </VStack>
302
- );
259
+ const priority = PRIORITY_META[item.priority];
260
+ const moveTargets = COLUMNS.filter((c) => c.id !== item.column).map((c) => ({
261
+ label: `Move to ${c.title}`,
262
+ onClick: () => onMove(item.id, c.id),
263
+ }));
264
+
265
+ return (
266
+ <VStack gap={2}>
267
+ <HStack hAlign="between" vAlign="start">
268
+ <HStack gap={2} vAlign="center" wrap="wrap">
269
+ <Token label={item.ref} size="sm" />
270
+ <Badge label={priority.label} variant={priority.variant} />
271
+ </HStack>
272
+ <MoreMenu
273
+ label="Work item actions"
274
+ size="sm"
275
+ items={[
276
+ { label: "Open" },
277
+ { label: "Assign to me" },
278
+ { type: "divider" },
279
+ ...moveTargets,
280
+ ]}
281
+ />
282
+ </HStack>
283
+
284
+ <VStack gap={1}>
285
+ <Heading level={3}>{item.title}</Heading>
286
+ <Text type="body" color="secondary" maxLines={2}>
287
+ {item.description}
288
+ </Text>
289
+ </VStack>
290
+
291
+ <Text type="supporting" color="secondary">
292
+ Edited {item.lastEdited} · Due {item.dueDate}
293
+ </Text>
294
+ </VStack>
295
+ );
303
296
  }
304
297
 
305
298
  // ============= BOARD CARD =============
306
299
 
307
300
  function BoardCard({
308
- item,
309
- cardRef,
310
- onPointerDown,
311
- onMove,
301
+ item,
302
+ cardRef,
303
+ onPointerDown,
304
+ onMove,
312
305
  }: {
313
- item: WorkItem;
314
- cardRef: (el: HTMLDivElement | null) => void;
315
- onPointerDown: (e: ReactPointerEvent, id: string) => void;
316
- onMove: (id: string, to: ColumnId) => void;
306
+ item: WorkItem;
307
+ cardRef: (el: HTMLDivElement | null) => void;
308
+ onPointerDown: (e: ReactPointerEvent, id: string) => void;
309
+ onMove: (id: string, to: ColumnId) => void;
317
310
  }) {
318
- // Keyboard-move path for the core drag interaction: arrow keys move the
319
- // focused card across columns. The pointer DnD below stays quarantined and
320
- // untouched; this only reuses its moveItem commit.
321
- const onKeyDown = (e: ReactKeyboardEvent) => {
322
- const at = COLUMNS.findIndex(c => c.id === item.column);
323
- const to =
324
- e.key === 'ArrowRight'
325
- ? COLUMNS[Math.min(COLUMNS.length - 1, at + 1)]
326
- : e.key === 'ArrowLeft'
327
- ? COLUMNS[Math.max(0, at - 1)]
328
- : undefined;
329
- if (!to || to.id === item.column) {
330
- return;
331
- }
332
- e.preventDefault();
333
- onMove(item.id, to.id);
334
- };
335
- return (
336
- <Card
337
- ref={cardRef}
338
- padding={3}
339
- // Cards stay static per the Dracula brand (no hover lift); the floating
340
- // drag clone floats above the board with the high shadow token instead.
341
- style={{cursor: 'grab', userSelect: 'none', touchAction: 'none'}}
342
- tabIndex={0}
343
- role="button"
344
- aria-label={`${item.title}. In ${COLUMNS.find(c => c.id === item.column)?.title}. Press left or right arrow to move across columns.`}
345
- onPointerDown={e => onPointerDown(e, item.id)}
346
- onKeyDown={onKeyDown}>
347
- <BoardCardBody item={item} onMove={onMove} />
348
- </Card>
349
- );
311
+ // Keyboard-move path for the core drag interaction: arrow keys move the
312
+ // focused card across columns. The pointer DnD below stays quarantined and
313
+ // untouched; this only reuses its moveItem commit.
314
+ const onKeyDown = (e: ReactKeyboardEvent) => {
315
+ const at = COLUMNS.findIndex((c) => c.id === item.column);
316
+ const to =
317
+ e.key === "ArrowRight"
318
+ ? COLUMNS[Math.min(COLUMNS.length - 1, at + 1)]
319
+ : e.key === "ArrowLeft"
320
+ ? COLUMNS[Math.max(0, at - 1)]
321
+ : undefined;
322
+ if (!to || to.id === item.column) {
323
+ return;
324
+ }
325
+ e.preventDefault();
326
+ onMove(item.id, to.id);
327
+ };
328
+ return (
329
+ <Card
330
+ ref={cardRef}
331
+ padding={3}
332
+ // Cards stay static per the Dracula brand (no hover lift); the floating
333
+ // drag clone floats above the board with the high shadow token instead.
334
+ style={{ cursor: "grab", userSelect: "none", touchAction: "none" }}
335
+ tabIndex={0}
336
+ role="button"
337
+ aria-label={`${item.title}. In ${COLUMNS.find((c) => c.id === item.column)?.title}. Press left or right arrow to move across columns.`}
338
+ onPointerDown={(e) => onPointerDown(e, item.id)}
339
+ onKeyDown={onKeyDown}
340
+ >
341
+ <BoardCardBody item={item} onMove={onMove} />
342
+ </Card>
343
+ );
350
344
  }
351
345
 
352
346
  // ============= BOARD COLUMN =============
353
347
 
354
348
  function BoardColumn({
355
- meta,
356
- count,
357
- contentRef,
358
- children,
349
+ meta,
350
+ count,
351
+ contentRef,
352
+ children,
359
353
  }: {
360
- meta: ColumnMeta;
361
- count: number;
362
- contentRef: (el: HTMLDivElement | null) => void;
363
- children: ReactNode;
354
+ meta: ColumnMeta;
355
+ count: number;
356
+ contentRef: (el: HTMLDivElement | null) => void;
357
+ children: ReactNode;
364
358
  }) {
365
- return (
366
- <Card variant="muted" padding={0} style={{minWidth: 0, height: '100%'}}>
367
- <Layout
368
- height="fill"
369
- header={
370
- <LayoutHeader hasDivider padding={3}>
371
- <HStack hAlign="between" vAlign="center">
372
- <HStack gap={1} vAlign="center">
373
- <StatusDot
374
- variant={meta.variant}
375
- label={`${meta.title} status`}
376
- isPulsing={meta.id === 'in-progress' || meta.id === 'in-review'}
377
- />
378
- <Heading level={2}>{meta.title}</Heading>
379
- <Popover
380
- placement="below"
381
- width="min(20rem, calc(100vw - 2 * var(--space-viewport)))"
382
- label={`${meta.title} column help`}
383
- content={
384
- <Text type="body" color="secondary">
385
- {meta.tooltip}
386
- </Text>
387
- }>
388
- <IconButton
389
- label={`About the ${meta.title} column`}
390
- icon={<Icon icon={Info} size="sm" />}
391
- variant="ghost"
392
- size="sm"
393
- />
394
- </Popover>
395
- </HStack>
396
- <Text type="supporting" color="secondary" hasTabularNumbers>
397
- {count}
398
- </Text>
399
- </HStack>
400
- </LayoutHeader>
401
- }
402
- content={
403
- <LayoutContent ref={contentRef} padding={3}>
404
- {children ?? (
405
- <EmptyState
406
- isCompact
407
- // 12px, not 24: this EmptyState sits inside a LayoutContent
408
- // padding={3} (12px), so 24 would render as 36px of block air
409
- // on the empty branch against 12px on the populated one.
410
- style={{paddingBlock: 'var(--spacing-3)'}}
411
- icon={
412
- <Icon icon={meta.emptyIcon} size="lg" color="secondary" />
413
- }
414
- title={meta.emptyTitle}
415
- description={meta.emptyDescription}
416
- />
417
- )}
418
- </LayoutContent>
419
- }
420
- />
421
- </Card>
422
- );
359
+ return (
360
+ <Card variant="muted" padding={0} style={{ minWidth: 0, height: "100%" }}>
361
+ <Layout
362
+ height="fill"
363
+ header={
364
+ <LayoutHeader hasDivider padding={3}>
365
+ <HStack hAlign="between" vAlign="center">
366
+ <HStack gap={1} vAlign="center">
367
+ <StatusDot
368
+ variant={meta.variant}
369
+ label={`${meta.title} status`}
370
+ isPulsing={meta.id === "in-progress" || meta.id === "in-review"}
371
+ />
372
+ <Heading level={2}>{meta.title}</Heading>
373
+ <Popover
374
+ placement="below"
375
+ width="min(20rem, calc(100vw - 2 * var(--space-viewport)))"
376
+ label={`${meta.title} column help`}
377
+ content={
378
+ <Text type="body" color="secondary">
379
+ {meta.tooltip}
380
+ </Text>
381
+ }
382
+ >
383
+ <IconButton
384
+ label={`About the ${meta.title} column`}
385
+ icon={<Icon icon={Info} size="sm" />}
386
+ variant="ghost"
387
+ size="sm"
388
+ />
389
+ </Popover>
390
+ </HStack>
391
+ <Text type="supporting" color="secondary" hasTabularNumbers>
392
+ {count}
393
+ </Text>
394
+ </HStack>
395
+ </LayoutHeader>
396
+ }
397
+ content={
398
+ <LayoutContent ref={contentRef} padding={3}>
399
+ {children ?? (
400
+ <EmptyState
401
+ isCompact
402
+ // 12px, not 24: this EmptyState sits inside a LayoutContent
403
+ // padding={3} (12px), so 24 would render as 36px of block air
404
+ // on the empty branch against 12px on the populated one.
405
+ style={{ paddingBlock: "var(--spacing-3)" }}
406
+ icon={<Icon icon={meta.emptyIcon} size="lg" color="secondary" />}
407
+ title={meta.emptyTitle}
408
+ description={meta.emptyDescription}
409
+ />
410
+ )}
411
+ </LayoutContent>
412
+ }
413
+ />
414
+ </Card>
415
+ );
423
416
  }
424
417
 
425
418
  // ============= MAIN =============
426
419
 
427
420
  export default function KanbanBoard() {
428
- const [items, setItems] = useState<WorkItem[]>(INITIAL_ITEMS);
429
- const [sprint, setSprint] = useState('003');
430
- const [drag, setDrag] = useState<DragState | null>(null);
431
-
432
- // Live element registries for pointer hit-testing (kept out of render state).
433
- const columnEls = useRef(new Map<ColumnId, HTMLElement>());
434
- const cardEls = useRef(new Map<string, HTMLElement>());
435
- const columnRefCbs = useRef(
436
- new Map<ColumnId, (el: HTMLDivElement | null) => void>(),
437
- );
438
- const cardRefCbs = useRef(
439
- new Map<string, (el: HTMLDivElement | null) => void>(),
440
- );
441
- const teardownRef = useRef<(() => void) | null>(null);
442
-
443
- // One helper for both registries: stable per-key ref callbacks so
444
- // registering an element never churns across renders.
445
- function refCallback<K>(cbs: Map<K, (el: HTMLDivElement | null) => void>, els: Map<K, HTMLElement>, key: K) {
446
- let cb = cbs.get(key);
447
- if (!cb) {
448
- cb = el => {
449
- if (el) {
450
- els.set(key, el);
451
- } else {
452
- els.delete(key);
453
- }
454
- };
455
- cbs.set(key, cb);
456
- }
457
- return cb;
458
- }
459
-
460
- const itemsByColumn = groupByColumn(items);
461
-
462
- const moveItem = (id: string, to: ColumnId) => {
463
- setItems(prev =>
464
- prev.map(item => (item.id === id ? {...item, column: to} : item)),
465
- );
466
- };
467
-
468
- // Resolve the pointer position to a column + insertion index, ignoring the
469
- // card being dragged so the math is against the cards that stay in place.
470
- const computeTarget = (
471
- px: number,
472
- py: number,
473
- draggedId: string,
474
- ): DropTarget | null => {
475
- for (const [colId, el] of Array.from(columnEls.current.entries())) {
476
- const r = el.getBoundingClientRect();
477
- if (px < r.left || px > r.right || py < r.top || py > r.bottom) {
478
- continue;
479
- }
480
-
481
- const ids: string[] = [];
482
- for (const it of itemsByColumn[colId]) {
483
- if (it.id !== draggedId) {
484
- ids.push(it.id);
485
- }
486
- }
487
-
488
- let index = ids.length;
489
- for (let i = 0; i < ids.length; i++) {
490
- const cardEl = cardEls.current.get(ids[i]);
491
- if (!cardEl) {
492
- continue;
493
- }
494
- const cr = cardEl.getBoundingClientRect();
495
- if (py < cr.top + cr.height / 2) {
496
- index = i;
497
- break;
498
- }
499
- }
500
- return {column: colId, index};
501
- }
502
- return null;
503
- };
504
-
505
- // Rebuild the flat item list so the dragged card lands at the resolved slot
506
- // while every other card keeps its relative order.
507
- const commitDrag = (id: string, target: DropTarget) => {
508
- setItems(prev => {
509
- const moved = prev.find(it => it.id === id);
510
- if (!moved) {
511
- return prev;
512
- }
513
-
514
- const rest = prev.filter(it => it.id !== id);
515
- const updated: WorkItem = {...moved, column: target.column};
516
- const colItems = rest.filter(it => it.column === target.column);
517
- const anchor = colItems[target.index];
518
-
519
- if (!anchor) {
520
- return [...rest, updated];
521
- }
522
- const at = rest.indexOf(anchor);
523
- return [...rest.slice(0, at), updated, ...rest.slice(at)];
524
- });
525
- };
526
-
527
- const onCardPointerDown = (e: ReactPointerEvent, id: string) => {
528
- if (e.button !== 0) {
529
- return;
530
- }
531
- // Let the card's own controls (the actions menu) handle the press.
532
- if (
533
- (e.target as HTMLElement).closest(
534
- 'button, [role="menuitem"], [role="menu"]',
535
- )
536
- ) {
537
- return;
538
- }
539
-
540
- const el = cardEls.current.get(id);
541
- if (!el) {
542
- return;
543
- }
544
-
545
- const rect = el.getBoundingClientRect();
546
- const startX = e.clientX;
547
- const startY = e.clientY;
548
- const offsetX = startX - rect.left;
549
- const offsetY = startY - rect.top;
550
- const {width, height} = rect;
551
-
552
- let started = false;
553
- let target: DropTarget | null = null;
554
-
555
- const onMove = (ev: PointerEvent) => {
556
- if (
557
- !started &&
558
- Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) <
559
- DRAG_THRESHOLD
560
- ) {
561
- return;
562
- }
563
- started = true;
564
- target = computeTarget(ev.clientX, ev.clientY, id);
565
- setDrag({
566
- id,
567
- width,
568
- height,
569
- offsetX,
570
- offsetY,
571
- pointerX: ev.clientX,
572
- pointerY: ev.clientY,
573
- target,
574
- });
575
- };
576
-
577
- const onUp = () => {
578
- teardownRef.current?.();
579
- if (started && target) {
580
- commitDrag(id, target);
581
- }
582
- setDrag(null);
583
- };
584
-
585
- const teardown = () => {
586
- window.removeEventListener('pointermove', onMove);
587
- window.removeEventListener('pointerup', onUp);
588
- teardownRef.current = null;
589
- };
590
- teardownRef.current = teardown;
591
-
592
- window.addEventListener('pointermove', onMove);
593
- window.addEventListener('pointerup', onUp);
594
- };
595
-
596
- const draggedItem = drag ? items.find(it => it.id === drag.id) : undefined;
597
- const isDragging = drag !== null;
598
-
599
- // Suppress selection while dragging and detach listeners on unmount.
600
- useEffect(() => {
601
- if (!isDragging) {
602
- return;
603
- }
604
- const previous = document.body.style.userSelect;
605
- document.body.style.userSelect = 'none';
606
- return () => {
607
- document.body.style.userSelect = previous;
608
- };
609
- }, [isDragging]);
610
-
611
- useEffect(() => () => teardownRef.current?.(), []);
612
-
613
- // Card nodes for a column, or null when the column should show its empty
614
- // state. A dashed ghost box marks the landing slot during a drag.
615
- const renderColumnCards = (colId: ColumnId): ReactNode => {
616
- const colItems = itemsByColumn[colId];
617
- const visible = drag ? colItems.filter(it => it.id !== drag.id) : colItems;
618
- const ghostTarget =
619
- drag && drag.target && drag.target.column === colId ? drag : null;
620
-
621
- if (visible.length === 0 && !ghostTarget) {
622
- return null;
623
- }
624
-
625
- const nodes: ReactNode[] = visible.map(it => (
626
- <BoardCard
627
- key={it.id}
628
- item={it}
629
- cardRef={refCallback(cardRefCbs.current, cardEls.current, it.id)}
630
- onPointerDown={onCardPointerDown}
631
- onMove={moveItem}
632
- />
633
- ));
634
-
635
- if (ghostTarget && ghostTarget.target) {
636
- const index = Math.min(ghostTarget.target.index, nodes.length);
637
- nodes.splice(
638
- index,
639
- 0,
640
- <VStack
641
- key="drag-ghost"
642
- // Placeholder marking the landing slot; matches the dragged card's height.
643
- style={{
644
- height: ghostTarget.height,
645
- borderRadius: 'var(--radius-element)',
646
- backgroundColor: 'var(--color-background-muted)',
647
- }}
648
- />,
649
- );
650
- }
651
-
652
- return <VStack gap={2}>{nodes}</VStack>;
653
- };
654
-
655
- return (
656
- <Section height="100dvh">
657
- <Layout
658
- height="fill"
659
- header={
660
- <LayoutHeader hasDivider padding={6}>
661
- <Toolbar
662
- label="Board actions"
663
- gap={2}
664
- startContent={
665
- <>
666
- <Heading level={1} type="display-2">Sprint Board</Heading>
667
- <Badge label={items.length} variant="neutral" />
668
- </>
669
- }
670
- endContent={
671
- <HStack gap={2} wrap="wrap">
672
- <Selector
673
- label="Sprint"
674
- size="lg"
675
- width={160}
676
- isLabelHidden
677
- value={sprint}
678
- onChange={setSprint}
679
- options={[
680
- {value: '003', label: 'Sprint 003'},
681
- {value: '002', label: 'Sprint 002'},
682
- {value: '001', label: 'Sprint 001'},
683
- ]}
684
- />
685
- <Divider
686
- variant="strong"
687
- orientation="vertical"
688
- style={{alignSelf: 'stretch'}}
689
- />
690
- <HStack gap={1} vAlign="center" wrap="wrap">
691
- <IconButton
692
- icon={<Icon icon={ArrowUpDown} size="sm" />}
693
- label="Sort"
694
- />
695
- <IconButton
696
- icon={<Icon icon={Filter} size="sm" />}
697
- label="Filter"
698
- />
699
- <IconButton
700
- icon={<Icon icon={Search} size="sm" />}
701
- label="Search"
702
- />
703
- </HStack>
704
- <Button
705
- label="Add task"
706
- variant="primary"
707
- icon={<Icon icon={Plus} size="sm" />}
708
- />
709
- </HStack>
710
- }
711
- />
712
- </LayoutHeader>
713
- }
714
- content={
715
- <LayoutContent padding={0} isScrollable={false}>
716
- <Grid
717
- columns={{ minWidth: 280, max: 4 }}
718
- gap={4}
719
- style={{overflowX: 'auto', overflowY: 'auto', height: '100%', padding: 'var(--spacing-6)'}}
720
- tabIndex={0}
721
- role="region"
722
- aria-label="Sprint board columns">
723
- {COLUMNS.map(meta => (
724
- <BoardColumn
725
- key={meta.id}
726
- meta={meta}
727
- count={itemsByColumn[meta.id].length}
728
- contentRef={refCallback(columnRefCbs.current, columnEls.current, meta.id)}>
729
- {renderColumnCards(meta.id)}
730
- </BoardColumn>
731
- ))}
732
- </Grid>
733
- </LayoutContent>
734
- }
735
- />
736
- {drag && draggedItem ? (
737
- <Card
738
- padding={3}
739
- elevation="none"
740
- style={{
741
- position: 'fixed',
742
- insetBlockStart: 0,
743
- insetInlineStart: 0,
744
- pointerEvents: 'none',
745
- cursor: 'grabbing',
746
- zIndex: DRAG_CLONE_LAYER,
747
- width: drag.width,
748
- borderColor: 'var(--color-border-emphasized)',
749
- transform: `translate(${drag.pointerX - drag.offsetX}px, ${drag.pointerY - drag.offsetY}px)`,
750
- }}>
751
- <BoardCardBody item={draggedItem} onMove={() => {}} />
752
- </Card>
753
- ) : null}
754
- </Section>
755
- );
421
+ const [items, setItems] = useState<WorkItem[]>(INITIAL_ITEMS);
422
+ const [sprint, setSprint] = useState("003");
423
+ const [drag, setDrag] = useState<DragState | null>(null);
424
+
425
+ // Live element registries for pointer hit-testing (kept out of render state).
426
+ const columnEls = useRef(new Map<ColumnId, HTMLElement>());
427
+ const cardEls = useRef(new Map<string, HTMLElement>());
428
+ const columnRefCbs = useRef(new Map<ColumnId, (el: HTMLDivElement | null) => void>());
429
+ const cardRefCbs = useRef(new Map<string, (el: HTMLDivElement | null) => void>());
430
+ const teardownRef = useRef<(() => void) | null>(null);
431
+
432
+ // One helper for both registries: stable per-key ref callbacks so
433
+ // registering an element never churns across renders.
434
+ function refCallback<K>(
435
+ cbs: Map<K, (el: HTMLDivElement | null) => void>,
436
+ els: Map<K, HTMLElement>,
437
+ key: K,
438
+ ) {
439
+ let cb = cbs.get(key);
440
+ if (!cb) {
441
+ cb = (el) => {
442
+ if (el) {
443
+ els.set(key, el);
444
+ } else {
445
+ els.delete(key);
446
+ }
447
+ };
448
+ cbs.set(key, cb);
449
+ }
450
+ return cb;
451
+ }
452
+
453
+ const itemsByColumn = groupByColumn(items);
454
+
455
+ const moveItem = (id: string, to: ColumnId) => {
456
+ setItems((prev) => prev.map((item) => (item.id === id ? { ...item, column: to } : item)));
457
+ };
458
+
459
+ // Resolve the pointer position to a column + insertion index, ignoring the
460
+ // card being dragged so the math is against the cards that stay in place.
461
+ const computeTarget = (px: number, py: number, draggedId: string): DropTarget | null => {
462
+ for (const [colId, el] of Array.from(columnEls.current.entries())) {
463
+ const r = el.getBoundingClientRect();
464
+ if (px < r.left || px > r.right || py < r.top || py > r.bottom) {
465
+ continue;
466
+ }
467
+
468
+ const ids: string[] = [];
469
+ for (const it of itemsByColumn[colId]) {
470
+ if (it.id !== draggedId) {
471
+ ids.push(it.id);
472
+ }
473
+ }
474
+
475
+ let index = ids.length;
476
+ for (let i = 0; i < ids.length; i++) {
477
+ const cardEl = cardEls.current.get(ids[i]);
478
+ if (!cardEl) {
479
+ continue;
480
+ }
481
+ const cr = cardEl.getBoundingClientRect();
482
+ if (py < cr.top + cr.height / 2) {
483
+ index = i;
484
+ break;
485
+ }
486
+ }
487
+ return { column: colId, index };
488
+ }
489
+ return null;
490
+ };
491
+
492
+ // Rebuild the flat item list so the dragged card lands at the resolved slot
493
+ // while every other card keeps its relative order.
494
+ const commitDrag = (id: string, target: DropTarget) => {
495
+ setItems((prev) => {
496
+ const moved = prev.find((it) => it.id === id);
497
+ if (!moved) {
498
+ return prev;
499
+ }
500
+
501
+ const rest = prev.filter((it) => it.id !== id);
502
+ const updated: WorkItem = { ...moved, column: target.column };
503
+ const colItems = rest.filter((it) => it.column === target.column);
504
+ const anchor = colItems[target.index];
505
+
506
+ if (!anchor) {
507
+ return [...rest, updated];
508
+ }
509
+ const at = rest.indexOf(anchor);
510
+ return [...rest.slice(0, at), updated, ...rest.slice(at)];
511
+ });
512
+ };
513
+
514
+ const onCardPointerDown = (e: ReactPointerEvent, id: string) => {
515
+ if (e.button !== 0) {
516
+ return;
517
+ }
518
+ // Let the card's own controls (the actions menu) handle the press.
519
+ if ((e.target as HTMLElement).closest('button, [role="menuitem"], [role="menu"]')) {
520
+ return;
521
+ }
522
+
523
+ const el = cardEls.current.get(id);
524
+ if (!el) {
525
+ return;
526
+ }
527
+
528
+ const rect = el.getBoundingClientRect();
529
+ const startX = e.clientX;
530
+ const startY = e.clientY;
531
+ const offsetX = startX - rect.left;
532
+ const offsetY = startY - rect.top;
533
+ const { width, height } = rect;
534
+
535
+ let started = false;
536
+ let target: DropTarget | null = null;
537
+
538
+ const onMove = (ev: PointerEvent) => {
539
+ if (
540
+ !started &&
541
+ Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) < DRAG_THRESHOLD
542
+ ) {
543
+ return;
544
+ }
545
+ started = true;
546
+ target = computeTarget(ev.clientX, ev.clientY, id);
547
+ setDrag({
548
+ id,
549
+ width,
550
+ height,
551
+ offsetX,
552
+ offsetY,
553
+ pointerX: ev.clientX,
554
+ pointerY: ev.clientY,
555
+ target,
556
+ });
557
+ };
558
+
559
+ const onUp = () => {
560
+ teardownRef.current?.();
561
+ if (started && target) {
562
+ commitDrag(id, target);
563
+ }
564
+ setDrag(null);
565
+ };
566
+
567
+ const teardown = () => {
568
+ window.removeEventListener("pointermove", onMove);
569
+ window.removeEventListener("pointerup", onUp);
570
+ teardownRef.current = null;
571
+ };
572
+ teardownRef.current = teardown;
573
+
574
+ window.addEventListener("pointermove", onMove);
575
+ window.addEventListener("pointerup", onUp);
576
+ };
577
+
578
+ const draggedItem = drag ? items.find((it) => it.id === drag.id) : undefined;
579
+ const isDragging = drag !== null;
580
+
581
+ // Suppress selection while dragging and detach listeners on unmount.
582
+ useEffect(() => {
583
+ if (!isDragging) {
584
+ return;
585
+ }
586
+ const previous = document.body.style.userSelect;
587
+ document.body.style.userSelect = "none";
588
+ return () => {
589
+ document.body.style.userSelect = previous;
590
+ };
591
+ }, [isDragging]);
592
+
593
+ useEffect(() => () => teardownRef.current?.(), []);
594
+
595
+ // Card nodes for a column, or null when the column should show its empty
596
+ // state. A dashed ghost box marks the landing slot during a drag.
597
+ const renderColumnCards = (colId: ColumnId): ReactNode => {
598
+ const colItems = itemsByColumn[colId];
599
+ const visible = drag ? colItems.filter((it) => it.id !== drag.id) : colItems;
600
+ const ghostTarget = drag?.target && drag.target.column === colId ? drag : null;
601
+
602
+ if (visible.length === 0 && !ghostTarget) {
603
+ return null;
604
+ }
605
+
606
+ const nodes: ReactNode[] = visible.map((it) => (
607
+ <BoardCard
608
+ key={it.id}
609
+ item={it}
610
+ cardRef={refCallback(cardRefCbs.current, cardEls.current, it.id)}
611
+ onPointerDown={onCardPointerDown}
612
+ onMove={moveItem}
613
+ />
614
+ ));
615
+
616
+ if (ghostTarget?.target) {
617
+ const index = Math.min(ghostTarget.target.index, nodes.length);
618
+ nodes.splice(
619
+ index,
620
+ 0,
621
+ <VStack
622
+ key="drag-ghost"
623
+ // Placeholder marking the landing slot; matches the dragged card's height.
624
+ style={{
625
+ height: ghostTarget.height,
626
+ borderRadius: "var(--radius-element)",
627
+ backgroundColor: "var(--color-background-muted)",
628
+ }}
629
+ />,
630
+ );
631
+ }
632
+
633
+ return <VStack gap={2}>{nodes}</VStack>;
634
+ };
635
+
636
+ return (
637
+ <Section height="100dvh">
638
+ <Layout
639
+ height="fill"
640
+ header={
641
+ <LayoutHeader hasDivider padding={6}>
642
+ <Toolbar
643
+ label="Board actions"
644
+ gap={2}
645
+ startContent={
646
+ <>
647
+ <Heading level={1} type="display-2">
648
+ Sprint Board
649
+ </Heading>
650
+ <Badge label={items.length} variant="neutral" />
651
+ </>
652
+ }
653
+ endContent={
654
+ <HStack gap={2} wrap="wrap">
655
+ <Selector
656
+ label="Sprint"
657
+ size="lg"
658
+ width={160}
659
+ isLabelHidden
660
+ value={sprint}
661
+ onChange={setSprint}
662
+ options={[
663
+ { value: "003", label: "Sprint 003" },
664
+ { value: "002", label: "Sprint 002" },
665
+ { value: "001", label: "Sprint 001" },
666
+ ]}
667
+ />
668
+ <Divider
669
+ variant="strong"
670
+ orientation="vertical"
671
+ style={{ alignSelf: "stretch" }}
672
+ />
673
+ <HStack gap={1} vAlign="center" wrap="wrap">
674
+ <IconButton icon={<Icon icon={ArrowUpDown} size="sm" />} label="Sort" />
675
+ <IconButton icon={<Icon icon={Filter} size="sm" />} label="Filter" />
676
+ <IconButton icon={<Icon icon={Search} size="sm" />} label="Search" />
677
+ </HStack>
678
+ <Button
679
+ label="Add task"
680
+ variant="primary"
681
+ icon={<Icon icon={Plus} size="sm" />}
682
+ />
683
+ </HStack>
684
+ }
685
+ />
686
+ </LayoutHeader>
687
+ }
688
+ content={
689
+ <LayoutContent padding={0} isScrollable={false}>
690
+ <Grid
691
+ columns={{ minWidth: 280, max: 4 }}
692
+ gap={4}
693
+ style={{
694
+ overflowX: "auto",
695
+ overflowY: "auto",
696
+ height: "100%",
697
+ padding: "var(--spacing-6)",
698
+ }}
699
+ tabIndex={0}
700
+ role="region"
701
+ aria-label="Sprint board columns"
702
+ >
703
+ {COLUMNS.map((meta) => (
704
+ <BoardColumn
705
+ key={meta.id}
706
+ meta={meta}
707
+ count={itemsByColumn[meta.id].length}
708
+ contentRef={refCallback(columnRefCbs.current, columnEls.current, meta.id)}
709
+ >
710
+ {renderColumnCards(meta.id)}
711
+ </BoardColumn>
712
+ ))}
713
+ </Grid>
714
+ </LayoutContent>
715
+ }
716
+ />
717
+ {drag && draggedItem ? (
718
+ <Card
719
+ padding={3}
720
+ elevation="none"
721
+ style={{
722
+ position: "fixed",
723
+ insetBlockStart: 0,
724
+ insetInlineStart: 0,
725
+ pointerEvents: "none",
726
+ cursor: "grabbing",
727
+ zIndex: DRAG_CLONE_LAYER,
728
+ width: drag.width,
729
+ borderColor: "var(--color-border-emphasized)",
730
+ transform: `translate(${drag.pointerX - drag.offsetX}px, ${drag.pointerY - drag.offsetY}px)`,
731
+ }}
732
+ >
733
+ <BoardCardBody item={draggedItem} onMove={() => {}} />
734
+ </Card>
735
+ ) : null}
736
+ </Section>
737
+ );
756
738
  }