@hyperframes/studio 0.8.51 → 0.8.53-desktop.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 (160) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-PTEnAHhg.js} +1 -1
  2. package/dist/assets/{index-CPS3Cvj8.js → index--myXp8ui.js} +1 -1
  3. package/dist/assets/{index-Clrb11lj.js → index-BjslOS9g.js} +1 -1
  4. package/dist/assets/index-BnXZuIJD.css +1 -0
  5. package/dist/assets/index-CNyviK_l.js +390 -0
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +808 -12
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +8109 -7583
  11. package/dist/index.js.map +1 -1
  12. package/package.json +9 -7
  13. package/src/App.tsx +45 -50
  14. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  15. package/src/captions/components/CaptionOverlay.tsx +8 -4
  16. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  17. package/src/components/EditorShell.tsx +56 -56
  18. package/src/components/RightPanelTabs.test.tsx +151 -0
  19. package/src/components/RightPanelTabs.tsx +59 -0
  20. package/src/components/StudioHeader.dom.test.tsx +181 -0
  21. package/src/components/StudioHeader.test.ts +0 -12
  22. package/src/components/StudioHeader.tsx +117 -160
  23. package/src/components/StudioLeftPanels.tsx +202 -0
  24. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
  25. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  26. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  27. package/src/components/TimelineHistoryButtons.tsx +54 -0
  28. package/src/components/TimelineToolbar.test.tsx +8 -0
  29. package/src/components/TimelineToolbar.tsx +3 -7
  30. package/src/components/dock/Dock.test.tsx +100 -18
  31. package/src/components/dock/Dock.tsx +32 -5
  32. package/src/components/dock/dock.css +5 -0
  33. package/src/components/dock/dockAccessibility.test.ts +166 -0
  34. package/src/components/dock/dockAccessibility.ts +98 -0
  35. package/src/components/dock/dockLayout.test.ts +134 -0
  36. package/src/components/dock/dockLayout.ts +53 -6
  37. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  38. package/src/components/dock/dockLayoutStore.ts +21 -4
  39. package/src/components/dock/dockTestHarness.ts +11 -0
  40. package/src/components/editor/LayersPanel.test.ts +97 -1
  41. package/src/components/editor/LayersPanel.tsx +4 -2
  42. package/src/components/editor/PreviewGuides.tsx +1 -1
  43. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  44. package/src/components/editor/TopologyLens.test.tsx +28 -0
  45. package/src/components/editor/TopologyLens.tsx +3 -1
  46. package/src/components/editor/useColorGradingController.test.ts +38 -0
  47. package/src/components/editor/useColorGradingController.ts +9 -1
  48. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  49. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  50. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  51. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  52. package/src/components/nle/NLEContext.tsx +0 -43
  53. package/src/components/nle/NLEPreview.tsx +1 -1
  54. package/src/components/nle/PreviewPane.tsx +1 -1
  55. package/src/components/nle/TimelinePane.tsx +53 -67
  56. package/src/components/panels/SlideshowPanel.tsx +1 -1
  57. package/src/components/panels/VariablesPanel.tsx +1 -1
  58. package/src/components/pointerTargetSize.test.tsx +8 -0
  59. package/src/components/renders/RenderQueue.tsx +1 -1
  60. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  61. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  62. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  63. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  64. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  65. package/src/components/timelineToolbarStyles.ts +5 -0
  66. package/src/components/ui/Button.test.tsx +222 -30
  67. package/src/components/ui/Button.tsx +88 -125
  68. package/src/components/ui/IconButton.tsx +38 -0
  69. package/src/components/ui/Input.tsx +115 -0
  70. package/src/components/ui/Menu.test.tsx +374 -0
  71. package/src/components/ui/Menu.tsx +213 -0
  72. package/src/components/ui/NumberField.tsx +122 -0
  73. package/src/components/ui/Popover.tsx +74 -0
  74. package/src/components/ui/Select.tsx +100 -0
  75. package/src/components/ui/Slider.tsx +153 -0
  76. package/src/components/ui/Tabs.test.tsx +98 -0
  77. package/src/components/ui/Tabs.tsx +71 -0
  78. package/src/components/ui/Toggle.tsx +63 -0
  79. package/src/components/ui/Tooltip.test.tsx +110 -0
  80. package/src/components/ui/Tooltip.tsx +38 -112
  81. package/src/components/ui/cn.test.ts +30 -0
  82. package/src/components/ui/cn.ts +25 -0
  83. package/src/components/ui/index.ts +29 -2
  84. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  85. package/src/components/ui/valueControls.test.tsx +524 -0
  86. package/src/contexts/PanelLayoutContext.tsx +3 -57
  87. package/src/hooks/appHotkeysDispatch.ts +3 -4
  88. package/src/hooks/timelineEditPermission.ts +31 -0
  89. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  90. package/src/hooks/useAppHotkeys.test.ts +0 -1
  91. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  92. package/src/hooks/useAppHotkeys.ts +20 -83
  93. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  94. package/src/hooks/useCaptionDetection.ts +3 -1
  95. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  96. package/src/hooks/useDomEditSession.ts +0 -7
  97. package/src/hooks/useDomEditWiring.ts +4 -9
  98. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  99. package/src/hooks/useEditHistoryActions.ts +106 -0
  100. package/src/hooks/useFileManager.ts +21 -123
  101. package/src/hooks/usePanelLayout.test.ts +108 -296
  102. package/src/hooks/usePanelLayout.ts +50 -255
  103. package/src/hooks/usePersistentEditHistory.ts +1 -1
  104. package/src/hooks/useProjectFileWriter.ts +150 -0
  105. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  106. package/src/hooks/useRightPanelIntents.ts +63 -0
  107. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  108. package/src/hooks/useServerConnection.ts +43 -10
  109. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  110. package/src/hooks/useSlideshowTabState.ts +6 -19
  111. package/src/hooks/useStudioContextValue.test.ts +6 -18
  112. package/src/hooks/useStudioContextValue.ts +5 -26
  113. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  114. package/src/hooks/useTimelineEditing.test.tsx +322 -3
  115. package/src/hooks/useTimelineEditing.ts +103 -16
  116. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  117. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  118. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  119. package/src/hostMount.test.tsx +222 -0
  120. package/src/index.ts +87 -0
  121. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  122. package/src/player/components/Timeline.tsx +1 -1
  123. package/src/player/components/TimelineCanvas.tsx +4 -4
  124. package/src/player/components/TimelineClip.tsx +0 -1
  125. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  126. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  127. package/src/player/components/timelineTheme.test.ts +4 -4
  128. package/src/player/components/timelineTheme.ts +30 -30
  129. package/src/player/lib/playbackShortcuts.ts +2 -0
  130. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  131. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  132. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  133. package/src/player/store/previewIframeStore.test.tsx +29 -0
  134. package/src/player/store/previewIframeStore.ts +25 -0
  135. package/src/renderQueueExports.test.tsx +58 -0
  136. package/src/styles/classCandidates.test.ts +105 -0
  137. package/src/styles/classCandidates.ts +281 -0
  138. package/src/styles/hex-baseline.json +84 -0
  139. package/src/styles/hexRatchet.test.ts +201 -0
  140. package/src/styles/styleSources.test.ts +20 -0
  141. package/src/styles/styleSources.ts +47 -0
  142. package/src/styles/theme.css +57 -0
  143. package/src/styles/theme.test.ts +2 -15
  144. package/src/styles/tokenGate.test.ts +109 -0
  145. package/src/utils/sourcePatcher.ts +1 -1
  146. package/src/utils/studioHelpers.test.ts +18 -0
  147. package/src/utils/studioHelpers.ts +11 -8
  148. package/src/utils/studioUiPreferences.test.ts +10 -16
  149. package/src/utils/studioUiPreferences.ts +0 -16
  150. package/src/utils/typingTarget.ts +4 -0
  151. package/dist/assets/index-C05SNrvY.js +0 -360
  152. package/dist/assets/index-DUR9qzQM.css +0 -1
  153. package/src/components/PanelTabButton.tsx +0 -31
  154. package/src/components/StudioLeftSidebar.tsx +0 -207
  155. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  156. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  157. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  158. package/src/hooks/useInspectorSplitResize.ts +0 -51
  159. package/src/utils/fitPanels.test.ts +0 -164
  160. package/src/utils/fitPanels.ts +0 -151
@@ -1,57 +1,249 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- // happy-dom has no CSS engine, so this pins the structure that makes the gap
4
- // work (children are direct flex items of the gap-bearing button), not the
5
- // computed pixels. The measured gap is verified in a real browser.
3
+ /**
4
+ * Button/IconButton: renders every variant and size, then compiles Studio's
5
+ * real stylesheet against the emitted classes so an unstyled class fails.
6
+ */
7
+ import { createRequire } from "node:module";
8
+ import { readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import React, { act } from "react";
11
+ import { compile } from "tailwindcss";
12
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
13
+ import { Button, type ButtonSize, type ButtonVariant } from "./Button";
14
+ import { IconButton } from "./IconButton";
15
+ import { Tab, Tabs, TabsList } from "./Tabs";
16
+ import { cleanupMounted, mountHost } from "./mountHost.testHelpers";
17
+ import { isTypingTarget } from "../../utils/typingTarget";
18
+ import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
6
19
 
7
- import { act } from "react";
8
- import { createRoot, type Root } from "react-dom/client";
9
- import { afterEach, describe, expect, it } from "vitest";
10
- import { Button } from "./Button";
20
+ const VARIANTS: ButtonVariant[] = ["primary", "secondary", "danger", "ghost"];
21
+ const SIZES: ButtonSize[] = ["sm", "md", "lg"];
11
22
 
12
- Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
23
+ afterEach(cleanupMounted);
13
24
 
14
- let root: Root | null = null;
25
+ function classesOf(host: HTMLElement, selector = "button"): string[] {
26
+ const el = host.querySelector(selector);
27
+ if (!el) throw new Error(`nothing matched ${selector}`);
28
+ return [...el.classList];
29
+ }
30
+
31
+ // -- the compiled stylesheet, built once for the whole file --
15
32
 
16
- afterEach(() => {
17
- if (root) act(() => root?.unmount());
18
- root = null;
19
- document.body.innerHTML = "";
33
+ const STYLES_DIR = path.resolve(__dirname, "../../styles");
34
+ const require = createRequire(import.meta.url);
35
+ const TAILWIND_DIR = path.dirname(require.resolve("tailwindcss/package.json"));
36
+
37
+ async function loadStylesheet(id: string, base: string) {
38
+ const file = id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
39
+ return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
40
+ }
41
+
42
+ let compileStudioCss: (candidates: string[]) => string;
43
+
44
+ beforeAll(async () => {
45
+ const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
46
+ base: STYLES_DIR,
47
+ loadStylesheet,
48
+ });
49
+ compileStudioCss = (candidates) => compiled.build(candidates);
20
50
  });
21
51
 
22
- function mount(element: React.ReactNode): HTMLButtonElement {
23
- const host = document.createElement("div");
24
- document.body.append(host);
25
- root = createRoot(host);
26
- act(() => root?.render(element));
27
- const button = host.querySelector("button");
28
- if (!button) throw new Error("button did not render");
29
- return button;
52
+ function unresolved(candidates: string[]): string[] {
53
+ const css = compileStudioCss(candidates);
54
+ return candidates.filter((candidate) => !css.includes(CSS.escape(candidate)));
30
55
  }
31
56
 
32
- describe("Button children layout", () => {
33
- it("lays label and kbd out as sibling flex items of the gap-bearing button", () => {
34
- const button = mount(
57
+ describe("Button classes", () => {
58
+ it("emits only classes Studio's stylesheet defines", () => {
59
+ const emitted = new Set<string>();
60
+ for (const variant of VARIANTS) {
61
+ for (const size of SIZES) {
62
+ const host = mountHost(
63
+ <>
64
+ <Button variant={variant} size={size}>
65
+ Export
66
+ </Button>
67
+ <IconButton variant={variant} size={size} icon={null} aria-label="Zoom out" />
68
+ </>,
69
+ );
70
+ for (const el of host.querySelectorAll("button")) {
71
+ for (const cls of el.classList) emitted.add(cls);
72
+ }
73
+ cleanupMounted();
74
+ }
75
+ }
76
+
77
+ expect(emitted.size).toBeGreaterThan(20);
78
+ expect(unresolved([...emitted])).toEqual([]);
79
+ });
80
+
81
+ it("fails an undefined class, so the check above is not vacuous", () => {
82
+ // Names in a real namespace with no token behind them; the theme file now defines the
83
+ // `rounded-button` pair this used to name.
84
+ expect(unresolved(["bg-not-a-token", "text-step-999"])).toEqual([
85
+ "bg-not-a-token",
86
+ "text-step-999",
87
+ ]);
88
+ });
89
+
90
+ it("lets a caller's type size beat the size's own", () => {
91
+ // The Renders Export case: `size="md"` brings `text-step-12`, the caller
92
+ // wants 11px, and with plain concatenation both survived into the class
93
+ // list and the stylesheet's order decided the winner.
94
+ const classes = classesOf(mountHost(<Button size="md" className="text-step-11" />));
95
+
96
+ expect(classes).toContain("text-step-11");
97
+ expect(classes).not.toContain("text-step-12");
98
+ });
99
+
100
+ it("keeps every interactive look reachable without a pointer", () => {
101
+ // `data-preview-state` exists so a gallery can show the hover, active and
102
+ // focus looks. It is worth nothing if it drifts from the real state, so
103
+ // each pair is matched here in both directions.
104
+ const realPrefix = {
105
+ hover: ["enabled:hover:", "hover:"],
106
+ active: ["enabled:active:", "active:"],
107
+ focus: ["focus-visible:"],
108
+ };
109
+
110
+ for (const variant of VARIANTS) {
111
+ const classes = classesOf(mountHost(<Button variant={variant}>Export</Button>));
112
+ cleanupMounted();
113
+
114
+ for (const [state, prefixes] of Object.entries(realPrefix)) {
115
+ const previewPrefix = `data-[preview-state=${state}]:`;
116
+ const preview = classes
117
+ .filter((cls) => cls.startsWith(previewPrefix))
118
+ .map((cls) => cls.slice(previewPrefix.length));
119
+ const real = classes
120
+ .filter((cls) => prefixes.some((prefix) => cls.startsWith(prefix)))
121
+ .map((cls) => cls.slice(cls.lastIndexOf(":") + 1));
122
+
123
+ expect([...preview].sort(), `${variant} ${state}`).toEqual([...real].sort());
124
+ expect(preview.length, `${variant} ${state}`).toBeGreaterThan(0);
125
+ }
126
+ }
127
+ });
128
+
129
+ it("uses a motion token that zeroes itself under reduced motion", () => {
130
+ // AE4. The zero-duration case lives in the `duration-press` utility itself
131
+ // (theme.css), not in a `motion-reduce:` class beside it, so a caller
132
+ // cannot use the token and forget the reduced-motion half. The button's
133
+ // job is to name the token; the stylesheet's job is the media query.
134
+ expect(classesOf(mountHost(<Button>Export</Button>))).toContain("duration-press");
135
+
136
+ expect(compileStudioCss(["duration-press"])).toMatch(
137
+ /\.duration-press \{[\s\S]*?prefers-reduced-motion: reduce[\s\S]*?transition-duration: 0ms/,
138
+ );
139
+ });
140
+ });
141
+
142
+ describe("Button behaviour", () => {
143
+ it("does not fire a disabled click, and says it is disabled", () => {
144
+ let clicks = 0;
145
+ const host = mountHost(
146
+ <Button disabled onClick={() => (clicks += 1)}>
147
+ Export
148
+ </Button>,
149
+ );
150
+ const button = host.querySelector("button")!;
151
+
152
+ act(() => button.click());
153
+
154
+ expect(clicks).toBe(0);
155
+ expect(button.getAttribute("aria-disabled")).toBe("true");
156
+ });
157
+
158
+ it("still fires a click while a preview state is forced", () => {
159
+ let clicks = 0;
160
+ const host = mountHost(
161
+ <Button data-preview-state="hover" onClick={() => (clicks += 1)}>
162
+ Export
163
+ </Button>,
164
+ );
165
+ const button = host.querySelector("button")!;
166
+
167
+ act(() => button.click());
168
+
169
+ expect(clicks).toBe(1);
170
+ expect(button.getAttribute("data-preview-state")).toBe("hover");
171
+ });
172
+
173
+ it("is classified by the hotkey selectors the way a plain button is", () => {
174
+ // KTD13. Both selector lists gate on roles and element names. A primitive
175
+ // that landed on a different role would leak or swallow global hotkeys with
176
+ // nothing to notice it.
177
+ const host = mountHost(
178
+ <>
179
+ <button type="button" data-testid="plain">
180
+ Export
181
+ </button>
182
+ <Button data-testid="primitive">Export</Button>
183
+ <Tabs defaultValue="code">
184
+ <TabsList aria-label="Sidebar panels">
185
+ <Tab value="code">Code</Tab>
186
+ </TabsList>
187
+ </Tabs>
188
+ </>,
189
+ );
190
+ const plain = host.querySelector('[data-testid="plain"]')!;
191
+ const button = host.querySelector('[data-testid="primitive"]')!;
192
+ const tab = host.querySelector('[role="tab"]')!;
193
+
194
+ for (const el of [plain, button, tab]) {
195
+ expect(isTypingTarget(el)).toBe(false);
196
+ expect(shouldIgnorePlaybackShortcutTarget(el)).toBe(true);
197
+ }
198
+ });
199
+ });
200
+
201
+ describe("disabled states", () => {
202
+ it("disables a loading Button", () => {
203
+ const host = mountHost(<Button loading>Save</Button>);
204
+ const button = host.querySelector("button");
205
+
206
+ expect(button?.disabled).toBe(true);
207
+ expect(button?.getAttribute("aria-disabled")).toBe("true");
208
+ });
209
+
210
+ it("marks a disabled IconButton for assistive tech", () => {
211
+ const host = mountHost(<IconButton aria-label="Undo" icon={<svg />} disabled />);
212
+ const button = host.querySelector("button");
213
+
214
+ expect(button?.disabled).toBe(true);
215
+ expect(button?.getAttribute("aria-disabled")).toBe("true");
216
+ });
217
+ });
218
+
219
+ describe("children layout", () => {
220
+ it("keeps label and shortcut direct flex items of the gap-bearing button without an icon", () => {
221
+ const host = mountHost(
35
222
  <Button size="md">
36
223
  <span>Export</span>
37
224
  <kbd>⌘E</kbd>
38
225
  </Button>,
39
226
  );
227
+ const button = host.querySelector("button");
40
228
 
41
- expect(button.classList.contains("inline-flex")).toBe(true);
42
- expect(button.classList.contains("gap-1.5")).toBe(true);
43
- expect(Array.from(button.children).map((child) => child.tagName)).toEqual(["SPAN", "KBD"]);
229
+ expect(button?.classList.contains("inline-flex")).toBe(true);
230
+ expect(button?.classList.contains("gap-1.5")).toBe(true);
231
+ expect(Array.from(button?.children ?? []).map((child) => child.tagName)).toEqual([
232
+ "SPAN",
233
+ "KBD",
234
+ ]);
44
235
  });
45
236
 
46
- it("puts children next to the icon as direct flex items too", () => {
47
- const button = mount(
237
+ it("makes label and shortcut direct flex items so the button gap applies between them", () => {
238
+ const host = mountHost(
48
239
  <Button icon={<svg />}>
49
240
  <span>Export</span>
50
241
  <kbd>⌘E</kbd>
51
242
  </Button>,
52
243
  );
244
+ const button = host.querySelector("button");
53
245
 
54
- expect(Array.from(button.children).map((child) => child.tagName)).toEqual([
246
+ expect(Array.from(button?.children ?? []).map((child) => child.tagName)).toEqual([
55
247
  "SPAN",
56
248
  "SPAN",
57
249
  "KBD",
@@ -1,159 +1,122 @@
1
1
  /**
2
- * Button & IconButton — The most important primitive.
2
+ * Button — a native `<button>` wearing Studio's tokens.
3
3
  *
4
- * Absorbs: active state (scale 0.98), hit target (min 32px),
5
- * shadow anatomy (primary), focus ring, disabled state,
6
- * loading state, reduced motion, proper timing tokens.
4
+ * Everything visual comes from `theme.css`: the three control heights, the
5
+ * radius scale, the semantic colours, the motion durations. No value is decided
6
+ * here, so a button cannot drift from the rest of the system.
7
7
  *
8
- * Rules applied:
9
- * - physics-active-state: scale(0.98) on :active
10
- * - ux-fitts-target-size: min 32px hit target
11
- * - visual-button-shadow-anatomy: 6-layer shadow on primary
12
- * - duration-press-hover: 120ms press, 150ms hover
8
+ * Two conventions: classes merge through `cn`, so a caller's `className`
9
+ * always wins within its group; and every interactive look is written twice,
10
+ * once as the real state and once as `data-[preview-state=…]` (CSS-only, for
11
+ * screenshots). `Button.test.tsx` asserts the two stay in sync.
13
12
  */
14
13
 
15
14
  import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
15
+ import { cn } from "./cn";
16
16
 
17
- // -- Button --
17
+ export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
18
+ export type ButtonSize = "sm" | "md" | "lg";
18
19
 
19
- type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
20
- type ButtonSize = "sm" | "md" | "lg";
20
+ /** Forces one interactive look for a gallery shot. CSS-only; see the header. */
21
+ export type PreviewState = "hover" | "active" | "focus";
21
22
 
22
- interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
23
+ export interface ButtonBaseProps extends ButtonHTMLAttributes<HTMLButtonElement> {
23
24
  variant?: ButtonVariant;
24
25
  size?: ButtonSize;
26
+ "data-preview-state"?: PreviewState;
27
+ }
28
+
29
+ interface ButtonProps extends ButtonBaseProps {
25
30
  loading?: boolean;
26
31
  icon?: ReactNode;
27
32
  }
28
33
 
29
- const variantStyles: Record<ButtonVariant, string> = {
30
- primary: [
31
- "bg-white text-neutral-950 font-medium",
32
- "shadow-btn-primary",
33
- "enabled:hover:bg-neutral-200",
34
- "enabled:active:scale-[0.97]",
35
- ].join(" "),
36
- secondary: [
37
- "bg-transparent text-neutral-300 font-medium",
38
- "border border-border",
39
- "enabled:hover:bg-surface-hover enabled:hover:text-white enabled:hover:border-border-strong",
40
- "enabled:active:scale-[0.98]",
41
- ].join(" "),
42
- danger: [
43
- "bg-accent-red text-white font-medium",
44
- "enabled:hover:bg-red-600",
45
- "enabled:active:scale-[0.97]",
46
- ].join(" "),
47
- ghost: [
48
- "bg-transparent text-neutral-400",
49
- "enabled:hover:bg-surface-hover enabled:hover:text-white",
50
- "enabled:active:scale-[0.98]",
51
- ].join(" "),
34
+ /**
35
+ * Shared by Button and IconButton. `disabled:` keeps pointer events alive so a
36
+ * wrapping Tooltip can still explain why the control is disabled.
37
+ */
38
+ export const buttonBase = cn(
39
+ "inline-flex items-center justify-center select-none cursor-pointer whitespace-nowrap",
40
+ "transition-[background-color,border-color,color,filter,transform] ease-out-quint duration-press",
41
+ "disabled:opacity-40 disabled:cursor-not-allowed",
42
+ "outline-hidden",
43
+ "focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent",
44
+ "data-[preview-state=focus]:outline-solid data-[preview-state=focus]:outline-2 data-[preview-state=focus]:outline-offset-1 data-[preview-state=focus]:outline-accent",
45
+ );
46
+
47
+ /**
48
+ * One entry per variant. The `data-[preview-state=…]` half of each string
49
+ * repeats the `hover:` and `active:` half exactly; the test pairs them.
50
+ */
51
+ export const buttonVariants: Record<ButtonVariant, string> = {
52
+ primary: cn(
53
+ "bg-accent text-bg-0 font-semibold",
54
+ "enabled:hover:brightness-110 data-[preview-state=hover]:brightness-110",
55
+ "enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
56
+ ),
57
+ secondary: cn(
58
+ "bg-transparent text-text-1 font-medium border border-border-strong",
59
+ "enabled:hover:bg-hover enabled:hover:text-text-0 data-[preview-state=hover]:bg-hover data-[preview-state=hover]:text-text-0",
60
+ "enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
61
+ ),
62
+ danger: cn(
63
+ "bg-danger text-text-0 font-medium",
64
+ "enabled:hover:brightness-110 data-[preview-state=hover]:brightness-110",
65
+ "enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
66
+ ),
67
+ ghost: cn(
68
+ "bg-transparent text-text-2 font-medium",
69
+ "enabled:hover:bg-hover enabled:hover:text-text-0 data-[preview-state=hover]:bg-hover data-[preview-state=hover]:text-text-0",
70
+ "enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
71
+ ),
52
72
  };
53
73
 
54
- const sizeStyles: Record<ButtonSize, string> = {
55
- sm: "h-7 px-2.5 text-xs gap-1.5 rounded-button",
56
- md: "h-8 px-3 text-sm gap-1.5 rounded-button",
57
- lg: "h-9 px-4 text-base gap-2 rounded-button",
74
+ /**
75
+ * The three control heights, 24 / 28 / 32 px, from `--spacing-ctl-*`.
76
+ * Exported so the Capture download `<a href>` can wear the same recipe as a Button.
77
+ */
78
+ export const buttonSizes: Record<ButtonSize, string> = {
79
+ sm: "h-ctl-sm px-2 gap-1 rounded-sm text-step-11",
80
+ md: "h-ctl px-3 gap-1.5 rounded-md text-step-12",
81
+ lg: "h-ctl-lg px-4 gap-2 rounded-md text-step-13",
58
82
  };
59
83
 
60
- // Imported by the shell/renders PRs later in this stack.
61
- // fallow-ignore-next-line unused-export
84
+ function Spinner() {
85
+ return (
86
+ <svg className="animate-spin size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
87
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
88
+ <path
89
+ className="opacity-75"
90
+ fill="currentColor"
91
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
92
+ />
93
+ </svg>
94
+ );
95
+ }
96
+
62
97
  export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
63
98
  (
64
- {
65
- variant = "secondary",
66
- size = "md",
67
- loading,
68
- icon,
69
- children,
70
- className = "",
71
- disabled,
72
- ...props
73
- },
99
+ { variant = "secondary", size = "md", loading, icon, children, className, disabled, ...props },
74
100
  ref,
75
101
  ) => {
102
+ const isDisabled = disabled || loading;
103
+ let leading: ReactNode = null;
104
+ if (loading) leading = <Spinner />;
105
+ else if (icon) leading = <span className="shrink-0">{icon}</span>;
76
106
  return (
77
107
  <button
78
108
  ref={ref}
79
- disabled={disabled || loading}
80
- className={[
81
- "inline-flex items-center justify-center",
82
- "transition-all duration-press ease-standard",
83
- // No pointer-events-none: disabled buttons must still receive hover
84
- // so a wrapping Tooltip can explain WHY they're disabled (A5).
85
- "disabled:opacity-40 disabled:cursor-not-allowed",
86
- "select-none cursor-pointer",
87
- "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
88
- variantStyles[variant],
89
- sizeStyles[size],
90
- className,
91
- ].join(" ")}
109
+ disabled={isDisabled}
110
+ // `aria-disabled` as well as `disabled`: assistive tech announces the
111
+ // state even where the native attribute is filtered out of the tree.
112
+ aria-disabled={isDisabled || undefined}
113
+ className={cn(buttonBase, buttonVariants[variant], buttonSizes[size], className)}
92
114
  {...props}
93
115
  >
94
- {loading ? (
95
- <svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none">
96
- <circle
97
- className="opacity-25"
98
- cx="12"
99
- cy="12"
100
- r="10"
101
- stroke="currentColor"
102
- strokeWidth="4"
103
- />
104
- <path
105
- className="opacity-75"
106
- fill="currentColor"
107
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
108
- />
109
- </svg>
110
- ) : icon ? (
111
- <span className="shrink-0">{icon}</span>
112
- ) : null}
116
+ {leading}
113
117
  {children}
114
118
  </button>
115
119
  );
116
120
  },
117
121
  );
118
122
  Button.displayName = "Button";
119
-
120
- // -- IconButton --
121
- // For icon-only buttons. Enforces min 32px hit target.
122
-
123
- interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
124
- icon: ReactNode;
125
- size?: ButtonSize;
126
- variant?: ButtonVariant;
127
- "aria-label": string; // REQUIRED for accessibility
128
- }
129
-
130
- const iconSizeStyles: Record<ButtonSize, string> = {
131
- sm: "min-w-7 min-h-7 rounded-button", // 28px
132
- md: "min-w-8 min-h-8 rounded-button", // 32px — minimum recommended
133
- lg: "min-w-9 min-h-9 rounded-button", // 36px
134
- };
135
-
136
- // fallow-ignore-next-line unused-export
137
- export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
138
- ({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
139
- return (
140
- <button
141
- ref={ref}
142
- className={[
143
- "inline-flex items-center justify-center",
144
- "transition-all duration-press ease-standard",
145
- "disabled:opacity-40 disabled:cursor-not-allowed",
146
- "select-none cursor-pointer",
147
- "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
148
- variantStyles[variant],
149
- iconSizeStyles[size],
150
- className,
151
- ].join(" ")}
152
- {...props}
153
- >
154
- {icon}
155
- </button>
156
- );
157
- },
158
- );
159
- IconButton.displayName = "IconButton";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * IconButton — Button's square sibling; shares its base/variant classes so
3
+ * the two never drift. `aria-label` is required for screen readers.
4
+ */
5
+
6
+ import { forwardRef } from "react";
7
+ import type { ReactNode } from "react";
8
+ import { buttonBase, buttonVariants, type ButtonBaseProps, type ButtonSize } from "./Button";
9
+ import { cn } from "./cn";
10
+
11
+ interface IconButtonProps extends ButtonBaseProps {
12
+ icon: ReactNode;
13
+ "aria-label": string;
14
+ }
15
+
16
+ /** Square boxes on the same 24 / 28 / 32 px control heights as Button. */
17
+ const iconSizeStyles: Record<ButtonSize, string> = {
18
+ sm: "size-ctl-sm rounded-sm",
19
+ md: "size-ctl rounded-md",
20
+ lg: "size-ctl-lg rounded-md",
21
+ };
22
+
23
+ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
24
+ ({ icon, size = "md", variant = "ghost", className, disabled, ...props }, ref) => {
25
+ return (
26
+ <button
27
+ ref={ref}
28
+ disabled={disabled}
29
+ aria-disabled={disabled || undefined}
30
+ className={cn(buttonBase, buttonVariants[variant], iconSizeStyles[size], className)}
31
+ {...props}
32
+ >
33
+ {icon}
34
+ </button>
35
+ );
36
+ },
37
+ );
38
+ IconButton.displayName = "IconButton";