cursedbelt 5.45.0 → 5.46.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 (144) hide show
  1. package/dist/react/canvas-hud/CanvasMinimap.d.ts +32 -0
  2. package/dist/react/canvas-hud/CanvasMinimap.d.ts.map +1 -0
  3. package/dist/react/canvas-hud/CanvasMinimap.js +32 -0
  4. package/dist/react/canvas-hud/CanvasMinimap.js.map +1 -0
  5. package/dist/react/canvas-hud/CanvasNodeMenu.d.ts +34 -0
  6. package/dist/react/canvas-hud/CanvasNodeMenu.d.ts.map +1 -0
  7. package/dist/react/canvas-hud/CanvasNodeMenu.js +21 -0
  8. package/dist/react/canvas-hud/CanvasNodeMenu.js.map +1 -0
  9. package/dist/react/canvas-hud/CanvasSearchField.d.ts +27 -0
  10. package/dist/react/canvas-hud/CanvasSearchField.d.ts.map +1 -0
  11. package/dist/react/canvas-hud/CanvasSearchField.js +35 -0
  12. package/dist/react/canvas-hud/CanvasSearchField.js.map +1 -0
  13. package/dist/react/canvas-hud/HudControls.d.ts +43 -0
  14. package/dist/react/canvas-hud/HudControls.d.ts.map +1 -0
  15. package/dist/react/canvas-hud/HudControls.js +22 -0
  16. package/dist/react/canvas-hud/HudControls.js.map +1 -0
  17. package/dist/react/canvas-hud/index.d.ts +13 -0
  18. package/dist/react/canvas-hud/index.d.ts.map +1 -0
  19. package/dist/react/canvas-hud/index.js +7 -0
  20. package/dist/react/canvas-hud/index.js.map +1 -0
  21. package/dist/react/canvas-hud/useCanvasWheel.d.ts +41 -0
  22. package/dist/react/canvas-hud/useCanvasWheel.d.ts.map +1 -0
  23. package/dist/react/canvas-hud/useCanvasWheel.js +65 -0
  24. package/dist/react/canvas-hud/useCanvasWheel.js.map +1 -0
  25. package/dist/react/canvas-hud/viewport.d.ts +68 -0
  26. package/dist/react/canvas-hud/viewport.d.ts.map +1 -0
  27. package/dist/react/canvas-hud/viewport.js +77 -0
  28. package/dist/react/canvas-hud/viewport.js.map +1 -0
  29. package/dist/react/charts/format.d.ts.map +1 -1
  30. package/dist/react/charts/format.js +2 -1
  31. package/dist/react/charts/format.js.map +1 -1
  32. package/dist/react/components/AppShell.d.ts.map +1 -1
  33. package/dist/react/components/AppShell.js +23 -3
  34. package/dist/react/components/AppShell.js.map +1 -1
  35. package/dist/react/components/AsyncPanel.d.ts.map +1 -1
  36. package/dist/react/components/AsyncPanel.js +1 -1
  37. package/dist/react/components/AsyncPanel.js.map +1 -1
  38. package/dist/react/components/Checkbox.d.ts.map +1 -1
  39. package/dist/react/components/Checkbox.js +7 -2
  40. package/dist/react/components/Checkbox.js.map +1 -1
  41. package/dist/react/components/Field.d.ts.map +1 -1
  42. package/dist/react/components/Field.js +4 -1
  43. package/dist/react/components/Field.js.map +1 -1
  44. package/dist/react/components/PageGuideButton.d.ts +1 -1
  45. package/dist/react/components/PageGuideButton.d.ts.map +1 -1
  46. package/dist/react/components/PageGuideButton.js +45 -6
  47. package/dist/react/components/PageGuideButton.js.map +1 -1
  48. package/dist/react/components/PageGuidePopover.d.ts +14 -0
  49. package/dist/react/components/PageGuidePopover.d.ts.map +1 -0
  50. package/dist/react/components/PageGuidePopover.js +25 -0
  51. package/dist/react/components/PageGuidePopover.js.map +1 -0
  52. package/dist/react/components/TagsInput.d.ts.map +1 -1
  53. package/dist/react/components/TagsInput.js +3 -1
  54. package/dist/react/components/TagsInput.js.map +1 -1
  55. package/dist/react/folder-tree/FolderPicker.d.ts.map +1 -1
  56. package/dist/react/folder-tree/FolderPicker.js +5 -1
  57. package/dist/react/folder-tree/FolderPicker.js.map +1 -1
  58. package/dist/react/icons.d.ts +28 -0
  59. package/dist/react/icons.d.ts.map +1 -1
  60. package/dist/react/icons.js +28 -0
  61. package/dist/react/icons.js.map +1 -1
  62. package/dist/react/index.d.ts +1 -1
  63. package/dist/react/index.d.ts.map +1 -1
  64. package/dist/react/index.js +1 -1
  65. package/dist/react/index.js.map +1 -1
  66. package/dist/react/lib/whenIdle.d.ts +7 -0
  67. package/dist/react/lib/whenIdle.d.ts.map +1 -0
  68. package/dist/react/lib/whenIdle.js +14 -0
  69. package/dist/react/lib/whenIdle.js.map +1 -0
  70. package/dist/react/media/DenseGalleryShell.d.ts +7 -1
  71. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  72. package/dist/react/media/DenseGalleryShell.js +3 -3
  73. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  74. package/dist/react/media/denseGallery.css +91 -0
  75. package/dist/react/media-gallery/MediaGallery.d.ts +5 -2
  76. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  77. package/dist/react/media-gallery/MediaGallery.js +1 -1
  78. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  79. package/dist/styles-areas/auth.css +2 -2
  80. package/dist/styles-areas/calendar.css +5 -6
  81. package/dist/styles-areas/canvas-hud.css +30 -0
  82. package/dist/styles-areas/core.css +5 -5
  83. package/dist/styles-areas/folder-tree.css +6 -6
  84. package/dist/styles-areas/layout-engine.css +4 -4
  85. package/dist/styles-areas/media-gallery.css +7 -6
  86. package/dist/styles-areas/nav.css +3 -3
  87. package/dist/styles-areas/palette.css +1 -1
  88. package/dist/styles-areas/rich-text.css +2 -2
  89. package/dist/styles-areas/sharing.css +4 -4
  90. package/dist/styles-areas/test-report.css +2 -2
  91. package/dist/styles-utilities.css +24 -3
  92. package/package.json +8 -1
  93. package/scripts/generateAreaStyles.ts +3 -1
  94. package/scripts/styleAreas.ts +88 -6
  95. package/src/bundlerOnlyEntries.spec.ts +25 -8
  96. package/src/react/canvas-hud/CanvasMinimap.tsx +88 -0
  97. package/src/react/canvas-hud/CanvasNodeMenu.tsx +91 -0
  98. package/src/react/canvas-hud/CanvasSearchField.tsx +91 -0
  99. package/src/react/canvas-hud/HudControls.spec.tsx +160 -0
  100. package/src/react/canvas-hud/HudControls.tsx +128 -0
  101. package/src/react/canvas-hud/index.ts +33 -0
  102. package/src/react/canvas-hud/useCanvasWheel.ts +99 -0
  103. package/src/react/canvas-hud/viewport.spec.ts +105 -0
  104. package/src/react/canvas-hud/viewport.ts +124 -0
  105. package/src/react/charts/capacityFormat.spec.ts +12 -0
  106. package/src/react/charts/format.ts +2 -1
  107. package/src/react/components/AppShell.tsx +23 -4
  108. package/src/react/components/AsyncPanel.spec.tsx +10 -0
  109. package/src/react/components/AsyncPanel.tsx +3 -1
  110. package/src/react/components/Checkbox.spec.tsx +18 -0
  111. package/src/react/components/Checkbox.tsx +8 -5
  112. package/src/react/components/Field.spec.tsx +3 -2
  113. package/src/react/components/Field.tsx +7 -4
  114. package/src/react/components/Field.width.spec.tsx +3 -3
  115. package/src/react/components/PageGuideButton.spec.tsx +39 -0
  116. package/src/react/components/PageGuideButton.tsx +91 -23
  117. package/src/react/components/PageGuidePopover.tsx +52 -0
  118. package/src/react/components/TagsInput.spec.tsx +8 -0
  119. package/src/react/components/TagsInput.tsx +3 -1
  120. package/src/react/components/entryChunks.graph.spec.ts +89 -0
  121. package/src/react/folder-tree/FolderPicker.spec.tsx +8 -0
  122. package/src/react/folder-tree/FolderPicker.tsx +4 -1
  123. package/src/react/icons.spec.ts +21 -1
  124. package/src/react/icons.tsx +135 -1
  125. package/src/react/index.ts +10 -0
  126. package/src/react/lib/whenIdle.ts +13 -0
  127. package/src/react/media/DenseGalleryShell.tsx +20 -4
  128. package/src/react/media/denseGallery.css +91 -0
  129. package/src/react/media-gallery/MediaGallery.spec.tsx +26 -0
  130. package/src/react/media-gallery/MediaGallery.tsx +10 -3
  131. package/src/styles-areas/auth.css +2 -2
  132. package/src/styles-areas/calendar.css +5 -6
  133. package/src/styles-areas/canvas-hud.css +30 -0
  134. package/src/styles-areas/core.css +5 -5
  135. package/src/styles-areas/folder-tree.css +6 -6
  136. package/src/styles-areas/layout-engine.css +4 -4
  137. package/src/styles-areas/media-gallery.css +7 -6
  138. package/src/styles-areas/nav.css +3 -3
  139. package/src/styles-areas/palette.css +1 -1
  140. package/src/styles-areas/rich-text.css +2 -2
  141. package/src/styles-areas/sharing.css +4 -4
  142. package/src/styles-areas/test-report.css +2 -2
  143. package/src/styles-utilities.css +24 -3
  144. package/src/stylesAreas.spec.ts +17 -0
@@ -1,4 +1,4 @@
1
- import { type ElementType, type ReactNode, type Ref, useLayoutEffect, useRef } from 'react';
1
+ import { type ElementType, type ReactNode, type Ref, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import {
3
3
  allowAllGate,
4
4
  deriveSideNavDomains,
@@ -9,18 +9,29 @@ import {
9
9
  import { useKeyboardInset } from '../hooks/useKeyboardInset.js';
10
10
  import { cn } from '../lib/cn.js';
11
11
  import { devWarnOnce } from '../lib/devWarn.js';
12
+ import { whenIdle } from '../lib/whenIdle.js';
12
13
  import { CommandBar } from '../nav/CommandBar.js';
13
14
  import { type IconResolver, isDomainActive, type NavBadgeTone, renderIcon, routeClick } from '../nav/navShared.js';
14
15
  import { PlaceBar } from '../nav/PlaceBar.js';
15
16
  import { SideNav } from '../nav/SideNav.js';
16
17
  import { TopBarPlaces } from '../nav/TopBar.js';
17
- import { CommandPalette } from '../palette/CommandPalette.js';
18
18
  import { CommandSourceProvider, useCommandSources } from '../palette/commandSources.js';
19
19
  import { useCommandPalette } from '../palette/useCommandPalette.js';
20
20
  import { AmbientBackdrop, type AmbientBackdropProps } from './AmbientBackdrop.js';
21
21
  import { BannerLane, type ShellBanner } from './BannerLane.js';
22
+ import { lazyPart } from './DataTable/lazyPart.js';
22
23
  import { ScrollArea, type ScrollAreaSpace } from './ScrollArea.js';
23
24
 
25
+ /**
26
+ * The shell's ⌘K palette, off the entry chunk (forge task 2533). It draws nothing until it is
27
+ * opened, yet a static import put cmdk, uFuzzy, Radix's Dialog and the palette itself (~48 kB
28
+ * gzip with the rest of the shell) in the entry of every app on AppShell — station's grew
29
+ * 212.9 → 261.2 kB the day it adopted the shell. The hotkey and the spine trigger stay static
30
+ * (`useCommandPalette`); the body is fetched on idle after the shell mounts, or on the first
31
+ * open if that comes sooner, and it mounts only once it has been opened.
32
+ */
33
+ const ShellCommandPalette = lazyPart(() => import('../palette/CommandPalette.js').then((m) => m.CommandPalette));
34
+
24
35
  export interface AppShellProps {
25
36
  /** Routed page content. Scrolls inside the single keyboard-safe `ScrollArea`. */
26
37
  children: ReactNode;
@@ -265,6 +276,14 @@ function Shell({
265
276
  const ownPalette = !palette && Boolean(nav) && (commandPalette === 'auto' ? pattern === 'side' : commandPalette);
266
277
  const paletteControls = useCommandPalette({ enabled: ownPalette });
267
278
  const sources = useCommandSources();
279
+ // Mounted from its first open on and never unmounted, so a close keeps Radix's exit and the
280
+ // palette's own reset-on-close; fetched on idle so ⌘K normally finds it already here.
281
+ const [paletteMounted, setPaletteMounted] = useState(false);
282
+ if (paletteControls.open && !paletteMounted) setPaletteMounted(true);
283
+ useEffect(() => {
284
+ if (!ownPalette) return;
285
+ return whenIdle(() => void ShellCommandPalette.preload().catch(() => undefined));
286
+ }, [ownPalette]);
268
287
  const openCommand = onOpenCommand ?? (ownPalette ? paletteControls.toggle : undefined);
269
288
  const navigate = (path: string) => {
270
289
  if (onNavigate) onNavigate(path);
@@ -454,8 +473,8 @@ function Shell({
454
473
 
455
474
  {/* The command palette portals its own Dialog; rendered here so one shell owns one. */}
456
475
  {palette}
457
- {ownPalette && nav ? (
458
- <CommandPalette
476
+ {ownPalette && nav && paletteMounted ? (
477
+ <ShellCommandPalette
459
478
  model={nav}
460
479
  gate={navGate}
461
480
  open={paletteControls.open}
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { cleanup, render } from '@testing-library/react';
3
3
  import { AsyncPanel, ageLabel } from './AsyncPanel.js';
4
+ import { Button } from './Button.js';
4
5
 
5
6
  afterEach(cleanup);
6
7
 
@@ -24,6 +25,15 @@ describe('AsyncPanel', () => {
24
25
  expect(getByText(/AccessDenied/)).toBeDefined();
25
26
  });
26
27
 
28
+ test('offers the retry action beside the error (STA-3) — Alert reads `actions`, not `action`', () => {
29
+ const { getByRole } = render(
30
+ <AsyncPanel label='AWS cost' error='boom' onRetryAction={<Button tooltip='Read it again'>Retry</Button>}>
31
+ <p>never</p>
32
+ </AsyncPanel>,
33
+ );
34
+ expect(getByRole('button', { name: 'Retry' })).toBeDefined();
35
+ });
36
+
27
37
  test('degrades only this panel — the children are not rendered', () => {
28
38
  const { queryByText } = render(
29
39
  <AsyncPanel error='boom'>
@@ -133,7 +133,9 @@ export function AsyncPanel({
133
133
  }
134
134
  if (error) {
135
135
  return (
136
- <Alert tone='warning' {...(onRetryAction ? { action: onRetryAction } : {})}>
136
+ // `actions`, typed: a spread of `{ action }` passed tsc's excess-property check and the
137
+ // Retry never rendered (found by station's task 2471).
138
+ <Alert tone='warning' actions={onRetryAction}>
137
139
  {label ? `${label} is unavailable — ` : ''}
138
140
  {messageOf(error)}
139
141
  </Alert>
@@ -81,4 +81,22 @@ describe('Checkbox', () => {
81
81
  // …and `className` still means the box, as it always has.
82
82
  expect(getByRole('checkbox').getAttribute('class') ?? '').toContain('size-4');
83
83
  });
84
+
85
+ test('a captioned checkbox starts at the left edge, never centred in a stretched row (FRM-2)', () => {
86
+ const { getByText } = render(
87
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
88
+ <Checkbox checked={false} onCheckedChange={() => {}} label='Short' />
89
+ </div>,
90
+ );
91
+ const target = (getByText('Short').closest('label')?.getAttribute('class') ?? '').split(/\s+/);
92
+ expect(target).not.toContain('justify-center');
93
+ expect(target).toContain('justify-start');
94
+ expect(target).toContain('self-start');
95
+ });
96
+
97
+ test('a bare box still centres itself in its 44px target', () => {
98
+ const { getByRole } = render(<Checkbox checked={false} onCheckedChange={() => {}} />);
99
+ const target = (getByRole('checkbox').closest('label')?.getAttribute('class') ?? '').split(/\s+/);
100
+ expect(target).toContain('justify-center');
101
+ });
84
102
  });
@@ -48,10 +48,13 @@ const BOX = cn(
48
48
  // region a thumb is actually aiming at, which is the words as much as the box.
49
49
  // `data-cb-tap-target` is the seam `e2e/touch-targets.spec.ts` measures a
50
50
  // sub-44px control through, the way a checkbox has to be measured.
51
- const TARGET = cn(
52
- 'inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 align-middle',
53
- TAP_TARGET,
54
- );
51
+ const TARGET = cn('inline-flex shrink-0 cursor-pointer items-center gap-2 align-middle', TAP_TARGET);
52
+
53
+ // A bare box centres itself in its 44px square. A captioned one starts at the left edge and is
54
+ // only as wide as its caption: `justify-center` on a stretched label (any column parent) set a
55
+ // short caption adrift in the middle of the row, off the edge every other control shares (FRM-2).
56
+ const BARE = 'justify-center';
57
+ const CAPTIONED = 'justify-start self-start text-left';
55
58
 
56
59
  /**
57
60
  * Checkbox vendored from shadcn (Radix `Checkbox`). White check on a solid-accent
@@ -77,7 +80,7 @@ export function Checkbox({
77
80
  // descendant: Radix renders a hidden bubble `<input type="checkbox">` inside
78
81
  // the Root when the control sits in a form, and an implicit association would
79
82
  // resolve to THAT instead of the button the user can see.
80
- <label htmlFor={id} data-cb-tap-target='' className={TARGET}>
83
+ <label htmlFor={id} data-cb-tap-target='' className={cn(TARGET, label == null ? BARE : CAPTIONED)}>
81
84
  <RadixCheckbox.Root id={id} className={cn(BOX, className)} {...props}>
82
85
  <RadixCheckbox.Indicator className='flex items-center justify-center text-current'>
83
86
  {props.checked === 'indeterminate' ? (
@@ -136,8 +136,9 @@ describe('Field', () => {
136
136
  </Field>
137
137
  </Harness>,
138
138
  );
139
- // InfoHint's trigger has aria-label "More info" and must NOT be nested in the label.
140
- const info = getByLabelText('More info');
139
+ // The trigger is named for its field (not InfoHint's generic "More info") and must NOT be
140
+ // nested in the label.
141
+ const info = getByLabelText('About email');
141
142
  expect(info.tagName).toBe('BUTTON');
142
143
  expect(container.querySelector('label')?.contains(info)).toBe(false);
143
144
  });
@@ -205,6 +205,9 @@ export function Field({
205
205
  ) : null;
206
206
 
207
207
  const labelTitle = typeof label === 'string' ? label : undefined;
208
+ // The ⓘ trigger is named for its field: three hints in a row are three "More info" buttons to
209
+ // a screen reader otherwise, and a browser test cannot find one by name (forge task 2532).
210
+ const hintLabel = labelTitle ? `About ${labelTitle.toLowerCase()}` : undefined;
208
211
 
209
212
  return (
210
213
  <div
@@ -236,23 +239,23 @@ export function Field({
236
239
  )
237
240
  ) : null}
238
241
  {foldsAlways ? (
239
- <InfoHint title={labelTitle}>
242
+ <InfoHint title={labelTitle} label={hintLabel}>
240
243
  {hint != null ? <p className='mb-1'>{hint}</p> : null}
241
244
  {description}
242
245
  </InfoHint>
243
246
  ) : null}
244
- {hint != null && !folds && !foldsAlways ? <InfoHint title={labelTitle}>{hint}</InfoHint> : null}
247
+ {hint != null && !folds && !foldsAlways ? <InfoHint title={labelTitle} label={hintLabel}>{hint}</InfoHint> : null}
245
248
  {folds ? (
246
249
  // Two triggers, one shown per breakpoint: wide screens keep `hint` alone in the ⓘ
247
250
  // (the description is on screen below), a phone gets the description folded in.
248
251
  <>
249
252
  {hint != null ? (
250
253
  <span className='inline-flex max-sm:hidden'>
251
- <InfoHint title={labelTitle}>{hint}</InfoHint>
254
+ <InfoHint title={labelTitle} label={hintLabel}>{hint}</InfoHint>
252
255
  </span>
253
256
  ) : null}
254
257
  <span className='inline-flex sm:hidden'>
255
- <InfoHint title={labelTitle}>
258
+ <InfoHint title={labelTitle} label={hintLabel}>
256
259
  {hint != null ? <p className='mb-1'>{hint}</p> : null}
257
260
  {description}
258
261
  </InfoHint>
@@ -141,7 +141,7 @@ describe('Field description', () => {
141
141
  expect(p?.className).toContain('text-muted-foreground');
142
142
  // Short: never folded, so no phone-only ⓘ.
143
143
  expect(p?.className).not.toContain('max-sm:hidden');
144
- expect(container.querySelectorAll('button[aria-label="More info"]').length).toBe(0);
144
+ expect(container.querySelectorAll('button[aria-label="About d"]').length).toBe(0);
145
145
  });
146
146
 
147
147
  test('a long one folds into an ⓘ on a phone, and stays described', () => {
@@ -152,7 +152,7 @@ describe('Field description', () => {
152
152
  );
153
153
  const p = container.querySelector('#cb-f-d-desc');
154
154
  expect(p?.className).toContain('max-sm:hidden');
155
- expect(container.querySelectorAll('button[aria-label="More info"]').length).toBe(1);
155
+ expect(container.querySelectorAll('button[aria-label="About d"]').length).toBe(1);
156
156
  expect(container.querySelector('input')?.getAttribute('aria-describedby')).toBe('cb-f-d-desc');
157
157
  });
158
158
 
@@ -171,7 +171,7 @@ describe('Field description', () => {
171
171
  <Input />
172
172
  </Field>,
173
173
  );
174
- const hosts = [...container.querySelectorAll('button[aria-label="More info"]')].map(
174
+ const hosts = [...container.querySelectorAll('button[aria-label="About d"]')].map(
175
175
  (b) => b.parentElement?.className ?? '',
176
176
  );
177
177
  expect(hosts.length).toBe(2);
@@ -0,0 +1,39 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { PageGuideButton } from './PageGuideButton.js';
4
+
5
+ afterEach(cleanup);
6
+
7
+ /** Let the dynamic import and React's Suspense retry settle. */
8
+ async function settle() {
9
+ for (let i = 0; i < 5; i += 1) {
10
+ await act(async () => {
11
+ await new Promise((r) => setTimeout(r, 0));
12
+ });
13
+ }
14
+ }
15
+
16
+ // Forge task 2535: the popover is a lazy chunk (the graph spec proves it is not static). These
17
+ // prove the swap from the placeholder trigger to the real one loses nothing a user did.
18
+ describe('PageGuideButton — a static trigger, the popover fetched on demand', () => {
19
+ test('a press on the placeholder, before the popover is loaded, opens the guide', async () => {
20
+ render(<PageGuideButton>First agent run after a gap.</PageGuideButton>);
21
+ const trigger = screen.getByRole('button', { name: 'Guide' });
22
+ expect(trigger.getAttribute('aria-haspopup')).toBe('dialog');
23
+ fireEvent.click(trigger);
24
+ await settle();
25
+ expect(await screen.findByText('First agent run after a gap.')).toBeTruthy();
26
+ expect(screen.getByRole('button', { name: 'Guide' }).getAttribute('aria-expanded')).toBe('true');
27
+ });
28
+
29
+ test('a page reached after the chunk is here draws the real trigger at once, closed', async () => {
30
+ render(<PageGuideButton label='Help'>Body.</PageGuideButton>);
31
+ const trigger = screen.getByRole('button', { name: 'Help' });
32
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
33
+ expect(screen.queryByText('Body.')).toBeNull();
34
+ fireEvent.click(trigger);
35
+ await settle();
36
+ expect(screen.getByText('Body.')).toBeTruthy();
37
+ });
38
+
39
+ });
@@ -1,9 +1,8 @@
1
1
  import { BookOpen } from 'lucide-react';
2
- import type { ReactNode } from 'react';
3
- import { cn } from '../lib/cn.js';
2
+ import { type ComponentType, type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
3
+ import { whenIdle } from '../lib/whenIdle.js';
4
4
  import { Button } from './Button.js';
5
- import { Popover } from './Popover.js';
6
- import { ScrollArea } from './ScrollArea.js';
5
+ import type { PageGuidePopoverProps } from './PageGuidePopover.js';
7
6
 
8
7
  // The first of CPY-2's two homes for a page's documentation; `Guide.tsx` has both, and why.
9
8
 
@@ -15,28 +14,97 @@ export interface PageGuideButtonProps {
15
14
  className?: string;
16
15
  }
17
16
 
17
+ /**
18
+ * ── Why the popover is a chunk of its own (forge task 2535) ──────────────────
19
+ * 🔴 `Page.tsx` renders this button, so a static import of `Popover` put Radix Popover,
20
+ * react-popper and @floating-ui (~15–20 kB gzip) in the entry chunk of every app whose first
21
+ * page has a header — for a panel that is closed until its button is pressed. This file is
22
+ * the trigger alone; `PageGuidePopover.tsx` is fetched on idle after the button mounts, or
23
+ * at once on a hover, focus or press if one comes sooner. `entryChunks.graph.spec.ts` is the
24
+ * WHETHER.
25
+ *
26
+ * 🔴 The placeholder is ONE element until the real trigger replaces it, once — the way
27
+ * `InfoHint.tsx` does it, not through Suspense. A Suspense fallback is a second copy of the
28
+ * button: the hover that starts the fetch swapped the node under the pointer between its
29
+ * pointerdown and its click, and the press was lost (the CPY-2 browser test caught it).
30
+ */
31
+ type PageGuidePopoverComponent = ComponentType<PageGuidePopoverProps>;
32
+
33
+ let loadedImpl: PageGuidePopoverComponent | null = null;
34
+ let pendingImpl: Promise<PageGuidePopoverComponent> | null = null;
35
+
36
+ /** Fetch the popover once per page; a failed fetch may be asked again. */
37
+ function loadPageGuide(): Promise<PageGuidePopoverComponent> {
38
+ pendingImpl ??= import('./PageGuidePopover.js').then(
39
+ (m) => {
40
+ loadedImpl = m.PageGuidePopover;
41
+ return m.PageGuidePopover;
42
+ },
43
+ (error: unknown) => {
44
+ pendingImpl = null;
45
+ throw error;
46
+ },
47
+ );
48
+ return pendingImpl;
49
+ }
50
+
51
+ /** What the placeholder saw before the real trigger replaced it. */
52
+ type Early = 'none' | 'focus' | 'press';
53
+
18
54
  /** The "Guide" button and its popover. */
19
55
  export function PageGuideButton({ children, label = 'Guide', className }: PageGuideButtonProps) {
56
+ // `() => loadedImpl`: a component IS a function, and `useState(fn)` would call it.
57
+ const [Impl, setImpl] = useState<PageGuidePopoverComponent | null>(() => loadedImpl);
58
+ // State, not a ref, so the render that swaps the real trigger in reads the latest.
59
+ const [early, setEarly] = useState<Early>('none');
60
+ const alive = useRef(true);
61
+
62
+ const load = useCallback(() => {
63
+ void loadPageGuide().then(
64
+ (c) => {
65
+ if (alive.current) setImpl(() => c);
66
+ },
67
+ () => undefined, // the placeholder stays; the next hover or press asks again
68
+ );
69
+ }, []);
70
+ useEffect(() => {
71
+ alive.current = true;
72
+ const cancel = Impl ? undefined : whenIdle(load);
73
+ return () => {
74
+ alive.current = false;
75
+ cancel?.();
76
+ };
77
+ }, [Impl, load]);
78
+
79
+ if (Impl) {
80
+ return (
81
+ <Impl label={label} className={className} initialOpen={early === 'press'} initialFocus={early === 'focus'}>
82
+ {children}
83
+ </Impl>
84
+ );
85
+ }
86
+ // The same button the popover renders, so nothing moves or changes when it is swapped in.
20
87
  return (
21
- <Popover
22
- label={label}
23
- align='end'
24
- width='var(--guide-popover-w)'
25
- // A bounded column, so the guide scrolls inside the popover rather than off the screen.
26
- className={cn('flex max-h-[var(--guide-popover-max-h)] flex-col', className)}
27
- trigger={
28
- <Button variant='ghost' size='sm' tooltip='How this page works' tooltipMobile='off'>
29
- <BookOpen aria-hidden />
30
- {label}
31
- </Button>
32
- }
88
+ <Button
89
+ variant='ghost'
90
+ size='sm'
91
+ tooltip='How this page works'
92
+ tooltipMobile='off'
93
+ aria-haspopup='dialog'
94
+ aria-expanded={false}
95
+ onPointerEnter={load}
96
+ onFocus={() => {
97
+ load();
98
+ setEarly((e) => (e === 'press' ? e : 'focus'));
99
+ }}
100
+ onBlur={() => setEarly((e) => (e === 'focus' ? 'none' : e))}
101
+ onClick={() => {
102
+ load();
103
+ setEarly('press');
104
+ }}
33
105
  >
34
- <ScrollArea space='none'>
35
- <div data-cb-prose='' data-cb-guide='' className='flex flex-col gap-3 text-sm leading-relaxed text-foreground'>
36
- {children}
37
- </div>
38
- </ScrollArea>
39
- </Popover>
106
+ <BookOpen aria-hidden />
107
+ {label}
108
+ </Button>
40
109
  );
41
110
  }
42
-
@@ -0,0 +1,52 @@
1
+ import { BookOpen } from 'lucide-react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { cn } from '../lib/cn.js';
4
+ import { Button } from './Button.js';
5
+ import type { PageGuideButtonProps } from './PageGuideButton.js';
6
+ import { Popover } from './Popover.js';
7
+ import { ScrollArea } from './ScrollArea.js';
8
+
9
+ export interface PageGuidePopoverProps extends PageGuideButtonProps {
10
+ label: string;
11
+ /** The placeholder trigger was pressed before this loaded: open at once. */
12
+ initialOpen?: boolean;
13
+ /** The placeholder trigger held the focus when this replaced it: take it back. */
14
+ initialFocus?: boolean;
15
+ }
16
+
17
+ /**
18
+ * The guide's popover — Radix Popover and its positioning engine. Loaded by
19
+ * `PageGuideButton.tsx`, which says why it is a chunk of its own.
20
+ */
21
+ export function PageGuidePopover({ children, label, className, initialOpen = false, initialFocus = false }: PageGuidePopoverProps) {
22
+ const [open, setOpen] = useState(initialOpen);
23
+ const trigger = useRef<HTMLButtonElement | null>(null);
24
+ // Once, on mount: the swap from the placeholder must not drop a keyboard user's place.
25
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the props are the placeholder's state at the swap, read once.
26
+ useEffect(() => {
27
+ if (initialFocus && !initialOpen) trigger.current?.focus();
28
+ }, []);
29
+ return (
30
+ <Popover
31
+ open={open}
32
+ onOpenChange={setOpen}
33
+ label={label}
34
+ align='end'
35
+ width='var(--guide-popover-w)'
36
+ // A bounded column, so the guide scrolls inside the popover rather than off the screen.
37
+ className={cn('flex max-h-[var(--guide-popover-max-h)] flex-col', className)}
38
+ trigger={
39
+ <Button ref={trigger} variant='ghost' size='sm' tooltip='How this page works' tooltipMobile='off'>
40
+ <BookOpen aria-hidden />
41
+ {label}
42
+ </Button>
43
+ }
44
+ >
45
+ <ScrollArea space='none'>
46
+ <div data-cb-prose='' data-cb-guide='' className='flex flex-col gap-3 text-sm leading-relaxed text-foreground'>
47
+ {children}
48
+ </div>
49
+ </ScrollArea>
50
+ </Popover>
51
+ );
52
+ }
@@ -22,6 +22,14 @@ const typeAndEnter = (input: HTMLInputElement, text: string) => {
22
22
  };
23
23
 
24
24
  describe('TagsInput', () => {
25
+ test('the empty row fits the control height beside an Input — py-1, not the 38px py-1.5 (FND-4)', () => {
26
+ const { container } = render(<TagsInput value={[]} onChange={() => {}} aria-label='Tags' />);
27
+ const cls = (container.firstElementChild?.getAttribute('class') ?? '').split(/\s+/);
28
+ expect(cls).toContain('min-h-[var(--control-h-md)]');
29
+ expect(cls).toContain('py-1');
30
+ expect(cls).not.toContain('py-1.5');
31
+ });
32
+
25
33
  test('adds 3 tags then removes the middle one → ["a","c"]', () => {
26
34
  const { container, getByLabelText } = render(<Controlled />);
27
35
  const input = container.querySelector('input') as HTMLInputElement;
@@ -98,8 +98,10 @@ export function TagsInput({
98
98
  <div
99
99
  aria-invalid={ariaInvalid}
100
100
  className={cn(
101
+ // `py-1`, not `py-1.5`: the empty row is a 20px line + 8px + 2px border, under the min-height, so it
102
+ // sits at exactly the control height beside an Input; `py-1.5` made it 38px next to a 36px one (FND-4).
101
103
  // guardrails-ignore control-height-tokens: min-height chip-wrapping row (not fixed) — py- clears wrapped lines
102
- 'flex w-full flex-wrap items-center gap-1.5 rounded-field border border-field-border bg-field-bg px-2 py-1.5 transition focus-within:border-field-ring focus-within:ring-2 focus-within:ring-field-ring/30 aria-[invalid=true]:border-field-error',
104
+ 'flex w-full flex-wrap items-center gap-1.5 rounded-field border border-field-border bg-field-bg px-2 py-1 transition focus-within:border-field-ring focus-within:ring-2 focus-within:ring-field-ring/30 aria-[invalid=true]:border-field-error',
103
105
  CONTROL_MIN_HEIGHT_CLASS[size],
104
106
  disabled && 'cursor-not-allowed opacity-50',
105
107
  className,
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, join, relative } from 'node:path';
4
+
5
+ /**
6
+ * What an app's entry chunk must NOT carry. Forge task 2533: AppShell keeps its ⌘K palette off
7
+ * it; task 2535: `Page`'s header keeps its ⓘ guide popover off it. Whatever a module reaches
8
+ * through STATIC imports lands in the chunk that imports it, so this walks AppShell's static
9
+ * graph (Bun's own scanner — type-only imports and `import()` excluded) and refuses the palette
10
+ * body and the packages only it needs. A dynamic `import()` is the split, and is not followed.
11
+ */
12
+ const SRC = join(import.meta.dir, '..', '..');
13
+ // One per loader: a `.ts` file's `<const T>` generic is not JSX, and a TSX parse refuses it.
14
+ const transpilers = { ts: new Bun.Transpiler({ loader: 'ts' }), tsx: new Bun.Transpiler({ loader: 'tsx' }) };
15
+
16
+ function resolveLocal(from: string, spec: string): string | null {
17
+ const base = join(dirname(from), spec.replace(/\.js$/, ''));
18
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts'), join(base, 'index.tsx')]) {
19
+ if (existsSync(candidate)) return candidate;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function staticGraph(entry: string): { files: Set<string>; packages: Set<string> } {
25
+ const files = new Set<string>();
26
+ const packages = new Set<string>();
27
+ const queue = [entry];
28
+ while (queue.length > 0) {
29
+ const file = queue.pop() as string;
30
+ if (files.has(file)) continue;
31
+ files.add(file);
32
+ for (const { path, kind } of transpilers[file.endsWith('.tsx') ? 'tsx' : 'ts'].scanImports(readFileSync(file, 'utf8'))) {
33
+ if (kind !== 'import-statement') continue;
34
+ if (!path.startsWith('.')) {
35
+ packages.add(path);
36
+ continue;
37
+ }
38
+ const next = resolveLocal(file, path);
39
+ if (next) queue.push(next);
40
+ }
41
+ }
42
+ return { files, packages };
43
+ }
44
+
45
+ describe('AppShell static graph', () => {
46
+ const { files, packages } = staticGraph(join(SRC, 'react', 'components', 'AppShell.tsx'));
47
+ const local = [...files].map((f) => relative(SRC, f));
48
+
49
+ test('the walker sees the shell itself and what it draws on every page', () => {
50
+ expect(local).toContain('react/components/AppShell.tsx');
51
+ expect(local).toContain('react/palette/useCommandPalette.ts');
52
+ expect(local).toContain('react/nav/SideNav.tsx');
53
+ });
54
+
55
+ test('🔴 the command palette body is not in it — it is fetched on idle or first open', () => {
56
+ expect(local).not.toContain('react/palette/CommandPalette.tsx');
57
+ expect(local).not.toContain('react/palette/Command.tsx');
58
+ });
59
+
60
+ test('🔴 neither are the packages only the palette needs', () => {
61
+ // `cursedbelt-core/palette/hotkeys` stays: it is the ⌘K binding `useCommandPalette` registers.
62
+ const palettePackages = [...packages].filter(
63
+ (p) => p === 'cmdk' || p.startsWith('@leeoniya/ufuzzy') || p === 'cursedbelt-core/palette/fuzzy-rank',
64
+ );
65
+ expect(palettePackages).toEqual([]);
66
+ });
67
+ });
68
+
69
+ describe('Page static graph', () => {
70
+ const { files, packages } = staticGraph(join(SRC, 'react', 'components', 'Page.tsx'));
71
+ const local = [...files].map((f) => relative(SRC, f));
72
+
73
+ test('the walker sees the page and its guide trigger', () => {
74
+ expect(local).toContain('react/components/Page.tsx');
75
+ expect(local).toContain('react/components/PageGuideButton.tsx');
76
+ });
77
+
78
+ test('🔴 the guide popover is not in it — it is fetched on idle or on the first hover, focus or press', () => {
79
+ expect(local).not.toContain('react/components/PageGuidePopover.tsx');
80
+ expect(local).not.toContain('react/components/Popover.tsx');
81
+ });
82
+
83
+ test('🔴 neither is the positioning engine a closed popover does not need', () => {
84
+ const floating = [...packages].filter(
85
+ (p) => p.startsWith('@radix-ui/react-popover') || p.startsWith('@radix-ui/react-popper') || p.startsWith('@floating-ui/'),
86
+ );
87
+ expect(floating).toEqual([]);
88
+ });
89
+ });
@@ -18,6 +18,14 @@ function Harness({ initial = [] as string[] }: { initial?: string[] }) {
18
18
  }
19
19
 
20
20
  describe("FolderPicker", () => {
21
+ test("the trigger is a field at the locked control height, not a hand-padded box (FND-4)", () => {
22
+ const { container } = render(<Harness />);
23
+ const cls = (container.querySelector("button[aria-expanded]")?.getAttribute("class") ?? "").split(/\s+/);
24
+ expect(cls).toContain("h-[var(--control-h-md)]");
25
+ expect(cls).toContain("border-field-border");
26
+ expect(cls).not.toContain("py-1.5");
27
+ });
28
+
21
29
  test("every existing-folder option is labeled with its FULL breadcrumb path", () => {
22
30
  const { getByLabelText, queryByLabelText } = render(<Harness />);
23
31
  fireEvent.click(getByLabelText("Folder")); // open the panel
@@ -15,6 +15,7 @@
15
15
  import { useState } from "react";
16
16
  import { Button } from "../components/Button.js";
17
17
  import { Input } from "../components/Input.js";
18
+ import { fieldControlClass } from "../lib/field.js";
18
19
  import { FolderBreadcrumb } from "./FolderBreadcrumb.js";
19
20
  import { formatFolderPath, isValidFolderName, normalizeFolderName } from "cursedbelt-core/folder-tree/path";
20
21
  import { type FolderNode, flattenFolderTree } from "cursedbelt-core/folder-tree/tree";
@@ -102,7 +103,9 @@ export function FolderPicker({
102
103
  type="button"
103
104
  aria-label={ariaLabel}
104
105
  aria-expanded={open}
105
- className="flex items-center justify-between gap-2 rounded-md border border-[var(--border)] bg-[var(--background)] px-2.5 py-1.5 text-left text-sm"
106
+ // The shared field chrome at the locked control height, so the trigger lines up with
107
+ // an Input or TagsInput beside it (FND-4); it was hand-padded to 34px in its own colours.
108
+ className={`${fieldControlClass("md")} flex items-center justify-between gap-2 text-left`}
106
109
  onClick={() => (open ? close() : setOpen(true))}
107
110
  >
108
111
  <span className="min-w-0 truncate">{selectedLabel}</span>
@@ -3,10 +3,20 @@ import { render } from '@testing-library/react';
3
3
  import {
4
4
  createIconMapRegistry,
5
5
  DynamicIcon,
6
+ IconAdd,
7
+ IconArchive,
8
+ IconCopies,
9
+ IconFullscreen,
10
+ IconFullscreenExit,
11
+ IconGrid,
12
+ IconMore,
13
+ IconRefresh,
6
14
  IconRegistry,
15
+ IconScissors,
7
16
  IconSearch,
8
17
  IconSidebar,
9
18
  IconTable,
19
+ IconTrash,
10
20
  } from './icons.js';
11
21
 
12
22
  describe('createIconMapRegistry', () => {
@@ -72,11 +82,21 @@ describe('DynamicIcon with no iconMap', () => {
72
82
  describe('hand-drawn icons', () => {
73
83
  // Dependency-free SVG components (not routed through IconToken/lucide) shared by
74
84
  // hosts that don't carry lucide-react — collections and desk used to hand-roll
75
- // these three locally; see task 2382.
85
+ // these locally; see tasks 2382 and 2473.
76
86
  test.each([
77
87
  ['IconSidebar', IconSidebar],
78
88
  ['IconSearch', IconSearch],
79
89
  ['IconTable', IconTable],
90
+ ['IconAdd', IconAdd],
91
+ ['IconMore', IconMore],
92
+ ['IconFullscreen', IconFullscreen],
93
+ ['IconFullscreenExit', IconFullscreenExit],
94
+ ['IconRefresh', IconRefresh],
95
+ ['IconGrid', IconGrid],
96
+ ['IconTrash', IconTrash],
97
+ ['IconCopies', IconCopies],
98
+ ['IconArchive', IconArchive],
99
+ ['IconScissors', IconScissors],
80
100
  ] as const)('%s renders a sized svg with no lucide dependency', (_name, Icon) => {
81
101
  const { container } = render(Icon({ size: 24, className: 'test-class' }));
82
102
  const svg = container.querySelector('svg');