@astryxdesign/cli 0.1.2-canary.04e71b2 → 0.1.2-canary.05be2c6

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.
@@ -0,0 +1,90 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('../../core/src/docs-types').ReferenceTranslationDoc} */
4
+
5
+ export const docsDense = {
6
+ description:
7
+ 'frame-first app layout: shell choice, region budgets, cards vs rows',
8
+ sections: [
9
+ {
10
+ title: 'Frame First',
11
+ content: [
12
+ {
13
+ type: 'prose',
14
+ text: 'decide frame before content. content-first (Card-wrapped sections in a scroll column) = prototype look.',
15
+ },
16
+ {
17
+ type: 'list',
18
+ items: [
19
+ 'pick frame: AppShell (nav apps) | Layout+LayoutPanel+LayoutContent (multi-pane tools) | plain column (docs/forms)',
20
+ 'budget regions in px first: side nav 240-280, rail 64-72, inspector 340-420, facet rail 220-260',
21
+ 'container policy per region: dense data = rows; dashboards/galleries = card grids',
22
+ 'write responsive contract up front',
23
+ ],
24
+ },
25
+ null,
26
+ ],
27
+ },
28
+ {
29
+ title: 'App Archetypes',
30
+ content: [
31
+ {
32
+ type: 'prose',
33
+ text: 'container choice tracks archetype, not preference.',
34
+ },
35
+ null,
36
+ {
37
+ type: 'prose',
38
+ text: 'start from matching template (astryx template --list), study with --skeleton.',
39
+ },
40
+ ],
41
+ },
42
+ {
43
+ title: 'Cards vs Rows',
44
+ content: [
45
+ {
46
+ type: 'prose',
47
+ text: 'Card = widget container, NOT list-item wrapper. dense/scannable/selectable data = rows: Table (columnar) or List/Item (single-line), edge-to-edge, 32-40px rows, dividers.',
48
+ },
49
+ {
50
+ type: 'list',
51
+ items: [
52
+ 'Table+plugins: hosts, deployments, monitors, users',
53
+ 'List/Item rows: issues, files, conversations',
54
+ 'Card: KPI tiles, chart panels, gallery entries, settings groups',
55
+ 'EmptyState for zero-match',
56
+ ],
57
+ },
58
+ {
59
+ type: 'list',
60
+ items: [
61
+ 'no Card-wrapped list items (card soup)',
62
+ 'no stacked full-width Cards as page structure',
63
+ 'no Cards in Cards',
64
+ 'no decorative Badge — counts/enums only; StatusDot/Token for status',
65
+ ],
66
+ },
67
+ ],
68
+ },
69
+ {
70
+ title: 'Panels and Inspectors',
71
+ content: [
72
+ {
73
+ type: 'prose',
74
+ text: 'master-detail: row select opens fixed-width inspector (LayoutPanel end slot + width budget + resizable/useResizable). overlay content <=1024px, do not compress.',
75
+ },
76
+ null,
77
+ ],
78
+ },
79
+ {
80
+ title: 'Responsive Contract',
81
+ content: [
82
+ {
83
+ type: 'prose',
84
+ text: 'declare breakpoint behavior as comment at frame root: which regions collapse/overlay/drop at which widths.',
85
+ },
86
+ null,
87
+ ],
88
+ },
89
+ ],
90
+ };
@@ -0,0 +1,160 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('../../core/src/docs-types').ReferenceDoc} */
4
+
5
+ export const docs = {
6
+ name: 'layout',
7
+ title: 'Layout',
8
+ category: 'guide',
9
+ description:
10
+ 'Frame-first app layout: choosing a shell, budgeting regions, and when to use cards vs rows.',
11
+
12
+ sections: [
13
+ {
14
+ title: 'Frame First',
15
+ content: [
16
+ {
17
+ type: 'prose',
18
+ text: 'Decide the frame before writing any content. Real applications are built top-down: pick the shell, name its regions, give each region an explicit size budget, then fill regions with content. Content-first layout (writing sections and wrapping each one in a Card) produces a padded scroll column that reads as a prototype, not a product.',
19
+ },
20
+ {
21
+ type: 'list',
22
+ style: 'ordered',
23
+ items: [
24
+ 'Pick the frame: AppShell (top nav and/or side nav apps), Layout + LayoutPanel + LayoutContent (multi-pane tools like explorers and consoles), or a plain content column (documents, marketing, forms)',
25
+ 'Budget regions in px before filling them: side nav 240–280, icon rail 64–72, detail/inspector panel 340–420, filter/facet rail 220–260',
26
+ 'Decide the container policy per region: dense data renders as rows; widget dashboards and galleries render as card grids (see Cards vs Rows)',
27
+ 'Write the responsive contract up front: which regions collapse, overlay, or drop at which breakpoints (see Responsive Contract)',
28
+ ],
29
+ },
30
+ {
31
+ type: 'code',
32
+ lang: 'tsx',
33
+ label: 'A three-region tool frame',
34
+ code: `// Frame: nav 256 | content flex | inspector 380 (resizable)
35
+ <AppShell sideNav={<SideNav>{/* nav items */}</SideNav>} contentPadding={0}>
36
+ <Layout>
37
+ <LayoutContent>{/* dense list or table, edge-to-edge */}</LayoutContent>
38
+ <LayoutPanel width={380} resizable={{minSizePx: 320, maxSizePx: 480}} hasDivider>
39
+ {/* inspector for the selected row */}
40
+ </LayoutPanel>
41
+ </Layout>
42
+ </AppShell>`,
43
+ },
44
+ ],
45
+ },
46
+ {
47
+ title: 'App Archetypes',
48
+ content: [
49
+ {
50
+ type: 'prose',
51
+ text: 'Match the frame and container policy to the kind of app you are building. These recipes are distilled from product-scale apps built with the design system; container choice tracks the archetype, not personal preference.',
52
+ },
53
+ {
54
+ type: 'table',
55
+ headers: ['Archetype', 'Frame', 'Container policy'],
56
+ rows: [
57
+ [
58
+ 'Tracker / work tool (issues, tickets, CRM)',
59
+ 'AppShell + SideNav; inspector LayoutPanel on select',
60
+ 'Rows only. Grouped edge-to-edge lists, zero cards',
61
+ ],
62
+ [
63
+ 'Console / observability (metrics, logs, deploys)',
64
+ 'AppShell + SideNav or TopNav + TabList',
65
+ 'Card grid for dashboard widgets; Table for everything else',
66
+ ],
67
+ [
68
+ 'Messaging / feed',
69
+ 'Column frame: rail + sidebar + stream + panel',
70
+ 'Rows and bubbles. No cards in the stream',
71
+ ],
72
+ [
73
+ 'Media library / gallery',
74
+ 'AppShell + TopNav; grid content',
75
+ 'Card grid (ClickableCard) with dense metadata rows in detail views',
76
+ ],
77
+ [
78
+ 'Settings / forms',
79
+ 'AppShell + SideNav or settings template',
80
+ 'Sections with FormLayout; Card only to group dangerous or billing actions',
81
+ ],
82
+ ],
83
+ },
84
+ {
85
+ type: 'prose',
86
+ text: 'Start from a template that matches the archetype (`npx astryx template --list`), then study its structure with `--skeleton` before customizing.',
87
+ },
88
+ ],
89
+ },
90
+ {
91
+ title: 'Cards vs Rows',
92
+ content: [
93
+ {
94
+ type: 'prose',
95
+ text: 'Card is a widget container, not a list-item wrapper. The fastest way to make an app look like a generic AI prototype is to wrap every record in a Card with a Badge. Dense data — anything the user scans, filters, or selects — belongs in rows: Table for columnar data, List/Item for single-line records, edge-to-edge with dividers and 32–40px row height.',
96
+ },
97
+ {
98
+ type: 'list',
99
+ style: 'do',
100
+ items: [
101
+ 'Table (with selection/sorting plugins) for columnar records: hosts, deployments, monitors, users',
102
+ 'List/Item rows for scannable single-line records: issues, files, conversations',
103
+ 'Card for self-contained widgets: KPI tiles, chart panels, gallery entries, settings groups',
104
+ 'EmptyState inside the region when a filter matches nothing',
105
+ ],
106
+ },
107
+ {
108
+ type: 'list',
109
+ style: 'dont',
110
+ items: [
111
+ 'Wrapping each list item in a Card (card soup)',
112
+ 'Stacking full-width Cards as a substitute for page structure',
113
+ 'Nesting Cards inside Cards',
114
+ 'Using Badge as decoration — reserve it for counts and enumerated states; use StatusDot or Token for status and metadata',
115
+ ],
116
+ },
117
+ ],
118
+ },
119
+ {
120
+ title: 'Panels and Inspectors',
121
+ content: [
122
+ {
123
+ type: 'prose',
124
+ text: 'Master-detail is the backbone of tool UIs: selecting a row opens a fixed-width inspector panel rather than navigating away. Use LayoutPanel in the end slot with an explicit width budget; add resizable (useResizable) for user control, and let the panel overlay the content region below ~1024px instead of compressing it.',
125
+ },
126
+ {
127
+ type: 'code',
128
+ lang: 'tsx',
129
+ label: 'Inspector that overlays at narrow widths',
130
+ code: `<LayoutPanel
131
+ width={380}
132
+ hasDivider
133
+ isScrollable
134
+ label="Details"
135
+ resizable={{minSizePx: 320, maxSizePx: 480, autoSaveId: 'inspector'}}>
136
+ {selected ? <DetailFields item={selected} /> : <EmptyState title="Nothing selected" />}
137
+ </LayoutPanel>`,
138
+ },
139
+ ],
140
+ },
141
+ {
142
+ title: 'Responsive Contract',
143
+ content: [
144
+ {
145
+ type: 'prose',
146
+ text: 'Declare breakpoint behavior as a contract before building, and keep it in a comment at the frame root. A typical contract: full frame above 1024px; inspector panels overlay the content column at 1024px and below; the side nav collapses into MobileNav at 768px and below. Deciding this up front keeps every region change intentional instead of emergent.',
147
+ },
148
+ {
149
+ type: 'code',
150
+ lang: 'tsx',
151
+ label: 'Contract comment at the frame root',
152
+ code: `// Responsive contract:
153
+ // > 1024px nav 256 | content | inspector 380
154
+ // <= 1024px inspector overlays content (position: absolute, end-aligned)
155
+ // <= 768px nav collapses into MobileNav drawer; toolbar actions wrap`,
156
+ },
157
+ ],
158
+ },
159
+ ],
160
+ };
@@ -6,9 +6,9 @@ export const docsDense = {
6
6
  description: 'core design principles + rules for the design system',
7
7
  sections: [
8
8
  { title: 'Philosophy', content: [{ type: 'list', items: ['components over primitives', 'semantic tokens over hardcoded values', 'theme-agnostic code', 'open internals'] }] },
9
- { title: 'Rules', content: [{ type: 'list', items: ['use components', 'StyleX or Tailwind for styling', 'semantic tokens only', 'CSS vars for colors', 'controlled form inputs', 'useLinkComponent() for navigation'] }] },
9
+ { title: 'Rules', content: [{ type: 'list', items: ['use components', 'frame-first layout: shell + region budgets before content (astryx docs layout)', 'dense data = rows (Table, List/Item) not Cards; Card = widgets/galleries/settings groups', 'StyleX or Tailwind for styling', 'semantic tokens only', 'CSS vars for colors', 'controlled form inputs', 'useLinkComponent() for navigation'] }] },
10
10
  { title: 'Styling', content: [{ type: 'prose', text: 'xstyle prop for component overrides. StyleX or Tailwind for layout. See astryx docs styling.' }] },
11
- { title: 'Anti-Patterns', content: [{ type: 'list', items: ['no inline styles on raw elements', 'no hardcoded colors — use tokens or Tailwind semantic classes', 'no hardcoded spacing', 'no hardcoded <a> — use useLinkComponent()', 'read docs before inventing props'] }] },
11
+ { title: 'Anti-Patterns', content: [{ type: 'list', items: ['no inline styles on raw elements', 'no hardcoded colors — use tokens or Tailwind semantic classes', 'no hardcoded spacing', 'no hardcoded <a> — use useLinkComponent()', 'no Card-wrapped list items — frame first, rows for dense data (astryx docs layout)', 'no decorative Badge — StatusDot/Token for status', 'read docs before inventing props'] }] },
12
12
  { title: 'Tokens', content: [{ type: 'prose', text: 'run npx astryx docs tokens for full reference' }] },
13
13
  ],
14
14
  };
@@ -39,6 +39,8 @@ export const docs = {
39
39
  style: 'ordered',
40
40
  items: [
41
41
  'Use components for everything they cover',
42
+ 'Layout is frame-first: pick the shell and budget regions before writing content (see \`npx astryx docs layout\`)',
43
+ 'Dense data renders as rows (Table, List/Item), edge-to-edge with dividers; Card is for widgets, galleries, and settings groups',
42
44
  'StyleX or Tailwind for custom styling; both are first-class (see \`npx astryx docs styling\`)',
43
45
  'Semantic tokens, not hardcoded values (see \`npx astryx docs tokens\`)',
44
46
  'CSS custom properties for colors, not hex values',
@@ -74,6 +76,8 @@ export const docs = {
74
76
  'Hardcoded colors (#fff). Use var(--color-*) or Tailwind semantic classes (text-primary, bg-surface)',
75
77
  'Hardcoded spacing (16px). Use spacing tokens or Tailwind spacing utilities',
76
78
  'Hardcoded <a> elements. Use useLinkComponent() so consumers can swap in their framework router via LinkProvider',
79
+ 'Wrapping every list item or page section in a Card. Decide the frame first; dense data renders as rows (see \`npx astryx docs layout\`)',
80
+ 'Badge as decoration. Reserve Badge for counts and enumerated states; use StatusDot or Token for status',
77
81
  'Inventing props. Read component docs first',
78
82
  ],
79
83
  },
@@ -6,9 +6,9 @@ export const docsZh = {
6
6
  description: 'XDS 核心设计原则和规则。',
7
7
  sections: [
8
8
  { title: '设计哲学', content: [{ type: 'list', items: ['组件优于原始元素 — 优先使用 XDS 组件', '语义化令牌优于硬编码值', '主题无关的代码 — 深色模式自动生效', '开放的内部机制 — 所有基础组件均可导出和组合'] }] },
9
- { title: '规则', content: [{ type: 'list', items: ['所有支持的场景都使用 XDS 组件', '使用 StyleX 或 Tailwind 进行样式设置', '使用语义化令牌,不使用硬编码值', '使用 CSS 变量设置颜色,不使用十六进制值', '表单输入为受控组件(value + onChange)', '使用 useLinkComponent() 进行导航'] }] },
9
+ { title: '规则', content: [{ type: 'list', items: ['所有支持的场景都使用 XDS 组件', '布局采用框架优先:先选定外壳并规划区域尺寸,再编写内容(见 astryx docs layout)', '密集数据使用行(Table、List/Item)通栏渲染;Card 用于小部件、画廊和设置分组', '使用 StyleX 或 Tailwind 进行样式设置', '使用语义化令牌,不使用硬编码值', '使用 CSS 变量设置颜色,不使用十六进制值', '表单输入为受控组件(value + onChange)', '使用 useLinkComponent() 进行导航'] }] },
10
10
  { title: '样式方法', content: [{ type: 'prose', text: '组件覆盖使用 xstyle 属性。布局使用 StyleX 或 Tailwind。详见 astryx docs styling。' }] },
11
- { title: '反模式', content: [{ type: 'list', items: ['不要在原始元素上使用内联样式', '不要硬编码颜色 — 使用令牌或 Tailwind 语义类', '不要硬编码间距', '不要硬编码 <a> 元素 — 使用 useLinkComponent()', '不要自创属性。先阅读组件文档'] }] },
11
+ { title: '反模式', content: [{ type: 'list', items: ['不要在原始元素上使用内联样式', '不要硬编码颜色 — 使用令牌或 Tailwind 语义类', '不要硬编码间距', '不要硬编码 <a> 元素 — 使用 useLinkComponent()', '不要把每个列表项都包在 Card 里 — 先定框架,密集数据用行渲染(见 astryx docs layout)', '不要把 Badge 当装饰 — 状态请使用 StatusDot 或 Token', '不要自创属性。先阅读组件文档'] }] },
12
12
  { title: '设计令牌', content: [{ type: 'prose', text: '运行 npx astryx docs tokens 查看完整参考' }] },
13
13
  ],
14
14
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/cli",
3
- "version": "0.1.2-canary.04e71b2",
3
+ "version": "0.1.2-canary.05be2c6",
4
4
  "displayName": "CLI",
5
5
  "description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
6
6
  "author": "Meta Open Source",
@@ -75,9 +75,9 @@
75
75
  "zod": "^4.4.3"
76
76
  },
77
77
  "peerDependencies": {
78
- "@astryxdesign/core": "0.1.2-canary.04e71b2",
79
- "@astryxdesign/lab": "0.1.2-canary.04e71b2",
80
- "@astryxdesign/theme-neutral": "0.1.2-canary.04e71b2",
78
+ "@astryxdesign/core": "0.1.2-canary.05be2c6",
79
+ "@astryxdesign/lab": "0.1.2-canary.05be2c6",
80
+ "@astryxdesign/theme-neutral": "0.1.2-canary.05be2c6",
81
81
  "gpt-tokenizer": "^2.0.0"
82
82
  },
83
83
  "peerDependenciesMeta": {
@@ -92,9 +92,9 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@astryxdesign/core": "0.1.2-canary.04e71b2",
96
- "@astryxdesign/lab": "0.1.2-canary.04e71b2",
97
- "@astryxdesign/theme-neutral": "0.1.2-canary.04e71b2",
95
+ "@astryxdesign/core": "0.1.2-canary.05be2c6",
96
+ "@astryxdesign/lab": "0.1.2-canary.05be2c6",
97
+ "@astryxdesign/theme-neutral": "0.1.2-canary.05be2c6",
98
98
  "gpt-tokenizer": "^2.0.0"
99
99
  },
100
100
  "scripts": {
@@ -180,6 +180,9 @@ export function generateCompressedIndex(version, {coreDir, runPrefix = getRunPre
180
180
  // Rules — the top error-preventers.
181
181
  lines.push('RULES:');
182
182
  lines.push('- No <div> — components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav.');
183
+ lines.push('- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (`astryx docs layout`).');
184
+ lines.push('- Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only.');
185
+ lines.push('- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration.');
183
186
  // Styling guidance tailored to the project's configured system — never
184
187
  // recommend a path that isn't compiled here (xstyle needs the StyleX compiler;
185
188
  // utilities need Tailwind). Tokens are always the source of truth.
@@ -0,0 +1,729 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ import {
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type PointerEvent as ReactPointerEvent,
11
+ type ReactNode,
12
+ } from 'react';
13
+ import * as stylex from '@stylexjs/stylex';
14
+
15
+ import {
16
+ Layout,
17
+ LayoutHeader,
18
+ LayoutContent,
19
+ HStack,
20
+ VStack,
21
+ } from '@astryxdesign/core/Layout';
22
+ import {Text, Heading} from '@astryxdesign/core/Text';
23
+ import {Card} from '@astryxdesign/core/Card';
24
+ import {Badge} from '@astryxdesign/core/Badge';
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 {Tooltip} from '@astryxdesign/core/Tooltip';
33
+ import {Divider} from '@astryxdesign/core/Divider';
34
+ import {Toolbar} from '@astryxdesign/core/Toolbar';
35
+ import {Section} from '@astryxdesign/core/Section';
36
+
37
+ import {
38
+ PlusIcon,
39
+ MagnifyingGlassIcon,
40
+ ArrowsUpDownIcon,
41
+ FunnelIcon,
42
+ ArrowPathIcon,
43
+ CheckCircleIcon,
44
+ InboxIcon,
45
+ InformationCircleIcon,
46
+ ClipboardDocumentCheckIcon,
47
+ } from '@heroicons/react/24/outline';
48
+
49
+ // ============= TYPES =============
50
+
51
+ type ColumnId = 'todo' | 'in-progress' | 'in-review' | 'done';
52
+ type Priority = 'high' | 'medium' | 'low';
53
+
54
+ interface WorkItem {
55
+ id: string;
56
+ column: ColumnId;
57
+ ref: string;
58
+ priority: Priority;
59
+ title: string;
60
+ description: string;
61
+ lastEdited: string;
62
+ dueDate: string;
63
+ }
64
+
65
+ interface ColumnMeta {
66
+ id: ColumnId;
67
+ title: string;
68
+ variant: 'neutral' | 'accent' | 'warning' | 'success';
69
+ tooltip: string;
70
+ emptyTitle: string;
71
+ emptyDescription: string;
72
+ emptyIcon: typeof InboxIcon;
73
+ }
74
+
75
+ // Where a dragged card will land: a column and an insertion index within it
76
+ // (measured against the cards remaining after the dragged card is removed).
77
+ interface DropTarget {
78
+ column: ColumnId;
79
+ index: number;
80
+ }
81
+
82
+ // Live state of an in-progress pointer drag. Coordinates are in viewport space.
83
+ interface DragState {
84
+ id: string;
85
+ width: number;
86
+ height: number;
87
+ offsetX: number;
88
+ offsetY: number;
89
+ pointerX: number;
90
+ pointerY: number;
91
+ target: DropTarget | null;
92
+ }
93
+
94
+ // ============= DATA =============
95
+
96
+ const COLUMNS: ColumnMeta[] = [
97
+ {
98
+ id: 'todo',
99
+ title: 'To-do',
100
+ variant: 'neutral',
101
+ tooltip: 'Items assigned to this sprint, waiting to be picked up.',
102
+ emptyTitle: 'To-do is empty',
103
+ emptyDescription: 'Items pulled into this sprint appear here.',
104
+ emptyIcon: InboxIcon,
105
+ },
106
+ {
107
+ id: 'in-progress',
108
+ title: 'In progress',
109
+ variant: 'accent',
110
+ tooltip: 'Items currently in progress.',
111
+ emptyTitle: 'Nothing in progress',
112
+ emptyDescription: 'Items being worked on appear here.',
113
+ emptyIcon: ArrowPathIcon,
114
+ },
115
+ {
116
+ id: 'in-review',
117
+ title: 'In review',
118
+ variant: 'warning',
119
+ tooltip: 'Items waiting for your review.',
120
+ emptyTitle: 'Nothing in review',
121
+ emptyDescription: 'Items awaiting your review appear here.',
122
+ emptyIcon: ClipboardDocumentCheckIcon,
123
+ },
124
+ {
125
+ id: 'done',
126
+ title: 'Done',
127
+ variant: 'success',
128
+ tooltip: 'Items that have been handled.',
129
+ emptyTitle: 'Nothing done yet',
130
+ emptyDescription: 'Completed items appear here.',
131
+ emptyIcon: CheckCircleIcon,
132
+ },
133
+ ];
134
+
135
+ const PRIORITY_META: Record<
136
+ Priority,
137
+ {label: string; variant: 'error' | 'warning' | 'teal'}
138
+ > = {
139
+ high: {label: 'High', variant: 'error'},
140
+ medium: {label: 'Medium', variant: 'warning'},
141
+ low: {label: 'Low', variant: 'teal'},
142
+ };
143
+
144
+ const INITIAL_ITEMS: WorkItem[] = [
145
+ {
146
+ id: 't1',
147
+ column: 'todo',
148
+ ref: 'Task 4821',
149
+ priority: 'low',
150
+ title: 'Draft project kickoff brief',
151
+ description:
152
+ 'Write a short brief outlining goals, scope, and success criteria for the upcoming project.',
153
+ lastEdited: '2h ago',
154
+ dueDate: 'Jul 8',
155
+ },
156
+ {
157
+ id: 't2',
158
+ column: 'todo',
159
+ ref: 'Task 4842',
160
+ priority: 'low',
161
+ title: 'Collect feedback from stakeholders',
162
+ description:
163
+ 'Gather input from key stakeholders and summarize the main themes for the next review.',
164
+ lastEdited: '1d ago',
165
+ dueDate: 'Jul 11',
166
+ },
167
+ {
168
+ id: 'p1',
169
+ column: 'in-progress',
170
+ ref: 'Task 4825',
171
+ priority: 'high',
172
+ title: 'Design the landing page layout',
173
+ description:
174
+ 'Create a first-pass layout for the landing page and share it for early feedback.',
175
+ lastEdited: '18m ago',
176
+ dueDate: 'Jul 3',
177
+ },
178
+ {
179
+ id: 'p2',
180
+ column: 'in-progress',
181
+ ref: 'Task 4833',
182
+ priority: 'medium',
183
+ title: 'Set up the project workspace',
184
+ description:
185
+ 'Configure the shared workspace and invite the team so everyone has access.',
186
+ lastEdited: '5m ago',
187
+ dueDate: 'Jul 4',
188
+ },
189
+ {
190
+ id: 'r1',
191
+ column: 'done',
192
+ ref: 'Task 4788',
193
+ priority: 'low',
194
+ title: 'Write the weekly status update',
195
+ description:
196
+ 'Summarize progress, blockers, and next steps in a short update for the team.',
197
+ lastEdited: 'Yesterday',
198
+ dueDate: 'Jul 1',
199
+ },
200
+ {
201
+ id: 'r2',
202
+ column: 'done',
203
+ ref: 'Task 4789',
204
+ priority: 'high',
205
+ title: 'Prepare the demo walkthrough',
206
+ description:
207
+ 'Put together a short walkthrough covering the main features for the demo.',
208
+ lastEdited: '3d ago',
209
+ dueDate: 'Jun 30',
210
+ },
211
+ {
212
+ id: 'r3',
213
+ column: 'done',
214
+ ref: 'Task 4790',
215
+ priority: 'medium',
216
+ title: 'Review and merge open changes',
217
+ description:
218
+ 'Go through the pending changes, leave comments, and merge the ones that are ready.',
219
+ lastEdited: '4d ago',
220
+ dueDate: 'Jun 28',
221
+ },
222
+ ];
223
+
224
+ // Pointer travel (px) before a press is promoted to a drag, so taps and clicks
225
+ // on card controls still register normally.
226
+ const DRAG_THRESHOLD = 5;
227
+
228
+ // Shared width for every board column, so they stay visually aligned.
229
+ const COLUMN_WIDTH = 300;
230
+
231
+ // ============= STYLES =============
232
+
233
+ const styles = stylex.create({
234
+ boardColumns: {
235
+ overflowX: 'auto',
236
+ overflowY: 'hidden',
237
+ height: '100%',
238
+ padding: 'var(--spacing-4)',
239
+ },
240
+ columnShell: {
241
+ flexShrink: 0,
242
+ flexBasis: COLUMN_WIDTH,
243
+ height: '100%',
244
+ },
245
+ card: {
246
+ cursor: 'grab',
247
+ userSelect: 'none',
248
+ touchAction: 'none',
249
+ transition: 'box-shadow 120ms ease',
250
+ ':hover': {
251
+ boxShadow: 'var(--shadow-med)',
252
+ },
253
+ },
254
+ // The dragged card is lifted out of flow and follows the pointer. It ignores
255
+ // pointer events so hit-testing reads the columns underneath it.
256
+ floating: {
257
+ position: 'fixed',
258
+ insetBlockStart: 0,
259
+ insetInlineStart: 0,
260
+ pointerEvents: 'none',
261
+ cursor: 'grabbing',
262
+ boxShadow: 'var(--shadow-high)',
263
+ zIndex: 1000,
264
+ },
265
+ floatingAt: (x: number, y: number, width: number) => ({
266
+ width,
267
+ transform: `translate(${x}px, ${y}px)`,
268
+ }),
269
+ // Placeholder marking the landing slot; matches the dragged card's height.
270
+ ghost: (height: number) => ({
271
+ height,
272
+ borderRadius: 'var(--radius-container)',
273
+ backgroundColor: 'var(--color-background-muted)',
274
+ }),
275
+ toolbarDivider: {
276
+ height: 'auto',
277
+ marginBlock: 'var(--spacing-1)',
278
+ alignSelf: 'stretch',
279
+ },
280
+ columnEmptyState: {
281
+ paddingBlock: 'var(--spacing-10)',
282
+ },
283
+ });
284
+
285
+ // ============= CARD BODY =============
286
+
287
+ // Shared card contents, rendered both in the column list and inside the
288
+ // floating drag clone so the two stay pixel-identical.
289
+ function BoardCardBody({
290
+ item,
291
+ onMove,
292
+ }: {
293
+ item: WorkItem;
294
+ onMove: (id: string, to: ColumnId) => void;
295
+ }) {
296
+ const priority = PRIORITY_META[item.priority];
297
+ const moveTargets = COLUMNS.filter(c => c.id !== item.column).map(c => ({
298
+ label: `Move to ${c.title}`,
299
+ onClick: () => onMove(item.id, c.id),
300
+ }));
301
+
302
+ return (
303
+ <VStack gap={2}>
304
+ <HStack hAlign="between" vAlign="start">
305
+ <HStack gap={1} vAlign="center" wrap="wrap">
306
+ <Badge label={item.ref} variant="neutral" />
307
+ <Badge label={priority.label} variant={priority.variant} />
308
+ </HStack>
309
+ <MoreMenu
310
+ label="Work item actions"
311
+ size="sm"
312
+ items={[
313
+ {label: 'Open', onClick: () => {}},
314
+ {label: 'Assign to me', onClick: () => {}},
315
+ {type: 'divider'},
316
+ ...moveTargets,
317
+ ]}
318
+ />
319
+ </HStack>
320
+
321
+ <VStack gap={1}>
322
+ <Heading level={4}>{item.title}</Heading>
323
+ <Text type="supporting" color="secondary" maxLines={2}>
324
+ {item.description}
325
+ </Text>
326
+ </VStack>
327
+
328
+ <Text type="supporting" color="secondary">
329
+ Edited {item.lastEdited} · Due {item.dueDate}
330
+ </Text>
331
+ </VStack>
332
+ );
333
+ }
334
+
335
+ // ============= BOARD CARD =============
336
+
337
+ function BoardCard({
338
+ item,
339
+ cardRef,
340
+ onPointerDown,
341
+ onMove,
342
+ }: {
343
+ item: WorkItem;
344
+ cardRef: (el: HTMLDivElement | null) => void;
345
+ onPointerDown: (e: ReactPointerEvent, id: string) => void;
346
+ onMove: (id: string, to: ColumnId) => void;
347
+ }) {
348
+ return (
349
+ <Card
350
+ ref={cardRef}
351
+ padding={3}
352
+ xstyle={styles.card}
353
+ onPointerDown={e => onPointerDown(e, item.id)}>
354
+ <BoardCardBody item={item} onMove={onMove} />
355
+ </Card>
356
+ );
357
+ }
358
+
359
+ // ============= BOARD COLUMN =============
360
+
361
+ function BoardColumn({
362
+ meta,
363
+ count,
364
+ contentRef,
365
+ children,
366
+ }: {
367
+ meta: ColumnMeta;
368
+ count: number;
369
+ contentRef: (el: HTMLDivElement | null) => void;
370
+ children: ReactNode;
371
+ }) {
372
+ return (
373
+ <Card variant="muted" padding={0} xstyle={styles.columnShell}>
374
+ <Layout
375
+ height="fill"
376
+ header={
377
+ <LayoutHeader hasDivider padding={3}>
378
+ <HStack hAlign="between" vAlign="center">
379
+ <HStack gap={2} vAlign="center">
380
+ <StatusDot
381
+ variant={meta.variant}
382
+ label={`${meta.title} status`}
383
+ />
384
+ <Heading level={4}>{meta.title}</Heading>
385
+ <Tooltip content={meta.tooltip}>
386
+ <Icon
387
+ icon={InformationCircleIcon}
388
+ size="sm"
389
+ color="secondary"
390
+ />
391
+ </Tooltip>
392
+ </HStack>
393
+ <Text type="supporting" color="secondary" hasTabularNumbers>
394
+ {count}
395
+ </Text>
396
+ </HStack>
397
+ </LayoutHeader>
398
+ }
399
+ content={
400
+ <LayoutContent ref={contentRef} padding={2}>
401
+ {children ?? (
402
+ <EmptyState
403
+ isCompact
404
+ xstyle={styles.columnEmptyState}
405
+ icon={
406
+ <Icon icon={meta.emptyIcon} size="lg" color="secondary" />
407
+ }
408
+ title={meta.emptyTitle}
409
+ description={meta.emptyDescription}
410
+ />
411
+ )}
412
+ </LayoutContent>
413
+ }
414
+ />
415
+ </Card>
416
+ );
417
+ }
418
+
419
+ // ============= MAIN =============
420
+
421
+ export default function KanbanBoardTemplate() {
422
+ const [items, setItems] = useState<WorkItem[]>(INITIAL_ITEMS);
423
+ const [sprint, setSprint] = useState('003');
424
+ const [drag, setDrag] = useState<DragState | null>(null);
425
+
426
+ // Live element registries for pointer hit-testing (kept out of render state).
427
+ const columnEls = useRef(new Map<ColumnId, HTMLElement>());
428
+ const cardEls = useRef(new Map<string, HTMLElement>());
429
+ const columnRefCbs = useRef(
430
+ new Map<ColumnId, (el: HTMLDivElement | null) => void>(),
431
+ );
432
+ const cardRefCbs = useRef(
433
+ new Map<string, (el: HTMLDivElement | null) => void>(),
434
+ );
435
+ const teardownRef = useRef<(() => void) | null>(null);
436
+
437
+ // Stable ref callbacks so registering an element never churns across renders.
438
+ const getColumnRef = (id: ColumnId) => {
439
+ let cb = columnRefCbs.current.get(id);
440
+ if (!cb) {
441
+ cb = el => {
442
+ if (el) {columnEls.current.set(id, el);}
443
+ else {columnEls.current.delete(id);}
444
+ };
445
+ columnRefCbs.current.set(id, cb);
446
+ }
447
+ return cb;
448
+ };
449
+
450
+ const getCardRef = (id: string) => {
451
+ let cb = cardRefCbs.current.get(id);
452
+ if (!cb) {
453
+ cb = el => {
454
+ if (el) {cardEls.current.set(id, el);}
455
+ else {cardEls.current.delete(id);}
456
+ };
457
+ cardRefCbs.current.set(id, cb);
458
+ }
459
+ return cb;
460
+ };
461
+
462
+ const itemsByColumn = useMemo(() => {
463
+ const map: Record<ColumnId, WorkItem[]> = {
464
+ todo: [],
465
+ 'in-progress': [],
466
+ 'in-review': [],
467
+ done: [],
468
+ };
469
+ for (const item of items) {
470
+ map[item.column].push(item);
471
+ }
472
+ return map;
473
+ }, [items]);
474
+
475
+ const moveItem = (id: string, to: ColumnId) => {
476
+ setItems(prev =>
477
+ prev.map(item => (item.id === id ? {...item, column: to} : item)),
478
+ );
479
+ };
480
+
481
+ // Resolve the pointer position to a column + insertion index, ignoring the
482
+ // card being dragged so the math is against the cards that stay in place.
483
+ const computeTarget = (
484
+ px: number,
485
+ py: number,
486
+ draggedId: string,
487
+ ): DropTarget | null => {
488
+ for (const [colId, el] of Array.from(columnEls.current.entries())) {
489
+ const r = el.getBoundingClientRect();
490
+ if (px < r.left || px > r.right || py < r.top || py > r.bottom) {continue;}
491
+
492
+ const ids = itemsByColumn[colId]
493
+ .filter(it => it.id !== draggedId)
494
+ .map(it => it.id);
495
+
496
+ let index = ids.length;
497
+ for (let i = 0; i < ids.length; i++) {
498
+ const cardEl = cardEls.current.get(ids[i]);
499
+ if (!cardEl) {continue;}
500
+ const cr = cardEl.getBoundingClientRect();
501
+ if (py < cr.top + cr.height / 2) {
502
+ index = i;
503
+ break;
504
+ }
505
+ }
506
+ return {column: colId, index};
507
+ }
508
+ return null;
509
+ };
510
+
511
+ // Rebuild the flat item list so the dragged card lands at the resolved slot
512
+ // while every other card keeps its relative order.
513
+ const commitDrag = (id: string, target: DropTarget) => {
514
+ setItems(prev => {
515
+ const moved = prev.find(it => it.id === id);
516
+ if (!moved) {return prev;}
517
+
518
+ const rest = prev.filter(it => it.id !== id);
519
+ const updated: WorkItem = {...moved, column: target.column};
520
+ const colItems = rest.filter(it => it.column === target.column);
521
+ const anchor = colItems[target.index];
522
+
523
+ if (!anchor) {return [...rest, updated];}
524
+ const at = rest.indexOf(anchor);
525
+ return [...rest.slice(0, at), updated, ...rest.slice(at)];
526
+ });
527
+ };
528
+
529
+ const onCardPointerDown = (e: ReactPointerEvent, id: string) => {
530
+ if (e.button !== 0) {return;}
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) {return;}
542
+
543
+ const rect = el.getBoundingClientRect();
544
+ const startX = e.clientX;
545
+ const startY = e.clientY;
546
+ const offsetX = startX - rect.left;
547
+ const offsetY = startY - rect.top;
548
+ const {width, height} = rect;
549
+
550
+ let started = false;
551
+ let target: DropTarget | null = null;
552
+
553
+ const onMove = (ev: PointerEvent) => {
554
+ if (
555
+ !started &&
556
+ Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) <
557
+ DRAG_THRESHOLD
558
+ ) {
559
+ return;
560
+ }
561
+ started = true;
562
+ target = computeTarget(ev.clientX, ev.clientY, id);
563
+ setDrag({
564
+ id,
565
+ width,
566
+ height,
567
+ offsetX,
568
+ offsetY,
569
+ pointerX: ev.clientX,
570
+ pointerY: ev.clientY,
571
+ target,
572
+ });
573
+ };
574
+
575
+ const onUp = () => {
576
+ teardownRef.current?.();
577
+ if (started && target) {commitDrag(id, target);}
578
+ setDrag(null);
579
+ };
580
+
581
+ const teardown = () => {
582
+ window.removeEventListener('pointermove', onMove);
583
+ window.removeEventListener('pointerup', onUp);
584
+ teardownRef.current = null;
585
+ };
586
+ teardownRef.current = teardown;
587
+
588
+ window.addEventListener('pointermove', onMove);
589
+ window.addEventListener('pointerup', onUp);
590
+ };
591
+
592
+ const draggedItem = drag ? items.find(it => it.id === drag.id) : undefined;
593
+ const isDragging = drag !== null;
594
+
595
+ // Suppress selection while dragging and detach listeners on unmount.
596
+ useEffect(() => {
597
+ if (!isDragging) {return;}
598
+ const previous = document.body.style.userSelect;
599
+ document.body.style.userSelect = 'none';
600
+ return () => {
601
+ document.body.style.userSelect = previous;
602
+ };
603
+ }, [isDragging]);
604
+
605
+ useEffect(() => () => teardownRef.current?.(), []);
606
+
607
+ // Card nodes for a column, or null when the column should show its empty
608
+ // state. A dashed ghost box marks the landing slot during a drag.
609
+ const renderColumnCards = (colId: ColumnId): ReactNode => {
610
+ const colItems = itemsByColumn[colId];
611
+ const visible = drag ? colItems.filter(it => it.id !== drag.id) : colItems;
612
+ const ghostTarget =
613
+ drag && drag.target && drag.target.column === colId ? drag : null;
614
+
615
+ if (visible.length === 0 && !ghostTarget) {return null;}
616
+
617
+ const nodes: ReactNode[] = visible.map(it => (
618
+ <BoardCard
619
+ key={it.id}
620
+ item={it}
621
+ cardRef={getCardRef(it.id)}
622
+ onPointerDown={onCardPointerDown}
623
+ onMove={moveItem}
624
+ />
625
+ ));
626
+
627
+ if (ghostTarget && ghostTarget.target) {
628
+ const index = Math.min(ghostTarget.target.index, nodes.length);
629
+ nodes.splice(
630
+ index,
631
+ 0,
632
+ <VStack key="drag-ghost" xstyle={styles.ghost(ghostTarget.height)} />,
633
+ );
634
+ }
635
+
636
+ return <VStack gap={2}>{nodes}</VStack>;
637
+ };
638
+
639
+ return (
640
+ <Section height="100dvh">
641
+ <Layout
642
+ height="fill"
643
+ header={
644
+ <LayoutHeader hasDivider padding={4}>
645
+ <Toolbar
646
+ label="Board actions"
647
+ gap={2}
648
+ startContent={
649
+ <>
650
+ <Heading level={3}>Sprint Board</Heading>
651
+ <Badge label={items.length} variant="neutral" />
652
+ </>
653
+ }
654
+ endContent={
655
+ <HStack gap={2}>
656
+ <Selector
657
+ label="Sprint"
658
+ width={200}
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
+ xstyle={styles.toolbarDivider}
672
+ />
673
+ <HStack gap={1} vAlign="center">
674
+ <IconButton
675
+ icon={<Icon icon={ArrowsUpDownIcon} size="sm" />}
676
+ label="Sort"
677
+ />
678
+ <IconButton
679
+ icon={<Icon icon={FunnelIcon} size="sm" />}
680
+ label="Filter"
681
+ />
682
+ <IconButton
683
+ icon={<Icon icon={MagnifyingGlassIcon} size="sm" />}
684
+ label="Search"
685
+ />
686
+ </HStack>
687
+ <Button
688
+ label="Add task"
689
+ variant="primary"
690
+ icon={<Icon icon={PlusIcon} size="sm" />}
691
+ />
692
+ </HStack>
693
+ }
694
+ />
695
+ </LayoutHeader>
696
+ }
697
+ content={
698
+ <LayoutContent padding={0}>
699
+ <HStack gap={4} xstyle={styles.boardColumns}>
700
+ {COLUMNS.map(meta => (
701
+ <BoardColumn
702
+ key={meta.id}
703
+ meta={meta}
704
+ count={itemsByColumn[meta.id].length}
705
+ contentRef={getColumnRef(meta.id)}>
706
+ {renderColumnCards(meta.id)}
707
+ </BoardColumn>
708
+ ))}
709
+ </HStack>
710
+ </LayoutContent>
711
+ }
712
+ />
713
+ {drag && draggedItem ? (
714
+ <Card
715
+ padding={3}
716
+ xstyle={[
717
+ styles.floating,
718
+ styles.floatingAt(
719
+ drag.pointerX - drag.offsetX,
720
+ drag.pointerY - drag.offsetY,
721
+ drag.width,
722
+ ),
723
+ ]}>
724
+ <BoardCardBody item={draggedItem} onMove={() => {}} />
725
+ </Card>
726
+ ) : null}
727
+ </Section>
728
+ );
729
+ }
@@ -0,0 +1,12 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('../../../../core/src/docs-types').TemplateDoc} */
4
+ export const doc = {
5
+ type: 'page',
6
+ name: 'Kanban Board',
7
+ displayName: 'Kanban Board',
8
+ description:
9
+ 'Task board with color-coded status columns, draggable task cards, priority tags, and metadata',
10
+ isReady: true,
11
+ category: 'Tools - Kanban Board',
12
+ };