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
@@ -0,0 +1,160 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { CanvasMinimap } from './CanvasMinimap.js';
4
+ import { CanvasSearchField } from './CanvasSearchField.js';
5
+ import { HudButton, HudZoomControls } from './HudControls.js';
6
+ import { readWheel } from './useCanvasWheel.js';
7
+
8
+ afterEach(cleanup);
9
+
10
+ describe('HudZoomControls (CNV-3)', () => {
11
+ test('draws the zoom as a percentage and wires −, +, Fit and Center', () => {
12
+ const calls: string[] = [];
13
+ const { getByRole, getByTestId } = render(
14
+ <HudZoomControls
15
+ scale={0.734}
16
+ onZoomIn={() => calls.push('in')}
17
+ onZoomOut={() => calls.push('out')}
18
+ onFit={() => calls.push('fit')}
19
+ onCenter={() => calls.push('center')}
20
+ />,
21
+ );
22
+ expect(getByTestId('hud-zoom').textContent).toBe('73%');
23
+ for (const name of ['Zoom out', 'Zoom in', 'Fit to screen', 'Center on the selection']) {
24
+ fireEvent.click(getByRole('button', { name }));
25
+ }
26
+ expect(calls).toEqual(['out', 'in', 'fit', 'center']);
27
+ });
28
+
29
+ test('no Center key when the canvas has nothing to centre on', () => {
30
+ const { queryByRole } = render(
31
+ <HudZoomControls scale={1} onZoomIn={() => {}} onZoomOut={() => {}} onFit={() => {}} />,
32
+ );
33
+ expect(queryByRole('button', { name: 'Center on the selection' })).toBeNull();
34
+ });
35
+ });
36
+
37
+ describe('HudButton', () => {
38
+ test('a toggle announces its pressed state; a refused one stays focusable and does not fire', () => {
39
+ const onClick = mock(() => {});
40
+ const { getByRole } = render(
41
+ <>
42
+ <HudButton label='Map' pressed onClick={() => {}}>
43
+ ▦
44
+ </HudButton>
45
+ <HudButton label='Arrange' unavailableReason='Not in this view' onClick={onClick}>
46
+ ⇹
47
+ </HudButton>
48
+ </>,
49
+ );
50
+ expect(getByRole('button', { name: 'Map' }).getAttribute('aria-pressed')).toBe('true');
51
+ const refused = getByRole('button', { name: 'Arrange' });
52
+ expect(refused.getAttribute('aria-disabled')).toBe('true');
53
+ fireEvent.click(refused);
54
+ expect(onClick).not.toHaveBeenCalled();
55
+ });
56
+ });
57
+
58
+ describe('CanvasSearchField', () => {
59
+ test('keys stop at the field, so a canvas binding the arrows on an ancestor never sees them', () => {
60
+ const canvasKeys = mock(() => {});
61
+ const { getByRole } = render(
62
+ // biome-ignore lint/a11y/noStaticElementInteractions: a stand-in for the canvas's own key binding
63
+ <div onKeyDown={canvasKeys}>
64
+ <CanvasSearchField value='ann' onChange={() => {}} aria-label='Find' />
65
+ </div>,
66
+ );
67
+ fireEvent.keyDown(getByRole('searchbox'), { key: 'ArrowLeft' });
68
+ expect(canvasKeys).not.toHaveBeenCalled();
69
+ });
70
+
71
+ test('Enter steps forward, Shift+Enter back, Escape empties', () => {
72
+ const steps: number[] = [];
73
+ const onChange = mock((_: string) => {});
74
+ const { getByRole, getByText } = render(
75
+ <CanvasSearchField
76
+ value='ann'
77
+ onChange={onChange}
78
+ onStep={(d) => steps.push(d)}
79
+ matches={3}
80
+ index={1}
81
+ aria-label='Find'
82
+ />,
83
+ );
84
+ const box = getByRole('searchbox');
85
+ fireEvent.keyDown(box, { key: 'Enter' });
86
+ fireEvent.keyDown(box, { key: 'Enter', shiftKey: true });
87
+ fireEvent.keyDown(box, { key: 'Escape' });
88
+ expect(steps).toEqual([1, -1]);
89
+ expect(onChange).toHaveBeenCalledWith('');
90
+ getByText('2/3');
91
+ });
92
+
93
+ test('a ✕ empties it too — a phone has no Escape key', () => {
94
+ const onChange = mock((_: string) => {});
95
+ const { getByRole, rerender, queryByRole } = render(
96
+ <CanvasSearchField value='ann' onChange={onChange} aria-label='Find' />,
97
+ );
98
+ fireEvent.click(getByRole('button', { name: 'Clear the search — Escape' }));
99
+ expect(onChange).toHaveBeenCalledWith('');
100
+ rerender(<CanvasSearchField value='' onChange={onChange} aria-label='Find' />);
101
+ expect(queryByRole('button', { name: 'Clear the search — Escape' })).toBeNull();
102
+ });
103
+ });
104
+
105
+ describe('CanvasMinimap', () => {
106
+ test('draws the visible window in world units, and a click jumps in world units', () => {
107
+ const jumps: { x: number; y: number }[] = [];
108
+ const { getByTestId, container } = render(
109
+ <CanvasMinimap
110
+ bounds={{ x: 0, y: 0, width: 1000, height: 500 }}
111
+ view={{ x: -500, y: 0, scale: 0.5 }}
112
+ viewport={{ width: 400, height: 200 }}
113
+ onJump={(p) => jumps.push(p)}
114
+ size={100}
115
+ />,
116
+ );
117
+ const win = getByTestId('canvas-minimap-window');
118
+ expect(win.getAttribute('x')).toBe('1000');
119
+ expect(win.getAttribute('width')).toBe('800');
120
+ const svg = container.querySelector('svg') as SVGSVGElement;
121
+ svg.getBoundingClientRect = () => ({ left: 10, top: 20 }) as DOMRect;
122
+ fireEvent.click(svg, { clientX: 60, clientY: 30 });
123
+ // 100px across 1000 units: one pixel is ten units.
124
+ expect(jumps).toEqual([{ x: 500, y: 100 }]);
125
+ });
126
+
127
+ test('draws nothing before there is anything to map', () => {
128
+ const { container } = render(
129
+ <CanvasMinimap
130
+ bounds={{ x: 0, y: 0, width: 0, height: 0 }}
131
+ view={{ x: 0, y: 0, scale: 1 }}
132
+ viewport={{ width: 400, height: 200 }}
133
+ />,
134
+ );
135
+ expect(container.innerHTML).toBe('');
136
+ });
137
+ });
138
+
139
+ describe('readWheel (CNV-1: one gesture, one meaning)', () => {
140
+ const wheel = (over: Partial<WheelEvent> = {}) => ({
141
+ ctrlKey: false,
142
+ metaKey: false,
143
+ shiftKey: false,
144
+ deltaX: 0,
145
+ deltaY: 10,
146
+ ...over,
147
+ });
148
+ test('a pinch (ctrl+wheel) and ⌘+wheel zoom in every mode', () => {
149
+ for (const mode of ['full', 'full-zoom', 'embedded'] as const) {
150
+ expect(readWheel(mode, wheel({ ctrlKey: true }))).toBe('zoom');
151
+ expect(readWheel(mode, wheel({ metaKey: true }))).toBe('zoom');
152
+ }
153
+ });
154
+ test('a plain wheel pans a full canvas, zooms a map-style one, and scrolls the page past an embedded one', () => {
155
+ expect(readWheel('full', wheel())).toBe('pan');
156
+ expect(readWheel('full-zoom', wheel())).toBe('zoom');
157
+ expect(readWheel('full-zoom', wheel({ shiftKey: true }))).toBe('pan');
158
+ expect(readWheel('embedded', wheel())).toBe('page');
159
+ });
160
+ });
@@ -0,0 +1,128 @@
1
+ import type { ReactNode } from 'react';
2
+ import { HudPill } from '../components/HudPill.js';
3
+ import { IconButton } from '../components/IconButton.js';
4
+ import { IconCenter, IconFit, IconZoomIn, IconZoomOut } from '../icons.js';
5
+ import { cn } from '../lib/cn.js';
6
+
7
+ /*
8
+ * The canvas HUD (CNV-3, CNV-4) — the floating chrome every pan-and-zoom canvas carries, built
9
+ * from the four the fleet had each drawn for itself (task 2401): family's tree (`TreeCanvas.tsx`,
10
+ * its zoom cluster, locator and find pill), patterns' and fractals' studios, and desk's boards.
11
+ *
12
+ * Every control is a belt `IconButton` / `Button` in a `HudPill`, so a canvas never hand-rolls a
13
+ * button again. Positioning is the canvas's: each piece takes `className` and is placed with
14
+ * `absolute bottom-4 right-4` and the like inside the canvas's own relative box (HudPill's rule).
15
+ *
16
+ * 🔴 One module per piece, never one file for all of them: a bundler splits by MODULE, so a
17
+ * canvas that draws the zoom keys on first paint and opens its node menu lazily would otherwise
18
+ * carry the menu's Popover in its entry chunk (family measured +27.8 kB gzip, task 2401).
19
+ */
20
+
21
+ export interface HudButtonProps {
22
+ /** What it does — the accessible name and the hover hint. */
23
+ label: string;
24
+ onClick: () => void;
25
+ children: ReactNode;
26
+ /** Present only on a control that TOGGLES, so the others announce no pressed state. */
27
+ pressed?: boolean;
28
+ /** Why it cannot act right now: drawn disabled, still focusable, the reason on hover. */
29
+ unavailableReason?: string | null;
30
+ className?: string;
31
+ }
32
+
33
+ /** One HUD control — an icon-sized belt `IconButton`, with a pressed state for a toggle. */
34
+ export function HudButton({ label, onClick, children, pressed, unavailableReason, className }: HudButtonProps) {
35
+ return (
36
+ <IconButton
37
+ label={label}
38
+ size='sm'
39
+ aria-pressed={pressed}
40
+ unavailableReason={unavailableReason ?? undefined}
41
+ tooltipMobile='off'
42
+ onClick={onClick}
43
+ className={cn('text-muted-foreground aria-pressed:bg-muted aria-pressed:text-foreground', className)}
44
+ >
45
+ {children}
46
+ </IconButton>
47
+ );
48
+ }
49
+
50
+ export interface HudZoomControlsProps {
51
+ /** The live scale; drawn as a percentage. */
52
+ scale: number;
53
+ onZoomIn: () => void;
54
+ onZoomOut: () => void;
55
+ /** Fit the content to the screen. */
56
+ onFit: () => void;
57
+ /** Center on the focused node. Omit it when the canvas has no focus to centre on. */
58
+ onCenter?: () => void;
59
+ fitLabel?: string;
60
+ centerLabel?: string;
61
+ /** The canvas's own utilities, drawn before the zoom keys in the same pill (CNV-4 — utilities
62
+ * only; preferences and primary actions belong in the page's panel). */
63
+ children?: ReactNode;
64
+ className?: string;
65
+ }
66
+
67
+ /** CNV-3's essentials as one pill: −, the zoom %, +, Fit and Center. */
68
+ export function HudZoomControls({
69
+ scale,
70
+ onZoomIn,
71
+ onZoomOut,
72
+ onFit,
73
+ onCenter,
74
+ fitLabel = 'Fit to screen',
75
+ centerLabel = 'Center on the selection',
76
+ children,
77
+ className,
78
+ }: HudZoomControlsProps) {
79
+ return (
80
+ <HudPill role='group' aria-label='View' className={cn('gap-0.5 rounded-lg px-1 py-1', className)}>
81
+ {children}
82
+ <HudButton label='Zoom out' onClick={onZoomOut}>
83
+ <IconZoomOut />
84
+ </HudButton>
85
+ <span className='min-w-11 text-center text-xs text-muted-foreground tabular-nums' data-testid='hud-zoom'>
86
+ {Math.round(scale * 100)}%
87
+ </span>
88
+ <HudButton label='Zoom in' onClick={onZoomIn}>
89
+ <IconZoomIn />
90
+ </HudButton>
91
+ <HudButton label={fitLabel} onClick={onFit}>
92
+ <IconFit />
93
+ </HudButton>
94
+ {onCenter ? (
95
+ <HudButton label={centerLabel} onClick={onCenter}>
96
+ <IconCenter />
97
+ </HudButton>
98
+ ) : null}
99
+ </HudPill>
100
+ );
101
+ }
102
+
103
+ export interface CanvasZoomHintProps {
104
+ /** `useCanvasWheel(…).hint` — up while the reader wheels over an embedded canvas. */
105
+ show: boolean;
106
+ className?: string;
107
+ }
108
+
109
+ /**
110
+ * CNV-1's "Hold ⌘ to zoom" — what an embedded canvas says when a plain wheel scrolled the page
111
+ * instead of zooming it, the way an embedded Google Map does. Says Ctrl off a Mac.
112
+ */
113
+ export function CanvasZoomHint({ show, className }: CanvasZoomHintProps) {
114
+ const mac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
115
+ return (
116
+ <div
117
+ aria-hidden={!show}
118
+ data-testid='canvas-zoom-hint'
119
+ className={cn(
120
+ 'pointer-events-none absolute inset-0 grid place-items-center bg-background/40 transition-opacity duration-200',
121
+ show ? 'opacity-100' : 'opacity-0',
122
+ className,
123
+ )}
124
+ >
125
+ <HudPill className='px-3 py-1.5 text-sm'>Hold {mac ? '⌘' : 'Ctrl'} to zoom</HudPill>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,33 @@
1
+ // cursedbelt/react/canvas-hud — the viewport arithmetic and the floating chrome every pan-and-zoom
2
+ // canvas carries (docs/design/canvas.md, CNV-1…CNV-5; forge task 2401). Its own subpath, so a
3
+ // page with no canvas never pulls it in.
4
+ export type { CanvasMinimapProps } from './CanvasMinimap.js';
5
+ export { CanvasMinimap } from './CanvasMinimap.js';
6
+ export type { CanvasNodeMenuItem, CanvasNodeMenuProps, CanvasNodeMenuSection } from './CanvasNodeMenu.js';
7
+ export { CanvasNodeMenu } from './CanvasNodeMenu.js';
8
+ export type { CanvasSearchFieldProps } from './CanvasSearchField.js';
9
+ export { CanvasSearchField } from './CanvasSearchField.js';
10
+ export type { CanvasZoomHintProps, HudButtonProps, HudZoomControlsProps } from './HudControls.js';
11
+ export { CanvasZoomHint, HudButton, HudZoomControls } from './HudControls.js';
12
+ export type { CanvasWheelMode, CanvasWheelOptions } from './useCanvasWheel.js';
13
+ export { readWheel, useCanvasWheel, useLevelOfDetail } from './useCanvasWheel.js';
14
+ export type {
15
+ CanvasBounds,
16
+ CanvasPoint,
17
+ CanvasSize,
18
+ CanvasView,
19
+ FitOptions,
20
+ LevelOfDetail,
21
+ LevelOfDetailThresholds,
22
+ } from './viewport.js';
23
+ export {
24
+ centerView,
25
+ clampScale,
26
+ fitScale,
27
+ fitView,
28
+ LEVEL_OF_DETAIL,
29
+ levelOfDetail,
30
+ visibleBounds,
31
+ wheelZoomFactor,
32
+ zoomAt,
33
+ } from './viewport.js';
@@ -0,0 +1,99 @@
1
+ import { type RefObject, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ type CanvasPoint,
4
+ LEVEL_OF_DETAIL,
5
+ type LevelOfDetail,
6
+ type LevelOfDetailThresholds,
7
+ levelOfDetail,
8
+ wheelZoomFactor,
9
+ } from './viewport.js';
10
+
11
+ /**
12
+ * How a canvas reads the wheel (CNV-1 — one gesture, one meaning, on every surface):
13
+ *
14
+ * - `'full'` — the canvas owns the viewport and the page does not scroll. Pinch and ⌘/Ctrl+wheel
15
+ * zoom; a plain wheel or a two-finger swipe PANS (Figma, Miro).
16
+ * - `'full-zoom'` — the same, except a plain wheel ZOOMS, the way a full-page map does. For a
17
+ * canvas whose readers already zoom with a mouse wheel (family's tree): a shift+wheel pans.
18
+ * - `'embedded'` — the page scrolls past the canvas. A plain wheel is the PAGE's and is left
19
+ * alone; pinch and ⌘/Ctrl+wheel zoom; the hook raises `hint` so the canvas can say
20
+ * "Hold ⌘ to zoom", the way an embedded Google Map does.
21
+ */
22
+ export type CanvasWheelMode = 'full' | 'full-zoom' | 'embedded';
23
+
24
+ export interface CanvasWheelOptions {
25
+ mode: CanvasWheelMode;
26
+ /** Zoom by `factor` about `anchor` (viewport pixels). The canvas clamps. */
27
+ onZoom: (factor: number, anchor: CanvasPoint) => void;
28
+ /** Pan by screen pixels. Unused in `'embedded'` mode. */
29
+ onPan?: (dx: number, dy: number) => void;
30
+ /** How long the embedded hint stays up after the last plain wheel. Default 1200 ms. */
31
+ hintMs?: number;
32
+ }
33
+
34
+ /** The decision, separated from the DOM so it is testable: what one wheel event means. */
35
+ export function readWheel(
36
+ mode: CanvasWheelMode,
37
+ event: Pick<WheelEvent, 'ctrlKey' | 'metaKey' | 'shiftKey' | 'deltaX' | 'deltaY'>,
38
+ ): 'zoom' | 'pan' | 'page' {
39
+ // A trackpad pinch arrives as a wheel event with ctrlKey set, on every browser.
40
+ if (event.ctrlKey || event.metaKey) return 'zoom';
41
+ if (mode === 'embedded') return 'page';
42
+ if (mode === 'full-zoom') return event.shiftKey ? 'pan' : 'zoom';
43
+ return 'pan';
44
+ }
45
+
46
+ /**
47
+ * Binds the wheel on `ref` per {@link CanvasWheelMode}.
48
+ *
49
+ * 🔴 A NON-passive native listener, never a JSX `onWheel`: React registers `wheel` at the root as
50
+ * passive, so `preventDefault()` there is ignored and the page scrolls (or zooms the whole
51
+ * browser on a pinch) under the canvas.
52
+ */
53
+ export function useCanvasWheel(ref: RefObject<HTMLElement | null>, options: CanvasWheelOptions): { hint: boolean } {
54
+ const latest = useRef(options);
55
+ latest.current = options;
56
+ const [hint, setHint] = useState(false);
57
+ useEffect(() => {
58
+ const element = ref.current;
59
+ if (!element) return;
60
+ let timer: ReturnType<typeof setTimeout> | undefined;
61
+ const onWheel = (event: WheelEvent): void => {
62
+ const { mode, onZoom, onPan, hintMs = 1200 } = latest.current;
63
+ const meaning = readWheel(mode, event);
64
+ if (meaning === 'page') {
65
+ setHint(true);
66
+ clearTimeout(timer);
67
+ timer = setTimeout(() => setHint(false), hintMs);
68
+ return;
69
+ }
70
+ event.preventDefault();
71
+ if (meaning === 'zoom') {
72
+ const rect = element.getBoundingClientRect();
73
+ onZoom(wheelZoomFactor(event.deltaY), { x: event.clientX - rect.left, y: event.clientY - rect.top });
74
+ return;
75
+ }
76
+ // A shift+wheel from a mouse reports its motion on Y; a sideways trackpad swipe on X.
77
+ const dx = event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX;
78
+ const dy = event.shiftKey && event.deltaX === 0 ? 0 : event.deltaY;
79
+ onPan?.(-dx, -dy);
80
+ };
81
+ element.addEventListener('wheel', onWheel, { passive: false });
82
+ return () => {
83
+ clearTimeout(timer);
84
+ element.removeEventListener('wheel', onWheel);
85
+ };
86
+ }, [ref]);
87
+ return { hint };
88
+ }
89
+
90
+ /**
91
+ * CNV-5 — the density to draw at this scale. A string that changes only at a threshold, so an
92
+ * effect or memo keyed on it re-runs three times across the whole zoom range, not every frame.
93
+ */
94
+ export function useLevelOfDetail(
95
+ scale: number,
96
+ thresholds: LevelOfDetailThresholds = LEVEL_OF_DETAIL,
97
+ ): LevelOfDetail {
98
+ return levelOfDetail(scale, thresholds);
99
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ centerView,
4
+ clampScale,
5
+ fitScale,
6
+ fitView,
7
+ levelOfDetail,
8
+ visibleBounds,
9
+ wheelZoomFactor,
10
+ zoomAt,
11
+ } from './viewport.js';
12
+
13
+ /** Where a world point lands on screen under a view. */
14
+ const toScreen = (view: { x: number; y: number; scale: number }, p: { x: number; y: number }) => ({
15
+ x: p.x * view.scale + view.x,
16
+ y: p.y * view.scale + view.y,
17
+ });
18
+ const toWorld = (view: { x: number; y: number; scale: number }, p: { x: number; y: number }) => ({
19
+ x: (p.x - view.x) / view.scale,
20
+ y: (p.y - view.y) / view.scale,
21
+ });
22
+
23
+ describe('zoomAt (CNV-2: zoom anchors on the pointer)', () => {
24
+ test('the world point under the anchor stays under the anchor', () => {
25
+ const view = { x: -120, y: 40, scale: 0.8 };
26
+ const anchor = { x: 311, y: 207 };
27
+ const under = toWorld(view, anchor);
28
+ for (const scale of [0.1, 0.5, 1.7, 4]) {
29
+ const next = zoomAt(view, scale, anchor);
30
+ expect(next.scale).toBe(scale);
31
+ const landed = toScreen(next, under);
32
+ expect(landed.x).toBeCloseTo(anchor.x, 9);
33
+ expect(landed.y).toBeCloseTo(anchor.y, 9);
34
+ }
35
+ });
36
+
37
+ test('a non-finite or non-positive scale leaves the view alone', () => {
38
+ const view = { x: 3, y: 4, scale: 1 };
39
+ expect(zoomAt(view, Number.NaN, { x: 0, y: 0 })).toBe(view);
40
+ expect(zoomAt(view, 0, { x: 0, y: 0 })).toBe(view);
41
+ expect(zoomAt(view, Number.POSITIVE_INFINITY, { x: 0, y: 0 })).toBe(view);
42
+ });
43
+
44
+ test('a wheel notch in and the same notch out return to the same scale', () => {
45
+ expect(wheelZoomFactor(100) * wheelZoomFactor(-100)).toBeCloseTo(1, 12);
46
+ expect(wheelZoomFactor(-100)).toBeGreaterThan(1);
47
+ });
48
+ });
49
+
50
+ describe('fit and center (CNV-3)', () => {
51
+ test('fit shrinks a large extent and centres it', () => {
52
+ const bounds = { x: 100, y: 50, width: 4000, height: 1000 };
53
+ const viewport = { width: 1000, height: 600 };
54
+ const view = fitView(bounds, viewport, { padding: 50 });
55
+ expect(view).not.toBeNull();
56
+ if (!view) return;
57
+ expect(view.scale).toBeCloseTo(900 / 4000, 12);
58
+ const middle = toScreen(view, { x: 2100, y: 550 });
59
+ expect(middle.x).toBeCloseTo(500, 9);
60
+ expect(middle.y).toBeCloseTo(300, 9);
61
+ });
62
+
63
+ test('fit never magnifies past maxScale (default 1)', () => {
64
+ expect(fitScale({ width: 10, height: 10 }, { width: 1000, height: 1000 })).toBe(1);
65
+ expect(fitScale({ width: 10, height: 10 }, { width: 1000, height: 1000 }, { maxScale: 2 })).toBe(2);
66
+ });
67
+
68
+ test('fit has nothing to do before the viewport is measured or with an empty extent', () => {
69
+ expect(fitScale({ width: 0, height: 10 }, { width: 100, height: 100 })).toBeNull();
70
+ expect(fitView({ x: 0, y: 0, width: 10, height: 10 }, { width: 0, height: 0 })).toBeNull();
71
+ });
72
+
73
+ test('fit is floored at minScale, so a huge extent is still drawable', () => {
74
+ expect(fitScale({ width: 1e9, height: 1 }, { width: 400, height: 400 })).toBe(0.02);
75
+ });
76
+
77
+ test('center puts the point in the middle at the asked scale', () => {
78
+ const view = centerView({ x: 40, y: -20 }, { width: 800, height: 400 }, 2);
79
+ expect(toScreen(view, { x: 40, y: -20 })).toEqual({ x: 400, y: 200 });
80
+ });
81
+
82
+ test('visibleBounds is the inverse of the view', () => {
83
+ const view = { x: -200, y: -100, scale: 0.5 };
84
+ expect(visibleBounds(view, { width: 800, height: 600 })).toEqual({ x: 400, y: 200, width: 1600, height: 1200 });
85
+ });
86
+
87
+ test('clampScale', () => {
88
+ expect(clampScale(5, 0.1, 2)).toBe(2);
89
+ expect(clampScale(0.01, 0.1, 2)).toBe(0.1);
90
+ });
91
+ });
92
+
93
+ describe('levelOfDetail (CNV-5)', () => {
94
+ test('far under 30%, mid to 50%, close from 50%', () => {
95
+ expect(levelOfDetail(0.29)).toBe('far');
96
+ expect(levelOfDetail(0.3)).toBe('mid');
97
+ expect(levelOfDetail(0.49)).toBe('mid');
98
+ expect(levelOfDetail(0.5)).toBe('close');
99
+ expect(levelOfDetail(2)).toBe('close');
100
+ });
101
+
102
+ test('a canvas may move the thresholds', () => {
103
+ expect(levelOfDetail(0.4, { mid: 0.2, close: 0.4 })).toBe('close');
104
+ });
105
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The camera arithmetic every pan-and-zoom canvas needs (CNV-2, CNV-3, CNV-5).
3
+ *
4
+ * Pure — no React, no DOM — so each rule is pinned by a spec rather than by opening an app.
5
+ * A view maps a WORLD point to the screen as `screen = world × scale + (x, y)`: `x`/`y` are the
6
+ * screen position of the world origin, which is what a CSS `translate(x, y) scale(scale)` on the
7
+ * stage means. Every canvas in the fleet already used that convention (family's `Camera`,
8
+ * patterns' `viewportGestures.ts`, desk's xyflow viewport), which is why it is the one here.
9
+ */
10
+
11
+ export interface CanvasView {
12
+ x: number;
13
+ y: number;
14
+ scale: number;
15
+ }
16
+
17
+ export interface CanvasPoint {
18
+ x: number;
19
+ y: number;
20
+ }
21
+
22
+ export interface CanvasSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+
27
+ /** A rectangle in WORLD units — the content's extent, or the visible window. */
28
+ export interface CanvasBounds extends CanvasPoint, CanvasSize {}
29
+
30
+ export function clampScale(scale: number, min: number, max: number): number {
31
+ return Math.min(max, Math.max(min, scale));
32
+ }
33
+
34
+ /**
35
+ * CNV-2 — zoom to `scale` keeping the screen point `anchor` still: the pointer for a wheel or a
36
+ * pinch, the middle of the viewport for the ± buttons. The caller clamps `scale` first, because
37
+ * the floor is the canvas's own business (family floors it at the whole tree).
38
+ */
39
+ export function zoomAt(view: CanvasView, scale: number, anchor: CanvasPoint): CanvasView {
40
+ if (!Number.isFinite(scale) || scale <= 0 || view.scale <= 0) return view;
41
+ const applied = scale / view.scale;
42
+ return {
43
+ scale,
44
+ x: anchor.x - (anchor.x - view.x) * applied,
45
+ y: anchor.y - (anchor.y - view.y) * applied,
46
+ };
47
+ }
48
+
49
+ /** The zoom factor for one wheel event — exponential, so a trackpad pinch (which arrives as a
50
+ * ctrl+wheel stream of small deltas) and a mouse notch feel equally linear. */
51
+ export function wheelZoomFactor(deltaY: number): number {
52
+ return Math.exp(-deltaY * 0.0015);
53
+ }
54
+
55
+ export interface FitOptions {
56
+ /** Screen pixels of breathing room on every side. Default 48. */
57
+ padding?: number;
58
+ /**
59
+ * The largest scale a fit may land on. Default 1: fit SHRINKS to fit and never magnifies a
60
+ * small graph past its natural size — a two-node graph "fitted" to 400% reads as a zoom
61
+ * nobody asked for (Figma's and Miro's zoom-to-fit behave the same way).
62
+ */
63
+ maxScale?: number;
64
+ /** The smallest. Default 0.02 — a degenerate extent must still be a drawable number. */
65
+ minScale?: number;
66
+ }
67
+
68
+ /** The scale at which `bounds` fits the viewport, or `null` while there is nothing to fit
69
+ * (an empty extent, or a viewport not measured yet). */
70
+ export function fitScale(bounds: CanvasSize, viewport: CanvasSize, options: FitOptions = {}): number | null {
71
+ const { padding = 48, maxScale = 1, minScale = 0.02 } = options;
72
+ if (bounds.width <= 0 || bounds.height <= 0 || viewport.width <= 0 || viewport.height <= 0) return null;
73
+ const scale = Math.min(
74
+ (viewport.width - padding * 2) / bounds.width,
75
+ (viewport.height - padding * 2) / bounds.height,
76
+ );
77
+ if (!Number.isFinite(scale)) return null;
78
+ return clampScale(scale, minScale, maxScale);
79
+ }
80
+
81
+ /** CNV-3 "Fit" — the view that centres `bounds` in the viewport at {@link fitScale}. */
82
+ export function fitView(bounds: CanvasBounds, viewport: CanvasSize, options: FitOptions = {}): CanvasView | null {
83
+ const scale = fitScale(bounds, viewport, options);
84
+ if (scale === null) return null;
85
+ return centerView({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }, viewport, scale);
86
+ }
87
+
88
+ /** CNV-3 "Center" — the view that puts the world point `point` in the middle of the viewport. */
89
+ export function centerView(point: CanvasPoint, viewport: CanvasSize, scale: number): CanvasView {
90
+ return {
91
+ scale,
92
+ x: viewport.width / 2 - point.x * scale,
93
+ y: viewport.height / 2 - point.y * scale,
94
+ };
95
+ }
96
+
97
+ /** The part of the world the viewport shows — what a minimap draws as its window. */
98
+ export function visibleBounds(view: CanvasView, viewport: CanvasSize): CanvasBounds {
99
+ return {
100
+ x: -view.x / view.scale,
101
+ y: -view.y / view.scale,
102
+ width: viewport.width / view.scale,
103
+ height: viewport.height / view.scale,
104
+ };
105
+ }
106
+
107
+ /** CNV-5's three densities. */
108
+ export type LevelOfDetail = 'far' | 'mid' | 'close';
109
+
110
+ export interface LevelOfDetailThresholds {
111
+ /** At and above this scale, primary labels may appear. Default 0.3. */
112
+ mid: number;
113
+ /** At and above this scale, full labels and metadata. Default 0.5. */
114
+ close: number;
115
+ }
116
+
117
+ export const LEVEL_OF_DETAIL: LevelOfDetailThresholds = { mid: 0.3, close: 0.5 };
118
+
119
+ /** CNV-5 — which density a canvas draws at this scale. */
120
+ export function levelOfDetail(scale: number, thresholds: LevelOfDetailThresholds = LEVEL_OF_DETAIL): LevelOfDetail {
121
+ if (scale >= thresholds.close) return 'close';
122
+ if (scale >= thresholds.mid) return 'mid';
123
+ return 'far';
124
+ }
@@ -1,6 +1,18 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import { formatBytes, formatCount, formatDays, formatDelta, formatNumber, formatRate } from './format.js';
3
3
 
4
+ describe('formatNumber', () => {
5
+ test('never rounds two axis ticks to one label', () => {
6
+ // A phone chart of small counts (max 1) ticks 0, 0.25, 0.5, 0.75, 1 — rounded, three read "1".
7
+ const ticks = [0, 0.25, 0.5, 0.75, 1].map(formatNumber);
8
+ expect(new Set(ticks).size).toBe(ticks.length);
9
+ expect(formatNumber(0.5)).toBe('0.5');
10
+ expect(formatNumber(12.5)).toBe('12.5');
11
+ expect(formatNumber(7)).toBe('7');
12
+ expect(formatNumber(1_500)).toBe('1.5k');
13
+ });
14
+ });
15
+
4
16
  describe('formatDelta', () => {
5
17
  test('signs the magnitude with the caller’s formatter', () => {
6
18
  expect(formatDelta(1_048_576, formatBytes)).toStartWith('+');
@@ -11,7 +11,8 @@ export const formatNumber = (n: number): string => {
11
11
  if (abs >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
12
12
  if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
13
13
  if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
14
- return Math.round(n).toString();
14
+ // A fraction keeps its digits: rounded, a count axis peaking at 1 ticks 0.5 as a second "1".
15
+ return Number(n.toFixed(abs < 10 ? 2 : abs < 100 ? 1 : 0)).toString();
15
16
  };
16
17
 
17
18
  export const formatMs = (n: number): string => {