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,62 +1,62 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
- // XLE (canonical structure, validated with `bunx astryx layout check`):
3
- // L > (LP[w=68] > V > Av + (V > IB*4) + IB) + (LP[w=260] > V > (H > Hd"Messages"[level=1] + IB) + TI"Jump to…" + (V > Tx"Channels"[t=label] + (List > (ListItem)*4)) + (V > Tx"Direct messages"[t=label] + (List > (ListItem)*3))) + (LC[p=0] > V > (H > Hd"design-systems"[level=2] + SD + IB) + (ChL > ChML > (ChM > ChB)*6 + ChC)) + (LP[w=340] > V > (H > Tx"Thread"[weight=semibold] + IB) + (ChL > ChML > (ChM > ChB)*3 + ChC))
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 > (LP[w=68] > V[g=2] > Av + IB*4 + SI[fill] + IB"Settings") + (LP[w=260] > V > (H[g=2] > (SI[fill] > Hd"Messages"[level=1]) + IB"New message") + (V[g=0] > TI"Jump to…") + (SI[fill] > (UL[density=compact edge=flush !hasDividers] > (Tx"Channels"[t=label] + LI*4)) + (UL[density=compact edge=flush !hasDividers] > (Tx"Direct messages"[t=label] + LI*3)))) + (LC[p=0] > V > (H[g=3] > Ic + Hd"design-systems"[level=2] + (SI[fill] > Tx"Component APIs, tokens, and release coordination"[t=body]) + SD"12 online" + IB"Members") + D + (SI[fill] > ChL > (ChML[density=balanced] > ChSM"Tuesday, June 30" + ChSM"Sasha Ortiz joined #design-systems" + (ChM > ChBu) + (ChM > ChBu*2) + (ChM > ChBu*2) + (ChM > ChBu) + (ChM > ChBu)) + ChC"Message #design-systems" + EmptyState"No messages yet")) + (LP[w=340] > V > (H[g=2] > (SI[fill] > (H[g=2] > Tx"Thread"[weight=semibold] + Tx"#design-systems"[t=supporting])) + IB"Close thread") + D + (SI[fill] > ChML[density=compact] > (ChM > ChBu) + ChSM"2 replies" + (ChM > ChBu)*2) + (V[g=0] > ChC"Reply in thread…")))
4
6
 
5
7
  /**
6
8
  * Messaging Shell — Slack-style column frame for team messaging tools. Frame (desktop, left to right): (Frame/responsive/container: see XLE header above.)
7
9
  */
8
10
 
9
- import {useState, type CSSProperties, type ReactNode} from 'react';
10
-
11
+ import { Avatar, AvatarStatusDot } from "@astryxdesign/core/Avatar";
12
+ import { Badge } from "@astryxdesign/core/Badge";
11
13
  import {
12
- Layout,
13
- LayoutContent,
14
- LayoutPanel,
15
- Stack,
16
- StackItem,
17
- HStack,
18
- VStack,
19
- } from '@astryxdesign/core/Layout';
20
- import {Text, Heading} from '@astryxdesign/core/Text';
14
+ ChatComposer,
15
+ ChatLayout,
16
+ ChatMessage,
17
+ ChatMessageBubble,
18
+ ChatMessageList,
19
+ ChatMessageMetadata,
20
+ ChatSystemMessage,
21
+ } from "@astryxdesign/core/Chat";
22
+ import { Divider } from "@astryxdesign/core/Divider";
23
+ import { EmptyState } from "@astryxdesign/core/EmptyState";
24
+ import { useMediaQuery } from "@astryxdesign/core/hooks";
25
+ import { Icon } from "@astryxdesign/core/Icon";
26
+ import { IconButton } from "@astryxdesign/core/IconButton";
21
27
  import {
22
- ChatComposer,
23
- ChatLayout,
24
- ChatMessage,
25
- ChatMessageBubble,
26
- ChatMessageList,
27
- ChatMessageMetadata,
28
- ChatSystemMessage,
29
- } from '@astryxdesign/core/Chat';
30
- import {Avatar, AvatarStatusDot} from '@astryxdesign/core/Avatar';
31
- import {StatusDot} from '@astryxdesign/core/StatusDot';
32
- import {Badge} from '@astryxdesign/core/Badge';
33
- import {List, ListItem} from '@astryxdesign/core/List';
34
- import {Divider} from '@astryxdesign/core/Divider';
35
- import {TextInput} from '@astryxdesign/core/TextInput';
36
- import {Timestamp} from '@astryxdesign/core/Timestamp';
37
- import {EmptyState} from '@astryxdesign/core/EmptyState';
38
- import {Icon} from '@astryxdesign/core/Icon';
39
- import {IconButton} from '@astryxdesign/core/IconButton';
40
- import {useMediaQuery} from '@astryxdesign/core/hooks';
41
-
28
+ HStack,
29
+ Layout,
30
+ LayoutContent,
31
+ LayoutPanel,
32
+ Stack,
33
+ StackItem,
34
+ VStack,
35
+ } from "@astryxdesign/core/Layout";
36
+ import { List, ListItem } from "@astryxdesign/core/List";
37
+ import { StatusDot } from "@astryxdesign/core/StatusDot";
38
+ import { Heading, Text } from "@astryxdesign/core/Text";
39
+ import { TextInput } from "@astryxdesign/core/TextInput";
40
+ import { Timestamp } from "@astryxdesign/core/Timestamp";
42
41
  // ============= ICONS (verified lucide-react exports) =============
43
42
  // Bell ← BellIcon, Bookmark ← BookmarkIcon, MessagesSquare ← ChatBubbleLeftRightIcon,
44
43
  // Settings ← Cog6ToothIcon, Hash ← HashtagIcon, House ← HomeIcon, Inbox ← InboxIcon,
45
44
  // Search ← MagnifyingGlassIcon, SquarePen ← PencilSquareIcon, Users ← UserGroupIcon,
46
45
  // X ← XMarkIcon.
47
46
  import {
48
- Bell,
49
- Bookmark,
50
- MessagesSquare,
51
- Settings,
52
- Hash,
53
- House,
54
- Inbox,
55
- Search,
56
- SquarePen,
57
- Users,
58
- X,
59
- } from 'lucide-react';
47
+ Bell,
48
+ Bookmark,
49
+ Hash,
50
+ House,
51
+ Inbox,
52
+ MessagesSquare,
53
+ Search,
54
+ Settings,
55
+ SquarePen,
56
+ Users,
57
+ X,
58
+ } from "lucide-react";
59
+ import { type CSSProperties, type ReactNode, useState } from "react";
60
60
 
61
61
  // ---------------------------------------------------------------------------
62
62
  // Styles — semantic tokens only. Layout/Stack own their padding via props,
@@ -66,360 +66,350 @@ import {
66
66
 
67
67
  // Fill the window: Layout height="fill" is height:100%, which only resolves
68
68
  // against a definite height — the host's <html>/<body> don't set one.
69
- const rootStyle: CSSProperties = {height: '100dvh', width: '100%'};
69
+ const rootStyle: CSSProperties = { height: "100dvh", width: "100%" };
70
70
  // Workspace rail: full height, centered, ring-safe vertical padding.
71
71
  const railStyle: CSSProperties = {
72
- height: '100%',
73
- alignItems: 'center',
74
- paddingTop: 'var(--spacing-3)',
75
- paddingBottom: 'var(--spacing-3)',
72
+ height: "100%",
73
+ alignItems: "center",
74
+ paddingTop: "var(--spacing-3)",
75
+ paddingBottom: "var(--spacing-3)",
76
76
  };
77
77
  // Sidebar frame: full height, scrolls internally.
78
- const sidebarStyle: CSSProperties = {height: '100%', minHeight: 0};
78
+ const sidebarStyle: CSSProperties = { height: "100%", minHeight: 0 };
79
79
  const sidebarHeaderStyle: CSSProperties = {
80
- alignItems: 'center',
81
- paddingInline: 'var(--spacing-3)',
82
- paddingBlock: 'var(--spacing-3)',
80
+ alignItems: "center",
81
+ paddingInline: "var(--spacing-3)",
82
+ paddingBlock: "var(--spacing-3)",
83
83
  };
84
84
  const sidebarSearchStyle: CSSProperties = {
85
- paddingInline: 'var(--spacing-3)',
86
- paddingBottom: 'var(--spacing-2)',
85
+ paddingInline: "var(--spacing-3)",
86
+ paddingBottom: "var(--spacing-2)",
87
87
  };
88
88
  // Block-start only: the inline (8px) and bottom (12px) edges already clear.
89
89
  // INLINE is a content gutter matching sidebarHeader/sidebarSearch (12px);
90
90
  // BLOCK-START is a 4px ring budget (offset 2 + thickness 2). Not redundant.
91
91
  const sidebarScrollStyle: CSSProperties = {
92
- minHeight: 0,
93
- overflowY: 'auto',
94
- paddingInline: 'var(--spacing-3)',
95
- paddingBlockStart: 'var(--spacing-1)',
96
- paddingBottom: 'var(--spacing-3)',
92
+ minHeight: 0,
93
+ overflowY: "auto",
94
+ paddingInline: "var(--spacing-3)",
95
+ paddingBlockStart: "var(--spacing-1)",
96
+ paddingBottom: "var(--spacing-3)",
97
97
  };
98
98
  // DM section gap: List is the row container, so the gap rides on the second
99
99
  // List's own margin instead of a wrapper element.
100
- const sectionGapStyle: CSSProperties = {marginTop: 'var(--spacing-4)'};
100
+ const sectionGapStyle: CSSProperties = { marginTop: "var(--spacing-4)" };
101
101
  // Stream column: full height, scrolls internally.
102
- const streamColumnStyle: CSSProperties = {height: '100%', minHeight: 0};
102
+ const streamColumnStyle: CSSProperties = { height: "100%", minHeight: 0 };
103
103
  const streamHeaderStyle: CSSProperties = {
104
- alignItems: 'center',
105
- paddingInline: 'var(--spacing-4)',
106
- paddingBlock: 'var(--spacing-3)',
104
+ alignItems: "center",
105
+ paddingInline: "var(--spacing-4)",
106
+ paddingBlock: "var(--spacing-3)",
107
107
  };
108
108
  // Truncation floor: the topic StackItem must shrink before the header does.
109
- const streamTopicStyle: CSSProperties = {minWidth: 0};
109
+ const streamTopicStyle: CSSProperties = { minWidth: 0 };
110
110
  const chatAreaStyle: CSSProperties = {
111
- minHeight: 0,
112
- display: 'flex',
113
- flexDirection: 'column',
111
+ minHeight: 0,
112
+ display: "flex",
113
+ flexDirection: "column",
114
114
  };
115
- const chatFillStyle: CSSProperties = {flex: 1, minHeight: 0};
115
+ const chatFillStyle: CSSProperties = { flex: 1, minHeight: 0 };
116
116
  // Thread column: full height, scrolls internally.
117
- const threadColumnStyle: CSSProperties = {height: '100%', minHeight: 0};
117
+ const threadColumnStyle: CSSProperties = { height: "100%", minHeight: 0 };
118
118
  const threadHeaderStyle: CSSProperties = {
119
- alignItems: 'center',
120
- paddingInline: 'var(--spacing-3)',
121
- paddingBlock: 'var(--spacing-3)',
119
+ alignItems: "center",
120
+ paddingInline: "var(--spacing-3)",
121
+ paddingBlock: "var(--spacing-3)",
122
122
  };
123
123
  const threadScrollStyle: CSSProperties = {
124
- minHeight: 0,
125
- overflowY: 'auto',
126
- paddingInline: 'var(--spacing-3)',
127
- paddingBlock: 'var(--spacing-3)',
124
+ minHeight: 0,
125
+ overflowY: "auto",
126
+ paddingInline: "var(--spacing-3)",
127
+ paddingBlock: "var(--spacing-3)",
128
128
  };
129
- const threadComposerStyle: CSSProperties = {padding: 'var(--spacing-3)'};
129
+ const threadComposerStyle: CSSProperties = { padding: "var(--spacing-3)" };
130
130
 
131
131
  // ---------------------------------------------------------------------------
132
132
  // Deterministic fixtures — fixed ISO timestamps, stable ordering.
133
133
  // ---------------------------------------------------------------------------
134
134
 
135
- type Presence = 'online' | 'busy' | 'offline';
135
+ type Presence = "online" | "busy" | "offline";
136
136
 
137
137
  interface User {
138
- name: string;
138
+ name: string;
139
139
  }
140
140
 
141
141
  const USERS: Record<string, User> = {
142
- mira: {name: 'Mira Chen'},
143
- devon: {name: 'Devon Park'},
144
- sasha: {name: 'Sasha Ortiz'},
145
- you: {name: 'Riley Quinn'},
142
+ mira: { name: "Mira Chen" },
143
+ devon: { name: "Devon Park" },
144
+ sasha: { name: "Sasha Ortiz" },
145
+ you: { name: "Riley Quinn" },
146
146
  };
147
147
 
148
148
  interface Channel {
149
- id: string;
150
- name: string;
151
- topic: string;
152
- unread: number;
149
+ id: string;
150
+ name: string;
151
+ topic: string;
152
+ unread: number;
153
153
  }
154
154
 
155
155
  const CHANNELS: Channel[] = [
156
- {
157
- id: 'design-systems',
158
- name: 'design-systems',
159
- topic: 'Component APIs, tokens, and release coordination',
160
- unread: 0,
161
- },
162
- {
163
- id: 'frontend-guild',
164
- name: 'frontend-guild',
165
- topic: 'Cross-team frontend practices',
166
- unread: 4,
167
- },
168
- {
169
- id: 'releases',
170
- name: 'releases',
171
- topic: 'Release announcements and rollbacks',
172
- unread: 12,
173
- },
174
- {id: 'random', name: 'random', topic: 'Everything else', unread: 0},
156
+ {
157
+ id: "design-systems",
158
+ name: "design-systems",
159
+ topic: "Component APIs, tokens, and release coordination",
160
+ unread: 0,
161
+ },
162
+ {
163
+ id: "frontend-guild",
164
+ name: "frontend-guild",
165
+ topic: "Cross-team frontend practices",
166
+ unread: 4,
167
+ },
168
+ {
169
+ id: "releases",
170
+ name: "releases",
171
+ topic: "Release announcements and rollbacks",
172
+ unread: 12,
173
+ },
174
+ { id: "random", name: "random", topic: "Everything else", unread: 0 },
175
175
  ];
176
176
 
177
177
  interface DirectMessage {
178
- id: string;
179
- userId: string;
180
- presence: Presence;
181
- unread: number;
178
+ id: string;
179
+ userId: string;
180
+ presence: Presence;
181
+ unread: number;
182
182
  }
183
183
 
184
184
  const DIRECT_MESSAGES: DirectMessage[] = [
185
- {id: 'dm-mira', userId: 'mira', presence: 'online', unread: 0},
186
- {id: 'dm-devon', userId: 'devon', presence: 'busy', unread: 2},
187
- {id: 'dm-sasha', userId: 'sasha', presence: 'offline', unread: 0},
185
+ { id: "dm-mira", userId: "mira", presence: "online", unread: 0 },
186
+ { id: "dm-devon", userId: "devon", presence: "busy", unread: 2 },
187
+ { id: "dm-sasha", userId: "sasha", presence: "offline", unread: 0 },
188
188
  ];
189
189
 
190
190
  // AvatarStatusDot supports success | neutral | error (no warning): busy stays
191
191
  // error (do-not-disturb), never warning. One config — variant and label travel
192
192
  // together, so the dot and its accessible name can't disagree.
193
- const PRESENCE: Record<
194
- Presence,
195
- {variant: 'success' | 'error' | 'neutral'; label: string}
196
- > = {
197
- online: {variant: 'success', label: 'Online'},
198
- busy: {variant: 'error', label: 'Busy'},
199
- offline: {variant: 'neutral', label: 'Offline'},
193
+ const PRESENCE: Record<Presence, { variant: "success" | "error" | "neutral"; label: string }> = {
194
+ online: { variant: "success", label: "Online" },
195
+ busy: { variant: "error", label: "Busy" },
196
+ offline: { variant: "neutral", label: "Offline" },
200
197
  };
201
198
 
202
199
  /** One bubble's copy. Ids key the bubble; its position only sets the grouping. */
203
200
  interface StreamBubble {
204
- id: string;
205
- text: string;
201
+ id: string;
202
+ text: string;
206
203
  }
207
204
 
208
205
  /** One message group: consecutive bubbles from the same sender. */
209
206
  interface StreamMessage {
210
- id: string;
211
- userId: string;
212
- time: string;
213
- bubbles: StreamBubble[];
207
+ id: string;
208
+ userId: string;
209
+ time: string;
210
+ bubbles: StreamBubble[];
214
211
  }
215
212
 
216
213
  const MESSAGES_BY_CHANNEL: Record<string, StreamMessage[]> = {
217
- 'design-systems': [
218
- {
219
- id: 'm1',
220
- userId: 'mira',
221
- time: '2026-06-30T09:12:00',
222
- bubbles: [
223
- {
224
- id: 'm1-1',
225
- text: 'Morning! The Timestamp component now supports a `system_date` format, worth switching the audit log over.',
226
- },
227
- {
228
- id: 'm1-2',
229
- text: 'I put the migration notes in the wiki under Decisions.',
230
- },
231
- ],
232
- },
233
- {
234
- id: 'm2',
235
- userId: 'devon',
236
- time: '2026-06-30T09:15:00',
237
- bubbles: [
238
- {
239
- id: 'm2-1',
240
- text: 'Nice. Does that unblock the incident console timeline work?',
241
- },
242
- ],
243
- },
244
- {
245
- id: 'm3',
246
- userId: 'you',
247
- time: '2026-06-30T09:17:00',
248
- bubbles: [
249
- {
250
- id: 'm3-1',
251
- text: 'It does. I will pick that up after the template review.',
252
- },
253
- {
254
- id: 'm3-2',
255
- text: 'One question on the List density defaults, will start a thread.',
256
- },
257
- ],
258
- },
259
- {
260
- id: 'm4',
261
- userId: 'sasha',
262
- time: '2026-06-30T09:24:00',
263
- bubbles: [
264
- {
265
- id: 'm4-1',
266
- text: 'Heads up: the token sync job ran clean overnight, no drift between core and the theme packages.',
267
- },
268
- ],
269
- },
270
- {
271
- id: 'm5',
272
- userId: 'mira',
273
- time: '2026-06-30T09:26:00',
274
- bubbles: [{id: 'm5-1', text: 'Great, closing out the drift task then.'}],
275
- },
276
- ],
277
- 'frontend-guild': [
278
- {
279
- id: 'g1',
280
- userId: 'devon',
281
- time: '2026-06-30T08:40:00',
282
- bubbles: [
283
- {
284
- id: 'g1-1',
285
- text: 'Guild sync moved to Thursday this week to avoid the release freeze.',
286
- },
287
- ],
288
- },
289
- {
290
- id: 'g2',
291
- userId: 'you',
292
- time: '2026-06-30T08:44:00',
293
- bubbles: [{id: 'g2-1', text: 'Works for me, agenda doc is updated.'}],
294
- },
295
- ],
214
+ "design-systems": [
215
+ {
216
+ id: "m1",
217
+ userId: "mira",
218
+ time: "2026-06-30T09:12:00",
219
+ bubbles: [
220
+ {
221
+ id: "m1-1",
222
+ text: "Morning! The Timestamp component now supports a `system_date` format, worth switching the audit log over.",
223
+ },
224
+ {
225
+ id: "m1-2",
226
+ text: "I put the migration notes in the wiki under Decisions.",
227
+ },
228
+ ],
229
+ },
230
+ {
231
+ id: "m2",
232
+ userId: "devon",
233
+ time: "2026-06-30T09:15:00",
234
+ bubbles: [
235
+ {
236
+ id: "m2-1",
237
+ text: "Nice. Does that unblock the incident console timeline work?",
238
+ },
239
+ ],
240
+ },
241
+ {
242
+ id: "m3",
243
+ userId: "you",
244
+ time: "2026-06-30T09:17:00",
245
+ bubbles: [
246
+ {
247
+ id: "m3-1",
248
+ text: "It does. I will pick that up after the template review.",
249
+ },
250
+ {
251
+ id: "m3-2",
252
+ text: "One question on the List density defaults, will start a thread.",
253
+ },
254
+ ],
255
+ },
256
+ {
257
+ id: "m4",
258
+ userId: "sasha",
259
+ time: "2026-06-30T09:24:00",
260
+ bubbles: [
261
+ {
262
+ id: "m4-1",
263
+ text: "Heads up: the token sync job ran clean overnight, no drift between core and the theme packages.",
264
+ },
265
+ ],
266
+ },
267
+ {
268
+ id: "m5",
269
+ userId: "mira",
270
+ time: "2026-06-30T09:26:00",
271
+ bubbles: [{ id: "m5-1", text: "Great, closing out the drift task then." }],
272
+ },
273
+ ],
274
+ "frontend-guild": [
275
+ {
276
+ id: "g1",
277
+ userId: "devon",
278
+ time: "2026-06-30T08:40:00",
279
+ bubbles: [
280
+ {
281
+ id: "g1-1",
282
+ text: "Guild sync moved to Thursday this week to avoid the release freeze.",
283
+ },
284
+ ],
285
+ },
286
+ {
287
+ id: "g2",
288
+ userId: "you",
289
+ time: "2026-06-30T08:44:00",
290
+ bubbles: [{ id: "g2-1", text: "Works for me, agenda doc is updated." }],
291
+ },
292
+ ],
296
293
  };
297
294
 
298
295
  interface ThreadReply {
299
- id: string;
300
- userId: string;
301
- time: string;
302
- text: string;
296
+ id: string;
297
+ userId: string;
298
+ time: string;
299
+ text: string;
303
300
  }
304
301
 
305
302
  // One map: the root plus its replies. [0] is the opener, the rest are replies —
306
303
  // one lookup for the divider count and one slice for the reply list.
307
304
  const THREAD: ThreadReply[] = [
308
- {
309
- id: 't0',
310
- userId: 'you',
311
- time: '2026-06-30T09:17:30',
312
- text: 'One question on the List density defaults: should channel sidebars use compact or balanced? The spec shows both.',
313
- },
314
- {
315
- id: 't1',
316
- userId: 'mira',
317
- time: '2026-06-30T09:19:00',
318
- text: 'Compact for navigation surfaces. Balanced is for content lists where descriptions carry weight.',
319
- },
320
- {
321
- id: 't2',
322
- userId: 'devon',
323
- time: '2026-06-30T09:21:00',
324
- text: 'Agreed, the sidebar rows here are a good reference implementation.',
325
- },
305
+ {
306
+ id: "t0",
307
+ userId: "you",
308
+ time: "2026-06-30T09:17:30",
309
+ text: "One question on the List density defaults: should channel sidebars use compact or balanced? The spec shows both.",
310
+ },
311
+ {
312
+ id: "t1",
313
+ userId: "mira",
314
+ time: "2026-06-30T09:19:00",
315
+ text: "Compact for navigation surfaces. Balanced is for content lists where descriptions carry weight.",
316
+ },
317
+ {
318
+ id: "t2",
319
+ userId: "devon",
320
+ time: "2026-06-30T09:21:00",
321
+ text: "Agreed, the sidebar rows here are a good reference implementation.",
322
+ },
326
323
  ];
327
324
 
328
325
  const RAIL_ITEMS = [
329
- {id: 'home', label: 'Home', icon: House},
330
- {id: 'dms', label: 'Direct messages', icon: MessagesSquare},
331
- {id: 'activity', label: 'Activity', icon: Bell},
332
- {id: 'saved', label: 'Saved items', icon: Bookmark},
326
+ { id: "home", label: "Home", icon: House },
327
+ { id: "dms", label: "Direct messages", icon: MessagesSquare },
328
+ { id: "activity", label: "Activity", icon: Bell },
329
+ { id: "saved", label: "Saved items", icon: Bookmark },
333
330
  ];
334
331
 
335
332
  // One thread message. The root carries the divider above the replies, so the
336
- function ThreadMessage({reply}: {reply: ThreadReply}) {
337
- return (
338
- <ChatMessage
339
- sender="assistant"
340
- avatar={<Avatar name={USERS[reply.userId].name} size="md" />}>
341
- <ChatMessageBubble
342
- name={USERS[reply.userId].name}
343
- metadata={
344
- <ChatMessageMetadata
345
- timestamp={<Timestamp value={reply.time} format="time" />}
346
- />
347
- }>
348
- {reply.text}
349
- </ChatMessageBubble>
350
- </ChatMessage>
351
- );
333
+ function ThreadMessage({ reply }: { reply: ThreadReply }) {
334
+ return (
335
+ <ChatMessage sender="assistant" avatar={<Avatar name={USERS[reply.userId].name} size="md" />}>
336
+ <ChatMessageBubble
337
+ name={USERS[reply.userId].name}
338
+ metadata={
339
+ <ChatMessageMetadata timestamp={<Timestamp value={reply.time} format="time" />} />
340
+ }
341
+ >
342
+ {reply.text}
343
+ </ChatMessageBubble>
344
+ </ChatMessage>
345
+ );
352
346
  }
353
347
 
354
348
  // One nav row factory for the sidebar lists. Search filtering stays at the
355
349
  // call sites (visibleChannels/visibleDms); unread badges stay per-row. Only
356
350
  // the ListItem shape is shared.
357
351
  function NavRow({
358
- label,
359
- isSelected,
360
- onSelect,
361
- startContent,
362
- unread,
352
+ label,
353
+ isSelected,
354
+ onSelect,
355
+ startContent,
356
+ unread,
363
357
  }: {
364
- label: string;
365
- isSelected: boolean;
366
- onSelect: () => void;
367
- startContent: ReactNode;
368
- unread: number;
358
+ label: string;
359
+ isSelected: boolean;
360
+ onSelect: () => void;
361
+ startContent: ReactNode;
362
+ unread: number;
369
363
  }) {
370
- return (
371
- <ListItem
372
- label={label}
373
- isSelected={isSelected}
374
- onClick={onSelect}
375
- startContent={startContent}
376
- endContent={
377
- unread > 0 ? (
378
- <Badge label={String(unread)} variant="neutral" />
379
- ) : undefined
380
- }
381
- />
382
- );
364
+ return (
365
+ <ListItem
366
+ label={label}
367
+ isSelected={isSelected}
368
+ onClick={onSelect}
369
+ startContent={startContent}
370
+ endContent={unread > 0 ? <Badge label={String(unread)} variant="neutral" /> : undefined}
371
+ />
372
+ );
383
373
  }
384
374
  // ---------------------------------------------------------------------------
385
375
  // Stream message group — avatar + name on the first bubble, timestamp on the
386
376
  // last, `group` positions tighten corner radii between consecutive bubbles.
387
377
  // ---------------------------------------------------------------------------
388
378
 
389
- function StreamMessageGroup({message}: {message: StreamMessage}) {
390
- const isSelf = message.userId === 'you';
391
- const user = USERS[message.userId];
392
- const lastIndex = message.bubbles.length - 1;
393
-
394
- return (
395
- <ChatMessage
396
- sender={isSelf ? 'user' : 'assistant'}
397
- avatar={isSelf ? undefined : <Avatar name={user.name} size="md" />}>
398
- {message.bubbles.map((bubble, index) => (
399
- <ChatMessageBubble
400
- key={bubble.id}
401
- group={
402
- message.bubbles.length === 1
403
- ? undefined
404
- : index === 0
405
- ? 'first'
406
- : index === lastIndex
407
- ? 'last'
408
- : 'middle'
409
- }
410
- name={!isSelf && index === 0 ? user.name : undefined}
411
- metadata={
412
- index === lastIndex ? (
413
- <ChatMessageMetadata
414
- timestamp={<Timestamp value={message.time} format="time" />}
415
- />
416
- ) : undefined
417
- }>
418
- {bubble.text}
419
- </ChatMessageBubble>
420
- ))}
421
- </ChatMessage>
422
- );
379
+ function StreamMessageGroup({ message }: { message: StreamMessage }) {
380
+ const isSelf = message.userId === "you";
381
+ const user = USERS[message.userId];
382
+ const lastIndex = message.bubbles.length - 1;
383
+
384
+ return (
385
+ <ChatMessage
386
+ sender={isSelf ? "user" : "assistant"}
387
+ avatar={isSelf ? undefined : <Avatar name={user.name} size="md" />}
388
+ >
389
+ {message.bubbles.map((bubble, index) => (
390
+ <ChatMessageBubble
391
+ key={bubble.id}
392
+ group={
393
+ message.bubbles.length === 1
394
+ ? undefined
395
+ : index === 0
396
+ ? "first"
397
+ : index === lastIndex
398
+ ? "last"
399
+ : "middle"
400
+ }
401
+ name={!isSelf && index === 0 ? user.name : undefined}
402
+ metadata={
403
+ index === lastIndex ? (
404
+ <ChatMessageMetadata timestamp={<Timestamp value={message.time} format="time" />} />
405
+ ) : undefined
406
+ }
407
+ >
408
+ {bubble.text}
409
+ </ChatMessageBubble>
410
+ ))}
411
+ </ChatMessage>
412
+ );
423
413
  }
424
414
 
425
415
  // ---------------------------------------------------------------------------
@@ -427,272 +417,264 @@ function StreamMessageGroup({message}: {message: StreamMessage}) {
427
417
  // ---------------------------------------------------------------------------
428
418
 
429
419
  export default function MessagingShell() {
430
- const [selectedChannelId, setSelectedChannelId] = useState('design-systems');
431
- const [selectedDmId, setSelectedDmId] = useState<string | null>(null);
432
- const [isThreadOpen, setIsThreadOpen] = useState(true);
433
- const [searchQuery, setSearchQuery] = useState('');
434
-
435
- // Responsive contract (see file header).
436
- const isThreadHidden = useMediaQuery('(max-width: 1024px)');
437
- const isSidebarHidden = useMediaQuery('(max-width: 768px)');
438
-
439
- const selectedChannel =
440
- CHANNELS.find(channel => channel.id === selectedChannelId) ?? CHANNELS[0];
441
- const messages = MESSAGES_BY_CHANNEL[selectedChannel.id] ?? [];
442
-
443
- const normalizedQuery = searchQuery.trim().toLowerCase();
444
- const visibleChannels = CHANNELS.filter(channel =>
445
- channel.name.toLowerCase().includes(normalizedQuery),
446
- );
447
- const visibleDms = DIRECT_MESSAGES.filter(dm =>
448
- USERS[dm.userId].name.toLowerCase().includes(normalizedQuery),
449
- );
450
-
451
- const showThreadPanel = isThreadOpen && !isThreadHidden;
452
-
453
- const workspaceRail = (
454
- <VStack gap={2} style={railStyle}>
455
- <Avatar name="Night watch" size="md" />
456
- {RAIL_ITEMS.map(item => (
457
- <IconButton
458
- key={item.id}
459
- label={item.label}
460
- tooltip={item.label}
461
- icon={<Icon icon={item.icon} size="sm" color="inherit" />}
462
- variant={item.id === 'home' ? 'secondary' : 'ghost'}
463
- onClick={() => {}}
464
- />
465
- ))}
466
- <StackItem size="fill" />
467
- <IconButton
468
- label="Settings"
469
- tooltip="Settings"
470
- icon={<Icon icon={Settings} size="sm" color="inherit" />}
471
- variant="ghost"
472
- onClick={() => {}}
473
- />
474
- </VStack>
475
- );
476
-
477
- const channelSidebar = (
478
- <Stack direction="vertical" style={sidebarStyle}>
479
- <HStack gap={2} style={sidebarHeaderStyle}>
480
- <StackItem size="fill">
481
- <Heading level={1}>Messages</Heading>
482
- </StackItem>
483
- <IconButton
484
- label="New message"
485
- tooltip="New message"
486
- icon={<Icon icon={SquarePen} size="sm" color="inherit" />}
487
- variant="ghost"
488
- size="sm"
489
- onClick={() => {}}
490
- />
491
- </HStack>
492
- <VStack gap={0} style={sidebarSearchStyle}>
493
- <TextInput
494
- label="Jump to"
495
- isLabelHidden
496
- size="sm"
497
- placeholder="Jump to…"
498
- startIcon={Search}
499
- value={searchQuery}
500
- onChange={setSearchQuery}
501
- />
502
- </VStack>
503
- <StackItem
504
- size="fill"
505
- style={sidebarScrollStyle}
506
- role="region"
507
- aria-label="Channels and direct messages"
508
- tabIndex={0}>
509
- <List
510
- density="compact"
511
- hasDividers={false}
512
- header={
513
- <Text type="label" size="sm" color="secondary">
514
- Channels
515
- </Text>
516
- }>
517
- {visibleChannels.map(channel => (
518
- <NavRow
519
- key={channel.id}
520
- label={channel.name}
521
- isSelected={
522
- selectedDmId === null && channel.id === selectedChannelId
523
- }
524
- onSelect={() => {
525
- setSelectedChannelId(channel.id);
526
- setSelectedDmId(null);
527
- }}
528
- startContent={<Icon icon={Hash} size="sm" color="secondary" />}
529
- unread={channel.unread}
530
- />
531
- ))}
532
- </List>
533
- {/* The section gap rides on this List's own margin: List is the row
420
+ const [selectedChannelId, setSelectedChannelId] = useState("design-systems");
421
+ const [selectedDmId, setSelectedDmId] = useState<string | null>(null);
422
+ const [isThreadOpen, setIsThreadOpen] = useState(true);
423
+ const [searchQuery, setSearchQuery] = useState("");
424
+
425
+ // Responsive contract (see file header).
426
+ const isThreadHidden = useMediaQuery("(max-width: 1024px)");
427
+ const isSidebarHidden = useMediaQuery("(max-width: 768px)");
428
+
429
+ const selectedChannel =
430
+ CHANNELS.find((channel) => channel.id === selectedChannelId) ?? CHANNELS[0];
431
+ const messages = MESSAGES_BY_CHANNEL[selectedChannel.id] ?? [];
432
+
433
+ const normalizedQuery = searchQuery.trim().toLowerCase();
434
+ const visibleChannels = CHANNELS.filter((channel) =>
435
+ channel.name.toLowerCase().includes(normalizedQuery),
436
+ );
437
+ const visibleDms = DIRECT_MESSAGES.filter((dm) =>
438
+ USERS[dm.userId].name.toLowerCase().includes(normalizedQuery),
439
+ );
440
+
441
+ const showThreadPanel = isThreadOpen && !isThreadHidden;
442
+
443
+ const workspaceRail = (
444
+ <VStack gap={2} style={railStyle}>
445
+ <Avatar name="Night watch" size="md" />
446
+ {RAIL_ITEMS.map((item) => (
447
+ <IconButton
448
+ key={item.id}
449
+ label={item.label}
450
+ tooltip={item.label}
451
+ icon={<Icon icon={item.icon} size="sm" color="inherit" />}
452
+ variant={item.id === "home" ? "secondary" : "ghost"}
453
+ onClick={() => {}}
454
+ />
455
+ ))}
456
+ <StackItem size="fill" />
457
+ <IconButton
458
+ label="Settings"
459
+ tooltip="Settings"
460
+ icon={<Icon icon={Settings} size="sm" color="inherit" />}
461
+ variant="ghost"
462
+ onClick={() => {}}
463
+ />
464
+ </VStack>
465
+ );
466
+
467
+ const channelSidebar = (
468
+ <Stack direction="vertical" style={sidebarStyle}>
469
+ <HStack gap={2} style={sidebarHeaderStyle}>
470
+ <StackItem size="fill">
471
+ <Heading level={1}>Messages</Heading>
472
+ </StackItem>
473
+ <IconButton
474
+ label="New message"
475
+ tooltip="New message"
476
+ icon={<Icon icon={SquarePen} size="sm" color="inherit" />}
477
+ variant="ghost"
478
+ size="sm"
479
+ onClick={() => {}}
480
+ />
481
+ </HStack>
482
+ <VStack gap={0} style={sidebarSearchStyle}>
483
+ <TextInput
484
+ label="Jump to"
485
+ isLabelHidden
486
+ size="sm"
487
+ placeholder="Jump to…"
488
+ startIcon={Search}
489
+ value={searchQuery}
490
+ onChange={setSearchQuery}
491
+ />
492
+ </VStack>
493
+ <StackItem
494
+ size="fill"
495
+ style={sidebarScrollStyle}
496
+ role="region"
497
+ aria-label="Channels and direct messages"
498
+ tabIndex={0}
499
+ >
500
+ <List
501
+ density="compact"
502
+ edge="flush"
503
+ hasDividers={false}
504
+ header={
505
+ <Text type="label" size="sm" color="secondary">
506
+ Channels
507
+ </Text>
508
+ }
509
+ >
510
+ {visibleChannels.map((channel) => (
511
+ <NavRow
512
+ key={channel.id}
513
+ label={channel.name}
514
+ isSelected={selectedDmId === null && channel.id === selectedChannelId}
515
+ onSelect={() => {
516
+ setSelectedChannelId(channel.id);
517
+ setSelectedDmId(null);
518
+ }}
519
+ startContent={<Icon icon={Hash} size="sm" color="secondary" />}
520
+ unread={channel.unread}
521
+ />
522
+ ))}
523
+ </List>
524
+ {/* The section gap rides on this List's own margin: List is the row
534
525
  container, so no wrapper element is needed. */}
535
- <List
536
- density="compact"
537
- hasDividers={false}
538
- style={sectionGapStyle}
539
- header={
540
- <Text type="label" size="sm" color="secondary">
541
- Direct messages
542
- </Text>
543
- }>
544
- {visibleDms.map(dm => (
545
- <NavRow
546
- key={dm.id}
547
- label={USERS[dm.userId].name}
548
- isSelected={selectedDmId === dm.id}
549
- onSelect={() => setSelectedDmId(dm.id)}
550
- startContent={
551
- <Avatar
552
- name={USERS[dm.userId].name}
553
- size="sm"
554
- status={
555
- <AvatarStatusDot
556
- variant={PRESENCE[dm.presence].variant}
557
- label={PRESENCE[dm.presence].label}
558
- />
559
- }
560
- />
561
- }
562
- unread={dm.unread}
563
- />
564
- ))}
565
- </List>
566
- </StackItem>
567
- </Stack>
568
- );
569
-
570
- const messageStream = (
571
- <Stack direction="vertical" style={streamColumnStyle}>
572
- <HStack gap={3} style={streamHeaderStyle}>
573
- <Icon icon={Hash} size="sm" color="secondary" />
574
- <Heading level={2}>{selectedChannel.name}</Heading>
575
- <StackItem size="fill" style={streamTopicStyle}>
576
- <Text type="body" color="secondary" maxLines={1}>
577
- {selectedChannel.topic}
578
- </Text>
579
- </StackItem>
580
- <StatusDot variant="success" label="12 online" />
581
- <IconButton
582
- label="Members"
583
- tooltip="Members"
584
- icon={<Icon icon={Users} size="sm" color="inherit" />}
585
- variant="ghost"
586
- size="sm"
587
- onClick={() => {}}
588
- />
589
- </HStack>
590
- <Divider />
591
- <StackItem size="fill" style={chatAreaStyle}>
592
- <ChatLayout
593
- style={chatFillStyle}
594
- composer={
595
- <ChatComposer
596
- placeholder={`Message #${selectedChannel.name}`}
597
- onSubmit={() => {}}
598
- />
599
- }
600
- emptyState={
601
- <EmptyState
602
- icon={<Icon icon={Inbox} size="lg" color="secondary" />}
603
- title="No messages yet"
604
- description="Start the conversation. Messages posted here are visible to the whole channel."
605
- />
606
- }>
607
- {messages.length > 0 ? (
608
- <ChatMessageList density="balanced">
609
- <ChatSystemMessage variant="divider">
610
- Tuesday, June 30
611
- </ChatSystemMessage>
612
- <ChatSystemMessage>
613
- Sasha Ortiz joined #{selectedChannel.name}
614
- </ChatSystemMessage>
615
- {messages.map(message => (
616
- <StreamMessageGroup key={message.id} message={message} />
617
- ))}
618
- </ChatMessageList>
619
- ) : null}
620
- </ChatLayout>
621
- </StackItem>
622
- </Stack>
623
- );
624
-
625
- const threadPanel = (
626
- <Stack direction="vertical" style={threadColumnStyle}>
627
- <HStack gap={2} style={threadHeaderStyle}>
628
- <StackItem size="fill">
629
- <HStack gap={2} style={{alignItems: 'baseline'}}>
630
- <Text weight="semibold">Thread</Text>
631
- <Text type="supporting" color="secondary">
632
- #{selectedChannel.name}
633
- </Text>
634
- </HStack>
635
- </StackItem>
636
- <IconButton
637
- label="Close thread"
638
- tooltip="Close thread"
639
- icon={<Icon icon={X} size="sm" color="inherit" />}
640
- variant="ghost"
641
- size="sm"
642
- onClick={() => setIsThreadOpen(false)}
643
- />
644
- </HStack>
645
- <Divider />
646
- <StackItem
647
- size="fill"
648
- style={threadScrollStyle}
649
- role="region"
650
- aria-label="Thread messages"
651
- tabIndex={0}>
652
- <ChatMessageList density="compact">
653
- <ThreadMessage reply={THREAD[0]} />
654
- <ChatSystemMessage variant="divider">
655
- {THREAD.length - 1} replies
656
- </ChatSystemMessage>
657
- {THREAD.slice(1).map(reply => (
658
- <ThreadMessage key={reply.id} reply={reply} />
659
- ))}
660
- </ChatMessageList>
661
- </StackItem>
662
- <VStack gap={0} style={threadComposerStyle}>
663
- <ChatComposer
664
- density="compact"
665
- placeholder="Reply in thread…"
666
- onSubmit={() => {}}
667
- />
668
- </VStack>
669
- </Stack>
670
- );
671
-
672
- return (
673
- <Layout
674
- height="fill"
675
- style={rootStyle}
676
- start={
677
- <>
678
- <LayoutPanel width={68} padding={0}>
679
- {workspaceRail}
680
- </LayoutPanel>
681
- {!isSidebarHidden && (
682
- <LayoutPanel width={260} padding={0}>
683
- {channelSidebar}
684
- </LayoutPanel>
685
- )}
686
- </>
687
- }
688
- end={
689
- showThreadPanel ? (
690
- <LayoutPanel width={340} padding={0}>
691
- {threadPanel}
692
- </LayoutPanel>
693
- ) : undefined
694
- }
695
- content={<LayoutContent padding={0}>{messageStream}</LayoutContent>}
696
- />
697
- );
526
+ <List
527
+ density="compact"
528
+ edge="flush"
529
+ hasDividers={false}
530
+ style={sectionGapStyle}
531
+ header={
532
+ <Text type="label" size="sm" color="secondary">
533
+ Direct messages
534
+ </Text>
535
+ }
536
+ >
537
+ {visibleDms.map((dm) => (
538
+ <NavRow
539
+ key={dm.id}
540
+ label={USERS[dm.userId].name}
541
+ isSelected={selectedDmId === dm.id}
542
+ onSelect={() => setSelectedDmId(dm.id)}
543
+ startContent={
544
+ <Avatar
545
+ name={USERS[dm.userId].name}
546
+ size="sm"
547
+ status={
548
+ <AvatarStatusDot
549
+ variant={PRESENCE[dm.presence].variant}
550
+ label={PRESENCE[dm.presence].label}
551
+ />
552
+ }
553
+ />
554
+ }
555
+ unread={dm.unread}
556
+ />
557
+ ))}
558
+ </List>
559
+ </StackItem>
560
+ </Stack>
561
+ );
562
+
563
+ const messageStream = (
564
+ <Stack direction="vertical" style={streamColumnStyle}>
565
+ <HStack gap={3} style={streamHeaderStyle}>
566
+ <Icon icon={Hash} size="sm" color="secondary" />
567
+ <Heading level={2}>{selectedChannel.name}</Heading>
568
+ <StackItem size="fill" style={streamTopicStyle}>
569
+ <Text type="body" color="secondary" maxLines={1}>
570
+ {selectedChannel.topic}
571
+ </Text>
572
+ </StackItem>
573
+ <StatusDot variant="success" label="12 online" />
574
+ <IconButton
575
+ label="Members"
576
+ tooltip="Members"
577
+ icon={<Icon icon={Users} size="sm" color="inherit" />}
578
+ variant="ghost"
579
+ size="sm"
580
+ onClick={() => {}}
581
+ />
582
+ </HStack>
583
+ <Divider />
584
+ <StackItem size="fill" style={chatAreaStyle}>
585
+ <ChatLayout
586
+ style={chatFillStyle}
587
+ composer={
588
+ <ChatComposer placeholder={`Message #${selectedChannel.name}`} onSubmit={() => {}} />
589
+ }
590
+ emptyState={
591
+ <EmptyState
592
+ icon={<Icon icon={Inbox} size="lg" color="secondary" />}
593
+ title="No messages yet"
594
+ description="Start the conversation. Messages posted here are visible to the whole channel."
595
+ />
596
+ }
597
+ >
598
+ {messages.length > 0 ? (
599
+ <ChatMessageList density="balanced">
600
+ <ChatSystemMessage variant="divider">Tuesday, June 30</ChatSystemMessage>
601
+ <ChatSystemMessage>Sasha Ortiz joined #{selectedChannel.name}</ChatSystemMessage>
602
+ {messages.map((message) => (
603
+ <StreamMessageGroup key={message.id} message={message} />
604
+ ))}
605
+ </ChatMessageList>
606
+ ) : null}
607
+ </ChatLayout>
608
+ </StackItem>
609
+ </Stack>
610
+ );
611
+
612
+ const threadPanel = (
613
+ <Stack direction="vertical" style={threadColumnStyle}>
614
+ <HStack gap={2} style={threadHeaderStyle}>
615
+ <StackItem size="fill">
616
+ <HStack gap={2} style={{ alignItems: "baseline" }}>
617
+ <Text weight="semibold">Thread</Text>
618
+ <Text type="supporting" color="secondary">
619
+ #{selectedChannel.name}
620
+ </Text>
621
+ </HStack>
622
+ </StackItem>
623
+ <IconButton
624
+ label="Close thread"
625
+ tooltip="Close thread"
626
+ icon={<Icon icon={X} size="sm" color="inherit" />}
627
+ variant="ghost"
628
+ size="sm"
629
+ onClick={() => setIsThreadOpen(false)}
630
+ />
631
+ </HStack>
632
+ <Divider />
633
+ <StackItem
634
+ size="fill"
635
+ style={threadScrollStyle}
636
+ role="region"
637
+ aria-label="Thread messages"
638
+ tabIndex={0}
639
+ >
640
+ <ChatMessageList density="compact">
641
+ <ThreadMessage reply={THREAD[0]} />
642
+ <ChatSystemMessage variant="divider">{THREAD.length - 1} replies</ChatSystemMessage>
643
+ {THREAD.slice(1).map((reply) => (
644
+ <ThreadMessage key={reply.id} reply={reply} />
645
+ ))}
646
+ </ChatMessageList>
647
+ </StackItem>
648
+ <VStack gap={0} style={threadComposerStyle}>
649
+ <ChatComposer density="compact" placeholder="Reply in thread…" onSubmit={() => {}} />
650
+ </VStack>
651
+ </Stack>
652
+ );
653
+
654
+ return (
655
+ <Layout
656
+ height="fill"
657
+ style={rootStyle}
658
+ start={
659
+ <>
660
+ <LayoutPanel width={68} padding={0}>
661
+ {workspaceRail}
662
+ </LayoutPanel>
663
+ {!isSidebarHidden && (
664
+ <LayoutPanel width={260} padding={0}>
665
+ {channelSidebar}
666
+ </LayoutPanel>
667
+ )}
668
+ </>
669
+ }
670
+ end={
671
+ showThreadPanel ? (
672
+ <LayoutPanel width={340} padding={0}>
673
+ {threadPanel}
674
+ </LayoutPanel>
675
+ ) : undefined
676
+ }
677
+ content={<LayoutContent padding={0}>{messageStream}</LayoutContent>}
678
+ />
679
+ );
698
680
  }