@bitvea/feedback-toolbar 0.0.1

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 (176) hide show
  1. package/README.md +262 -0
  2. package/dist/FeedbackPanel.d.ts +117 -0
  3. package/dist/FeedbackPanel.d.ts.map +1 -0
  4. package/dist/FeedbackPanel.js +622 -0
  5. package/dist/FeedbackPanel.js.map +1 -0
  6. package/dist/FeedbackToolbar.d.ts +54 -0
  7. package/dist/FeedbackToolbar.d.ts.map +1 -0
  8. package/dist/FeedbackToolbar.js +1149 -0
  9. package/dist/FeedbackToolbar.js.map +1 -0
  10. package/dist/InboxSheet.d.ts +80 -0
  11. package/dist/InboxSheet.d.ts.map +1 -0
  12. package/dist/InboxSheet.js +580 -0
  13. package/dist/InboxSheet.js.map +1 -0
  14. package/dist/PinLayer.d.ts +59 -0
  15. package/dist/PinLayer.d.ts.map +1 -0
  16. package/dist/PinLayer.js +1702 -0
  17. package/dist/PinLayer.js.map +1 -0
  18. package/dist/RichText.d.ts +6 -0
  19. package/dist/RichText.d.ts.map +1 -0
  20. package/dist/RichText.js +135 -0
  21. package/dist/RichText.js.map +1 -0
  22. package/dist/browser/entry.d.ts +3 -0
  23. package/dist/browser/entry.d.ts.map +1 -0
  24. package/dist/browser/entry.js +13 -0
  25. package/dist/browser/entry.js.map +1 -0
  26. package/dist/browser/mount.d.ts +37 -0
  27. package/dist/browser/mount.d.ts.map +1 -0
  28. package/dist/browser/mount.js +150 -0
  29. package/dist/browser/mount.js.map +1 -0
  30. package/dist/browser/toolbar.js +38 -0
  31. package/dist/browser/toolbar.js.map +7 -0
  32. package/dist/capture.d.ts +58 -0
  33. package/dist/capture.d.ts.map +1 -0
  34. package/dist/capture.js +98 -0
  35. package/dist/capture.js.map +1 -0
  36. package/dist/cli/bin.d.ts +3 -0
  37. package/dist/cli/bin.d.ts.map +1 -0
  38. package/dist/cli/bin.js +196 -0
  39. package/dist/cli/bin.js.map +1 -0
  40. package/dist/cli/fs.d.ts +16 -0
  41. package/dist/cli/fs.d.ts.map +1 -0
  42. package/dist/cli/fs.js +33 -0
  43. package/dist/cli/fs.js.map +1 -0
  44. package/dist/cli/init.d.ts +75 -0
  45. package/dist/cli/init.d.ts.map +1 -0
  46. package/dist/cli/init.js +266 -0
  47. package/dist/cli/init.js.map +1 -0
  48. package/dist/cli/register.d.ts +55 -0
  49. package/dist/cli/register.d.ts.map +1 -0
  50. package/dist/cli/register.js +84 -0
  51. package/dist/cli/register.js.map +1 -0
  52. package/dist/cli/registration.d.ts +37 -0
  53. package/dist/cli/registration.d.ts.map +1 -0
  54. package/dist/cli/registration.js +69 -0
  55. package/dist/cli/registration.js.map +1 -0
  56. package/dist/cli/strategies.d.ts +98 -0
  57. package/dist/cli/strategies.d.ts.map +1 -0
  58. package/dist/cli/strategies.js +198 -0
  59. package/dist/cli/strategies.js.map +1 -0
  60. package/dist/client.d.ts +59 -0
  61. package/dist/client.d.ts.map +1 -0
  62. package/dist/client.js +113 -0
  63. package/dist/client.js.map +1 -0
  64. package/dist/config.d.ts +92 -0
  65. package/dist/config.d.ts.map +1 -0
  66. package/dist/config.js +65 -0
  67. package/dist/config.js.map +1 -0
  68. package/dist/deepLink.d.ts +86 -0
  69. package/dist/deepLink.d.ts.map +1 -0
  70. package/dist/deepLink.js +185 -0
  71. package/dist/deepLink.js.map +1 -0
  72. package/dist/fontFace.d.ts +70 -0
  73. package/dist/fontFace.d.ts.map +1 -0
  74. package/dist/fontFace.js +88 -0
  75. package/dist/fontFace.js.map +1 -0
  76. package/dist/gating.d.ts +32 -0
  77. package/dist/gating.d.ts.map +1 -0
  78. package/dist/gating.js +49 -0
  79. package/dist/gating.js.map +1 -0
  80. package/dist/icons.d.ts +68 -0
  81. package/dist/icons.d.ts.map +1 -0
  82. package/dist/icons.js +97 -0
  83. package/dist/icons.js.map +1 -0
  84. package/dist/index.d.ts +14 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/index.js +68 -0
  87. package/dist/index.js.map +1 -0
  88. package/dist/markdown.d.ts +59 -0
  89. package/dist/markdown.d.ts.map +1 -0
  90. package/dist/markdown.js +130 -0
  91. package/dist/markdown.js.map +1 -0
  92. package/dist/motion.d.ts +178 -0
  93. package/dist/motion.d.ts.map +1 -0
  94. package/dist/motion.js +302 -0
  95. package/dist/motion.js.map +1 -0
  96. package/dist/preferences.d.ts +71 -0
  97. package/dist/preferences.d.ts.map +1 -0
  98. package/dist/preferences.js +195 -0
  99. package/dist/preferences.js.map +1 -0
  100. package/dist/rasterise.d.ts +45 -0
  101. package/dist/rasterise.d.ts.map +1 -0
  102. package/dist/rasterise.js +490 -0
  103. package/dist/rasterise.js.map +1 -0
  104. package/dist/selector.d.ts +19 -0
  105. package/dist/selector.d.ts.map +1 -0
  106. package/dist/selector.js +142 -0
  107. package/dist/selector.js.map +1 -0
  108. package/dist/session.d.ts +19 -0
  109. package/dist/session.d.ts.map +1 -0
  110. package/dist/session.js +36 -0
  111. package/dist/session.js.map +1 -0
  112. package/dist/shortcut.d.ts +88 -0
  113. package/dist/shortcut.d.ts.map +1 -0
  114. package/dist/shortcut.js +149 -0
  115. package/dist/shortcut.js.map +1 -0
  116. package/dist/tokens.d.ts +709 -0
  117. package/dist/tokens.d.ts.map +1 -0
  118. package/dist/tokens.js +749 -0
  119. package/dist/tokens.js.map +1 -0
  120. package/dist/vendor/types/account.d.ts +26 -0
  121. package/dist/vendor/types/account.d.ts.map +1 -0
  122. package/dist/vendor/types/account.js +23 -0
  123. package/dist/vendor/types/account.js.map +1 -0
  124. package/dist/vendor/types/apiKey.d.ts +38 -0
  125. package/dist/vendor/types/apiKey.d.ts.map +1 -0
  126. package/dist/vendor/types/apiKey.js +36 -0
  127. package/dist/vendor/types/apiKey.js.map +1 -0
  128. package/dist/vendor/types/auth.d.ts +10 -0
  129. package/dist/vendor/types/auth.d.ts.map +1 -0
  130. package/dist/vendor/types/auth.js +15 -0
  131. package/dist/vendor/types/auth.js.map +1 -0
  132. package/dist/vendor/types/entity.d.ts +74 -0
  133. package/dist/vendor/types/entity.d.ts.map +1 -0
  134. package/dist/vendor/types/entity.js +111 -0
  135. package/dist/vendor/types/entity.js.map +1 -0
  136. package/dist/vendor/types/feedback.d.ts +285 -0
  137. package/dist/vendor/types/feedback.d.ts.map +1 -0
  138. package/dist/vendor/types/feedback.js +286 -0
  139. package/dist/vendor/types/feedback.js.map +1 -0
  140. package/dist/vendor/types/index.d.ts +14 -0
  141. package/dist/vendor/types/index.d.ts.map +1 -0
  142. package/dist/vendor/types/index.js +16 -0
  143. package/dist/vendor/types/index.js.map +1 -0
  144. package/dist/vendor/types/invite.d.ts +42 -0
  145. package/dist/vendor/types/invite.d.ts.map +1 -0
  146. package/dist/vendor/types/invite.js +30 -0
  147. package/dist/vendor/types/invite.js.map +1 -0
  148. package/dist/vendor/types/mcpToken.d.ts +38 -0
  149. package/dist/vendor/types/mcpToken.d.ts.map +1 -0
  150. package/dist/vendor/types/mcpToken.js +39 -0
  151. package/dist/vendor/types/mcpToken.js.map +1 -0
  152. package/dist/vendor/types/organization.d.ts +13 -0
  153. package/dist/vendor/types/organization.d.ts.map +1 -0
  154. package/dist/vendor/types/organization.js +12 -0
  155. package/dist/vendor/types/organization.js.map +1 -0
  156. package/dist/vendor/types/project.d.ts +73 -0
  157. package/dist/vendor/types/project.d.ts.map +1 -0
  158. package/dist/vendor/types/project.js +117 -0
  159. package/dist/vendor/types/project.js.map +1 -0
  160. package/dist/vendor/types/role.d.ts +12 -0
  161. package/dist/vendor/types/role.d.ts.map +1 -0
  162. package/dist/vendor/types/role.js +20 -0
  163. package/dist/vendor/types/role.js.map +1 -0
  164. package/dist/vendor/types/roleDefinition.d.ts +61 -0
  165. package/dist/vendor/types/roleDefinition.d.ts.map +1 -0
  166. package/dist/vendor/types/roleDefinition.js +45 -0
  167. package/dist/vendor/types/roleDefinition.js.map +1 -0
  168. package/dist/vendor/types/team.d.ts +25 -0
  169. package/dist/vendor/types/team.d.ts.map +1 -0
  170. package/dist/vendor/types/team.js +17 -0
  171. package/dist/vendor/types/team.js.map +1 -0
  172. package/dist/vendor/types/util.d.ts +39 -0
  173. package/dist/vendor/types/util.d.ts.map +1 -0
  174. package/dist/vendor/types/util.js +31 -0
  175. package/dist/vendor/types/util.js.map +1 -0
  176. package/package.json +59 -0
@@ -0,0 +1,1149 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The toolbar shell: a floating bar at the bottom centre of the host page, and
4
+ // the popovers its buttons open.
5
+ //
6
+ // The shell owns the gate, which tool is active, and the theme. What matters
7
+ // most here is the gate: when the toolbar is disabled this component returns
8
+ // null before anything else runs, so a production build renders no markup and
9
+ // issues no request. That is a hard requirement rather than hidden UI, and it
10
+ // is why the early return is the very first statement and why nothing in this
11
+ // file fetches on mount.
12
+ //
13
+ // There is no panel any more. A fixed 360px panel sits badly over an arbitrary
14
+ // host app, and the surfaces it held want different shapes: the note composer
15
+ // is small, the account surface is smaller still, and anything list-shaped
16
+ // belongs in the inbox sheet. Each bar button therefore opens a popover
17
+ // anchored to itself.
18
+ //
19
+ // The bar has TWO FORMS, and issue #22 is why: one full pill is permanently in
20
+ // the way of the very page it exists to review - on a narrow viewport, or for
21
+ // a reviewer who is reading rather than annotating - and the only alternative
22
+ // used to be not having the toolbar at all.
23
+ //
24
+ // The collapsed form is ONE CIRCULAR DOT: the product mark, the unread
25
+ // indicator on its edge, and a click anywhere on it to bring the bar back. It
26
+ // was a two-control rectangle - the note action beside a hamburger - and the
27
+ // argument for that was that a horizontal strip is the only orientation a
28
+ // bottom-edge dock can ask for. Issue #21 removed that argument by making the
29
+ // dock movable, and a circle is the one shape that reads the same at all eight
30
+ // of them: it has no orientation to get wrong, and its diameter is the
31
+ // expanded bar's own height, so a collapse reads as the bar's ends closing in
32
+ // rather than as the bar also changing thickness. The cost is stated where it
33
+ // falls, in `setCollapsed`: with no note button in this form, the composer can
34
+ // no longer survive a collapse.
35
+ //
36
+ // AND THE BAR MOVES, which is issue #21. Eight discrete docks, dragged by the
37
+ // bar's own body and chosen from a 3x3 grid in the account surface for anybody
38
+ // not using a mouse, remembered in the same preference record as the collapsed
39
+ // flag. What is NOT here is a stored coordinate: see `ToolbarDock` in
40
+ // tokens.ts for why that makes "never leaves the bar off-screen" and "a resize
41
+ // re-docks it" structural rather than a listener.
42
+ //
43
+ // THE ONE RULE THIS FILE CANNOT BREAK, and the reason the drag is where it is:
44
+ // the toolbar ROOT is the sole ancestor of every `position: fixed` pin marker
45
+ // on the page, because `PinLayer` renders a bare fragment. A `transform`
46
+ // anywhere above those markers makes that box their containing block and moves
47
+ // every pin on the host page. So the drag's transform is on the PILL - a leaf
48
+ // that holds no marker and is the pin layer's nephew, not its ancestor - the
49
+ // dock moves by LAYOUT only, and the root carries neither.
50
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
51
+ import { createPortal } from "react-dom";
52
+ import { resolveConfig, } from "./config.js";
53
+ import { captureDeepLink, clearDeepLinkHash } from "./deepLink.js";
54
+ import { AccountSurface, FeedbackPanel } from "./FeedbackPanel.js";
55
+ // Every glyph in the product, drawn once - see the header of icons.tsx for why
56
+ // the size lives in `style` rather than in a `width` attribute.
57
+ import { AccountIcon, CollapseIcon, InboxIcon, NoteIcon, PinIcon, } from "./icons.js";
58
+ import { INBOX_OPEN_STORE, InboxSheet, SHEET_WIDTH, writeInboxOpen, } from "./InboxSheet.js";
59
+ import { surfaceMotion, useEntered, useJustChanged, usePrefersReducedMotion, useSwapEntered, } from "./motion.js";
60
+ import { PinLayer } from "./PinLayer.js";
61
+ import { formatChord, isApplePlatform } from "./shortcut.js";
62
+ import { PREFERENCES_STORE, writePreferences } from "./preferences.js";
63
+ import { createToolbarClient } from "./client.js";
64
+ import { useToolbarSession } from "./session.js";
65
+ import { bar, DARK_SCHEME_QUERY, DEFAULT_DOCK, dockLayout, DRAG_THRESHOLD, fonts, isThemeMode, isToolbarDock, layers, motion, motionTransform, radii, resetBox, sizes, spacing, themeFor, THEME_STORAGE_KEY, transition, typeScale, Z_INDEX, } from "./tokens.js";
66
+ import { injectFontFaces } from "./fontFace.js";
67
+ /** The default inbox: this package's own sheet. */
68
+ const defaultRenderInbox = (props) => (_jsx(InboxSheet, { ...props }));
69
+ export function FeedbackToolbar({ env, children, renderInbox = defaultRenderInbox, ...overrides }) {
70
+ const config = useMemo(() => resolveConfig(env ?? readProcessEnv(), overrides),
71
+ // The env bag and the overrides are static for the life of a page: they
72
+ // come from the bundle, not from state.
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ [env]);
75
+ // Disabled: render nothing at all, and make no request. The gate is a
76
+ // separate component from the toolbar itself precisely so that the hooks
77
+ // below it - including the one that resolves the session and the one that
78
+ // reads the host's colour scheme - never run on a production build. A
79
+ // conditional hook would be the usual way to get this wrong; a conditional
80
+ // component cannot be.
81
+ if (!config.enabled)
82
+ return null;
83
+ return (_jsx(EnabledToolbar, { config: config, renderInbox: renderInbox, children: children }));
84
+ }
85
+ function EnabledToolbar({ config, children, renderInbox, }) {
86
+ const [tool, setTool] = useState(null);
87
+ const [picking, setPicking] = useState(false);
88
+ // Read from storage, because a reviewer working through a round of feedback
89
+ // navigates between pages and a sheet that closed on every navigation would
90
+ // be useless. Through a store with a server snapshot rather than a
91
+ // `useState` initialiser: the initialiser answered false on the server and
92
+ // true on the client, and the value reaches the DOM as `aria-expanded`,
93
+ // `aria-pressed` and an inline background on the inbox button - so a
94
+ // reviewer who left the sheet open got a React hydration error in the host
95
+ // app's console on every page load. See INBOX_OPEN_STORE.
96
+ const inboxOpen = useSyncExternalStore(INBOX_OPEN_STORE.subscribe, INBOX_OPEN_STORE.getSnapshot, INBOX_OPEN_STORE.getServerSnapshot);
97
+ const [showResolved, setShowResolved] = useState(false);
98
+ const [focusPinId, setFocusPinId] = useState(null);
99
+ const [openCount, setOpenCount] = useState(0);
100
+ const { mode, theme, setMode } = useToolbarTheme();
101
+ // Which form the bar is in, from the reviewer's persisted preferences and
102
+ // through the same store shape the theme and the inbox flag use. Issue #22
103
+ // asks for it to survive a reload, and on a host MPA every link is a fresh
104
+ // page load, so "I collapsed the bar" cannot live in React state.
105
+ //
106
+ // Emphatically not a `useState` initialiser that reads storage: this flag
107
+ // decides which CONTROLS EXIST, so the server would render one set of
108
+ // buttons and the hydrating client another - a harder version of the
109
+ // hydration error `INBOX_OPEN_STORE` was rewritten to fix, and it lands in
110
+ // the CUSTOMER's console. See PREFERENCES_STORE.
111
+ //
112
+ // ONE subscription for both fields, not two. They come out of one record
113
+ // (see preferences.ts) and they have to come out of one SNAPSHOT: a second
114
+ // `useSyncExternalStore` on the same store would be a second server snapshot
115
+ // to keep in step, and the form of the bar and the edge it is docked to are
116
+ // exactly the pair issue #22 says must not be able to disagree.
117
+ const { collapsed, dock } = useSyncExternalStore(PREFERENCES_STORE.subscribe, PREFERENCES_STORE.getSnapshot, PREFERENCES_STORE.getServerSnapshot);
118
+ // Every per-edge answer in this component - the dock's flex triple, the
119
+ // popover's origin and gap, the unread dot's corner, the chevron's angle -
120
+ // derived once from that one value. See `dockLayout`.
121
+ const edge = dockLayout(dock);
122
+ // The drag, on the pill and nowhere above it. See `useBarDrag`.
123
+ const drag = useBarDrag();
124
+ // The URL fragment, read exactly once. It has to be read in the shell's
125
+ // first render pass: a host framework that owns the hash rewrites it during
126
+ // hydration, and the pin layer renders after this line.
127
+ //
128
+ // Read through a store for the same reason the theme is. `captureDeepLink`
129
+ // is a memoised one-shot, so `getSnapshot` is stable and the subscription
130
+ // has nothing to publish - what the store buys is the SERVER snapshot: null
131
+ // during hydration, adopted afterwards, instead of a value that differs
132
+ // between the two renders. The read itself still happens in this first
133
+ // render pass, because React calls `getSnapshot` on the client even while it
134
+ // is rendering the server's value.
135
+ const deepLinkPinId = useSyncExternalStore(subscribeDeepLink, captureDeepLink, serverDeepLink);
136
+ // The address bar, tidied. Twice, and from an effect both times.
137
+ //
138
+ // From an effect because a `history.replaceState` is a side effect on the
139
+ // host's document, and a render pass is not where those belong: React is
140
+ // entitled to run one twice and to throw one away. Twice because the first
141
+ // clear is not the last word - Next's App Router restores the address it
142
+ // considered canonical at load, our fragment included, from a commit-phase
143
+ // effect. A parent's effects run after its children's, and the router is an
144
+ // ancestor of every host app, so there is no effect this component can
145
+ // register that is guaranteed to run later. A timeout is: it is scheduled
146
+ // from inside the commit and runs after all of it. `clearDeepLinkHash`
147
+ // re-checks the prefix, so neither call can eat a fragment that is not ours.
148
+ useEffect(() => {
149
+ if (deepLinkPinId === null)
150
+ return;
151
+ clearDeepLinkHash();
152
+ const timer = window.setTimeout(clearDeepLinkHash, 0);
153
+ return () => window.clearTimeout(timer);
154
+ }, [deepLinkPinId]);
155
+ // DESIGN.md's two faces, put into the REVIEWED page. Naming them in a font
156
+ // stack is not enough on a site that has never heard of Geist - see the
157
+ // note at the head of `fontFace.ts` for the two separate ways that missed.
158
+ //
159
+ // Deliberately NOT cleaned up on unmount. The `<style>` defines two
160
+ // namespaced families and no selectors, so it cannot affect anything the
161
+ // host renders; removing it would instead evict the font from the document
162
+ // between an unmount and the next mount and flash the fallback back in.
163
+ useEffect(() => {
164
+ injectFontFaces(document, config.assetOrigin);
165
+ }, [config.assetOrigin]);
166
+ const client = useMemo(() => createToolbarClient(config.apiUrl), [config.apiUrl]);
167
+ // Resolved here rather than in a popover: the pin layer needs the answer
168
+ // before anybody opens anything, or existing pins are invisible until a
169
+ // reviewer opens the toolbar - by which point they may already be writing a
170
+ // duplicate of the report the pin was.
171
+ const session = useToolbarSession(client, config.projectUrl);
172
+ const startPicking = useCallback(() => {
173
+ setPicking(true);
174
+ // Out of the way: the reviewer is about to click the page underneath.
175
+ setTool(null);
176
+ }, []);
177
+ const toggleTool = useCallback((next) => {
178
+ setTool((current) => (current === next ? null : next));
179
+ setPicking(false);
180
+ }, []);
181
+ const focusPin = useCallback((id) => {
182
+ setFocusPinId(id);
183
+ // The sheet stays open - the reviewer is working down a list - but a
184
+ // popover over the bar would sit on top of the pin they just asked to see.
185
+ setTool(null);
186
+ }, []);
187
+ // Written to storage, which notifies the store, which re-renders this. There
188
+ // is no component copy of the flag to keep in step with it.
189
+ const setInbox = useCallback((open) => {
190
+ writeInboxOpen(open);
191
+ }, []);
192
+ // Same shape, and the same reason there is no component copy of the flag.
193
+ const setCollapsed = useCallback((next) => {
194
+ writePreferences({ collapsed: next });
195
+ // Collapsing only. Expanding takes no control away, and closing the
196
+ // composer on the way back to the full bar would throw away a note the
197
+ // reviewer was in the middle of writing.
198
+ if (!next)
199
+ return;
200
+ // EVERY popover, the composer included, and that carve-out inverted with
201
+ // the collapsed form. `layers` in tokens.ts writes the rule down: every
202
+ // surface here is something the bar opened and the bar has to be able to
203
+ // close. The note popover used to survive a collapse because its button
204
+ // did; the single dot issue #22 asks for has no note button, so a surviving
205
+ // composer would be a `role="dialog"` hanging over a 46px circle with
206
+ // nothing left in the DOM to close it. Losing a half-written note is the
207
+ // stated cost of the one-control dot, and it is the smaller failure.
208
+ setTool(null);
209
+ // Picking is worse than an orphaned popover: the crosshair wash stays over
210
+ // the host page with the toggle that cancels it gone from the DOM, so the
211
+ // only way out is a key nobody told the reviewer about.
212
+ setPicking(false);
213
+ }, []);
214
+ // Escape closes whichever popover is open. The container declares
215
+ // `role="dialog"`, and Escape is that role's documented dismissal - there
216
+ // was no key handling in this package at all.
217
+ useEffect(() => {
218
+ if (tool === null)
219
+ return;
220
+ const onKeyDown = (event) => {
221
+ if (event.key !== "Escape")
222
+ return;
223
+ setTool(null);
224
+ // Only when something was open, so a host app's own Escape handling is
225
+ // untouched the rest of the time.
226
+ event.stopPropagation();
227
+ };
228
+ window.addEventListener("keydown", onKeyDown);
229
+ return () => window.removeEventListener("keydown", onKeyDown);
230
+ }, [tool]);
231
+ // And a click on the host page closes it too. A 320px composer parked over
232
+ // the very element under review, dismissable only by finding the bar button
233
+ // again, is the review tool getting in the way of the review. Clicks inside
234
+ // the toolbar's own root are not "outside": that includes the bar, whose
235
+ // buttons do their own toggling.
236
+ useEffect(() => {
237
+ if (tool === null)
238
+ return;
239
+ const onPointerDown = (event) => {
240
+ const target = event.target;
241
+ if (target instanceof Element &&
242
+ target.closest("[data-bitvea-feedback-toolbar]") !== null) {
243
+ return;
244
+ }
245
+ setTool(null);
246
+ };
247
+ window.addEventListener("pointerdown", onPointerDown, true);
248
+ return () => window.removeEventListener("pointerdown", onPointerDown, true);
249
+ }, [tool]);
250
+ // Whether this render may portal into `document.body`. False on the server
251
+ // and false for the first client render, then true - the same
252
+ // `useSyncExternalStore` shape the theme and the deep link above use, and
253
+ // for the same reason: a value that differs between the server render and
254
+ // the hydrating one is a React error in the CUSTOMER's console. There is
255
+ // nothing to subscribe to; what this buys is the server snapshot.
256
+ const canPortal = useSyncExternalStore(subscribeNothing, browserCanPortal, serverCanPortal);
257
+ // The chord as this reviewer's platform writes it, or null where the host
258
+ // switched it off. Computed here rather than in the button because it is a
259
+ // read of `navigator`, and every such read in this file goes through a
260
+ // deterministic first render - a hint that differed between the server's
261
+ // markup and the client's is a hydration error in somebody else's console.
262
+ const pinShortcutHint = useSyncExternalStore(subscribeNothing, () => config.pinShortcut === null
263
+ ? null
264
+ : formatChord(config.pinShortcut, isApplePlatform(navigator)), () => null);
265
+ const branchLabel = config.gitBranch ?? config.environment;
266
+ const commitLabel = config.gitCommit?.slice(0, 7) ?? null;
267
+ const ready = session.readyProjectId;
268
+ // Whether the sheet is actually on screen, which is what the dock has to
269
+ // make room for. Not the same as `inboxOpen`: the sheet only mounts once a
270
+ // project has resolved, and a host may have replaced or removed it.
271
+ const sheetOpen = renderInbox != null && inboxOpen && ready !== null;
272
+ // The unread signal, read by both forms of the bar from one place. It is the
273
+ // INBOX's signal: a host that suppressed the inbox with `renderInbox={null}`
274
+ // gets no count on the expanded bar, so the collapsed form must not invent
275
+ // one for the control that would have opened it.
276
+ const unread = renderInbox ? openCount : 0;
277
+ // The note action. It used to be the one control BOTH forms carried, and
278
+ // declaring it once was how "the note action survives a collapse" stayed the
279
+ // same element rather than two spellings of it. The collapsed form is a
280
+ // single dot now, so it is the expanded bar's alone - but it keeps its own
281
+ // binding, because its test id is what four e2e specs seed feedback through
282
+ // and a control worth naming once is worth naming where a reader finds it.
283
+ const noteButton = (_jsx(BarButton, { theme: theme,
284
+ // Named for the control it replaces: every e2e spec that seeds feedback
285
+ // opens the composer through this id, and renaming it would break four
286
+ // of them for no gain.
287
+ testId: "feedback-toggle", label: tool === "note" ? "Close feedback" : "Leave a note", expanded: tool === "note", active: tool === "note", onClick: () => toggleTool("note"), children: _jsx(NoteIcon, {}) }));
288
+ const tree = (_jsxs("div", { "data-bitvea-feedback-toolbar": "",
289
+ // The root is a zero-sized, UNTRANSFORMED anchor, and both facts are
290
+ // load-bearing. A transform on it would make it the containing block for
291
+ // every `position: fixed` descendant, which is what the pin markers are:
292
+ // they would then be offset by the bar's own position instead of sitting
293
+ // on the elements they are anchored to. Zero-sized so it can never
294
+ // swallow a click meant for the host page.
295
+ style: {
296
+ // A host `div { padding: 8px }` would otherwise inflate a box this
297
+ // file went to some trouble to make zero-sized.
298
+ ...resetBox,
299
+ position: "fixed",
300
+ left: 0,
301
+ top: 0,
302
+ width: 0,
303
+ height: 0,
304
+ zIndex: Z_INDEX,
305
+ fontFamily: fonts.sans,
306
+ // Every inherited text property the host could have set on `html`,
307
+ // `body` or `*`, stated once at the top of the toolbar's own tree.
308
+ // `fontFamily` alone was never enough: a host that letter-spaces its
309
+ // body copy, uppercases with `text-transform`, italicises, right-
310
+ // aligns for an RTL locale or sets `line-height: 2` was re-typesetting
311
+ // every surface below this line, and none of it is visible in the
312
+ // preview harness because apps/web sets none of it.
313
+ letterSpacing: "normal",
314
+ wordSpacing: "normal",
315
+ textTransform: "none",
316
+ fontStyle: "normal",
317
+ fontVariant: "normal",
318
+ textAlign: "left",
319
+ textIndent: 0,
320
+ lineHeight: 1.5,
321
+ textDecoration: "none",
322
+ whiteSpace: "normal",
323
+ // Follows the reviewer's resolved theme rather than pinning one, so
324
+ // the browser's own form-control chrome inside the popovers matches
325
+ // the surface they sit on.
326
+ colorScheme: theme.resolved,
327
+ }, children: [_jsxs("div", { "data-testid": "feedback-dock", style: {
328
+ position: "fixed",
329
+ // Four longhands rather than the `inset` shorthand: jsdom does not
330
+ // expand `inset` into `style.left`/`style.right`, and two tests read
331
+ // those to prove the dock is a box rather than a centred transform.
332
+ top: 0,
333
+ right: 0,
334
+ bottom: 0,
335
+ left: 0,
336
+ display: "flex",
337
+ flexDirection: edge.flexDirection,
338
+ // Always the bar's own end of the column, so a popover grows away
339
+ // from the pill instead of pushing it. See the note above.
340
+ justifyContent: "flex-end",
341
+ alignItems: edge.alignItems,
342
+ boxSizing: "border-box",
343
+ // Issue #21's "standard viewport inset", now on all four sides
344
+ // because all four are dockable. See `bar.inset`.
345
+ padding: bar.inset,
346
+ // ...except the bottom, which is also how a mid-edge dock centres
347
+ // the bar. See `dockLayout`.
348
+ paddingBottom: edge.paddingBottom,
349
+ // The sheet is `right: 0`, so the bar centres in what is left of the
350
+ // viewport. `clamp` rather than a flat 340px because on a narrow
351
+ // screen the sheet is itself clamped, and subtracting its full width
352
+ // there would squeeze the bar to nothing: below ~420px the offset
353
+ // reaches zero and the bar centres on the whole viewport again,
354
+ // under the sheet's own edge.
355
+ //
356
+ // Unconditional across the eight docks, which is the whole reason it
357
+ // is a padding and not a centring offset: for a left dock it changes
358
+ // nothing, for a centred one it re-centres in what is left, and for a
359
+ // RIGHT dock - including the mid-edge, where a 46px dot would
360
+ // otherwise sit squarely under a full-height sheet - it holds the bar
361
+ // clear of the sheet's edge. That is issue #21's "a bar docked right
362
+ // should not sit under the sheet", answered once.
363
+ paddingRight: sheetOpen
364
+ ? `clamp(${spacing.xs}, calc(100vw - 420px), ${SHEET_WIDTH}px)`
365
+ : bar.inset,
366
+ // The box must never swallow a click meant for the host page. Each
367
+ // thing inside it turns pointer events back on for itself.
368
+ pointerEvents: "none",
369
+ // Above every surface the toolbar opens. The dock used to carry no
370
+ // z-index at all, which in this stacking context means it lost to
371
+ // the picking overlay, the pin markers, the threads and the inbox
372
+ // sheet alike - and losing to the picking overlay meant the pin tool
373
+ // could not be turned off. See `layers`.
374
+ zIndex: layers.bar,
375
+ }, children: [children === undefined ? null : (_jsx("div", { style: { pointerEvents: "auto" }, children: children })), tool !== null ? (_jsx(BarPopover, { theme: theme, tool: tool, edge: edge, children: tool === "note" ? (_jsx(FeedbackPanel, { config: config, phase: session.phase, theme: theme, onReloadSession: session.reload, onDone: () => setTool(null) })) : (_jsx(AccountSurface, { config: config, phase: session.phase, theme: theme, mode: mode, onModeChange: setMode, onReloadSession: session.reload })) })) : null, _jsx("div", { "aria-label": "BitVea Feedback toolbar", "data-testid": "feedback-bar", "data-toolbar-state": collapsed ? "collapsed" : "expanded", ...drag.handlers, style: {
376
+ display: "flex",
377
+ alignItems: "center",
378
+ padding: bar.padding,
379
+ // Clamped to the dock, which is the viewport less its inset.
380
+ // Without this the 402px pill overhung both edges of a 390px
381
+ // phone and both 32px rounded ends were cut off.
382
+ maxWidth: "100%",
383
+ boxSizing: "border-box",
384
+ pointerEvents: "auto",
385
+ background: theme.colors.barSurface,
386
+ border: `1px solid ${theme.colors.barBorder}`,
387
+ // A CIRCLE when collapsed, and it needs no width or height of its
388
+ // own to be one: 4px of `bar.padding` around a single 36px button
389
+ // is a 44px content box, so a pill radius on it is a circle - the
390
+ // same 46px across as the expanded bar is tall. That is why the
391
+ // collapse reads as the bar's ends closing in rather than as the
392
+ // bar also getting thinner, and it clears DESIGN.md's 44x44 touch
393
+ // floor (Responsive -> Touch Targets), which a bare 36px dot does
394
+ // not.
395
+ borderRadius: collapsed ? radii.pill : bar.radius,
396
+ boxShadow: theme.shadows.bar,
397
+ // The drag affordance, issue #21. A grab cursor on the bar's body
398
+ // is the whole discovery mechanism - there is no grip handle,
399
+ // because a 36px square in a bar already fighting for width on a
400
+ // phone is a worse trade than a cursor. The buttons inside declare
401
+ // `pointer` and win over their own boxes.
402
+ cursor: "grab",
403
+ // Stated permanently rather than while dragging, because a browser
404
+ // decides whether a touch gesture scrolls the HOST page at the
405
+ // moment the finger lands: by the time a `touchAction` applied
406
+ // mid-drag reached the compositor the page would already be
407
+ // moving. The bar is a draggable object, so a touch that starts on
408
+ // it belongs to the bar.
409
+ touchAction: "none",
410
+ ...drag.style,
411
+ }, children: _jsx(BarContents, { form: collapsed, children: collapsed ? (_jsxs(BarButton, { theme: theme, testId: "toolbar-expand",
412
+ // The count is in the name as well as on the edge, because the
413
+ // indicator is now a bare dot and this control is the only
414
+ // thing a screen-reader user has left to hear the number from.
415
+ label: unread > 0
416
+ ? `Expand toolbar, ${unread} open`
417
+ : "Expand toolbar", expanded: false, onClick: () => setCollapsed(false), children: [_jsx(BitveaMark, { theme: theme, size: 18, inline: true }), unread > 0 ? _jsx(UnreadDot, { theme: theme, at: edge.badge }) : null] })) : (_jsxs(_Fragment, { children: [_jsx(BitveaMark, { theme: theme }), _jsx(BarButton, { theme: theme, testId: "toolbar-pin",
418
+ // The label is what the e2e suite finds this control by, and
419
+ // it survived the panel it used to live in on purpose. The
420
+ // shortcut goes in `hint` and not here for exactly that
421
+ // reason - see the prop's note on BarButton.
422
+ label: "Pin to an element",
423
+ // Discoverability at the one moment it lands: hovering the
424
+ // button you were about to click. Derived from the config'd
425
+ // chord rather than written out, so a host that remaps it
426
+ // does not leave this teaching a keystroke that does
427
+ // nothing, and a host that switches it off shows no hint.
428
+ hint: pinShortcutHint ?? undefined, active: picking, onClick: () => (picking ? setPicking(false) : startPicking()), children: _jsx(PinIcon, {}) }), noteButton, renderInbox ? (_jsx(BarButton, { theme: theme, testId: "toolbar-inbox", label: unread > 0 ? `Inbox, ${unread} open` : "Inbox", expanded: inboxOpen, active: inboxOpen, onClick: () => setInbox(!inboxOpen), badge: unread, children: _jsx(InboxIcon, {}) })) : null, _jsx(BarButton, { theme: theme, testId: "toolbar-account", label: "Account", expanded: tool === "account", active: tool === "account", onClick: () => toggleTool("account"), children: _jsx(AccountIcon, {}) }), _jsxs("span", { style: {
429
+ display: "inline-flex",
430
+ alignItems: "center",
431
+ gap: spacing.xs,
432
+ height: bar.button,
433
+ padding: `0 ${spacing.sm}`,
434
+ maxWidth: "220px",
435
+ // The one thing in the bar allowed to give up width, and
436
+ // the reason the pill can be clamped at all: the buttons
437
+ // are fixed 36px squares, so a narrow viewport has to come
438
+ // out of here. It has one more square to make room for
439
+ // since issue #22, which is the same viewport the collapse
440
+ // control exists to rescue.
441
+ flex: "0 1 auto",
442
+ minWidth: 0,
443
+ boxSizing: "border-box",
444
+ borderRadius: radii.pill,
445
+ background: theme.colors.barHover,
446
+ ...typeScale.captionMono,
447
+ }, children: [_jsx("span", { "data-testid": "toolbar-branch", title: branchLabel, style: {
448
+ minWidth: 0,
449
+ overflow: "hidden",
450
+ whiteSpace: "nowrap",
451
+ textOverflow: "ellipsis",
452
+ // `body`, not `barInkMute`: mute on the hover wash is
453
+ // 3.2:1 at 12px, and this is the one label the bar exists
454
+ // to make readable at a glance.
455
+ color: theme.colors.body,
456
+ }, children: branchLabel }), commitLabel ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", style: { color: theme.colors.body }, children: "\u00B7" }), _jsx("span", { "data-testid": "toolbar-commit", title: config.gitCommit, style: {
457
+ // `0 0 auto`, not `0 1 auto`. Both labels shrinking
458
+ // meant the 220px clamp took width off the commit
459
+ // too, and with `overflow: hidden` and no ellipsis it
460
+ // clipped mid-glyph - a seven-character SHA rendered
461
+ // as six and a sliver, which is worse than absent
462
+ // because it still looks like a value. The short SHA
463
+ // is a fixed width and is the identifier that pins a
464
+ // report to a build, so it is the one thing in this
465
+ // pill that never gives up room; the branch above
466
+ // already ellipsises and is the label that can afford
467
+ // to.
468
+ flex: "0 0 auto",
469
+ whiteSpace: "nowrap",
470
+ color: theme.colors.body,
471
+ }, children: commitLabel })] })) : null] }), _jsx(BarButton, { theme: theme, testId: "toolbar-collapse", label: "Collapse toolbar", expanded: true, onClick: () => setCollapsed(true), children: _jsx(CollapseIcon, { turn: edge.chevron }) })] })) }) })] }), ready ? (_jsxs(_Fragment, { children: [_jsx(PinLayer, { client: client, config: config, organizationId: ready, theme: theme, picking: picking, onPickingChange: setPicking, showResolved: showResolved, focusPinId: focusPinId, deepLinkPinId: deepLinkPinId, onFocusHandled: () => setFocusPinId(null), onOpenCountChange: setOpenCount }), renderInbox?.({
472
+ client,
473
+ config,
474
+ organizationId: ready,
475
+ theme,
476
+ open: inboxOpen,
477
+ onClose: () => setInbox(false),
478
+ showResolved,
479
+ onShowResolvedChange: setShowResolved,
480
+ onFocusPin: focusPin,
481
+ })] })) : null] }));
482
+ // Portalled to `document.body`, which is a correctness fix rather than a
483
+ // tidy-up.
484
+ //
485
+ // Every surface this package draws is `position: fixed`, and a fixed box is
486
+ // positioned against the VIEWPORT only while no ancestor establishes a
487
+ // containing block. Any ancestor carrying `transform`, `filter`,
488
+ // `perspective`, `backdrop-filter`, `will-change` or `contain` does exactly
489
+ // that - and on the npm install path the toolbar is mounted wherever the
490
+ // host's root layout puts it, commonly inside a page-transition wrapper or
491
+ // an animated shell. The whole toolbar - bar, popovers, inbox sheet and
492
+ // every pin marker on the page - then renders offset into that ancestor's
493
+ // box, with the pins pointing at nothing. The script-tag path never showed
494
+ // it because mount.tsx appends its container to `document.body` already.
495
+ //
496
+ // `data-bitvea-feedback-toolbar` travels with the portalled root, so the
497
+ // outside-click check above and `elementUnder`'s "never anchor to our own
498
+ // UI" exclusion in PinLayer still match: both walk `closest()` from the
499
+ // event target through the real DOM tree, which is where the portal puts
500
+ // this.
501
+ return canPortal ? createPortal(tree, document.body) : null;
502
+ }
503
+ /* ── The deep link, as a store ─────────────────────────────────────────── */
504
+ /**
505
+ * `captureDeepLink` is a module-scope one-shot: it reads the hash the first
506
+ * time it is called on the client and answers every later caller from memory.
507
+ * So there is nothing to subscribe to - the value cannot change - and this
508
+ * exists only to satisfy `useSyncExternalStore`, which is used here for its
509
+ * server snapshot rather than for its subscription.
510
+ */
511
+ function subscribeDeepLink() {
512
+ return () => { };
513
+ }
514
+ /* ── The portal target, as a store ────────────────────────────────────── */
515
+ /** Neither of the two stores below can change, so neither publishes. */
516
+ function subscribeNothing() {
517
+ return () => { };
518
+ }
519
+ /**
520
+ * A browser with a `<body>` to portal into. Both halves are checked because
521
+ * this also runs from a `<script>` in `<head>`, where `document` exists and
522
+ * `document.body` does not yet.
523
+ */
524
+ function browserCanPortal() {
525
+ return typeof document !== "undefined" && document.body !== null;
526
+ }
527
+ /**
528
+ * The server's answer, and the hydrating render's. False: `createPortal` is
529
+ * not supported by the server renderer at all, and a client render that
530
+ * portalled while the server rendered inline would be a hydration mismatch in
531
+ * the host app. So the toolbar is client-only, which it effectively already
532
+ * was - every surface here reads `window`, `localStorage` or `location`.
533
+ */
534
+ function serverCanPortal() {
535
+ return false;
536
+ }
537
+ /**
538
+ * No `location` on the server, and none during hydration either. React renders
539
+ * this value first and adopts `captureDeepLink`'s answer once hydration is
540
+ * finished, which is what keeps the two renders identical.
541
+ */
542
+ function serverDeepLink() {
543
+ return null;
544
+ }
545
+ /* ── Theme ────────────────────────────────────────────────────────────── */
546
+ /**
547
+ * The reviewer's manual override, held at module scope so the media query and
548
+ * the stored preference are one external store rather than component state.
549
+ *
550
+ * `useSyncExternalStore` rather than an effect that calls `setState`: the
551
+ * server render has no `window`, and the two snapshots below give React a
552
+ * deterministic first paint (`system`, light) that it then reconciles, instead
553
+ * of a hydration mismatch inside somebody else's app.
554
+ */
555
+ // Only used when `localStorage` is unreachable - private mode, a blocked
556
+ // origin, a host that disabled storage. The toolbar still honours the choice
557
+ // for this page; it just cannot survive a reload, which beats the toggle
558
+ // silently doing nothing.
559
+ let memoryMode = null;
560
+ const modeListeners = new Set();
561
+ function readStoredMode() {
562
+ try {
563
+ const raw = window.localStorage.getItem(THEME_STORAGE_KEY);
564
+ if (isThemeMode(raw))
565
+ return raw;
566
+ }
567
+ catch {
568
+ // Fall through to the in-memory value.
569
+ }
570
+ return memoryMode ?? "system";
571
+ }
572
+ function writeStoredMode(mode) {
573
+ try {
574
+ window.localStorage.setItem(THEME_STORAGE_KEY, mode);
575
+ memoryMode = null;
576
+ }
577
+ catch {
578
+ memoryMode = mode;
579
+ }
580
+ modeListeners.forEach((listener) => listener());
581
+ }
582
+ function subscribeMode(listener) {
583
+ modeListeners.add(listener);
584
+ return () => modeListeners.delete(listener);
585
+ }
586
+ function subscribeSystemScheme(listener) {
587
+ if (typeof window.matchMedia !== "function")
588
+ return () => { };
589
+ const query = window.matchMedia(DARK_SCHEME_QUERY);
590
+ query.addEventListener("change", listener);
591
+ return () => query.removeEventListener("change", listener);
592
+ }
593
+ function systemPrefersDark() {
594
+ if (typeof window === "undefined" ||
595
+ typeof window.matchMedia !== "function") {
596
+ return false;
597
+ }
598
+ return window.matchMedia(DARK_SCHEME_QUERY).matches;
599
+ }
600
+ function serverMode() {
601
+ return "system";
602
+ }
603
+ function useToolbarTheme() {
604
+ // The server snapshot: no `window`, so no stored choice and no media query.
605
+ // Both halves therefore render "follow the host, and assume light" first,
606
+ // which is a deterministic first paint rather than a hydration mismatch
607
+ // inside somebody else's app.
608
+ const mode = useSyncExternalStore(subscribeMode, readStoredMode, serverMode);
609
+ const prefersDark = useSyncExternalStore(subscribeSystemScheme, systemPrefersDark, () => false);
610
+ const resolved = mode === "system" ? (prefersDark ? "dark" : "light") : mode;
611
+ return {
612
+ mode,
613
+ theme: useMemo(() => themeFor(resolved), [resolved]),
614
+ setMode: writeStoredMode,
615
+ };
616
+ }
617
+ /** Keep the pill's box inside the viewport, on one axis. */
618
+ function clampDelta(delta, start, length, viewport) {
619
+ // `Math.min` last, so a viewport narrower than the bar answers the low bound
620
+ // rather than an impossible one - the bar hangs off the far edge instead of
621
+ // off the near one, which is the edge a reviewer is reaching from.
622
+ return Math.max(-start, Math.min(delta, viewport - start - length));
623
+ }
624
+ /**
625
+ * Which of the eight docks a point belongs to: the viewport in thirds on each
626
+ * axis, which is the whole snap rule.
627
+ *
628
+ * Thirds rather than "nearest of eight centres", because the eight positions
629
+ * are not points - a bottom-centre bar is a 400px object and a bottom-right
630
+ * one is the same object 200px along - so a distance to a centre would depend
631
+ * on the bar's own width and would move the boundaries as it collapsed.
632
+ *
633
+ * `middle-center` is not a dock, so a release in the dead centre of the
634
+ * viewport resolves to the nearer of top-centre and bottom-centre. Validated
635
+ * through `isToolbarDock` rather than cast: the composed string is exactly the
636
+ * shape that guard exists to check, and it means a ninth combination cannot
637
+ * reach `dockLayout` as a key it has no row for.
638
+ */
639
+ function nearestDock(x, y) {
640
+ const width = window.innerWidth || 1;
641
+ const height = window.innerHeight || 1;
642
+ const horizontal = x < width / 3 ? "left" : x > (width * 2) / 3 ? "right" : "center";
643
+ const band = y < height / 3 ? "top" : y > (height * 2) / 3 ? "bottom" : "middle";
644
+ const vertical = band === "middle" && horizontal === "center"
645
+ ? y < height / 2
646
+ ? "top"
647
+ : "bottom"
648
+ : band;
649
+ const candidate = `${vertical}-${horizontal}`;
650
+ return isToolbarDock(candidate) ? candidate : DEFAULT_DOCK;
651
+ }
652
+ /**
653
+ * The bar's drag, and every line of it is about where the transform is allowed
654
+ * to live.
655
+ *
656
+ * The pill carries `translate3d` while the pointer is down and NOTHING at
657
+ * rest - an omitted property rather than `transform: none`, because a resting
658
+ * transform of any spelling is still a stacking context and two tests read
659
+ * that declaration to prove the collapse motion is not on this box. The dock
660
+ * above it and the root above that stay transform-free permanently: the root
661
+ * is the sole ancestor of every `position: fixed` pin marker on the page, and
662
+ * a transform there moves all of them. Issue #21 names the pill as the one
663
+ * sanctioned place, and this is it.
664
+ *
665
+ * `window` listeners rather than `setPointerCapture`, and that is not a
666
+ * preference either. Pointer capture RETARGETS every later event at the
667
+ * capturing element, so the bar button under the pointer would never see its
668
+ * own `pointerup` and would stay visibly pressed for the rest of the session.
669
+ * Listening on the window keeps the button's own events intact and picks up a
670
+ * pointer that left the viewport, which capture does too but at that price.
671
+ * What capture WOULD have given for free is the guarantee that a gesture ends,
672
+ * so this one has to notice on its own that it did: see the `buttons === 0`
673
+ * line in `onMove`, without which a release over another application left the
674
+ * bar following the cursor around the host page for the rest of the session.
675
+ *
676
+ * Reduced motion is deliberately not consulted. `prefers-reduced-motion` is
677
+ * about animation a reviewer did not ask for; a box following their own
678
+ * pointer 1:1 is direct manipulation, and refusing to move it would be
679
+ * refusing the interaction rather than calming it. There is no `transition` on
680
+ * this transform for the same reason - it has to track the pointer exactly.
681
+ *
682
+ * AND THE SNAP ITSELF IS INSTANT, which is a decision rather than an omission.
683
+ * The dock changes by flex alignment, and DESIGN.md's Motion section names
684
+ * `top`/`left`/`right`/`bottom`/`margin`/`padding` as the properties this
685
+ * package may never transition over a host page - a reflow per frame on a
686
+ * document nobody here wrote. The only legal way to animate a layout move is
687
+ * to invert it into a transform and play that back, which needs the new box
688
+ * measured after the commit; the bytes and the frame of extra work buy an
689
+ * acknowledgement the reviewer's own hand has already given them. The drag IS
690
+ * the motion, and it ends when they let go. The transform and the new dock
691
+ * land in one commit, so there is no frame of the bar in the wrong place.
692
+ */
693
+ function useBarDrag() {
694
+ const [origin, setOrigin] = useState(null);
695
+ /** The live delta, set only once the threshold has been crossed. */
696
+ const [offset, setOffset] = useState(null);
697
+ /**
698
+ * Whether the press that is ending turned out to be a drag.
699
+ *
700
+ * A ref rather than state, and the reason is ordering rather than renders.
701
+ * Nothing about the bar LOOKS different because a drag just finished; what
702
+ * this flag does is swallow exactly one click, and it is written by
703
+ * `pointerup` and read by the `click` that follows it a moment later. State
704
+ * makes that a question about when React has re-rendered - and the answer
705
+ * differs between a browser, which flushes a discrete event synchronously,
706
+ * and a test that dispatches the pair inside one `act`. A ref is true the
707
+ * instant it is set, in both.
708
+ */
709
+ const dragged = useRef(false);
710
+ const moved = offset !== null;
711
+ // `origin` and `moved` are the only deps, and both change at most twice per
712
+ // drag - so this subscribes once when the pointer goes down and re-subscribes
713
+ // once when the threshold is crossed, rather than on every pointermove.
714
+ useEffect(() => {
715
+ if (origin === null)
716
+ return;
717
+ /** The clamped delta and whether this event is past the threshold. */
718
+ const measure = (event) => {
719
+ const rawX = event.clientX - origin.x;
720
+ const rawY = event.clientY - origin.y;
721
+ return {
722
+ x: clampDelta(rawX, origin.left, origin.width, window.innerWidth),
723
+ y: clampDelta(rawY, origin.top, origin.height, window.innerHeight),
724
+ // Latched: a drag that wanders out and comes back to the pixel it
725
+ // started on is still a drag, and must not also fire the click.
726
+ past: moved || Math.hypot(rawX, rawY) >= DRAG_THRESHOLD,
727
+ };
728
+ };
729
+ const onMove = (event) => {
730
+ // A MOVE WITH NO BUTTON HELD IS A RELEASE THIS WINDOW NEVER SAW, and
731
+ // without this line the bar follows the pointer around the host page
732
+ // for the rest of the session. It is what `setPointerCapture` would
733
+ // have bought, at the price named above: a release over another
734
+ // application, a native menu or a second monitor does not always come
735
+ // back as a `pointerup`, and the next thing the page hears is a plain
736
+ // move. Reverting rather than docking where the pointer reappeared,
737
+ // because the reviewer did not choose that spot - they let go somewhere
738
+ // this document was never told about.
739
+ if (event.buttons === 0) {
740
+ setOrigin(null);
741
+ setOffset(null);
742
+ return;
743
+ }
744
+ const { x, y, past } = measure(event);
745
+ if (past)
746
+ setOffset({ x, y });
747
+ };
748
+ const onUp = (event) => {
749
+ const { x, y, past } = measure(event);
750
+ if (past) {
751
+ // The pill's centre where the reviewer let go. Written straight to the
752
+ // shared preference record, which notifies the store, which re-renders
753
+ // the dock - there is no component copy of the dock to keep in step.
754
+ writePreferences({
755
+ dock: nearestDock(origin.left + x + origin.width / 2, origin.top + y + origin.height / 2),
756
+ });
757
+ dragged.current = true;
758
+ }
759
+ setOrigin(null);
760
+ setOffset(null);
761
+ };
762
+ // A cancelled pointer - the OS took the gesture, the touch became a
763
+ // scroll - is not a choice, so it reverts rather than docking somewhere
764
+ // the reviewer never released.
765
+ const onCancel = () => {
766
+ setOrigin(null);
767
+ setOffset(null);
768
+ };
769
+ window.addEventListener("pointermove", onMove);
770
+ window.addEventListener("pointerup", onUp);
771
+ window.addEventListener("pointercancel", onCancel);
772
+ return () => {
773
+ window.removeEventListener("pointermove", onMove);
774
+ window.removeEventListener("pointerup", onUp);
775
+ window.removeEventListener("pointercancel", onCancel);
776
+ };
777
+ }, [origin, moved]);
778
+ return {
779
+ style: {
780
+ // Only while it is actually moving, so the property is absent at rest.
781
+ ...(offset === null
782
+ ? {}
783
+ : {
784
+ transform: `translate3d(${offset.x}px, ${offset.y}px, 0)`,
785
+ cursor: "grabbing",
786
+ }),
787
+ // From the pointer going DOWN rather than from the threshold, because by
788
+ // the time 6px have been travelled the browser has already started
789
+ // selecting the deployment chip's text under the cursor.
790
+ ...(origin === null ? {} : { userSelect: "none" }),
791
+ },
792
+ handlers: {
793
+ onPointerDown: (event) => {
794
+ // Secondary buttons open host context menus; they are not drags.
795
+ if (event.button > 0)
796
+ return;
797
+ // FIRST, AND BEFORE THE CONTROL CHECK BELOW RETURNS. A press is the
798
+ // start of a new gesture, so nothing a previous one left behind may
799
+ // survive into it - and the swallow below is armed by a `pointerup`
800
+ // and disarmed by a `click` that is not guaranteed to arrive. A drag
801
+ // whose pointer was clamped inside the viewport while the pointer ran
802
+ // past it ends with the two events on different elements, and the
803
+ // browser then fires the click on their common ancestor, which is the
804
+ // host page's own tree rather than this pill. Clearing it only where
805
+ // a drag can be armed left that stale `true` to eat the reviewer's
806
+ // next click on a bar button - the account popover simply did not
807
+ // open, once, after every drag into a corner.
808
+ dragged.current = false;
809
+ const target = event.target;
810
+ // The bar's BODY is the handle - the mark, the deployment chip, the
811
+ // padding between controls - and a press that lands on a control is
812
+ // that control's. Without this every click on a bar button would arm a
813
+ // drag and flash its 3% press squash on the way.
814
+ //
815
+ // The collapsed dot is the exception, and the reason it needs one is
816
+ // that it has no body: it is a single button filling the whole circle,
817
+ // so "drag by the body" would leave it un-draggable. Its whole surface
818
+ // is the handle, and the click still fires because a press under the
819
+ // threshold is not a drag - which is what `onClickCapture` below
820
+ // decides.
821
+ const onControl = target instanceof Element &&
822
+ target.closest("button, a, input, textarea, select") !== null;
823
+ const wholeSurface = event.currentTarget.dataset.toolbarState === "collapsed";
824
+ if (onControl && !wholeSurface)
825
+ return;
826
+ const box = event.currentTarget.getBoundingClientRect();
827
+ setOrigin({
828
+ x: event.clientX,
829
+ y: event.clientY,
830
+ left: box.left,
831
+ top: box.top,
832
+ width: box.width,
833
+ height: box.height,
834
+ });
835
+ },
836
+ // A drag that ended over the collapsed dot would otherwise also expand
837
+ // the bar, since the pointer went down and came up on the same button.
838
+ // Capture phase, so the button's own `onClick` never runs.
839
+ onClickCapture: (event) => {
840
+ if (!dragged.current)
841
+ return;
842
+ event.stopPropagation();
843
+ event.preventDefault();
844
+ dragged.current = false;
845
+ },
846
+ },
847
+ };
848
+ }
849
+ /* ── Bar chrome ───────────────────────────────────────────────────────── */
850
+ /**
851
+ * The bar's contents, and the only thing that moves when a reviewer collapses
852
+ * or expands it.
853
+ *
854
+ * A wrapper INSIDE the pill rather than the pill itself, and that is the whole
855
+ * design of this component. Two rules meet here and both of them have been
856
+ * broken in this package before:
857
+ *
858
+ * DESIGN.md's Motion section allows `transform` and `opacity` and forbids
859
+ * `width`. The pill's width is exactly what a collapse changes, so the box
860
+ * snaps - instantly, in the frame the cross-fade starts, which is that
861
+ * section's own rule for a layout property that has to change alongside an
862
+ * animation. Animating it instead would reflow the host document once per
863
+ * frame, and the host gets blamed for that jank.
864
+ *
865
+ * And the opacity and the transform go HERE, never on the pill, the dock or
866
+ * the toolbar root. Both create a stacking context for every frame they are
867
+ * mid-flight, and issue #23 was that exact defect one surface over: a wrapper
868
+ * dimmed to 45% painted the thread it contained straight through to the host
869
+ * page and swallowed the `layers` rung the thread had set on itself. This
870
+ * element is safe for it because of what is INSIDE it - bar buttons, the mark
871
+ * and the deployment chip, none of them `position: fixed` and none of them
872
+ * declaring a z-index. Everything that does stack against `layers` is a
873
+ * sibling of the pill or lives outside the dock: the popovers, the inbox sheet
874
+ * and every pin marker on the page. Adding anything to the bar that has to
875
+ * escape this box is the change that breaks the rule.
876
+ *
877
+ * `settle` rather than `fast`, and `motion.duration.settle` exists in
878
+ * tokens.ts for this surface. It is the one transition in the package whose
879
+ * box changes shape underneath it: 120ms of cross-fade lands before the eye
880
+ * has followed the pill's snap from ~400px to 46px, which reads as the
881
+ * contents having been swapped rather than as one bar changing form. 180ms is
882
+ * DESIGN.md's ceiling and this is what the ceiling is for.
883
+ */
884
+ function BarContents({ form, children, }) {
885
+ const entered = useSwapEntered(form);
886
+ const reduced = usePrefersReducedMotion();
887
+ return (_jsx("div", { style: {
888
+ display: "flex",
889
+ alignItems: "center",
890
+ gap: bar.gap,
891
+ // The flex item that gives the pill its width, so it is what has to be
892
+ // allowed to shrink: a flex item's default `min-width: auto` is the
893
+ // width of its content, and without this the deployment chip's own
894
+ // `flex: 0 1 auto` had nothing to shrink inside and the pill overhung a
895
+ // narrow viewport again.
896
+ minWidth: 0,
897
+ ...surfaceMotion({
898
+ visible: entered,
899
+ reduced,
900
+ duration: motion.duration.settle,
901
+ }),
902
+ }, children: children }, String(form)));
903
+ }
904
+ function BarButton({ theme, testId, label, hint, active, expanded, badge, onClick, children, }) {
905
+ const [hovered, setHovered] = useState(false);
906
+ // Focus is tracked in state because an inline style cannot express
907
+ // `:focus-visible`, and there is no stylesheet here to put it in. Every
908
+ // control in this package needs it: a host shipping `*:focus { outline:
909
+ // none }` - which plenty do - erased keyboard focus from the entire review
910
+ // tool, and the package declared no focus style of its own to survive it.
911
+ const [focused, setFocused] = useState(false);
912
+ const [pressed, setPressed] = useState(false);
913
+ const reduced = usePrefersReducedMotion();
914
+ return (_jsxs("button", { type: "button", "data-testid": testId, "aria-label": label, title: hint === undefined ? label : `${label} (${hint})`, ...(expanded === undefined ? {} : { "aria-expanded": expanded }), ...(active === undefined ? {} : { "aria-pressed": active }), onClick: onClick, onPointerEnter: () => setHovered(true), onPointerLeave: () => {
915
+ setHovered(false);
916
+ // Dragging off cancels the press, the way a native control does.
917
+ setPressed(false);
918
+ }, onPointerDown: () => setPressed(true), onPointerUp: () => setPressed(false), onPointerCancel: () => setPressed(false), onFocus: () => setFocused(true), onBlur: () => setFocused(false), style: {
919
+ position: "relative",
920
+ display: "inline-flex",
921
+ alignItems: "center",
922
+ justifyContent: "center",
923
+ width: bar.button,
924
+ height: bar.button,
925
+ flex: "0 0 auto",
926
+ padding: 0,
927
+ border: "none",
928
+ borderRadius: radii.pill,
929
+ // Ink, not the accent. "Which mode am I in" is a selected state, and
930
+ // DESIGN.md gives selection to the ink polarity flip; the accent was
931
+ // carrying chrome, which is the one job it is not for. The bar's own
932
+ // ink flips with the palette, so this is a black pill with a white
933
+ // glyph on a light host and the reverse on a dark one.
934
+ background: active
935
+ ? theme.colors.ink
936
+ : hovered
937
+ ? theme.colors.barHover
938
+ : "transparent",
939
+ color: active ? theme.colors.onInk : theme.colors.barInk,
940
+ cursor: "pointer",
941
+ // A 3% squash under the pointer. Safe on this button and on nothing
942
+ // above it: it is a leaf with no `position: fixed` descendant, whereas
943
+ // a transform on the toolbar ROOT would make it the containing block
944
+ // for every pin marker on the page - `PinLayer` is a child of it - and
945
+ // every pin would then be offset by the bar's own position. The dock
946
+ // is a sibling of the pin layer and holds no marker, so its own rule
947
+ // is the narrower one: it is the ancestor of the pill and the
948
+ // popovers, and their motion belongs on them. See the note beside
949
+ // `createPortal` below, and the two tests that assert both stay
950
+ // transform-free.
951
+ transform: motionTransform(pressed ? `scale(${motion.pressScale})` : "none", reduced),
952
+ // The one inline duration this package shipped - `background 120ms
953
+ // ease`, written before there were tokens - now spelled from them, and
954
+ // at `instant` rather than `fast`: DESIGN.md's Motion section gives
955
+ // 80ms to feedback on a control the pointer is already on, and this is
956
+ // the definition of that. `background-color` rather than `background`,
957
+ // because that is the animatable longhand and the shorthand quietly
958
+ // included properties nobody chose to animate.
959
+ transition: transition(["background-color", "color", "transform"], motion.duration.instant, motion.easing.inOut, reduced),
960
+ // `revert`, not `none`: if the host has no rule at all the UA ring is
961
+ // a perfectly good second ring, and this only replaces it where a host
962
+ // suppressed it.
963
+ outline: "revert",
964
+ boxShadow: focused ? theme.shadows.focusRing : "none",
965
+ }, children: [children, badge !== undefined && badge > 0 ? (_jsx(CountBubble, { theme: theme, testId: testId, active: active ?? false, count: badge, children: badge > 99 ? "99+" : badge })) : null] }));
966
+ }
967
+ /**
968
+ * The panel a bar button opens, above the bar and anchored to it.
969
+ *
970
+ * Enter only, and that is a decision rather than an omission. This element
971
+ * carries `role="dialog"`, so deferring its unmount would leave a dialog in
972
+ * the accessibility tree after the reviewer closed it, and several e2e specs
973
+ * assert `feedback-panel` reaches zero elements. The acknowledgement on the
974
+ * way out is the button's own fill cross-fading back off, which is on the
975
+ * control the reviewer just clicked and is where they are looking.
976
+ */
977
+ function BarPopover({ theme, tool, edge, children, }) {
978
+ const entered = useEntered();
979
+ const reduced = usePrefersReducedMotion();
980
+ return (_jsx("div", { role: "dialog", "aria-label": "BitVea Feedback", "data-testid": "feedback-panel", "data-toolbar-surface": tool, style: {
981
+ width: tool === "note" ? "320px" : "280px",
982
+ maxWidth: "100%",
983
+ // Every fixed-width surface in this package states this rather than
984
+ // inheriting it. The toolbar mounts in a host page that may ship no
985
+ // CSS reset at all, and under the default `content-box` this popover
986
+ // renders 320px + 24px of padding + 2px of border, 26px wider than it
987
+ // was designed. It is invisible on the preview route only because
988
+ // apps/web loads Tailwind's preflight.
989
+ boxSizing: "border-box",
990
+ pointerEvents: "auto",
991
+ // The gap goes on the edge FACING THE BAR, which flips with the dock -
992
+ // `marginBottom` for a bottom or mid-edge dock, `marginTop` for a top
993
+ // one, where `column-reverse` has put this popover underneath.
994
+ //
995
+ // And the horizontal `auto` that used to centre it is gone, which was
996
+ // a latent bug rather than a tidy-up: an `auto` margin on a flex item
997
+ // absorbs the free space on its axis and OVERRIDES `align-items`, so a
998
+ // 320px composer would have stayed pinned to the viewport's centre
999
+ // while the bar it belongs to sat in a corner. The dock's
1000
+ // `alignItems` is what places it now.
1001
+ [edge.popoverGap]: spacing.xs,
1002
+ padding: spacing.sm,
1003
+ background: theme.colors.canvas,
1004
+ // The border STAYS and the shadow's ring is inset. Every popover here
1005
+ // used to draw two edges - this 1px border plus an outset `0 0 0 1px`
1006
+ // baked into the shadow - which on a light host read as a double
1007
+ // outline. The border is the theme-aware one; the ring is DESIGN.md's
1008
+ // Level 1 inset hairline, drawn inside.
1009
+ border: `1px solid ${theme.colors.hairline}`,
1010
+ borderRadius: radii.lg,
1011
+ boxShadow: theme.shadows.popover,
1012
+ color: theme.colors.body,
1013
+ ...typeScale.bodySm,
1014
+ // Grows out of the bar, because that is what it is anchored to - see
1015
+ // the comment at the call site. The origin is the edge it is ATTACHED
1016
+ // to, which is DESIGN.md's rule for a surface anchored to a control
1017
+ // rather than dropped at a clicked pixel - and since issue #21 that
1018
+ // edge is whichever one the bar is docked to, so it comes from
1019
+ // `dockLayout` instead of being the hard-coded `bottom center` it was.
1020
+ //
1021
+ // The transform is on THIS element and never on the dock above it or
1022
+ // the root above that. The ROOT is the ancestor every `position:
1023
+ // fixed` pin marker resolves against - `PinLayer` is its child - so a
1024
+ // transform there would become their containing block and offset every
1025
+ // pin on the host page by the bar's own position. The dock holds no
1026
+ // marker, being the pin layer's sibling; what it holds is this popover
1027
+ // and the pill, so the motion those two need belongs on them and not
1028
+ // on the box that positions both. Two tests assert the dock and the
1029
+ // root stay transform-free.
1030
+ ...surfaceMotion({
1031
+ visible: entered,
1032
+ reduced,
1033
+ origin: edge.popoverOrigin,
1034
+ }),
1035
+ }, children: children }));
1036
+ }
1037
+ /**
1038
+ * The unread count on a bar button.
1039
+ *
1040
+ * Split out of `BarButton` because acknowledging a number that moved needs a
1041
+ * hook, and `useJustChanged` has to be keyed on the count rather than on the
1042
+ * button. Everything about this bubble is identical before and after an
1043
+ * increment - same ink, same position, same 18px box - so a count going 3 → 4
1044
+ * with no motion is a glyph swap nobody notices, which is the whole reason the
1045
+ * issue names it.
1046
+ */
1047
+ function CountBubble({ theme, testId, active, count, children, }) {
1048
+ const reduced = usePrefersReducedMotion();
1049
+ // The COUNT, not `children`. `useJustChanged` compares with `!==`, and a
1050
+ // `ReactNode` is only a primitive here by luck of the current markup - the
1051
+ // day somebody wraps the number in a `<span>` or a fragment, every render
1052
+ // produces a new object, `changed` is true forever and the badge sits
1053
+ // permanently squashed. Keyed on the number, that cannot happen.
1054
+ const changed = useJustChanged(count);
1055
+ return (_jsx("span", { "data-testid": `${testId}-count`, style: {
1056
+ position: "absolute",
1057
+ top: 0,
1058
+ right: 0,
1059
+ // 18px, because 12px mono is the smallest type in this system and
1060
+ // the bubble has to hold it. It was a 16px circle carrying 10px
1061
+ // sans - a size on no step in DESIGN.md's table and the smallest
1062
+ // type anywhere in the product.
1063
+ minWidth: sizes.badge,
1064
+ height: sizes.badge,
1065
+ padding: `0 ${spacing.xxs}`,
1066
+ boxSizing: "border-box",
1067
+ display: "inline-flex",
1068
+ alignItems: "center",
1069
+ justifyContent: "center",
1070
+ borderRadius: radii.pill,
1071
+ // Ink rather than the accent it used to wear - and inverted while
1072
+ // the button is active, because the active pill is itself an ink
1073
+ // fill and an ink bubble on it would vanish.
1074
+ background: active ? theme.colors.onInk : theme.colors.ink,
1075
+ color: active ? theme.colors.ink : theme.colors.onInk,
1076
+ ...typeScale.captionMono,
1077
+ border: `1px solid ${active ? theme.colors.ink : theme.colors.barSurface}`,
1078
+ // The bubble's own colours invert when the button goes active, and
1079
+ // both of them are palette extremes - an ink bubble becoming an onInk
1080
+ // one is black to white. Transitioned so opening the inbox does not
1081
+ // strobe the badge beside the glyph that is already cross-fading.
1082
+ //
1083
+ // The dip is `enterScale` used in reverse: the bubble is already on
1084
+ // screen, so it acknowledges a new number by settling back into place
1085
+ // rather than by arriving. Not an overshoot - DESIGN.md's Motion
1086
+ // section rules those out, and one scale step is all this system has.
1087
+ transform: motionTransform(changed ? `scale(${motion.enterScale})` : "none", reduced),
1088
+ transition: transition(["background-color", "color", "border-color", "transform"], motion.duration.instant, motion.easing.inOut, reduced),
1089
+ }, children: children }));
1090
+ }
1091
+ /**
1092
+ * The unread signal on the collapsed dot: `sizes.dot`, on the corner facing
1093
+ * away from the docked edge.
1094
+ *
1095
+ * The same object as the pin marker's own unread dot - 8px of ink inside a 2px
1096
+ * ring of whatever surface it sits on - because they mean the same thing and
1097
+ * `sizes.dot` is the token they now share. It replaces an 18px `CountBubble`
1098
+ * carrying the number, which on a 46px circle was a badge nearly half the
1099
+ * control's diameter; the number itself did not go anywhere, it moved into the
1100
+ * button's accessible name, which already carried it.
1101
+ */
1102
+ function UnreadDot({ theme, at, }) {
1103
+ return (_jsx("span", { "data-testid": "toolbar-expand-count", "aria-hidden": "true", style: {
1104
+ position: "absolute",
1105
+ ...at,
1106
+ width: sizes.dot,
1107
+ height: sizes.dot,
1108
+ borderRadius: radii.pill,
1109
+ // Ink, like every other indicator here since the accent was demoted to
1110
+ // identity only - and the ring is the bar's own surface, so the dot
1111
+ // separates from the mark behind it whichever palette is in play.
1112
+ background: theme.colors.ink,
1113
+ border: `2px solid ${theme.colors.barSurface}`,
1114
+ boxSizing: "content-box",
1115
+ } }));
1116
+ }
1117
+ /**
1118
+ * The BitVea mark, inlined from `BitveaLogo.tsx` in the website project. Its
1119
+ * paths are `fill="currentColor"`, so it tints itself from the bar's ink and
1120
+ * needs neither an image dependency nor a network fetch - both of which would
1121
+ * be visible in a host app's waterfall, and the second of which would breach
1122
+ * the "a disabled build makes no request" rule the moment it was cached wrong.
1123
+ */
1124
+ export function BitveaMark({ theme, size = 18, inline = false, }) {
1125
+ return (_jsx("span", { "aria-hidden": "true", style: {
1126
+ display: "inline-flex",
1127
+ alignItems: "center",
1128
+ justifyContent: "center",
1129
+ ...(inline ? {} : { width: bar.button, height: bar.button }),
1130
+ flex: "0 0 auto",
1131
+ color: theme?.colors.barInk,
1132
+ }, children: _jsxs("svg", { viewBox: "0 0 435 439", fill: "none", xmlns: "http://www.w3.org/2000/svg", role: "img", "aria-label": "BitVea",
1133
+ // Size in the style object, not in `width`/`height` attributes. Author
1134
+ // CSS beats a presentation attribute, so a host rule as ordinary as
1135
+ // `svg { width: 100% }` resized every icon in the toolbar - invisible
1136
+ // in the preview harness, visible in the wild.
1137
+ style: { display: "block", width: `${size}px`, height: `${size}px` }, children: [_jsx("path", { d: "M347.997 88.1161H87.207L159.233 0.373047H333.067L347.997 88.1161Z", fill: "currentColor" }), _jsx("path", { d: "M434.913 160.795V336.203L347.996 351.268V88.1167L434.913 160.795Z", fill: "currentColor" }), _jsx("path", { d: "M347.997 351.267L275.971 438.971H102.098L87.207 351.267H347.997Z", fill: "currentColor" }), _jsx("path", { d: "M87.2056 88.1167V351.268L0.25 278.59V103.143L87.2056 88.1167Z", fill: "currentColor" })] }) }));
1138
+ }
1139
+ /**
1140
+ * `process.env` where a bundler has provided one, and an empty bag otherwise.
1141
+ * Guarded because this component also runs in a browser where `process` may
1142
+ * not exist at all, and a ReferenceError here would take the host app down.
1143
+ */
1144
+ function readProcessEnv() {
1145
+ if (typeof process === "undefined" || !process.env)
1146
+ return {};
1147
+ return process.env;
1148
+ }
1149
+ //# sourceMappingURL=FeedbackToolbar.js.map