astryx-dracula 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/BRAND.md +54 -3
  2. package/README.md +11 -4
  3. package/USAGE.md +1 -1
  4. package/astryx-dracula.d.ts +2 -2
  5. package/astryx-dracula.js +26 -4
  6. package/astryx-dracula.variants.d.ts +2 -2
  7. package/package.json +11 -11
  8. package/shared/auth-chrome-config.ts +0 -4
  9. package/shared/auth-chrome.tsx +2 -3
  10. package/shared/chaptered-doc-config.ts +3 -2
  11. package/shared/order-desk.tsx +52 -54
  12. package/shared/revenue-chart.tsx +1 -1
  13. package/shared/scene-castle.tsx +21 -13
  14. package/templates/ai-chat-landing.tsx +6 -9
  15. package/templates/ai-chat.tsx +5 -1
  16. package/templates/centered-hero.tsx +2 -2
  17. package/templates/classic-gallery.tsx +5 -0
  18. package/templates/contact-form.tsx +9 -0
  19. package/templates/dashboard-portfolio.tsx +17 -4
  20. package/templates/dashboard.tsx +5 -5
  21. package/templates/detail-page.tsx +20 -28
  22. package/templates/documentation-design.tsx +3 -0
  23. package/templates/documentation-technical.tsx +8 -5
  24. package/templates/documentation.tsx +13 -6
  25. package/templates/form-two-column.tsx +11 -2
  26. package/templates/form-wizard.template.mjs +15 -0
  27. package/templates/form-wizard.tsx +878 -0
  28. package/templates/gallery-hero.tsx +10 -4
  29. package/templates/ide.tsx +177 -96
  30. package/templates/incident-console.tsx +15 -5
  31. package/templates/kanban-board.tsx +46 -4
  32. package/templates/library.tsx +12 -3
  33. package/templates/login-split.tsx +3 -3
  34. package/templates/login-sso.tsx +9 -17
  35. package/templates/messaging-shell.tsx +109 -46
  36. package/templates/mixed-gallery.tsx +6 -0
  37. package/templates/payment-form.tsx +29 -28
  38. package/templates/product-detail.tsx +12 -3
  39. package/templates/product-gallery.tsx +8 -3
  40. package/templates/product-tour.tsx +11 -7
  41. package/templates/settings-dialog.tsx +5 -0
  42. package/templates/settings-sidebar.tsx +603 -584
  43. package/templates/settings.tsx +5 -2
  44. package/templates/shell-nav.tsx +23 -3
  45. package/templates/shell-side-nav.tsx +94 -81
  46. package/templates/shell-top-nav.tsx +98 -86
  47. package/templates/table-grouped.tsx +146 -132
  48. package/templates/{table-page-shoe-store-heatmap.template.mjs → table-page-shoe-store.template.mjs} +1 -1
  49. package/templates/{table-page-shoe-store-heatmap.tsx → table-page-shoe-store.tsx} +1 -1
  50. package/templates/table-page.tsx +8 -0
  51. package/templates/table-tree.template.mjs +16 -0
  52. package/templates/table-tree.tsx +707 -0
  53. package/templates/tech-report.tsx +8 -5
  54. package/templates/theme-showcase.tsx +18 -11
  55. package/templates/work-item-detail.template.mjs +8 -0
  56. package/templates/work-item-detail.tsx +1049 -0
  57. package/theme.css +38 -3
  58. package/theme.css.d.ts +2 -2
  59. package/tokens.css +41 -4
@@ -67,9 +67,15 @@ function BatFlightScene() {
67
67
  }
68
68
 
69
69
  const SCENES = [
70
- { alt: "Moonlit castle on midnight hills", scene: <SceneCastle variant="card" /> },
71
- { alt: "Harvest moon over midnight pines", scene: <SceneCastle variant="tall" /> },
72
- { alt: "Bats crossing a moonless night sky", scene: <BatFlightScene /> },
70
+ {
71
+ alt: "Moonlit castle on midnight hills",
72
+ render: (label: string) => <SceneCastle variant="card" label={label} />,
73
+ },
74
+ {
75
+ alt: "Harvest moon over midnight pines",
76
+ render: (label: string) => <SceneCastle variant="tall" label={label} />,
77
+ },
78
+ { alt: "Bats crossing a moonless night sky", render: () => <BatFlightScene /> },
73
79
  ];
74
80
 
75
81
  export default function GalleryHero() {
@@ -100,7 +106,7 @@ export default function GalleryHero() {
100
106
  <Grid columns={{ minWidth: 200, repeat: "fit" }} gap={4}>
101
107
  {SCENES.map((item) => (
102
108
  <AspectRatio key={item.alt} ratio={4 / 5} style={galleryImageClip}>
103
- {item.scene}
109
+ {item.render(item.alt)}
104
110
  </AspectRatio>
105
111
  ))}
106
112
  </Grid>
package/templates/ide.tsx CHANGED
@@ -2,15 +2,23 @@
2
2
  // XLE (canonical structure of the tree below; the CLI had no XLE validator
3
3
  // after `astryx layout` was removed in 0.7.0, so this is a reading aid and a
4
4
  // human drift check — update it with the file, never alone):
5
- // L[h=fill] > LC[p=0] > (VH"Code editor" + L[h=fill] > (LP[w=256 p=4] > V[g=2] > TI"Search the night" + TrL) + RH + (LC[p=0] > L[h=fill] > (LC[p=0] > V > (SI[fill] > Cd) + RH + (V[h=300] > (TL[size=sm] > Tab"Terminal"! + Tab"Problems" + Tab"Output" + Tab"Debug") + (SI[fill] > Cd))) + (RH + (LP[w=320 p=4] > V[g=3] > (SG > SGI"Properties"! + SGI"History") + (V[g=3] > (V[g=1] > Hd"NightCounter.tsx"[level=3] + Tx"src/components/NightCounter.tsx"[t=supporting]) + ML + (V[g=2] > B.secondary"Format Document" + B.secondary"Go to Definition" + B.secondary"Find References")) + (V[g=1] > UL > LI*3)))))
5
+ // L[h=fill] > (LH[divider p=4 narrow] > (H[g=2] > B.secondary"Browse files" + B.secondary"File properties")) + LC[p=0] > (VH"Code editor" + L[h=fill] > (LP[w=256 p=4] > V[g=2] > TI"Search the night" + TrL) + RH + (LC[p=0] > L[h=fill] > (LC[p=0] > V > (SI[fill] > Cd) + RH + (V[h=300] > (TL[size=sm] > Tab"Terminal"! + Tab"Problems" + Tab"Output" + Tab"Debug") + (SI[fill] > Cd))) + (RH + (LP[w=320 p=4] > V[g=3] > (SG > SGI"Properties"! + SGI"History") + (V[g=3] > (V[g=1] > Hd"NightCounter.tsx"[level=3] + Tx"src/components/NightCounter.tsx"[t=supporting]) + ML + (V[g=2] > B.secondary"Format Document" + B.secondary"Go to Definition" + B.secondary"Find References")) + (V[g=1] > UL > LI*3))))) ;; Drawer"File tree"[side=start narrow] > (L[h=fill] > (DrawerHeader"File tree" + LC[p=4] > V[g=2] > TI"Search the night" + TrL)) + Drawer"File properties"[side=end narrow] > (L[h=fill] > (DrawerHeader"File properties" + LC[p=4] > V[g=3] > (SG > SGI"Properties" + SGI"History" + ML + (V[g=2] > B.secondary*3 + UL))))
6
6
 
7
7
  import { Button } from "@astryxdesign/core/Button";
8
8
  import { Center } from "@astryxdesign/core/Center";
9
9
  import { CodeBlock } from "@astryxdesign/core/CodeBlock";
10
+ import { Drawer, DrawerHeader } from "@astryxdesign/core/Drawer";
10
11
  import { EmptyState } from "@astryxdesign/core/EmptyState";
11
12
  import { useMediaQuery } from "@astryxdesign/core/hooks";
12
13
  import { Icon } from "@astryxdesign/core/Icon";
13
- import { Layout, LayoutContent, LayoutPanel, Stack, StackItem } from "@astryxdesign/core/Layout";
14
+ import {
15
+ Layout,
16
+ LayoutContent,
17
+ LayoutHeader,
18
+ LayoutPanel,
19
+ Stack,
20
+ StackItem,
21
+ } from "@astryxdesign/core/Layout";
14
22
  import { List, ListItem } from "@astryxdesign/core/List";
15
23
  import { MetadataList, MetadataListItem } from "@astryxdesign/core/MetadataList";
16
24
  import { ResizeHandle, useResizable } from "@astryxdesign/core/Resizable";
@@ -84,6 +92,13 @@ const codeBlockFlush: CSSProperties = {
84
92
  borderRadius: 0,
85
93
  };
86
94
 
95
+ // Three nested `Layout height="fill"`s resolve against their parent's definite
96
+ // height, and a host's <html>/<body> set none — so without this the outermost
97
+ // one computes to auto and the document scrolls instead of the editor panes.
98
+ // 100dvh is absolute and needs no ancestor; minHeight: '100%' would compute to
99
+ // 0 here and look like a fix while doing nothing.
100
+ const pageStyle: CSSProperties = { height: "100dvh" };
101
+
87
102
  const EDITOR_CODE = `import {useState, useCallback} from 'react';
88
103
  import {Button} from '@astryxdesign/core/Button';
89
104
  import {Text} from '@astryxdesign/core/Text';
@@ -213,7 +228,20 @@ export default function IdeWorkspace() {
213
228
  const [activeTermTab, setActiveTermTab] = useState("terminal");
214
229
  const [activePropertiesTab, setActivePropertiesTab] = useState("properties");
215
230
  const [query, setQuery] = useState("");
216
- const fileTree = useMemo(() => buildFileTree(setActiveFile), []);
231
+ // Below 768 there is no room for a file tree and a properties rail beside
232
+ // the editor, so each panel becomes a Drawer opened from the header.
233
+ const [isFileTreeOpen, setFileTreeOpen] = useState(false);
234
+ const [isPropertiesOpen, setPropertiesOpen] = useState(false);
235
+ const fileTree = useMemo(
236
+ () =>
237
+ buildFileTree((name) => {
238
+ setActiveFile(name);
239
+ // A drawer covers the editor on a phone, so picking a file closes
240
+ // it; on the desktop column the setter is already false.
241
+ setFileTreeOpen(false);
242
+ }),
243
+ [],
244
+ );
217
245
  const visibleTree = useMemo(() => {
218
246
  const q = query.trim().toLowerCase();
219
247
  return q ? filterFileTree(fileTree, q) : fileTree;
@@ -241,15 +269,127 @@ export default function IdeWorkspace() {
241
269
  defaultSize: 300,
242
270
  minSize: 80,
243
271
  maxSize: 520,
272
+ // Must match the ResizeHandle's `direction`; the hook defaults to
273
+ // horizontal, and a mismatch makes core warn and picks the wrong
274
+ // percentage basis.
275
+ direction: "vertical",
244
276
  collapsible: true,
245
277
  collapsedSize: 40,
246
278
  });
247
279
 
248
280
  const isMobile = useMediaQuery("(max-width: 768px)");
249
281
 
282
+ // One render of each panel's body, shared by the desktop column and the
283
+ // narrow-viewport Drawer so the two cannot drift apart.
284
+ const fileExplorer = (
285
+ <Stack direction="vertical" style={styles.fileExplorer} gap={2}>
286
+ <TextInput
287
+ label="Search files"
288
+ isLabelHidden
289
+ value={query}
290
+ onChange={setQuery}
291
+ placeholder="Search the night"
292
+ size="md"
293
+ startIcon={Search}
294
+ hasClear
295
+ />
296
+ {visibleTree.length > 0 ? (
297
+ <TreeList items={visibleTree} density="compact" />
298
+ ) : (
299
+ <EmptyState
300
+ title="No files match"
301
+ description={`Nothing in the night answers to "${query.trim()}".`}
302
+ isCompact
303
+ />
304
+ )}
305
+ </Stack>
306
+ );
307
+
308
+ const propertiesBody = (
309
+ <Stack direction="vertical" gap={3} style={styles.propertiesPanel}>
310
+ <SegmentedControl
311
+ label="Properties panel sections"
312
+ value={activePropertiesTab}
313
+ onChange={setActivePropertiesTab}
314
+ size="sm"
315
+ layout="fill"
316
+ >
317
+ <SegmentedControlItem label="Properties" value="properties" />
318
+ <SegmentedControlItem label="History" value="history" />
319
+ </SegmentedControl>
320
+ {activePropertiesTab === "properties" ? (
321
+ <Stack direction="vertical" gap={3} style={styles.propertiesContent}>
322
+ <Stack direction="vertical" gap={1}>
323
+ <Heading level={3} maxLines={1}>
324
+ {activeFile}
325
+ </Heading>
326
+ <Text color="secondary" type="supporting" maxLines={1}>
327
+ src/components/{activeFile}
328
+ </Text>
329
+ </Stack>
330
+ <MetadataList style={styles.metadataCompact}>
331
+ {PROPERTIES.map((prop) => (
332
+ <MetadataListItem key={prop.label} label={prop.label}>
333
+ {prop.value}
334
+ </MetadataListItem>
335
+ ))}
336
+ </MetadataList>
337
+ <Stack direction="vertical" gap={2} style={styles.propertyActions}>
338
+ <Button label="Format Document" size="sm" variant="secondary" />
339
+ <Button label="Go to Definition" size="sm" variant="secondary" />
340
+ <Button label="Find References" size="sm" variant="secondary" />
341
+ </Stack>
342
+ </Stack>
343
+ ) : (
344
+ <Stack direction="vertical" gap={1}>
345
+ <List>
346
+ {HISTORY_ITEMS.map((item) => (
347
+ <ListItem
348
+ key={item.label}
349
+ label={item.label}
350
+ endContent={
351
+ <Text type="supporting" color="secondary" maxLines={1} hasTabularNumbers>
352
+ {item.time}
353
+ </Text>
354
+ }
355
+ startContent={<StatusDot variant="neutral" label="Timeline marker" />}
356
+ />
357
+ ))}
358
+ </List>
359
+ </Stack>
360
+ )}
361
+ </Stack>
362
+ );
363
+
250
364
  return (
251
365
  <Layout
252
366
  height="fill"
367
+ style={pageStyle}
368
+ // Below 768 the two side panels have no room beside the editor; these
369
+ // are the only controls that reach them there. Desktop keeps the
370
+ // resize handles and omits the header entirely.
371
+ header={
372
+ isMobile ? (
373
+ <LayoutHeader hasDivider padding={4}>
374
+ <Stack direction="horizontal" gap={2} vAlign="center" wrap="wrap">
375
+ <Button
376
+ label="Browse files"
377
+ variant="secondary"
378
+ aria-haspopup="dialog"
379
+ aria-expanded={isFileTreeOpen}
380
+ onClick={() => setFileTreeOpen(true)}
381
+ />
382
+ <Button
383
+ label="File properties"
384
+ variant="secondary"
385
+ aria-haspopup="dialog"
386
+ aria-expanded={isPropertiesOpen}
387
+ onClick={() => setPropertiesOpen(true)}
388
+ />
389
+ </Stack>
390
+ </LayoutHeader>
391
+ ) : undefined
392
+ }
253
393
  content={
254
394
  <LayoutContent padding={0}>
255
395
  <VisuallyHidden as="h1">Code editor</VisuallyHidden>
@@ -260,27 +400,7 @@ export default function IdeWorkspace() {
260
400
  <>
261
401
  {!startPanel.isCollapsed && (
262
402
  <LayoutPanel width={startPanel.size} hasDivider={false} padding={4}>
263
- <Stack direction="vertical" style={styles.fileExplorer} gap={2}>
264
- <TextInput
265
- label="Search files"
266
- isLabelHidden
267
- value={query}
268
- onChange={setQuery}
269
- placeholder="Search the night"
270
- size="md"
271
- startIcon={Search}
272
- hasClear
273
- />
274
- {visibleTree.length > 0 ? (
275
- <TreeList items={visibleTree} density="compact" />
276
- ) : (
277
- <EmptyState
278
- title="No files match"
279
- description={`Nothing in the night answers to "${query.trim()}".`}
280
- isCompact
281
- />
282
- )}
283
- </Stack>
403
+ {fileExplorer}
284
404
  </LayoutPanel>
285
405
  )}
286
406
  <ResizeHandle
@@ -399,78 +519,7 @@ export default function IdeWorkspace() {
399
519
  />
400
520
  {!endPanel.isCollapsed && (
401
521
  <LayoutPanel width={endPanel.size} hasDivider={false} padding={4}>
402
- <Stack direction="vertical" gap={3} style={styles.propertiesPanel}>
403
- <SegmentedControl
404
- label="Properties panel sections"
405
- value={activePropertiesTab}
406
- onChange={setActivePropertiesTab}
407
- size="sm"
408
- layout="fill"
409
- >
410
- <SegmentedControlItem label="Properties" value="properties" />
411
- <SegmentedControlItem label="History" value="history" />
412
- </SegmentedControl>
413
- {activePropertiesTab === "properties" ? (
414
- <Stack
415
- direction="vertical"
416
- gap={3}
417
- style={styles.propertiesContent}
418
- >
419
- <Stack direction="vertical" gap={1}>
420
- <Heading level={3} maxLines={1}>
421
- {activeFile}
422
- </Heading>
423
- <Text color="secondary" type="supporting" maxLines={1}>
424
- src/components/{activeFile}
425
- </Text>
426
- </Stack>
427
- <MetadataList style={styles.metadataCompact}>
428
- {PROPERTIES.map((prop) => (
429
- <MetadataListItem key={prop.label} label={prop.label}>
430
- {prop.value}
431
- </MetadataListItem>
432
- ))}
433
- </MetadataList>
434
- <Stack
435
- direction="vertical"
436
- gap={2}
437
- style={styles.propertyActions}
438
- >
439
- <Button label="Format Document" size="sm" variant="secondary" />
440
- <Button
441
- label="Go to Definition"
442
- size="sm"
443
- variant="secondary"
444
- />
445
- <Button label="Find References" size="sm" variant="secondary" />
446
- </Stack>
447
- </Stack>
448
- ) : (
449
- <Stack direction="vertical" gap={1}>
450
- <List>
451
- {HISTORY_ITEMS.map((item) => (
452
- <ListItem
453
- key={item.label}
454
- label={item.label}
455
- endContent={
456
- <Text
457
- type="supporting"
458
- color="secondary"
459
- maxLines={1}
460
- hasTabularNumbers
461
- >
462
- {item.time}
463
- </Text>
464
- }
465
- startContent={
466
- <StatusDot variant="neutral" label="Timeline marker" />
467
- }
468
- />
469
- ))}
470
- </List>
471
- </Stack>
472
- )}
473
- </Stack>
522
+ {propertiesBody}
474
523
  </LayoutPanel>
475
524
  )}
476
525
  </>
@@ -480,6 +529,38 @@ export default function IdeWorkspace() {
480
529
  </LayoutContent>
481
530
  }
482
531
  />
532
+ {/* Below 768 the file tree and the properties rail are Drawers
533
+ opened from the header: as columns they would leave the
534
+ editor nothing. Both are gated on `isMobile`, so widening
535
+ the viewport restores the desktop panes without leaving an
536
+ overlay behind. DrawerHeader draws the close button only
537
+ when it is given onOpenChange. */}
538
+ <Drawer
539
+ side="start"
540
+ label="File tree"
541
+ isOpen={isMobile && isFileTreeOpen}
542
+ onOpenChange={setFileTreeOpen}
543
+ width={startPanel.size}
544
+ >
545
+ <Layout
546
+ height="fill"
547
+ header={<DrawerHeader title="File tree" onOpenChange={setFileTreeOpen} />}
548
+ content={<LayoutContent padding={4}>{fileExplorer}</LayoutContent>}
549
+ />
550
+ </Drawer>
551
+ <Drawer
552
+ side="end"
553
+ label="File properties"
554
+ isOpen={isMobile && isPropertiesOpen}
555
+ onOpenChange={setPropertiesOpen}
556
+ width={endPanel.size}
557
+ >
558
+ <Layout
559
+ height="fill"
560
+ header={<DrawerHeader title="File properties" onOpenChange={setPropertiesOpen} />}
561
+ content={<LayoutContent padding={4}>{propertiesBody}</LayoutContent>}
562
+ />
563
+ </Drawer>
483
564
  </LayoutContent>
484
565
  }
485
566
  />
@@ -47,6 +47,12 @@ import { BellRing, Plus } from "lucide-react";
47
47
  import { type CSSProperties, useMemo, useState } from "react";
48
48
 
49
49
  const styles: Record<string, CSSProperties> = {
50
+ // The page's definite height. `Layout height="fill"` is height:100%, which
51
+ // resolves to auto against a content-sized host and hands the scroll to the
52
+ // document; 100dvh is absolute and needs no ancestor.
53
+ page: {
54
+ height: "100dvh",
55
+ },
50
56
  contentFill: {
51
57
  height: "100%",
52
58
  minHeight: 0,
@@ -56,7 +62,12 @@ const styles: Record<string, CSSProperties> = {
56
62
  },
57
63
  groupHeader: {
58
64
  padding: "var(--spacing-2) var(--spacing-3)",
59
- backgroundColor: "var(--color-background-muted)",
65
+ // A band sitting over its own rows, so it lifts off the page surface.
66
+ // It used to lean on --color-background-muted, which held Selection
67
+ // #44475A; that token is Background Dark #21222C now, a RECESS, and a
68
+ // header painted in it reads as a trough between the rows instead of a
69
+ // band above them.
70
+ backgroundColor: "var(--color-background-surface)",
60
71
  },
61
72
  // All FOUR edges, not block-only. With `overflowY: auto` and no `overflowX`,
62
73
  // the inline axis computes to `auto` too, so this box clips horizontally as
@@ -360,10 +371,8 @@ function IncidentRows({
360
371
  {groups.map((group) => (
361
372
  <VStack gap={0} key={group.status}>
362
373
  <HStack gap={2} vAlign="center" style={styles.groupHeader}>
363
- {/* Primary, not secondary: --color-text-secondary #9AA1BC on
364
- --color-background-muted #44475A is 3.57:1, under the 4.5:1
365
- floor. The group heading is the string this sidebar is scanned
366
- for. Hierarchy comes from the two type roles (principle 6),
374
+ {/* The group heading is the string this sidebar is scanned
375
+ for, so hierarchy comes from the two type roles (principle 6),
367
376
  not from dimming the count. */}
368
377
  <Text type="label">{STATUS[group.status].label}</Text>
369
378
  <Text type="supporting" hasTabularNumbers>
@@ -547,6 +556,7 @@ export default function IncidentConsole() {
547
556
  <>
548
557
  <Layout
549
558
  height="fill"
559
+ style={styles.page}
550
560
  header={
551
561
  <LayoutHeader hasDivider padding={6}>
552
562
  {isNarrow ? (
@@ -2,7 +2,7 @@
2
2
  // XLE (canonical structure of the tree below; the CLI had no XLE validator
3
3
  // after `astryx layout` was removed in 0.7.0, so this is a reading aid and a
4
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)
5
+ // S[h=100dvh] > VH + (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
6
 
7
7
  import { Badge } from "@astryxdesign/core/Badge";
8
8
  import { Button } from "@astryxdesign/core/Button";
@@ -21,6 +21,7 @@ import { StatusDot } from "@astryxdesign/core/StatusDot";
21
21
  import { Heading, Text } from "@astryxdesign/core/Text";
22
22
  import { Token } from "@astryxdesign/core/Token";
23
23
  import { Toolbar } from "@astryxdesign/core/Toolbar";
24
+ import { VisuallyHidden } from "@astryxdesign/core/VisuallyHidden";
24
25
  import {
25
26
  ArrowUpDown,
26
27
  CircleCheck,
@@ -312,6 +313,10 @@ function BoardCard({
312
313
  // focused card across columns. The pointer DnD below stays quarantined and
313
314
  // untouched; this only reuses its moveItem commit.
314
315
  const onKeyDown = (e: ReactKeyboardEvent) => {
316
+ // Let the card's own controls (the actions menu) keep arrow keys.
317
+ if ((e.target as HTMLElement).closest('button, [role="menuitem"], [role="menu"]')) {
318
+ return;
319
+ }
315
320
  const at = COLUMNS.findIndex((c) => c.id === item.column);
316
321
  const to =
317
322
  e.key === "ArrowRight"
@@ -333,7 +338,11 @@ function BoardCard({
333
338
  // drag clone floats above the board with the high shadow token instead.
334
339
  style={{ cursor: "grab", userSelect: "none", touchAction: "none" }}
335
340
  tabIndex={0}
336
- role="button"
341
+ // Not `button`: the card contains its own actions menu and a heading,
342
+ // and `button` makes every descendant presentational (ARIA
343
+ // "children presentational"), hiding both from assistive tech. The
344
+ // card is a member of the column list that happens to be focusable.
345
+ role="listitem"
337
346
  aria-label={`${item.title}. In ${COLUMNS.find((c) => c.id === item.column)?.title}. Press left or right arrow to move across columns.`}
338
347
  onPointerDown={(e) => onPointerDown(e, item.id)}
339
348
  onKeyDown={onKeyDown}
@@ -357,7 +366,15 @@ function BoardColumn({
357
366
  children: ReactNode;
358
367
  }) {
359
368
  return (
360
- <Card variant="muted" padding={0} style={{ minWidth: 0, height: "100%" }}>
369
+ <Card
370
+ variant="muted"
371
+ padding={0}
372
+ style={{ minWidth: 0, height: "100%" }}
373
+ // Each column is a landmark: the board region alone announces four
374
+ // undifferentiated lists of cards.
375
+ role="region"
376
+ aria-label={`${meta.title} column`}
377
+ >
361
378
  <Layout
362
379
  height="fill"
363
380
  header={
@@ -421,6 +438,9 @@ export default function KanbanBoard() {
421
438
  const [items, setItems] = useState<WorkItem[]>(INITIAL_ITEMS);
422
439
  const [sprint, setSprint] = useState("003");
423
440
  const [drag, setDrag] = useState<DragState | null>(null);
441
+ // Polite announcement for moves that have no visual follow (keyboard arrows,
442
+ // menu "Move to …"). Pointer drags show their own landing ghost.
443
+ const [announcement, setAnnouncement] = useState("");
424
444
 
425
445
  // Live element registries for pointer hit-testing (kept out of render state).
426
446
  const columnEls = useRef(new Map<ColumnId, HTMLElement>());
@@ -453,7 +473,18 @@ export default function KanbanBoard() {
453
473
  const itemsByColumn = groupByColumn(items);
454
474
 
455
475
  const moveItem = (id: string, to: ColumnId) => {
476
+ // A cross-column move remounts the card inside the destination list, so
477
+ // focus would fall to <body> and the next arrow press would be lost.
478
+ const hadFocus = cardEls.current.get(id)?.contains(document.activeElement) ?? false;
479
+ const moved = items.find((item) => item.id === id);
480
+ const column = COLUMNS.find((meta) => meta.id === to);
456
481
  setItems((prev) => prev.map((item) => (item.id === id ? { ...item, column: to } : item)));
482
+ if (moved && column) {
483
+ setAnnouncement(`Moved ${moved.title} to ${column.title}`);
484
+ }
485
+ if (hadFocus) {
486
+ requestAnimationFrame(() => cardEls.current.get(id)?.focus());
487
+ }
457
488
  };
458
489
 
459
490
  // Resolve the pointer position to a column + insertion index, ignoring the
@@ -621,6 +652,8 @@ export default function KanbanBoard() {
621
652
  <VStack
622
653
  key="drag-ghost"
623
654
  // Placeholder marking the landing slot; matches the dragged card's height.
655
+ // Hidden from assistive tech: it is a visual target, not a list member.
656
+ aria-hidden="true"
624
657
  style={{
625
658
  height: ghostTarget.height,
626
659
  borderRadius: "var(--radius-element)",
@@ -630,11 +663,16 @@ export default function KanbanBoard() {
630
663
  );
631
664
  }
632
665
 
633
- return <VStack gap={2}>{nodes}</VStack>;
666
+ return (
667
+ <VStack gap={2} role="list">
668
+ {nodes}
669
+ </VStack>
670
+ );
634
671
  };
635
672
 
636
673
  return (
637
674
  <Section height="100dvh">
675
+ <VisuallyHidden role="status">{announcement}</VisuallyHidden>
638
676
  <Layout
639
677
  height="fill"
640
678
  header={
@@ -718,6 +756,10 @@ export default function KanbanBoard() {
718
756
  <Card
719
757
  padding={3}
720
758
  elevation="none"
759
+ // Visual proxy for the pointer drag: its contents duplicate the
760
+ // real card (including an actions menu), so keep it out of the
761
+ // accessibility tree and the tab order.
762
+ inert
721
763
  style={{
722
764
  position: "fixed",
723
765
  insetBlockStart: 0,
@@ -2,7 +2,7 @@
2
2
  // XLE (canonical structure of the tree below; the CLI had no XLE validator
3
3
  // after `astryx layout` was removed in 0.7.0, so this is a reading aid and a
4
4
  // human drift check — update it with the file, never alone):
5
- // L[h=fill] > (LH[divider p=6] > (V[g=1] > Hd"Library"[level=1] + Tx"30 components across 5 categories"[t=body])) + (LC[p=6] > V[g=8] > (V[g=4] > TI"Search" + (H[g=3 a=center] > (SI[fill] > V > TgG"Filter" > OFL > Tg"All"! + Tg"Layout" + Tg"Forms" + Tg"Navigation" + Tg"Feedback" + Tg"Data") + DD"A-Z")) + (V[g=8] > (D + (V[g=8] > (H[j=between a=center] > Hd"Layout"[level=2] + Tx"6 items"[t=body]) + (G[c={min:280} g=4] > (C[p=0] > AR[ratio=16/9] > Scene{SceneTile}) + (S.transparent[p=4] > V[g=1] > Hd"Card"[level=3] + Tx"Description"[t=body]))*6))*5))
5
+ // L[h=fill] > (LH[divider p=6] > (V[g=1] > Hd"Library"[level=1 t=display-2] + Tx"{ITEMS.length} components across {CATEGORIES.length - 1} categories"[t=body])) + (LC[p=6] > V[g=8] > (V[g=4] > TI"Search" + (H[g=3 a=center] > (SI[fill] > V > TgG"Filter" > OFL > Tg"All"! + Tg"Layout" + Tg"Forms" + Tg"Navigation" + Tg"Feedback" + Tg"Data") + DD"A-Z")) + (V[g=8] > (D + (V[g=8] > (H[j=between a=center] > Hd"Layout"[level=2] + Tx"{section.items.length} items"[t=body]) + (G[c={min:280} g=4] > (C[p=0] > AR[ratio=16/9] > Scene{SceneTile}) + (S.transparent[p=4] > V[g=1] > Hd"Card"[level=3] + Tx"Description"[t=body]))*{section.items.length}))*{CATEGORIES.length - 1}))
6
6
 
7
7
  /**
8
8
  * Library — a browsable grid of design-system entries grouped by category. Frame: page header (title) | content column (search, filter row, s (Frame/responsive/container: see XLE header above.)
@@ -26,6 +26,7 @@ import { ToggleButton, ToggleButtonGroup } from "@astryxdesign/core/ToggleButton
26
26
  import type { SceneHue } from "astryx-dracula/shared/scene-hues";
27
27
  import { SceneTile } from "astryx-dracula/shared/scene-tile";
28
28
  import { Search } from "lucide-react";
29
+ import type { CSSProperties } from "react";
29
30
  import { useState } from "react";
30
31
 
31
32
  interface LibraryItem {
@@ -285,6 +286,11 @@ function LibraryCard({ item }: { item: LibraryItem }) {
285
286
  );
286
287
  }
287
288
 
289
+ // Definite ancestor for `height="fill"`: 100dvh, not minHeight: '100%' (a
290
+ // percentage min-height against an indefinite block computes to 0 — CSS 2.1
291
+ // 10.5). Templates ship standalone, so the page owns this, not the host.
292
+ const pageStyle: CSSProperties = { height: "100dvh" };
293
+
288
294
  export default function LibraryGrid() {
289
295
  const [activeTab, setActiveTab] = useState("All");
290
296
  const [search, setSearch] = useState("");
@@ -329,12 +335,15 @@ export default function LibraryGrid() {
329
335
  return (
330
336
  <Layout
331
337
  height="fill"
338
+ style={pageStyle}
332
339
  header={
333
340
  <LayoutHeader hasDivider padding={6}>
334
341
  <VStack gap={1}>
335
- <Heading level={1}>Library</Heading>
342
+ <Heading level={1} type="display-2">
343
+ Library
344
+ </Heading>
336
345
  <Text type="body" color="secondary">
337
- 30 components across 5 categories
346
+ {ITEMS.length} components across {CATEGORIES.length - 1} categories
338
347
  </Text>
339
348
  </VStack>
340
349
  </LayoutHeader>
@@ -15,7 +15,7 @@ import { Link } from "@astryxdesign/core/Link";
15
15
  import { Section } from "@astryxdesign/core/Section";
16
16
  import { Heading, Text } from "@astryxdesign/core/Text";
17
17
  import { TextInput } from "@astryxdesign/core/TextInput";
18
- import { authPageStyle, inputAutoComplete } from "astryx-dracula/shared/auth-chrome-config";
18
+ import { authPageStyle } from "astryx-dracula/shared/auth-chrome-config";
19
19
  import {
20
20
  AUTH_BRAND_NAME,
21
21
  AUTH_EMAIL_PLACEHOLDER,
@@ -136,7 +136,7 @@ export default function LoginSplit() {
136
136
  label="Email"
137
137
  isLabelHidden
138
138
  type="email"
139
- {...inputAutoComplete("email")}
139
+ autoComplete="email"
140
140
  placeholder={AUTH_EMAIL_PLACEHOLDER}
141
141
  value={email}
142
142
  onChange={(v: string) => {
@@ -153,7 +153,7 @@ export default function LoginSplit() {
153
153
  isLabelHidden
154
154
  placeholder={AUTH_PASSWORD_PLACEHOLDER}
155
155
  type="password"
156
- {...inputAutoComplete("current-password")}
156
+ autoComplete="current-password"
157
157
  value={password}
158
158
  onChange={(v: string) => {
159
159
  setPassword(v);