cursedbelt 5.78.0 → 5.79.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 (138) hide show
  1. package/dist/react/canvas-hud/HudControls.d.ts +8 -2
  2. package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
  3. package/dist/react/canvas-hud/HudControls.js +6 -3
  4. package/dist/react/canvas-hud/HudControls.js.map +1 -1
  5. package/dist/react/components/AppShell.d.ts +14 -0
  6. package/dist/react/components/AppShell.d.ts.map +1 -1
  7. package/dist/react/components/AppShell.js +7 -31
  8. package/dist/react/components/AppShell.js.map +1 -1
  9. package/dist/react/components/ChipFilterStrip.d.ts.map +1 -1
  10. package/dist/react/components/ChipFilterStrip.js +2 -18
  11. package/dist/react/components/ChipFilterStrip.js.map +1 -1
  12. package/dist/react/components/KanbanBoard.d.ts +55 -0
  13. package/dist/react/components/KanbanBoard.d.ts.map +1 -0
  14. package/dist/react/components/KanbanBoard.js +89 -0
  15. package/dist/react/components/KanbanBoard.js.map +1 -0
  16. package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
  17. package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
  18. package/dist/react/components/ResponsiveWorkbench.js +33 -4
  19. package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
  20. package/dist/react/components/SelectField.d.ts +18 -48
  21. package/dist/react/components/SelectField.d.ts.map +1 -1
  22. package/dist/react/components/SelectField.js +88 -11
  23. package/dist/react/components/SelectField.js.map +1 -1
  24. package/dist/react/components/SelectFieldImpl.d.ts +7 -0
  25. package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
  26. package/dist/react/components/SelectFieldImpl.js +26 -0
  27. package/dist/react/components/SelectFieldImpl.js.map +1 -0
  28. package/dist/react/components/filterChip.d.ts +8 -0
  29. package/dist/react/components/filterChip.d.ts.map +1 -0
  30. package/dist/react/components/filterChip.js +20 -0
  31. package/dist/react/components/filterChip.js.map +1 -0
  32. package/dist/react/hooks/pointerDnd.d.ts +37 -3
  33. package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
  34. package/dist/react/hooks/pointerDnd.js +101 -9
  35. package/dist/react/hooks/pointerDnd.js.map +1 -1
  36. package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
  37. package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
  38. package/dist/react/hooks/pragmaticDnd.js +9 -3
  39. package/dist/react/hooks/pragmaticDnd.js.map +1 -1
  40. package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
  41. package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
  42. package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
  43. package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
  44. package/dist/react/index.d.ts +3 -0
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +3 -0
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/lib/selectField.d.ts +72 -0
  49. package/dist/react/lib/selectField.d.ts.map +1 -0
  50. package/dist/react/lib/selectField.js +23 -0
  51. package/dist/react/lib/selectField.js.map +1 -0
  52. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  53. package/dist/react/media/VideoChapterEditor.js +2 -1
  54. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  55. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  56. package/dist/react/media/VideoCutEditor.js +2 -1
  57. package/dist/react/media/VideoCutEditor.js.map +1 -1
  58. package/dist/react/media/denseGallery.css +2 -0
  59. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  60. package/dist/react/media-gallery/MediaGallery.js +6 -7
  61. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  62. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
  63. package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
  64. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
  65. package/dist/react/nav/CommandBar.d.ts.map +1 -1
  66. package/dist/react/nav/CommandBar.js +2 -1
  67. package/dist/react/nav/CommandBar.js.map +1 -1
  68. package/dist/react/palette/CommandPalette.d.ts +15 -1
  69. package/dist/react/palette/CommandPalette.d.ts.map +1 -1
  70. package/dist/react/palette/CommandPalette.js +58 -8
  71. package/dist/react/palette/CommandPalette.js.map +1 -1
  72. package/dist/react/palette/Kbd.d.ts +15 -0
  73. package/dist/react/palette/Kbd.d.ts.map +1 -0
  74. package/dist/react/palette/Kbd.js +17 -0
  75. package/dist/react/palette/Kbd.js.map +1 -0
  76. package/dist/react/palette/ShortcutSheet.d.ts +30 -0
  77. package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
  78. package/dist/react/palette/ShortcutSheet.js +15 -0
  79. package/dist/react/palette/ShortcutSheet.js.map +1 -0
  80. package/dist/react/palette/commandSources.d.ts +14 -0
  81. package/dist/react/palette/commandSources.d.ts.map +1 -1
  82. package/dist/react/palette/commandSources.js +11 -1
  83. package/dist/react/palette/commandSources.js.map +1 -1
  84. package/dist/react/palette/index.d.ts +4 -2
  85. package/dist/react/palette/index.d.ts.map +1 -1
  86. package/dist/react/palette/index.js +2 -0
  87. package/dist/react/palette/index.js.map +1 -1
  88. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  89. package/dist/styles-areas/canvas-hud.css +2 -2
  90. package/dist/styles-areas/kanban.css +31 -0
  91. package/dist/styles-areas/palette.css +3 -3
  92. package/dist/styles-areas/workbench.css +15 -13
  93. package/dist/styles-utilities.css +42 -0
  94. package/package.json +3 -2
  95. package/scripts/guardrails-baseline.json +2 -1
  96. package/scripts/guardrailsEnforce.spec.ts +62 -0
  97. package/scripts/guardrailsEnforce.ts +77 -6
  98. package/scripts/styleAreas.ts +2 -0
  99. package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
  100. package/src/react/canvas-hud/HudControls.tsx +33 -9
  101. package/src/react/components/AppShell.spec.tsx +40 -0
  102. package/src/react/components/AppShell.tsx +25 -27
  103. package/src/react/components/ChipFilterStrip.tsx +2 -38
  104. package/src/react/components/KanbanBoard.spec.tsx +89 -0
  105. package/src/react/components/KanbanBoard.tsx +270 -0
  106. package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
  107. package/src/react/components/ResponsiveWorkbench.tsx +69 -4
  108. package/src/react/components/SelectField.spec.tsx +117 -0
  109. package/src/react/components/SelectField.tsx +131 -166
  110. package/src/react/components/SelectFieldImpl.tsx +140 -0
  111. package/src/react/components/filterChip.tsx +43 -0
  112. package/src/react/hooks/pointerDnd.spec.ts +89 -3
  113. package/src/react/hooks/pointerDnd.ts +103 -8
  114. package/src/react/hooks/pragmaticDnd.ts +13 -2
  115. package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
  116. package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
  117. package/src/react/index.ts +3 -0
  118. package/src/react/lib/floatingStyles.spec.ts +1 -1
  119. package/src/react/lib/selectField.ts +96 -0
  120. package/src/react/media/VideoChapterEditor.tsx +3 -2
  121. package/src/react/media/VideoCutEditor.tsx +3 -2
  122. package/src/react/media/denseGallery.css +2 -0
  123. package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
  124. package/src/react/media-gallery/MediaGallery.tsx +16 -18
  125. package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
  126. package/src/react/nav/CommandBar.tsx +2 -3
  127. package/src/react/palette/CommandPalette.spec.tsx +73 -0
  128. package/src/react/palette/CommandPalette.tsx +153 -13
  129. package/src/react/palette/Kbd.tsx +46 -0
  130. package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
  131. package/src/react/palette/ShortcutSheet.tsx +110 -0
  132. package/src/react/palette/commandSources.tsx +30 -1
  133. package/src/react/palette/index.ts +9 -0
  134. package/src/styles-areas/canvas-hud.css +2 -2
  135. package/src/styles-areas/kanban.css +31 -0
  136. package/src/styles-areas/palette.css +3 -3
  137. package/src/styles-areas/workbench.css +15 -13
  138. package/src/styles-utilities.css +42 -0
@@ -297,6 +297,9 @@
297
297
  .bottom-6 {
298
298
  bottom: calc(var(--spacing) * 6);
299
299
  }
300
+ .bottom-\[max\(env\(safe-area-inset-bottom\,0px\)\,0\.75rem\)\] {
301
+ bottom: max(env(safe-area-inset-bottom,0px), 0.75rem);
302
+ }
300
303
  .-left-1\.5 {
301
304
  left: calc(var(--spacing) * -1.5);
302
305
  }
@@ -1323,6 +1326,9 @@
1323
1326
  .resize-y {
1324
1327
  resize: vertical;
1325
1328
  }
1329
+ .snap-start {
1330
+ scroll-snap-align: start;
1331
+ }
1326
1332
  .\[scrollbar-width\:none\] {
1327
1333
  scrollbar-width: none;
1328
1334
  }
@@ -1576,6 +1582,9 @@
1576
1582
  .overscroll-contain {
1577
1583
  overscroll-behavior: contain;
1578
1584
  }
1585
+ .overscroll-x-auto {
1586
+ overscroll-behavior-x: auto;
1587
+ }
1579
1588
  .rounded {
1580
1589
  border-radius: 0.25rem;
1581
1590
  }
@@ -2648,6 +2657,9 @@
2648
2657
  .pt-1 {
2649
2658
  padding-top: var(--spacing);
2650
2659
  }
2660
+ .pt-1\.5 {
2661
+ padding-top: calc(var(--spacing) * 1.5);
2662
+ }
2651
2663
  .pt-2 {
2652
2664
  padding-top: calc(var(--spacing) * 2);
2653
2665
  }
@@ -3201,6 +3213,10 @@
3201
3213
  --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
3202
3214
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
3203
3215
  }
3216
+ .shadow-2xl {
3217
+ --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
3218
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
3219
+ }
3204
3220
  .shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.55\)\] {
3205
3221
  --tw-shadow: 0 0 0 1px var(--tw-shadow-color, rgba(0,0,0,0.55));
3206
3222
  box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -3295,6 +3311,12 @@
3295
3311
  --tw-ring-color: color-mix(in oklab, var(--accent) 40%, transparent);
3296
3312
  }
3297
3313
  }
3314
+ .ring-accent\/50 {
3315
+ --tw-ring-color: var(--accent);
3316
+ @supports (color: color-mix(in lab, red, red)) {
3317
+ --tw-ring-color: color-mix(in oklab, var(--accent) 50%, transparent);
3318
+ }
3319
+ }
3298
3320
  .ring-background {
3299
3321
  --tw-ring-color: var(--background);
3300
3322
  }
@@ -3979,6 +4001,9 @@
3979
4001
  --tw-outline-style: none;
3980
4002
  outline-style: none;
3981
4003
  }
4004
+ .focus-visible\:border-accent:focus-visible {
4005
+ border-color: var(--accent);
4006
+ }
3982
4007
  .focus-visible\:border-border-strong:focus-visible {
3983
4008
  border-color: var(--border-strong);
3984
4009
  }
@@ -4482,6 +4507,14 @@
4482
4507
  scale: var(--tw-scale-x) var(--tw-scale-y);
4483
4508
  }
4484
4509
  }
4510
+ @media (width < 64rem) {
4511
+ .max-lg\:snap-x {
4512
+ scroll-snap-type: x var(--tw-scroll-snap-strictness);
4513
+ }
4514
+ .max-lg\:snap-mandatory {
4515
+ --tw-scroll-snap-strictness: mandatory;
4516
+ }
4517
+ }
4485
4518
  @media (width < 48rem) {
4486
4519
  .max-md\:absolute {
4487
4520
  position: absolute;
@@ -4495,6 +4528,9 @@
4495
4528
  .max-md\:hidden {
4496
4529
  display: none;
4497
4530
  }
4531
+ .max-md\:w-full {
4532
+ width: 100%;
4533
+ }
4498
4534
  }
4499
4535
  @media (width < 40rem) {
4500
4536
  .max-sm\:inset-x-0 {
@@ -5785,6 +5821,11 @@
5785
5821
  inherits: false;
5786
5822
  initial-value: 1;
5787
5823
  }
5824
+ @property --tw-scroll-snap-strictness {
5825
+ syntax: "*";
5826
+ inherits: false;
5827
+ initial-value: proximity;
5828
+ }
5788
5829
  @property --tw-content {
5789
5830
  syntax: "*";
5790
5831
  initial-value: "";
@@ -5915,6 +5956,7 @@
5915
5956
  --tw-scale-x: 1;
5916
5957
  --tw-scale-y: 1;
5917
5958
  --tw-scale-z: 1;
5959
+ --tw-scroll-snap-strictness: proximity;
5918
5960
  --tw-content: "";
5919
5961
  }
5920
5962
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.78.0",
3
+ "version": "5.79.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -75,7 +75,7 @@
75
75
  "demo:check": "tsc --noEmit --project demo/tsconfig.json",
76
76
  "demo:paths": "bun run demo/generatePaths.ts --write",
77
77
  "demo:coverage": "bun run scripts/demoCoverage.ts",
78
- "guardrails:report": "bun run scripts/guardrailsEnforce.ts no-raw-action-button,raw-button-resets-face,no-raw-overflow,no-raw-inputs,no-hardcoded-colors,no-raw-modal-footer,no-unguarded-editing-modal,control-height-tokens,no-raw-zindex,no-raw-data-table,no-hand-rolled-property-list,no-tailwind-palette,no-raw-title-attr,micro-typography-floor,choice-needs-empty-state,no-select-as-action-menu,no-native-select,no-native-dialog,no-fast-tooltip,no-raw-password,no-raw-lazy,no-raw-popover,no-hand-rolled-scroll-memory,no-hand-rolled-surface,no-inline-style,no-arbitrary-length,opt-out-count,opt-out-needs-cause --scope cursedbelt src/react src/styles.css",
78
+ "guardrails:report": "bun run scripts/guardrailsEnforce.ts no-raw-action-button,raw-button-resets-face,no-raw-overflow,no-raw-inputs,no-hardcoded-colors,no-raw-modal-footer,no-unguarded-editing-modal,control-height-tokens,no-raw-zindex,no-raw-data-table,no-hand-rolled-property-list,no-raw-kbd,no-tailwind-palette,no-raw-title-attr,micro-typography-floor,choice-needs-empty-state,no-select-as-action-menu,no-native-select,no-native-dialog,no-fast-tooltip,no-raw-password,no-raw-lazy,no-raw-popover,no-hand-rolled-scroll-memory,no-hand-rolled-surface,no-inline-style,no-arbitrary-length,opt-out-count,opt-out-needs-cause --scope cursedbelt src/react src/styles.css",
79
79
  "lint": "biome lint --error-on-warnings src e2e demo scripts",
80
80
  "styles": "bun run styles:static && bun run styles:utilities && bun run styles:areas",
81
81
  "styles:static": "bun run scripts/generateStaticStyles.ts",
@@ -657,6 +657,7 @@
657
657
  "./react/file-tree/geometry.json": "./src/react/file-tree/fileTreeGeometry.fixture.json",
658
658
  "./styles-areas/filter-rail.css": "./src/styles-areas/filter-rail.css",
659
659
  "./styles-areas/folder-tree.css": "./src/styles-areas/folder-tree.css",
660
+ "./styles-areas/kanban.css": "./src/styles-areas/kanban.css",
660
661
  "./styles-areas/keep-awake.css": "./src/styles-areas/keep-awake.css",
661
662
  "./styles-areas/layout-engine.css": "./src/styles-areas/layout-engine.css",
662
663
  "./styles-areas/markdown.css": "./src/styles-areas/markdown.css",
@@ -11,6 +11,7 @@
11
11
  "no-unguarded-editing-modal": 0,
12
12
  "no-raw-data-table": 0,
13
13
  "no-hand-rolled-property-list": 0,
14
+ "no-raw-kbd": 0,
14
15
  "no-tailwind-palette": 0,
15
16
  "no-raw-title-attr": 0,
16
17
  "no-local-reimplementation": 0,
@@ -22,7 +23,7 @@
22
23
  "no-hand-rolled-surface": 0,
23
24
  "no-inline-style": 73,
24
25
  "no-arbitrary-length": 0,
25
- "opt-out-count": 272,
26
+ "opt-out-count": 273,
26
27
  "opt-out-needs-cause": 255
27
28
  },
28
29
  "cursedshell": {
@@ -1054,6 +1054,15 @@ describe('no-native-select — the OS draws its list (owner, 2026-10-10)', () =>
1054
1054
  expect(check('no-native-select', source)).toHaveLength(2);
1055
1055
  });
1056
1056
 
1057
+ test('a raw <select> is the same OS list, and counts too (forge task 3097)', () => {
1058
+ const source = `<select className="cs-select" value={rule.field} onChange={(e) => change(e.target.value)}>
1059
+ {FIELDS.map((f) => <option key={f}>{f}</option>)}
1060
+ </select>`;
1061
+ expect(check('no-native-select', source)).toHaveLength(1);
1062
+ expect(check('no-native-select', `{/* a \`<select>\` here would be an OS list */}\n<SelectField options={o} />`)).toHaveLength(0);
1063
+ expect(check('no-native-select', `<selection-bar />`)).toHaveLength(0);
1064
+ });
1065
+
1057
1066
  test('the styled controls, and a comment that names it, are not one', () => {
1058
1067
  expect(check('no-native-select', `<SelectField value={sort} options={SORTS} />`)).toHaveLength(0);
1059
1068
  expect(check('no-native-select', `// was a <NativeSelect> until 2026-10-10\n<PropertyPicker name="Type" />`)).toHaveLength(0);
@@ -1442,6 +1451,36 @@ describe('--baseline (a consuming repo\'s ratchet)', () => {
1442
1451
  );
1443
1452
  });
1444
1453
 
1454
+ test('🔴 a row for a rule this belt does not have yet only warns — the app is behind a bump, not drifting (task 3098)', async () => {
1455
+ // 2026-10-13: a new rule's row went into every app's baseline with the belt still
1456
+ // unpublished, and the six apps on the LOCKED belt went red at once.
1457
+ await withFixture(
1458
+ { ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2, 'no-rule-from-the-future': 0 }) },
1459
+ async (dir) => {
1460
+ const { code, report } = await runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']);
1461
+ expect(report).toContain('this cursedbelt does not have yet: no-rule-from-the-future');
1462
+ expect(report).not.toContain('nobody scans');
1463
+ expect(report).toContain('✓ no-hardcoded-colors: 2 violation(s)');
1464
+ expect(code).toBe(0);
1465
+ },
1466
+ );
1467
+ });
1468
+
1469
+ test('a row ahead of the belt does not excuse a row the belt knows and does not scan', async () => {
1470
+ await withFixture(
1471
+ {
1472
+ ...FIXTURE,
1473
+ 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2, 'no-raw-zindex': 0, 'no-rule-from-the-future': 0 }),
1474
+ },
1475
+ async (dir) => {
1476
+ const { code, report } = await runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']);
1477
+ expect(code).toBe(2);
1478
+ expect(report).toContain('row(s) for rules nobody scans: no-raw-zindex');
1479
+ expect(report).not.toContain('nobody scans: no-raw-zindex, no-rule-from-the-future');
1480
+ },
1481
+ );
1482
+ });
1483
+
1445
1484
  test('a scanned rule with no row is refused — a silent 0 demands the whole pile at once', async () => {
1446
1485
  await withFixture({ ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2 }) }, async (dir) => {
1447
1486
  const { code, report } = await runCli(dir, [
@@ -1651,6 +1690,29 @@ describe('no-hand-rolled-property-list', () => {
1651
1690
  });
1652
1691
  });
1653
1692
 
1693
+ describe('no-raw-kbd', () => {
1694
+ const rule = RULES['no-raw-kbd'];
1695
+ test('a raw <kbd> in app source is a violation — desk\'s switcher foot drew its own', () => {
1696
+ const found = rule.check('src/ui/QuickSwitcher.tsx', `<footer>\n <kbd>↑</kbd> move\n</footer>`);
1697
+ expect(found).toHaveLength(1);
1698
+ expect(found[0]?.line).toBe(2);
1699
+ });
1700
+ test('the belt\'s Kbd is the fix, and is not flagged', () => {
1701
+ expect(rule.check('src/ui/QuickSwitcher.tsx', `<Kbd keys="$mod+k" /> <Kbd>↑</Kbd>`)).toHaveLength(0);
1702
+ });
1703
+ test('an opt-out with a reason on the block above is honoured; a comment mentioning <kbd> is not a violation', () => {
1704
+ expect(
1705
+ rule.check('src/ui/Menu.tsx', `// guardrails-ignore no-raw-kbd: a menu accelerator is bare text,\n// not a cap\n<kbd>{shortcut}</kbd>`),
1706
+ ).toHaveLength(0);
1707
+ expect(rule.check('src/ui/Menu.tsx', `// the old <kbd> here was hand-drawn`)).toHaveLength(0);
1708
+ });
1709
+ test('exempt by construction: Kbd itself, fixtures and stylesheets', () => {
1710
+ for (const file of ['src/react/palette/Kbd.tsx', 'src/ui/Foot.spec.tsx', 'src/ui/foot.css']) {
1711
+ expect(rule.check(file, `<kbd>K</kbd>`)).toHaveLength(0);
1712
+ }
1713
+ });
1714
+ });
1715
+
1654
1716
  describe('no-raw-nav', () => {
1655
1717
  test('🔴 flags a hand-rolled <nav> — the header music shipped on 2026-09-25', () => {
1656
1718
  expect(
@@ -76,6 +76,10 @@
76
76
  // `Property`. On the ELEMENT, like `no-raw-data-table`. The belt's own PropertyList, the
77
77
  // markdown/rich-text renderers and `*.spec|test.*` fixtures are exempt by construction.
78
78
  //
79
+ // no-raw-kbd — forge task 3087 (2026-10-13): no hand-drawn `<kbd>` — a key is cursedbelt's
80
+ // `Kbd` (`<Kbd keys="$mod+k" />` → ⌘K / Ctrl K), and an app's `?` list is `ShortcutSheet`.
81
+ // On the ELEMENT. The belt's own `Kbd`, stylesheets and `*.spec|test.*` fixtures are exempt.
82
+ //
79
83
  // no-raw-data-table — DESIGN.md §3 "Visual Formatting & Data Presentation" (owner, 2026-08-14): no hand-rolled `<table>` —
80
84
  // use `cursedbelt/react/data-table`'s `DataTable`, which carries sorting, a global
81
85
  // filter, column visibility, pagination and the phone card tier. The rule is on the
@@ -113,7 +117,7 @@
113
117
  // Semantic classes are never matched — `text-xs` IS 12px. A non-text glyph takes
114
118
  // the per-line opt-out.
115
119
  //
116
- // no-native-select — owner, 2026-10-10: a `NativeSelect`'s list is drawn by the OS, where no
120
+ // no-native-select — owner, 2026-10-10: a `NativeSelect`'s (or raw `<select>`'s) list is drawn by the OS, where no
117
121
  // stylesheet reaches it, so it breaks the app's cohesion. `SelectField` / `ComboboxField` /
118
122
  // `PropertyPicker` / `ChoiceMenu` instead; a use where the OS picker is genuinely better says
119
123
  // why with `// guardrails-ignore no-native-select: <why>` on the line above. Ratcheted per app.
@@ -1057,6 +1061,7 @@ const CONTROL_HEIGHT_FILES = new Set([
1057
1061
  "IconButton.tsx",
1058
1062
  "Input.tsx",
1059
1063
  "SelectField.tsx",
1064
+ "SelectFieldImpl.tsx",
1060
1065
  "ComboboxField.tsx",
1061
1066
  "Autocomplete.tsx",
1062
1067
  "TagsInput.tsx",
@@ -1296,6 +1301,48 @@ const noHandRolledPropertyList: Rule = {
1296
1301
  },
1297
1302
  };
1298
1303
 
1304
+ // ── no-raw-kbd (forge task 3087, 2026-10-13) ───────────────────────────────────
1305
+ // By 2026-10-13 four apps drew the same key four ways: vault's cap was mono with a 2px bottom
1306
+ // border, desk's a bare browser `<kbd>`, family's its own grey box, music's another — and ⌘ showed
1307
+ // to a Windows reader wherever a glyph had been typed by hand. cursedbelt 5.79.0's `Kbd` is the
1308
+ // one cap, formatted per platform from a binding (`<Kbd keys="$mod+k" />`), or drawn as written
1309
+ // from children (`<Kbd>↑</Kbd>`); `ShortcutSheet` is the `?` list built from it.
1310
+ //
1311
+ // So the rule is on the ELEMENT, like `no-hand-rolled-property-list`: a `<kbd` in app source is
1312
+ // a violation. Exempt by construction: `Kbd` itself, `DropdownMenu`'s accelerator column (bare
1313
+ // text in the platform's notation, as macOS's and Linear's menus draw it — not a cap), and
1314
+ // `*.spec|test.*` fixtures. A key named in
1315
+ // prose is still a key — it is `<Kbd>`; a BUTTON named in prose is `<strong>`, never a cap.
1316
+ const RAW_KBD_RE = /<kbd\b/g;
1317
+ const RAW_KBD_IGNORE_RE = /guardrails-ignore\s+no-raw-kbd\s*:/;
1318
+ const RAW_KBD_EXEMPT_FRAGMENTS = ["react/palette/Kbd.tsx", "react/components/DropdownMenu.tsx"];
1319
+
1320
+ const noRawKbd: Rule = {
1321
+ name: "no-raw-kbd",
1322
+ description:
1323
+ "forge task 3087: no hand-drawn <kbd> — a key cap is cursedbelt's Kbd (cursedbelt/react: <Kbd keys=\"$mod+k\" /> draws ⌘K on a Mac and Ctrl K elsewhere; <Kbd>↑</Kbd> draws a literal), and an app's ? list is ShortcutSheet. A raw <kbd> is how every app picks its own cap, and how ⌘ reaches a Windows reader.",
1324
+ check(filePath, source) {
1325
+ const normalized = filePath.replace(/\\/g, "/");
1326
+ if (extname(normalized) === ".css" || isTestSource(normalized)) return [];
1327
+ if (RAW_KBD_EXEMPT_FRAGMENTS.some((fragment) => normalized.includes(fragment))) return [];
1328
+ const violations: Violation[] = [];
1329
+ const scannable = blankComments(source);
1330
+ const lines = source.split("\n");
1331
+ for (const match of scannable.matchAll(RAW_KBD_RE)) {
1332
+ const line = lineOf(source, match.index ?? 0);
1333
+ const block: string[] = [];
1334
+ for (let i = line - 2; i >= 0 && block.length < 12; i--) {
1335
+ const raw = lines[i] ?? "";
1336
+ if (raw.trim() === "") break;
1337
+ block.push(raw);
1338
+ }
1339
+ if (RAW_KBD_IGNORE_RE.test(block.join("\n"))) continue;
1340
+ violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
1341
+ }
1342
+ return violations;
1343
+ },
1344
+ };
1345
+
1299
1346
  // ── no-unguarded-editing-modal ──────────────────────────────────────────────
1300
1347
  // The owner lost a half-typed password by clicking outside an editing modal,
1301
1348
  // which dismissed it and threw the form away with no confirmation. `Modal`/
@@ -2041,7 +2088,9 @@ const noSelectAsActionMenu: Rule = {
2041
2088
  // macOS Chrome a grey system menu no stylesheet reaches — so every app that used one had a control
2042
2089
  // that was not the kit's. `no-select-as-action-menu` caught only the action-menu shape of it.
2043
2090
  //
2044
- // Every app now counts its `<NativeSelect` uses in its two-sided ratchet, so the number only falls:
2091
+ // Every app now counts its `<NativeSelect` uses — and its raw `<select>`s, the same OS list without
2092
+ // even the kit's frame (forge task 3097: desk had eight under `no-raw-inputs`, which no count of
2093
+ // NativeSelect could see) — in its two-sided ratchet, so the number only falls:
2045
2094
  // `SelectField` / `ComboboxField` for a field, `PropertyPicker` for a record's property pill,
2046
2095
  // `ChoiceMenu` to choose-and-act. A use that is genuinely right — a form on a phone where the OS
2047
2096
  // wheel IS the better control — says so on the line above:
@@ -2051,18 +2100,19 @@ const NATIVE_SELECT_IGNORE_RE = /guardrails-ignore\s+no-native-select\s*:/;
2051
2100
  const noNativeSelect: Rule = {
2052
2101
  name: "no-native-select",
2053
2102
  description:
2054
- "Owner, 2026-10-10: unstyled dropdowns break the app's cohesiveness. A `NativeSelect`'s list is drawn by the OS, outside the page, where no stylesheet reaches it. Use `SelectField` / `ComboboxField` for a form field, `PropertyPicker` for a record's property (Type, Folder, Tags), or `ChoiceMenu` to choose-and-act. Where the OS picker is the better control, say why on the line above: `// guardrails-ignore no-native-select: <why>`.",
2103
+ "Owner, 2026-10-10: unstyled dropdowns break the app's cohesiveness. A `NativeSelect`'s (or a raw `<select>`'s) list is drawn by the OS, outside the page, where no stylesheet reaches it. Use `SelectField` / `ComboboxField` for a form field, `PropertyPicker` for a record's property (Type, Folder, Tags), or `ChoiceMenu` to choose-and-act. Where the OS picker is the better control, say why on the line above: `// guardrails-ignore no-native-select: <why>`.",
2055
2104
  check(filePath, source) {
2056
2105
  const unix = filePath.replace(/\\/g, "/");
2057
2106
  // The kit is held to its own law (forge task 3063: its DataTable filter builder and
2058
2107
  // FocusViewer drew OS menus into every app whose count read 0). Only the control itself
2059
2108
  // may render one.
2060
2109
  if (/(^|\/)(?:packages|libs)\/cursedbelt\/src\/react\/components\/NativeSelect\.tsx$/.test(unix)) return [];
2110
+ if (isBeltOwnSource(filePath) && /(^|\/)src\/react\/components\/NativeSelect\.tsx$/.test(unix)) return [];
2061
2111
  if (/\.(?:spec|test)\.[tj]sx?$/.test(unix)) return [];
2062
2112
  const scannable = blankComments(source);
2063
2113
  const lines = source.split("\n");
2064
2114
  const out: Violation[] = [];
2065
- for (const match of scannable.matchAll(/<NativeSelect\b/g)) {
2115
+ for (const match of scannable.matchAll(/<(?:NativeSelect|select)\b/g)) {
2066
2116
  const line = lineOf(source, match.index ?? 0);
2067
2117
  if (NATIVE_SELECT_IGNORE_RE.test(lines[line - 1] ?? "") || NATIVE_SELECT_IGNORE_RE.test(blockAbove(lines, line))) continue;
2068
2118
  out.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
@@ -3022,6 +3072,7 @@ export const RULES: Record<string, Rule> = {
3022
3072
  [noRawZindex.name]: noRawZindex,
3023
3073
  [noRawDataTable.name]: noRawDataTable,
3024
3074
  [noHandRolledPropertyList.name]: noHandRolledPropertyList,
3075
+ [noRawKbd.name]: noRawKbd,
3025
3076
  [noTailwindPalette.name]: noTailwindPalette,
3026
3077
  [themeColoursResolve.name]: themeColoursResolve,
3027
3078
  [noRawTitleAttr.name]: noRawTitleAttr,
@@ -3206,12 +3257,25 @@ function parseFlatBaseline(json: string, label: string): FlatBaseline {
3206
3257
  function baselineDisagreements(rows: FlatBaseline, ruleNames: readonly string[]): string[] {
3207
3258
  const complaints: string[] = [];
3208
3259
  const missing = ruleNames.filter((name) => !(name in rows));
3209
- const orphans = Object.keys(rows).filter((name) => !ruleNames.includes(name));
3260
+ const orphans = Object.keys(rows).filter((name) => !ruleNames.includes(name) && name in RULES);
3210
3261
  if (missing.length > 0) complaints.push(`no row for rule(s) being scanned: ${missing.join(", ")}`);
3211
3262
  if (orphans.length > 0) complaints.push(`row(s) for rules nobody scans: ${orphans.join(", ")}`);
3212
3263
  return complaints;
3213
3264
  }
3214
3265
 
3266
+ /**
3267
+ * Rows naming a rule this belt has never heard of — forge task 3098. A new rule lands
3268
+ * in the belt's tree and every consumer's baseline gets its row in the same pass, but a
3269
+ * consumer still on the LOCKED belt then carries a row ahead of its library: not drift,
3270
+ * a bump not made yet. Refusing it red every app off the tree at once (2026-10-13, six
3271
+ * fast-lane deploys). It is a warning; a row this belt KNOWS and does not scan is still
3272
+ * refused above, and a mistyped row cannot hide anything — the real rule's row is then
3273
+ * missing, which is red.
3274
+ */
3275
+ function rowsAheadOfThisBelt(rows: FlatBaseline, ruleNames: readonly string[]): string[] {
3276
+ return Object.keys(rows).filter((name) => !ruleNames.includes(name) && !(name in RULES));
3277
+ }
3278
+
3215
3279
  // ── `--reimplements-from-belt`: the name list, off this package's own exports ───
3216
3280
  // `no-local-reimplementation` matches app-declared names against the ones cursedbelt
3217
3281
  // exports (384 of them as this lands). A hand-kept copy of a dependency's export list
@@ -3322,6 +3386,13 @@ function main() {
3322
3386
  console.error((error as Error).message);
3323
3387
  process.exit(2);
3324
3388
  }
3389
+ const ahead = rowsAheadOfThisBelt(flatBaseline, ruleNames);
3390
+ if (ahead.length > 0) {
3391
+ console.error(
3392
+ `⚠ ${baselinePath} has row(s) for rule(s) this cursedbelt does not have yet: ${ahead.join(", ")} — ` +
3393
+ "not scanned; they apply once the app is on a cursedbelt that ships them.",
3394
+ );
3395
+ }
3325
3396
  const complaints = baselineDisagreements(flatBaseline, ruleNames);
3326
3397
  if (complaints.length > 0) {
3327
3398
  console.error(`${baselinePath} and the rules being scanned disagree:`);
@@ -3329,7 +3400,7 @@ function main() {
3329
3400
  // A row with no rule stops here. A rule with no row — what a belt bump that adds a
3330
3401
  // rule produces — is still scanned, so the run prints the exact row to record
3331
3402
  // instead of making the bumping agent find the count by hand; it exits 2 at the end.
3332
- if (Object.keys(flatBaseline).some((name) => !ruleNames.includes(name))) process.exit(2);
3403
+ if (Object.keys(flatBaseline).some((name) => !ruleNames.includes(name) && name in RULES)) process.exit(2);
3333
3404
  for (const name of ruleNames) if (!(name in flatBaseline)) missingRows.add(name);
3334
3405
  }
3335
3406
  }
@@ -195,6 +195,7 @@ export const AREAS: readonly Area[] = [
195
195
  'components/Section.tsx',
196
196
  'components/SegmentedControl.tsx',
197
197
  'components/SelectField.tsx',
198
+ 'components/SelectFieldImpl.tsx',
198
199
  'components/Skeleton.tsx',
199
200
  'components/Slider.tsx',
200
201
  'components/Spinner.tsx',
@@ -291,6 +292,7 @@ export const AREAS: readonly Area[] = [
291
292
  // The breadcrumb's menus and pivot are `import()`ed by `nav/BreadcrumbTrail.tsx` (lazyImportFault).
292
293
  roots: ['folder-tree', 'nav/BreadcrumbMenus.tsx', 'nav/BreadcrumbPivot.tsx'],
293
294
  },
295
+ { name: 'kanban', why: 'the Kanban board — columns of dragged cards; an app with no board renders none of it', roots: ['components/KanbanBoard.tsx'] },
294
296
  { name: 'keep-awake', why: 'the keep-awake toggle and its badge', roots: ['keep-awake'] },
295
297
  { name: 'layout-engine', why: 'the persisted pane/grid layout engine — apps with a fixed layout render none of it', roots: ['layoutEngine'] },
296
298
  { name: 'markdown', why: 'the rendered-markdown document', roots: ['markdown', 'markdown/MarkdownDoc.tsx'] },
@@ -26,6 +26,17 @@ describe('HudZoomControls (CNV-3)', () => {
26
26
  expect(calls).toEqual(['out', 'in', 'fit', 'center']);
27
27
  });
28
28
 
29
+ test('vertical: a column with + on top and − under the percentage, announced as one', () => {
30
+ const { getByRole } = render(
31
+ <HudZoomControls orientation='vertical' scale={0.5} onZoomIn={() => {}} onZoomOut={() => {}} onFit={() => {}} />,
32
+ );
33
+ const group = getByRole('group', { name: 'View' });
34
+ expect(group.getAttribute('aria-orientation')).toBe('vertical');
35
+ expect(group.className).toContain('flex-col');
36
+ const names = Array.from(group.querySelectorAll('button')).map((b) => b.getAttribute('aria-label'));
37
+ expect(names).toEqual(['Zoom in', 'Zoom out', 'Fit to screen']);
38
+ });
39
+
29
40
  test('no Center key when the canvas has nothing to centre on', () => {
30
41
  const { queryByRole } = render(
31
42
  <HudZoomControls scale={1} onZoomIn={() => {}} onZoomOut={() => {}} onFit={() => {}} />,
@@ -60,10 +60,16 @@ export interface HudZoomControlsProps {
60
60
  /** The canvas's own utilities, drawn before the zoom keys in the same pill (CNV-4 — utilities
61
61
  * only; preferences and primary actions belong in the page's panel). */
62
62
  children?: ReactNode;
63
+ /**
64
+ * `vertical` stacks the keys down an edge — +, the zoom %, −, Fit, Center — the way Figma and
65
+ * Miro draw them on a phone, where a bottom-centre pill sits over the drawing and the thumb's
66
+ * sheet handle. Place it with `absolute right-3 top-1/2 -translate-y-1/2` or the like.
67
+ */
68
+ orientation?: 'horizontal' | 'vertical';
63
69
  className?: string;
64
70
  }
65
71
 
66
- /** CNV-3's essentials as one pill: −, the zoom %, +, Fit and Center. */
72
+ /** CNV-3's essentials as one pill: −, the zoom %, +, Fit and Center (a column: + on top). */
67
73
  export function HudZoomControls({
68
74
  scale,
69
75
  onZoomIn,
@@ -73,20 +79,38 @@ export function HudZoomControls({
73
79
  fitLabel = 'Fit to screen',
74
80
  centerLabel = 'Center on the selection',
75
81
  children,
82
+ orientation = 'horizontal',
76
83
  className,
77
84
  }: HudZoomControlsProps) {
85
+ const vertical = orientation === 'vertical';
86
+ const zoomOut = (
87
+ <HudButton label='Zoom out' onClick={onZoomOut}>
88
+ <IconZoomOut />
89
+ </HudButton>
90
+ );
91
+ const zoomIn = (
92
+ <HudButton label='Zoom in' onClick={onZoomIn}>
93
+ <IconZoomIn />
94
+ </HudButton>
95
+ );
78
96
  return (
79
- <HudPill role='group' aria-label='View' className={cn('gap-0.5 rounded-lg px-1 py-1', className)}>
97
+ <HudPill
98
+ role='group'
99
+ aria-label='View'
100
+ aria-orientation={orientation}
101
+ data-orientation={orientation}
102
+ className={cn('gap-0.5 rounded-lg px-1 py-1', vertical && 'flex-col py-1.5', className)}
103
+ >
80
104
  {children}
81
- <HudButton label='Zoom out' onClick={onZoomOut}>
82
- <IconZoomOut />
83
- </HudButton>
84
- <span className='min-w-11 text-center text-xs text-muted-foreground tabular-nums' data-testid='hud-zoom'>
105
+ {/* Up the column is in: + on top, as on every map. */}
106
+ {vertical ? zoomIn : zoomOut}
107
+ <span
108
+ className={cn('text-center text-xs text-muted-foreground tabular-nums', vertical ? 'w-full' : 'min-w-11')}
109
+ data-testid='hud-zoom'
110
+ >
85
111
  {Math.round(scale * 100)}%
86
112
  </span>
87
- <HudButton label='Zoom in' onClick={onZoomIn}>
88
- <IconZoomIn />
89
- </HudButton>
113
+ {vertical ? zoomOut : zoomIn}
90
114
  <HudButton label={fitLabel} onClick={onFit}>
91
115
  <IconFit />
92
116
  </HudButton>
@@ -302,6 +302,46 @@ describe('AppShell', () => {
302
302
  expect(q(account as HTMLElement, '[data-testid="avatar"]')).not.toBeNull();
303
303
  });
304
304
 
305
+ test('headerCenter: a one-place app’s toolbar takes the center zone, in place of the command spine', () => {
306
+ const { container } = render(
307
+ <AppShell navHeader={<h1>App</h1>} headerCenter={<div data-testid='toolbar'>tools</div>}>
308
+ x
309
+ </AppShell>,
310
+ );
311
+ const center = q(container, '[data-cb-header-zone="center"]') as HTMLElement;
312
+ expect(q(center, '[data-testid="toolbar"]')).not.toBeNull();
313
+ expect(q(container, '[data-cb-commandbar]')).toBeNull();
314
+ });
315
+
316
+ test('headerCenter is ignored when nav gives the header places to draw', () => {
317
+ const { container } = render(
318
+ <AppShell nav={nav} onNavigate={() => {}} headerCenter={<div data-testid='toolbar'>tools</div>}>
319
+ x
320
+ </AppShell>,
321
+ );
322
+ expect(q(container, '[data-testid="toolbar"]')).toBeNull();
323
+ });
324
+
325
+ test('immersive: the header and the bottom chrome step aside, a banner stays, the content stays', () => {
326
+ const { container, rerender } = render(
327
+ <AppShell nav={nav} onNavigate={() => {}} account={<span>A</span>} banners={[{ id: 'b', tone: 'error', message: 'Down' }]}>
328
+ page content
329
+ </AppShell>,
330
+ );
331
+ expect(q(container, '[data-cb-appheader]')).not.toBeNull();
332
+ expect(q(container, '[data-cb-placebar]')).not.toBeNull();
333
+ rerender(
334
+ <AppShell immersive nav={nav} onNavigate={() => {}} account={<span>A</span>} banners={[{ id: 'b', tone: 'error', message: 'Down' }]}>
335
+ page content
336
+ </AppShell>,
337
+ );
338
+ expect(q(container, '[data-cb-appshell]')?.hasAttribute('data-cb-immersive')).toBe(true);
339
+ expect(q(container, '[data-cb-appheader]')).toBeNull();
340
+ expect(q(container, '[data-cb-placebar]')).toBeNull();
341
+ expect(container.textContent).toContain('Down');
342
+ expect(container.textContent).toContain('page content');
343
+ });
344
+
305
345
  test('the active place carries aria-current=page in every rail variant and the PlaceBar', () => {
306
346
  const { container } = render(
307
347
  <AppShell nav={station} activeHref='/console' onNavigate={() => {}}>
@@ -1,4 +1,4 @@
1
- import { type ElementType, type ReactNode, type Ref, useEffect, useLayoutEffect, useRef, useState } from 'react';
1
+ import { type ElementType, type ReactNode, type Ref, useEffect, useState } from 'react';
2
2
  import {
3
3
  allowAllGate,
4
4
  deriveSideNavDomains,
@@ -13,6 +13,7 @@ import { whenIdle } from '../lib/whenIdle.js';
13
13
  import { CommandBar } from '../nav/CommandBar.js';
14
14
  import { type IconResolver, isDomainActive, type NavBadgeTone, renderIcon, routeClick } from '../nav/navShared.js';
15
15
  import { PlaceBar } from '../nav/PlaceBar.js';
16
+ import { usePublishedBottomChrome } from '../hooks/usePublishedBottomChrome.js';
16
17
  import { SideNav } from '../nav/SideNav.js';
17
18
  import { TopBarPlaces } from '../nav/TopBar.js';
18
19
  import { CommandSourceProvider, useCommandSources } from '../palette/commandSources.js';
@@ -137,6 +138,22 @@ export interface AppShellProps {
137
138
  /** App slot at the trailing edge of the command spine (e.g. presence dots). */
138
139
  commandBarExtra?: ReactNode;
139
140
 
141
+ /**
142
+ * The header's center zone, for an app with no places and no command spine: a one-place
143
+ * canvas app's own toolbar (its depth switch, Save, Export) folded INTO the header instead of
144
+ * stacked under it as a second bar. On a phone that row is a quarter of what the canvas gets
145
+ * (arch, forge task 3059). Ignored when `nav` gives the header places to draw; otherwise it
146
+ * REPLACES the command spine (`onOpenCommand`, `onCapture`, `commandBarExtra`) — put them in it.
147
+ */
148
+ headerCenter?: ReactNode;
149
+
150
+ /**
151
+ * Presentation mode: the header and the bottom chrome (dock, `PlaceBar`) step aside so the
152
+ * content has the whole viewport, until the app turns it off — a canvas's double-tap on blank
153
+ * space (Figma's and Miro's ⌘\). Banners stay: one is a fault the reader must still see.
154
+ */
155
+ immersive?: boolean;
156
+
140
157
  /** Account chrome behind the avatar (profile/settings/sign-out) — NOT a nav door
141
158
  * (§6). Rendered at the trailing edge of the top spine. */
142
159
  account?: ReactNode;
@@ -262,6 +279,8 @@ function Shell({
262
279
  commandLabel,
263
280
  commandHint,
264
281
  commandBarExtra,
282
+ headerCenter,
283
+ immersive = false,
265
284
  account,
266
285
  bottomDock,
267
286
  palette,
@@ -302,8 +321,8 @@ function Shell({
302
321
  else window.location.assign(path);
303
322
  };
304
323
 
305
- const showSpine = Boolean(nav || navHeader || account || openCommand || onCapture || commandBarExtra);
306
- const showBottom = Boolean(bottomDock || nav);
324
+ const showSpine = !immersive && Boolean(nav || navHeader || account || openCommand || onCapture || commandBarExtra || headerCenter);
325
+ const showBottom = !immersive && Boolean(bottomDock || nav);
307
326
  const bottomChromeRef = usePublishedBottomChrome(showBottom);
308
327
  // Chrome that lets the field through. The rails/spine keep their own directional
309
328
  // border either way — only the FILL changes, so the layout is byte-identical and
@@ -344,6 +363,7 @@ function Shell({
344
363
  <div
345
364
  data-cb-appshell=''
346
365
  data-cb-nav-pattern={pattern}
366
+ data-cb-immersive={immersive ? '' : undefined}
347
367
  className={cn(
348
368
  'flex h-dvh flex-col overflow-hidden bg-background text-foreground',
349
369
  // `isolation: isolate` is load-bearing, not decoration: the blobs blend with
@@ -397,6 +417,8 @@ function Shell({
397
417
  linkProps: navLinkComponent ? undefined : { onClick: routeClick(d.path, onNavigate) },
398
418
  }))}
399
419
  />
420
+ ) : headerCenter ? (
421
+ headerCenter
400
422
  ) : (
401
423
  commandBar
402
424
  )}
@@ -507,27 +529,3 @@ function Shell({
507
529
  );
508
530
  }
509
531
 
510
- /**
511
- * Publishes the bottom chrome's height (dock + `PlaceBar` + safe area) as
512
- * `--cb-bottom-chrome-h` on the root, so what floats over the page — the `Toaster`'s stack
513
- * (OVL-9) — rises above the navigation instead of covering it. A `PlaceBar` hidden at `md` up
514
- * measures 0, so the desktop stack drops back to the corner by itself.
515
- */
516
- function usePublishedBottomChrome(shown: boolean) {
517
- const ref = useRef<HTMLDivElement | null>(null);
518
- // biome-ignore lint/correctness/useExhaustiveDependencies: `shown` mounts or drops the element the ref points at.
519
- useLayoutEffect(() => {
520
- const el = ref.current;
521
- const root = document.documentElement;
522
- if (!el) return;
523
- const publish = () => root.style.setProperty('--cb-bottom-chrome-h', `${el.offsetHeight}px`);
524
- publish();
525
- const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(publish);
526
- observer?.observe(el);
527
- return () => {
528
- observer?.disconnect();
529
- root.style.removeProperty('--cb-bottom-chrome-h');
530
- };
531
- }, [shown]);
532
- return ref;
533
- }