@weasel-js/labkit 1.1.0 → 1.3.0-pre.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 (226) hide show
  1. package/README.md +27 -0
  2. package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
  3. package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
  4. package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
  5. package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
  6. package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
  7. package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
  8. package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
  9. package/dist/canvas/index.d.ts +8 -3
  10. package/dist/canvas/index.js +2 -1
  11. package/dist/chrome/index.d.ts +86 -0
  12. package/dist/chrome/index.js +8 -0
  13. package/dist/chrome/index.js.map +1 -0
  14. package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
  15. package/dist/chunk-4TMMVIDM.js.map +1 -0
  16. package/dist/chunk-HFDVGF4X.js +170 -0
  17. package/dist/chunk-HFDVGF4X.js.map +1 -0
  18. package/dist/chunk-LN6JDUGB.js +106 -0
  19. package/dist/chunk-LN6JDUGB.js.map +1 -0
  20. package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
  21. package/dist/chunk-NS54R52R.js.map +1 -0
  22. package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
  23. package/dist/chunk-O2BULFHX.js.map +1 -0
  24. package/dist/chunk-O7NVSCLN.js +266 -0
  25. package/dist/chunk-O7NVSCLN.js.map +1 -0
  26. package/dist/chunk-PO6L3NSH.js +366 -0
  27. package/dist/chunk-PO6L3NSH.js.map +1 -0
  28. package/dist/chunk-PQJ5B2U2.js +27 -0
  29. package/dist/chunk-PQJ5B2U2.js.map +1 -0
  30. package/dist/chunk-RVNN4CHQ.js +212 -0
  31. package/dist/chunk-RVNN4CHQ.js.map +1 -0
  32. package/dist/chunk-TLGRYALP.js +6151 -0
  33. package/dist/chunk-TLGRYALP.js.map +1 -0
  34. package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
  35. package/dist/chunk-V7PRSIRQ.js.map +1 -0
  36. package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
  37. package/dist/chunk-Z6HJ5FEM.js.map +1 -0
  38. package/dist/chunk-ZZAYVX4X.js +505 -0
  39. package/dist/chunk-ZZAYVX4X.js.map +1 -0
  40. package/dist/controls/index.d.ts +19 -5
  41. package/dist/controls/index.js +3 -1
  42. package/dist/dragdrop/index.d.ts +5 -2
  43. package/dist/index.d.ts +307 -107
  44. package/dist/index.js +797 -540
  45. package/dist/index.js.map +1 -1
  46. package/dist/job/index.d.ts +13 -0
  47. package/dist/job/index.js +3 -0
  48. package/dist/job/index.js.map +1 -0
  49. package/dist/layers/index.d.ts +6 -3
  50. package/dist/layers/index.js +2 -1
  51. package/dist/passthrough/weasel-canvas.d.ts +69 -13
  52. package/dist/passthrough/weasel-canvas.js +1 -1
  53. package/dist/passthrough/weasel-ui.d.ts +3804 -3661
  54. package/dist/passthrough/weasel-ui.js +2 -2
  55. package/dist/primitives/index.d.ts +102 -8
  56. package/dist/primitives/index.js +4 -1
  57. package/dist/state/index.d.ts +3 -2
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +543 -144
  60. package/dist/surface/index.d.ts +77 -0
  61. package/dist/surface/index.js +4 -0
  62. package/dist/surface/index.js.map +1 -0
  63. package/dist/ui/layers/index.js +4 -3
  64. package/dist/undo/index.d.ts +5 -2
  65. package/package.json +24 -7
  66. package/src/canvas/AGENTS.md +12 -0
  67. package/src/canvas/CanvasStack.tsx +6 -2
  68. package/src/canvas/useLayerScheduler.ts +22 -15
  69. package/src/canvas/useOrbit.test.ts +71 -0
  70. package/src/canvas/useOrbit.ts +149 -0
  71. package/src/canvas/usePanZoom.test.ts +80 -0
  72. package/src/canvas/usePanZoom.ts +16 -2
  73. package/src/chrome/ChromeRegions.stories.tsx +106 -0
  74. package/src/chrome/builtins.test.ts +153 -0
  75. package/src/chrome/builtins.tsx +205 -0
  76. package/src/chrome/index.ts +25 -0
  77. package/src/chrome/merge.test.ts +51 -0
  78. package/src/chrome/merge.ts +52 -0
  79. package/src/chrome/regions/PaletteRegion.less +14 -0
  80. package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
  81. package/src/chrome/regions/PaletteRegion.tsx +41 -0
  82. package/src/chrome/regions/SidebarRegion.tsx +56 -0
  83. package/src/chrome/regions/StatusRegion.tsx +31 -0
  84. package/src/chrome/regions/TitleBarRegion.tsx +41 -0
  85. package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
  86. package/src/chrome/regions/ToolbarRegion.tsx +71 -0
  87. package/src/chrome/regions/ViewportRegion.less +42 -0
  88. package/src/chrome/regions/ViewportRegion.tsx +37 -0
  89. package/src/chrome/regions/regions.test.tsx +81 -0
  90. package/src/chrome/types.ts +127 -0
  91. package/src/config/builder.test.ts +87 -0
  92. package/src/config/builder.ts +224 -0
  93. package/src/config/fromConfigField.test.ts +93 -0
  94. package/src/config/fromConfigField.ts +74 -0
  95. package/src/config/index.ts +22 -0
  96. package/src/config/resolve.test.ts +120 -0
  97. package/src/config/resolve.ts +67 -0
  98. package/src/config/rules.ts +68 -0
  99. package/src/config/types.ts +108 -0
  100. package/src/config/useConfigSchema.ts +30 -0
  101. package/src/config/visible.ts +20 -0
  102. package/src/controls/ControlPanel.less +4 -58
  103. package/src/controls/ControlPanel.stories.tsx +86 -0
  104. package/src/controls/ControlPanel.test.tsx +223 -10
  105. package/src/controls/ControlPanel.tsx +202 -203
  106. package/src/dragdrop/DragDropRuntime.tsx +36 -18
  107. package/src/dragdrop/DragGhost.less +1 -1
  108. package/src/dragdrop/Palette.less +1 -1
  109. package/src/index.test.ts +18 -0
  110. package/src/index.ts +38 -3
  111. package/src/instrument/SineWave.smoke.test.tsx +9 -1
  112. package/src/instrument/defineInstrument.test.ts +38 -1
  113. package/src/instrument/defineInstrument.ts +31 -4
  114. package/src/instrument/index.ts +1 -0
  115. package/src/instrument/types.ts +38 -3
  116. package/src/instrument/validateConfigSchema.test.ts +13 -3
  117. package/src/instrument/validateConfigSchema.ts +7 -10
  118. package/src/job/index.ts +3 -0
  119. package/src/job/types.ts +47 -0
  120. package/src/job/useJob.test.tsx +210 -0
  121. package/src/job/useJob.ts +134 -0
  122. package/src/lab/Lab.chrome.test.tsx +65 -0
  123. package/src/lab/Lab.stories.tsx +1 -2
  124. package/src/lab/Lab.tsx +58 -15
  125. package/src/lab/LabContext.ts +6 -0
  126. package/src/lab/LabFullChrome.stories.tsx +308 -0
  127. package/src/lab/LabHeader.test.tsx +42 -0
  128. package/src/lab/LabHeader.tsx +57 -0
  129. package/src/lab/LabPalette.test.tsx +35 -0
  130. package/src/lab/LabPalette.tsx +34 -0
  131. package/src/lab/LabShell.less +50 -3
  132. package/src/lab/Workspace.less +1 -30
  133. package/src/lab/Workspace.surface.test.tsx +49 -0
  134. package/src/lab/Workspace.test.tsx +20 -10
  135. package/src/lab/Workspace.tsx +19 -9
  136. package/src/lab/index.ts +2 -0
  137. package/src/layers/AGENTS.md +1 -1
  138. package/src/layers/LayerList.less +31 -7
  139. package/src/layers/LayerList.tsx +17 -4
  140. package/src/passthrough/weasel-ui.ts +23 -0
  141. package/src/primitives/DragHandleGlyph.tsx +29 -0
  142. package/src/primitives/FloatingPanel.less +28 -0
  143. package/src/primitives/FloatingPanel.stories.tsx +46 -0
  144. package/src/primitives/FloatingPanel.test.tsx +240 -0
  145. package/src/primitives/FloatingPanel.tsx +190 -0
  146. package/src/primitives/FpsMeter.test.tsx +44 -0
  147. package/src/primitives/FpsMeter.tsx +20 -9
  148. package/src/primitives/JobProgress.less +69 -0
  149. package/src/primitives/JobProgress.stories.tsx +48 -0
  150. package/src/primitives/JobProgress.tsx +65 -0
  151. package/src/primitives/Legend.less +47 -0
  152. package/src/primitives/Legend.stories.tsx +32 -0
  153. package/src/primitives/Legend.test.tsx +57 -0
  154. package/src/primitives/Legend.tsx +40 -0
  155. package/src/primitives/Sidebar.less +32 -2
  156. package/src/primitives/StatusBar.less +7 -0
  157. package/src/primitives/StatusBar.tsx +10 -3
  158. package/src/primitives/Toolbar.less +50 -3
  159. package/src/primitives/Toolbar.test.tsx +67 -1
  160. package/src/primitives/Toolbar.tsx +72 -6
  161. package/src/primitives/ZoomControl.less +31 -0
  162. package/src/primitives/ZoomControl.tsx +76 -0
  163. package/src/primitives/floating.entry.test.tsx +14 -0
  164. package/src/primitives/index.ts +10 -2
  165. package/src/primitives/useRovingTabIndex.test.ts +27 -0
  166. package/src/primitives/useRovingTabIndex.ts +79 -0
  167. package/src/state/store.ts +16 -2
  168. package/src/state/toolSlot.test.ts +36 -0
  169. package/src/state/types.ts +8 -2
  170. package/src/state/view.test.ts +127 -0
  171. package/src/state/view.ts +18 -0
  172. package/src/styles.less +6 -0
  173. package/src/surface/AGENTS.md +64 -0
  174. package/src/surface/SurfaceContext.ts +5 -0
  175. package/src/surface/composeRects.test.ts +50 -0
  176. package/src/surface/composeRects.ts +19 -0
  177. package/src/surface/deviceRect.test.ts +40 -0
  178. package/src/surface/deviceRect.ts +19 -0
  179. package/src/surface/index.ts +7 -0
  180. package/src/surface/rect.ts +16 -0
  181. package/src/surface/useSurfaceTile.test.tsx +67 -0
  182. package/src/surface/useSurfaceTile.ts +32 -0
  183. package/src/surface/useTiledSurface.test.tsx +231 -0
  184. package/src/surface/useTiledSurface.ts +161 -0
  185. package/src/test-setup.ts +26 -0
  186. package/src/theme/base.less +19 -2
  187. package/src/theme/interstellar.tokens.json +6 -2
  188. package/src/tools/types.ts +24 -0
  189. package/src/trial/Trial.config.test.tsx +113 -0
  190. package/src/trial/Trial.job.test.tsx +73 -0
  191. package/src/trial/Trial.less +121 -4
  192. package/src/trial/Trial.stories.tsx +9 -1
  193. package/src/trial/Trial.test.tsx +67 -96
  194. package/src/trial/Trial.tsx +112 -45
  195. package/src/trial/TrialChrome.tsx +110 -64
  196. package/src/trial/TrialDragContext.ts +10 -0
  197. package/src/trial/TrialTitleBar.tsx +41 -0
  198. package/src/trial/index.ts +1 -15
  199. package/src/trial/trialChrome.borders.test.ts +38 -0
  200. package/src/ui/layers/LayerStack.less +9 -9
  201. package/src/ui/layers/LayerStack.tsx +1 -13
  202. package/src/ui/properties/CurveField.less +2 -2
  203. package/src/ui/properties/CurveField.test.tsx +11 -11
  204. package/src/ui/properties/PropertyGroup.less +2 -2
  205. package/src/ui/properties/PropertyPanel.less +55 -41
  206. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
  207. package/src/ui/properties/storyLayouts.tsx +2 -2
  208. package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
  209. package/dist/_dts/types-x92Kfeme.d.ts +0 -62
  210. package/dist/chunk-3TYUJR7Z.js.map +0 -1
  211. package/dist/chunk-574LJAV4.js.map +0 -1
  212. package/dist/chunk-5R2ATYPJ.js +0 -201
  213. package/dist/chunk-5R2ATYPJ.js.map +0 -1
  214. package/dist/chunk-C6GJKPUI.js +0 -5986
  215. package/dist/chunk-C6GJKPUI.js.map +0 -1
  216. package/dist/chunk-DJLDIRFN.js.map +0 -1
  217. package/dist/chunk-NRKWVTVT.js.map +0 -1
  218. package/dist/chunk-PMAU3SEE.js +0 -126
  219. package/dist/chunk-PMAU3SEE.js.map +0 -1
  220. package/dist/chunk-SFL7NFKN.js.map +0 -1
  221. package/src/instrument/capabilityDetector.test.ts +0 -64
  222. package/src/instrument/capabilityDetector.ts +0 -20
  223. package/src/trial/DefaultSidebar.tsx +0 -29
  224. package/src/trial/DefaultStatusBar.tsx +0 -19
  225. package/src/trial/DefaultToolbar.tsx +0 -92
  226. package/src/trial/slotTypes.ts +0 -53
@@ -0,0 +1,65 @@
1
+ import { ErrorIcon, WarningIcon } from '@weasel-js/ui';
2
+ import type { JobHandle } from '../job/types';
3
+
4
+ /** Props for `<JobProgress>`. */
5
+ export interface JobProgressProps {
6
+ job: JobHandle;
7
+ className?: string;
8
+ }
9
+
10
+ /** A running job's progress, failures and outcome. Determinate once the job
11
+ * reports a total; a job that cannot count up front never sends one, so the
12
+ * bar stays indeterminate rather than inventing a denominator. */
13
+ export function JobProgress({ job, className }: JobProgressProps) {
14
+ const { status, done, total, failures, error } = job;
15
+ if (status === 'idle') return null;
16
+
17
+ const determinate = total !== null && total > 0;
18
+ const fraction = determinate ? Math.min(1, done / total) : 0;
19
+ const cls = ['lk-job', `lk-job--${status}`, className].filter(Boolean).join(' ');
20
+
21
+ return (
22
+ <div className={cls}>
23
+ <div
24
+ className={`lk-job__track${determinate ? '' : ' lk-job__track--indeterminate'}`}
25
+ role="progressbar"
26
+ aria-label="Job progress"
27
+ aria-valuenow={determinate ? done : undefined}
28
+ aria-valuemin={determinate ? 0 : undefined}
29
+ aria-valuemax={determinate ? total : undefined}
30
+ >
31
+ {/* Scaling a full-width bar keeps the fill off the layout path, so
32
+ progress updates never reflow the status bar. */}
33
+ <div
34
+ className="lk-job__fill"
35
+ style={{ transform: `scaleX(${determinate ? fraction : 1})` }}
36
+ />
37
+ </div>
38
+
39
+ <span className="lk-job__count">
40
+ {done}
41
+ {determinate ? ` / ${total}` : ''}
42
+ </span>
43
+
44
+ {failures.length > 0 && (
45
+ <span className="lk-job__failures" title={failures.map((f) => f.error).join('\n')}>
46
+ <WarningIcon size={14} />
47
+ {failures.length} failed
48
+ </span>
49
+ )}
50
+
51
+ {error && (
52
+ <span className="lk-job__error" title={error}>
53
+ <ErrorIcon size={14} />
54
+ {error}
55
+ </span>
56
+ )}
57
+
58
+ {status === 'running' && (
59
+ <button type="button" className="lk-job__cancel" onClick={job.cancel}>
60
+ Cancel
61
+ </button>
62
+ )}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,47 @@
1
+ .lk-legend {
2
+ margin: 0;
3
+ padding: 0;
4
+ list-style: none;
5
+ font-family: var(--wzl-font-mono);
6
+ font-size: var(--wzl-font-size-sm);
7
+ line-height: 1.4;
8
+ color: var(--wzl-fg-muted);
9
+ }
10
+
11
+ .lk-legend__row {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: var(--wzl-space-sm);
15
+ padding: 1px 0;
16
+ white-space: nowrap;
17
+ }
18
+
19
+ .lk-legend__swatch {
20
+ flex: none;
21
+ width: 15px;
22
+ }
23
+
24
+ .lk-legend__swatch--line {
25
+ height: 4px;
26
+ border-radius: var(--wzl-radius-sm);
27
+ background: var(--lk-legend-ink);
28
+ }
29
+
30
+ .lk-legend__swatch--dash {
31
+ height: 0;
32
+ border-top: 2px dashed var(--lk-legend-ink);
33
+ }
34
+
35
+ .lk-legend__swatch--dot {
36
+ width: 6px;
37
+ height: 6px;
38
+ margin: 0 4px 0 5px;
39
+ border-radius: 50%;
40
+ background: var(--lk-legend-ink);
41
+ }
42
+
43
+ .lk-legend__swatch--band {
44
+ height: 9px;
45
+ border-radius: var(--wzl-radius-sm);
46
+ background: var(--lk-legend-ink);
47
+ }
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { Legend } from './Legend';
3
+
4
+ const meta: Meta<typeof Legend> = {
5
+ title: 'labkit/Primitives/Legend',
6
+ component: Legend,
7
+ };
8
+ export default meta;
9
+
10
+ type Story = StoryObj<typeof Legend>;
11
+
12
+ export const AllMarks: Story = {
13
+ args: {
14
+ entries: [
15
+ { key: 'contour', label: 'contour', color: '#7d7f86' },
16
+ { key: 'floor', label: 'bend floor', color: '#9a9ca3', mark: 'dash' },
17
+ { key: 'authored', label: 'authored', color: '#2aa87a', mark: 'dot' },
18
+ { key: 'replaced', label: 'replaced', color: 'rgba(255,107,96,.28)', mark: 'band' },
19
+ ],
20
+ },
21
+ };
22
+
23
+ export const DefaultMark: Story = {
24
+ args: {
25
+ entries: [
26
+ { key: 'a', label: 'input', color: '#4c9be8' },
27
+ { key: 'b', label: 'output', color: '#e8a04c' },
28
+ ],
29
+ },
30
+ };
31
+
32
+ export const Empty: Story = { args: { entries: [] } };
@@ -0,0 +1,57 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { Legend } from './Legend';
4
+
5
+ const entries = [
6
+ { key: 'contour', label: 'contour', color: '#7d7f86' },
7
+ { key: 'floor', label: 'bend floor', color: '#9a9ca3', mark: 'dash' as const },
8
+ { key: 'authored', label: 'authored', color: '#2aa87a', mark: 'dot' as const },
9
+ { key: 'replaced', label: 'replaced', color: 'rgba(255,107,96,.28)', mark: 'band' as const },
10
+ ];
11
+
12
+ describe('Legend', () => {
13
+ it('renders one row per entry', () => {
14
+ const { container } = render(<Legend entries={entries} />);
15
+ expect(container.querySelectorAll('.lk-legend__row')).toHaveLength(4);
16
+ });
17
+
18
+ it('shows every label as text', () => {
19
+ render(<Legend entries={entries} />);
20
+ for (const e of entries) expect(screen.getByText(e.label)).toBeInTheDocument();
21
+ });
22
+
23
+ it('marks each swatch with its shape, defaulting to a line', () => {
24
+ const { container } = render(<Legend entries={entries} />);
25
+ const swatches = container.querySelectorAll('.lk-legend__swatch');
26
+ expect(swatches[0]?.className).toContain('lk-legend__swatch--line');
27
+ expect(swatches[1]?.className).toContain('lk-legend__swatch--dash');
28
+ expect(swatches[2]?.className).toContain('lk-legend__swatch--dot');
29
+ expect(swatches[3]?.className).toContain('lk-legend__swatch--band');
30
+ });
31
+
32
+ it('hides swatches from assistive tech, leaving the label to carry meaning', () => {
33
+ const { container } = render(<Legend entries={entries} />);
34
+ for (const s of container.querySelectorAll('.lk-legend__swatch')) {
35
+ expect(s.getAttribute('aria-hidden')).toBe('true');
36
+ }
37
+ });
38
+
39
+ it('takes its color from the entry', () => {
40
+ const { container } = render(
41
+ <Legend entries={[{ key: 'contour', label: 'contour', color: '#7d7f86' }]} />,
42
+ );
43
+ const swatch = container.querySelector('.lk-legend__swatch') as HTMLElement;
44
+ expect(swatch.style.getPropertyValue('--lk-legend-ink')).toBe('#7d7f86');
45
+ });
46
+
47
+ it('renders nothing but an empty list for no entries', () => {
48
+ const { container } = render(<Legend entries={[]} />);
49
+ expect(container.querySelectorAll('.lk-legend__row')).toHaveLength(0);
50
+ expect(container.querySelector('.lk-legend')).toBeInTheDocument();
51
+ });
52
+
53
+ it('accepts an extra class name', () => {
54
+ const { container } = render(<Legend entries={[]} className="is-mine" />);
55
+ expect(container.querySelector('.lk-legend')?.className).toContain('is-mine');
56
+ });
57
+ });
@@ -0,0 +1,40 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ /** How a legend entry's swatch is drawn, matching how the thing looks on canvas. */
4
+ export type LegendMark = 'line' | 'dash' | 'dot' | 'band';
5
+
6
+ /** One row of a legend: a swatch and what it means. */
7
+ export interface LegendEntry {
8
+ key: string;
9
+ label: string;
10
+ color: string;
11
+ /** Defaults to `'line'`. */
12
+ mark?: LegendMark;
13
+ }
14
+
15
+ /** Props for `<Legend>`. */
16
+ export interface LegendProps {
17
+ entries: readonly LegendEntry[];
18
+ className?: string;
19
+ }
20
+
21
+ /** A color key. Presentational only — no handlers, no state, no hover behavior. */
22
+ export function Legend({ entries, className }: LegendProps) {
23
+ return (
24
+ <ul className={className ? `lk-legend ${className}` : 'lk-legend'}>
25
+ {entries.map((entry) => {
26
+ const mark = entry.mark ?? 'line';
27
+ return (
28
+ <li className="lk-legend__row" key={entry.key}>
29
+ <span
30
+ aria-hidden="true"
31
+ className={`lk-legend__swatch lk-legend__swatch--${mark}`}
32
+ style={{ '--lk-legend-ink': entry.color } as CSSProperties}
33
+ />
34
+ <span className="lk-legend__label">{entry.label}</span>
35
+ </li>
36
+ );
37
+ })}
38
+ </ul>
39
+ );
40
+ }
@@ -2,8 +2,10 @@
2
2
  display: flex;
3
3
  flex-direction: column;
4
4
  width: 280px;
5
+ flex: 1 1 auto;
5
6
  background: var(--wzl-surface-raised);
6
- border-left: 1px solid var(--wzl-border);
7
+ border-right: 1px solid var(--wzl-border);
8
+ min-height: 0;
7
9
  transition: width 150ms ease;
8
10
 
9
11
  &--collapsed {
@@ -21,7 +23,7 @@
21
23
 
22
24
  .lk-sidebar-title {
23
25
  flex: 1;
24
- font-weight: 600;
26
+ font-weight: var(--wzl-font-weight-bold);
25
27
  color: var(--wzl-fg);
26
28
  font-size: var(--wzl-font-size);
27
29
 
@@ -55,3 +57,31 @@
55
57
  display: none;
56
58
  }
57
59
  }
60
+
61
+ .lk-sidebar-section {
62
+ border-bottom: var(--wzl-border-w) solid var(--wzl-line-subtle);
63
+ }
64
+
65
+ .lk-sidebar-section__head {
66
+ width: 100%;
67
+ padding: var(--wzl-space-xs);
68
+ border: 0;
69
+ background: transparent;
70
+ color: var(--wzl-fg-muted);
71
+ font-family: var(--wzl-font-display);
72
+ font-size: var(--wzl-font-size-sm);
73
+ letter-spacing: 0.08em;
74
+ text-align: left;
75
+ text-transform: uppercase;
76
+ cursor: pointer;
77
+
78
+ &:hover {
79
+ color: var(--wzl-fg);
80
+ }
81
+ }
82
+
83
+ // The section body is the only thing that insets a panel — the panels
84
+ // themselves carry no padding, so this is the whole gutter.
85
+ .lk-sidebar-section__body {
86
+ padding: 0 var(--wzl-space-xs) var(--wzl-space-xs);
87
+ }
@@ -18,4 +18,11 @@
18
18
  border-right: 1px solid var(--wzl-line-subtle);
19
19
 
20
20
  &:last-child { border-right: none; }
21
+
22
+ &--end {
23
+ margin-left: auto;
24
+ }
25
+
26
+ // A section pushed to the end already reads as separated by the gap.
27
+ &:has(+ &--end) { border-right: none; }
21
28
  }
@@ -10,11 +10,18 @@ export function StatusBar({ children }: StatusBarProps) {
10
10
  return <div className="lk-status-bar">{children}</div>;
11
11
  }
12
12
 
13
- interface SectionProps {
13
+ /** Props for `<StatusBar.Section>`. */
14
+ export interface StatusBarSectionProps {
14
15
  children: ReactNode;
16
+ /** Pushes this section, and everything after it, to the far end. */
17
+ end?: boolean;
15
18
  }
16
- function Section({ children }: SectionProps) {
17
- return <span className="lk-status-bar-section">{children}</span>;
19
+ function Section({ children, end }: StatusBarSectionProps) {
20
+ return (
21
+ <span className={`lk-status-bar-section${end ? ' lk-status-bar-section--end' : ''}`}>
22
+ {children}
23
+ </span>
24
+ );
18
25
  }
19
26
 
20
27
  /** One readout within a status bar. */
@@ -1,15 +1,20 @@
1
1
  .lk-toolbar {
2
+ // A trial toolbar is chrome around the instrument, not a page's own control
3
+ // strip, so it runs a compact control height. Overriding the token rather
4
+ // than the button keeps the slider and number field in step with it.
5
+ --wzl-control-h: 22px;
6
+
2
7
  display: flex;
3
8
  align-items: center;
4
9
  gap: var(--wzl-space-sm);
5
- padding: var(--wzl-space-xs) var(--wzl-space-sm);
10
+ padding: 2px var(--wzl-space-sm);
6
11
  background: var(--wzl-surface-raised);
7
12
  border-bottom: 1px solid var(--wzl-border);
8
- min-height: calc(var(--wzl-control-h) + 2 * var(--wzl-space-xs));
13
+ min-height: calc(var(--wzl-control-h) + 4px);
9
14
  }
10
15
 
11
16
  .lk-toolbar-title {
12
- font-weight: 600;
17
+ font-weight: var(--wzl-font-weight-bold);
13
18
  color: var(--wzl-fg);
14
19
  margin-right: var(--wzl-space-sm);
15
20
  }
@@ -18,7 +23,35 @@
18
23
  flex: 1;
19
24
  }
20
25
 
26
+ .lk-toolbar-group {
27
+ display: flex;
28
+ align-items: center;
29
+ gap: 2px;
30
+ min-width: 0;
31
+
32
+ // The rule belongs between groups, so it rides on the group rather than
33
+ // being a separate node the caller has to remember to interleave.
34
+ & + & {
35
+ padding-left: var(--wzl-space-sm);
36
+ border-left: 1px solid var(--wzl-line-subtle);
37
+ }
38
+
39
+ &--end {
40
+ margin-left: auto;
41
+ }
42
+
43
+ // A group pushed to the end already reads as separated by the gap.
44
+ &--end + &,
45
+ & + &--end {
46
+ border-left: none;
47
+ padding-left: 0;
48
+ }
49
+ }
50
+
21
51
  .lk-toolbar-button {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ gap: var(--wzl-space-xs);
22
55
  height: var(--wzl-control-h);
23
56
  padding: 0 var(--wzl-space-sm);
24
57
  font-size: var(--wzl-font-size);
@@ -27,15 +60,29 @@
27
60
  border: 1px solid transparent;
28
61
  border-radius: var(--wzl-radius-md);
29
62
  cursor: pointer;
63
+ white-space: nowrap;
64
+
65
+ &--icon {
66
+ padding: 0;
67
+ width: var(--wzl-control-h);
68
+ justify-content: center;
69
+ }
30
70
 
31
71
  &:hover:not(:disabled) {
32
72
  background: var(--wzl-surface);
33
73
  border-color: var(--wzl-border);
34
74
  }
75
+
76
+ &--danger:hover:not(:disabled) {
77
+ color: var(--wzl-danger);
78
+ border-color: var(--wzl-danger);
79
+ }
80
+
35
81
  &:focus-visible {
36
82
  outline: 2px solid var(--wzl-focus-ring);
37
83
  outline-offset: 1px;
38
84
  }
85
+
39
86
  &:disabled {
40
87
  color: var(--wzl-fg-subtle);
41
88
  cursor: not-allowed;
@@ -1,4 +1,4 @@
1
- import { render, screen } from '@testing-library/react';
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
2
  import { describe, expect, test } from 'vitest';
3
3
  import { Toolbar } from './Toolbar';
4
4
 
@@ -63,3 +63,69 @@ describe('Toolbar', () => {
63
63
  expect(screen.getByRole('button', { name: 'X' })).toBeDisabled();
64
64
  });
65
65
  });
66
+
67
+ function threeButtons() {
68
+ return render(
69
+ <Toolbar aria-label="Trial actions">
70
+ <Toolbar.Group aria-label="History">
71
+ <Toolbar.Button onClick={() => {}} title="Undo">
72
+ U
73
+ </Toolbar.Button>
74
+ <Toolbar.Button onClick={() => {}} title="Redo">
75
+ R
76
+ </Toolbar.Button>
77
+ </Toolbar.Group>
78
+ <Toolbar.Button onClick={() => {}} title="Close">
79
+ X
80
+ </Toolbar.Button>
81
+ </Toolbar>,
82
+ );
83
+ }
84
+
85
+ describe('Toolbar keyboard contract', () => {
86
+ test('claims the toolbar role and is nameable', () => {
87
+ threeButtons();
88
+ expect(screen.getByRole('toolbar', { name: 'Trial actions' })).toBeInTheDocument();
89
+ });
90
+
91
+ test('keeps exactly one button in the tab order', () => {
92
+ threeButtons();
93
+ const inOrder = screen.getAllByRole('button').filter((b) => b.tabIndex === 0);
94
+ expect(inOrder).toHaveLength(1);
95
+ });
96
+
97
+ test('keeps one tab stop when a button becomes enabled', async () => {
98
+ function Bar({ busy }: { busy: boolean }) {
99
+ return (
100
+ <Toolbar aria-label="Trial actions">
101
+ <Toolbar.Button onClick={() => {}} title="Undo" disabled={busy}>
102
+ U
103
+ </Toolbar.Button>
104
+ <Toolbar.Button onClick={() => {}} title="Close">
105
+ X
106
+ </Toolbar.Button>
107
+ </Toolbar>
108
+ );
109
+ }
110
+ const { rerender } = render(<Bar busy />);
111
+ rerender(<Bar busy={false} />);
112
+ await waitFor(() => {
113
+ const inOrder = screen
114
+ .getAllByRole<HTMLButtonElement>('button')
115
+ .filter((b) => !b.disabled && b.tabIndex === 0);
116
+ expect(inOrder).toHaveLength(1);
117
+ });
118
+ });
119
+
120
+ test('moves focus with the arrow keys, wrapping at the end', async () => {
121
+ const { default: userEvent } = await import('@testing-library/user-event');
122
+ const user = userEvent.setup();
123
+ threeButtons();
124
+ await user.tab();
125
+ expect(screen.getByTitle('Undo')).toHaveFocus();
126
+ await user.keyboard('{ArrowRight}');
127
+ expect(screen.getByTitle('Redo')).toHaveFocus();
128
+ await user.keyboard('{ArrowRight}{ArrowRight}');
129
+ expect(screen.getByTitle('Undo')).toHaveFocus();
130
+ });
131
+ });
@@ -1,14 +1,29 @@
1
1
  import type { MouseEventHandler, ReactNode } from 'react';
2
+ import { useRovingTabIndex } from './useRovingTabIndex';
2
3
 
3
4
  /** Props for `<Toolbar>`. */
4
5
  export interface ToolbarProps {
5
6
  children: ReactNode;
7
+ /** Names the toolbar for assistive tech. Required by the APG pattern when a
8
+ * view holds more than one. */
9
+ 'aria-label'?: string;
6
10
  }
7
11
 
8
12
  /** A horizontal bar of controls. Fill it with `<Toolbar.Title>`,
9
- * `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
10
- export function Toolbar({ children }: ToolbarProps) {
11
- return <div className="lk-toolbar">{children}</div>;
13
+ * `<Toolbar.Group>`, `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
14
+ export function Toolbar({ children, 'aria-label': ariaLabel }: ToolbarProps) {
15
+ const { ref, onKeyDown } = useRovingTabIndex<HTMLDivElement>();
16
+ return (
17
+ <div
18
+ ref={ref}
19
+ className="lk-toolbar"
20
+ role="toolbar"
21
+ aria-label={ariaLabel}
22
+ onKeyDown={onKeyDown}
23
+ >
24
+ {children}
25
+ </div>
26
+ );
12
27
  }
13
28
 
14
29
  interface TitleProps {
@@ -18,20 +33,69 @@ function Title({ children }: TitleProps) {
18
33
  return <span className="lk-toolbar-title">{children}</span>;
19
34
  }
20
35
 
21
- interface ButtonProps {
36
+ /** Props for `<Toolbar.Group>`. */
37
+ export interface ToolbarGroupProps {
38
+ children: ReactNode;
39
+ /** Pushes this group, and everything after it, to the far end. */
40
+ end?: boolean;
41
+ 'aria-label'?: string;
42
+ }
43
+
44
+ /** Related controls, kept tight and ruled off from their neighbors. Group
45
+ * rather than separate with repeated `<Toolbar.Spacer>`: consecutive spacers
46
+ * share the free space, so every gap collapses to the same slack and the bar
47
+ * reads as one undifferentiated run. */
48
+ function Group({ children, end, 'aria-label': ariaLabel }: ToolbarGroupProps) {
49
+ return (
50
+ // biome-ignore lint/a11y/useSemanticElements: a group inside role="toolbar" is the APG pattern; the rule is local and cannot see the parent. <fieldset> means form controls, needs a <legend> to be named, and its UA min-width breaks flex children.
51
+ <div
52
+ className={`lk-toolbar-group${end ? ' lk-toolbar-group--end' : ''}`}
53
+ role="group"
54
+ aria-label={ariaLabel}
55
+ >
56
+ {children}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /** Props for `<Toolbar.Button>`. */
62
+ export interface ToolbarButtonProps {
22
63
  children: ReactNode;
23
64
  onClick: MouseEventHandler<HTMLButtonElement>;
24
65
  disabled?: boolean;
25
66
  title?: string;
67
+ /** Required when `children` is an icon with no text of its own. */
68
+ 'aria-label'?: string;
69
+ /** `danger` reddens on hover — for actions that discard work. */
70
+ variant?: 'default' | 'danger';
71
+ /** Square, for a button whose whole content is one glyph. */
72
+ iconOnly?: boolean;
26
73
  }
27
- function Button({ children, onClick, disabled, title }: ButtonProps) {
74
+
75
+ function Button({
76
+ children,
77
+ onClick,
78
+ disabled,
79
+ title,
80
+ 'aria-label': ariaLabel,
81
+ variant = 'default',
82
+ iconOnly,
83
+ }: ToolbarButtonProps) {
84
+ const cls = [
85
+ 'lk-toolbar-button',
86
+ variant === 'danger' && 'lk-toolbar-button--danger',
87
+ iconOnly && 'lk-toolbar-button--icon',
88
+ ]
89
+ .filter(Boolean)
90
+ .join(' ');
28
91
  return (
29
92
  <button
30
93
  type="button"
31
- className="lk-toolbar-button"
94
+ className={cls}
32
95
  onClick={onClick}
33
96
  disabled={disabled}
34
97
  title={title}
98
+ aria-label={ariaLabel ?? title}
35
99
  >
36
100
  {children}
37
101
  </button>
@@ -44,6 +108,8 @@ function Spacer() {
44
108
 
45
109
  /** A label within a toolbar. */
46
110
  Toolbar.Title = Title;
111
+ /** A ruled-off run of related controls. */
112
+ Toolbar.Group = Group;
47
113
  /** A button within a toolbar. */
48
114
  Toolbar.Button = Button;
49
115
  /** Flexible space that pushes what follows to the far end of the toolbar. */
@@ -0,0 +1,31 @@
1
+ .lk-zoom {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: var(--wzl-space-sm);
5
+ min-width: 0;
6
+
7
+ &__slider {
8
+ flex: 0 1 108px;
9
+ }
10
+
11
+ &__field {
12
+ flex: 0 0 auto;
13
+ }
14
+ }
15
+
16
+ // Both children are weasel-ui components whose module classes set `width`.
17
+ // See the `.lk-root` prefix convention in theme/base.less.
18
+ .lk-root {
19
+ .lk-zoom {
20
+ // Narrow enough to sit in a trial toolbar, wide enough for the octave
21
+ // detents to be separately hittable.
22
+ &__slider {
23
+ width: 108px;
24
+ min-width: 64px;
25
+ }
26
+
27
+ &__field {
28
+ width: 62px;
29
+ }
30
+ }
31
+ }