@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,622 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The two popover bodies: the note composer and the account surface.
4
+ //
5
+ // Both answer the same five questions the API can be asked about this visitor
6
+ // and this project, and they answer them separately on purpose. "Sign in",
7
+ // "this deployment has not registered yet" and "you are signed in but not
8
+ // invited here" are three different problems with three different fixes, and a
9
+ // reviewer who is shown a generic failure for all three cannot act on any of
10
+ // them.
11
+ //
12
+ // The composer stayed named `FeedbackPanel` through the restyle: it is the
13
+ // package's public export for "the thing a bar button opens", and the 360px
14
+ // frame that used to wrap it was the part that had to go, not this.
15
+ import { useMemo, useState, useSyncExternalStore } from "react";
16
+ import { FEEDBACK_BODY_MAX } from "./vendor/types/index.js";
17
+ import { captureMetadata, captureScreenshot } from "./capture.js";
18
+ import { createToolbarClient, ToolbarApiError } from "./client.js";
19
+ import { usePrefersReducedMotion } from "./motion.js";
20
+ import { PREFERENCES_STORE, writePreferences } from "./preferences.js";
21
+ import { dockLayout, lightTheme, motion, motionTransform, radii, resetBox, sizes, spacing, TOOLBAR_DOCKS, transition, typeScale, } from "./tokens.js";
22
+ /** The note composer: free text plus a screenshot of the current viewport. */
23
+ export function FeedbackPanel({ config, phase, theme = lightTheme, onReloadSession, onDone, }) {
24
+ const client = useMemo(() => createToolbarClient(config.apiUrl), [config.apiUrl]);
25
+ if (phase.kind === "loading") {
26
+ return _jsx(Muted, { theme: theme, children: "Loading\u2026" });
27
+ }
28
+ if (phase.kind === "error") {
29
+ return (_jsxs(Stack, { children: [_jsx(Muted, { theme: theme, role: "alert", children: phase.message }), _jsx(Button, { theme: theme, onClick: onReloadSession, children: "Try again" })] }));
30
+ }
31
+ switch (phase.session.state) {
32
+ case "signed_out":
33
+ return (_jsxs(Stack, { children: [_jsx(Muted, { theme: theme, children: "Sign in to leave feedback, from the account button in the bar." }), _jsx(SignIn, { theme: theme, client: client, onSignedIn: onReloadSession })] }));
34
+ case "no_project":
35
+ return (_jsx(Muted, { theme: theme, children: "This deployment has not registered with BitVea Feedback yet. It registers on its first build, so try again after the next deploy." }));
36
+ case "no_access":
37
+ return (_jsxs(Muted, { theme: theme, children: ["You do not have access to this project. Ask a BitVea admin to invite", " ", _jsx("strong", {
38
+ // 500, stated. A bare `<strong>` inherits the UA's `bold` - 700
39
+ // against a 400 parent - which is the one weight DESIGN.md says
40
+ // the face never appears in, and a host `strong { font-weight:
41
+ // 900 }` beats the UA with nothing here to override it.
42
+ style: { color: theme.colors.ink, fontWeight: 500 }, children: phase.session.user?.email }), " ", "to it."] }));
43
+ case "ready":
44
+ return (_jsx(SubmitFeedback, { theme: theme, client: client, config: config, organizationId: phase.session.project.id, onDone: onDone }));
45
+ }
46
+ }
47
+ /**
48
+ * Identity, sign out, and the theme override. The magic-link flow lives here
49
+ * now rather than in the composer: signing in is an account action, and a
50
+ * reviewer who is signed out sees the same surface whichever button they
51
+ * reached for.
52
+ */
53
+ export function AccountSurface({ config, phase, theme = lightTheme, mode, onModeChange, onReloadSession, }) {
54
+ const client = useMemo(() => createToolbarClient(config.apiUrl), [config.apiUrl]);
55
+ const [signingOut, setSigningOut] = useState(false);
56
+ async function signOut() {
57
+ setSigningOut(true);
58
+ try {
59
+ await client.signOut();
60
+ }
61
+ catch {
62
+ // Either it worked or the cookie is unreachable from here; re-resolving
63
+ // shows the truth either way, and an error toast for a sign-out that
64
+ // probably succeeded is worse than the reload.
65
+ }
66
+ finally {
67
+ setSigningOut(false);
68
+ onReloadSession();
69
+ }
70
+ }
71
+ const user = phase.kind === "session" ? phase.session.user : undefined;
72
+ return (_jsxs(Stack, { children: [phase.kind === "loading" ? _jsx(Muted, { theme: theme, children: "Loading\u2026" }) : null, phase.kind === "error" ? (_jsxs(Stack, { gap: spacing.xxs, children: [_jsx(Muted, { theme: theme, role: "alert", children: phase.message }), _jsx(Button, { theme: theme, onClick: onReloadSession, children: "Try again" })] })) : null, user ? (_jsxs("div", { style: { display: "flex", alignItems: "center", gap: spacing.xs }, children: [_jsx(Avatar, { theme: theme, name: user.name, email: user.email, size: 28 }), _jsxs("div", { style: { minWidth: 0 }, children: [_jsx("div", { style: {
73
+ color: theme.colors.ink,
74
+ ...typeScale.bodySmStrong,
75
+ overflow: "hidden",
76
+ textOverflow: "ellipsis",
77
+ whiteSpace: "nowrap",
78
+ }, children: displayNameFor(user.name, user.email) }), (user.name ?? "").trim().length === 0 ? null : (_jsx("div", { style: {
79
+ // The address is an identifier, so it takes the mono voice
80
+ // the rest of the product gives identifiers - and `body`
81
+ // rather than `mute`, 3.4:1 on this ground at 12px.
82
+ color: theme.colors.body,
83
+ ...typeScale.captionMono,
84
+ overflow: "hidden",
85
+ textOverflow: "ellipsis",
86
+ whiteSpace: "nowrap",
87
+ }, children: user.email }))] })] })) : null, phase.kind === "session" && phase.session.state === "signed_out" ? (_jsx(SignIn, { theme: theme, client: client, onSignedIn: onReloadSession })) : null, phase.kind === "session" && phase.session.state === "no_access" ? (_jsx(Muted, { theme: theme, children: "You are signed in but not invited to this project." })) : null, phase.kind === "session" && phase.session.project ? (_jsxs(Muted, { theme: theme, children: ["Reporting against", " ", _jsx("strong", { style: { color: theme.colors.ink, fontWeight: 500 }, children: phase.session.project.name }), "."] })) : null, _jsx(ThemeChoice, { theme: theme, mode: mode, onModeChange: onModeChange }), _jsx(DockChoice, { theme: theme }), user ? (_jsx(Button, { theme: theme, tone: "quiet", disabled: signingOut, onClick: () => void signOut(), children: signingOut ? "Signing out…" : "Sign out" })) : null] }));
88
+ }
89
+ const THEME_CHOICES = [
90
+ { mode: "system", label: "Auto" },
91
+ { mode: "light", label: "Light" },
92
+ { mode: "dark", label: "Dark" },
93
+ ];
94
+ /**
95
+ * Three states rather than a two-way switch, because "follow this app" is a
96
+ * real answer and the only one that stays correct when the host flips its own
97
+ * scheme mid-session.
98
+ */
99
+ function ThemeChoice({ theme, mode, onModeChange, }) {
100
+ return (_jsxs("div", { role: "group", "aria-label": "Toolbar theme", children: [_jsx("div", { style: {
101
+ marginBottom: spacing.xxs,
102
+ color: theme.colors.body,
103
+ ...typeScale.caption,
104
+ }, children: "Theme" }), _jsx("div", { style: {
105
+ display: "flex",
106
+ // 4px, the base unit, replacing an off-grid 2px. It also makes the
107
+ // track and thumb radii concentric: an 8px outer corner with a 4px
108
+ // gutter leaves exactly a 4px inner corner.
109
+ gap: spacing.xxs,
110
+ padding: spacing.xxs,
111
+ background: theme.colors.canvasSoft2,
112
+ border: `1px solid ${theme.colors.hairline}`,
113
+ borderRadius: radii.md,
114
+ }, children: THEME_CHOICES.map((choice) => {
115
+ const selected = choice.mode === mode;
116
+ return (_jsx("button", { type: "button", "data-testid": `toolbar-theme-${choice.mode}`, "aria-pressed": selected, onClick: () => onModeChange(choice.mode), style: {
117
+ flex: 1,
118
+ padding: `${spacing.xxs} 0`,
119
+ border: "none",
120
+ // 4px, concentric inside the track's 8px. See the gutter note
121
+ // above.
122
+ borderRadius: radii.xs,
123
+ background: selected ? theme.colors.canvas : "transparent",
124
+ // DESIGN.md's Level 1 inset hairline, not the popover shadow:
125
+ // Level 5's 24px drop is for modal surfaces, and under a
126
+ // 24px-tall segment it read as a card floating inside the card
127
+ // it sits in. One token now, shared with the inbox's identical
128
+ // control.
129
+ boxShadow: selected ? theme.shadows.insetHairline : "none",
130
+ color: selected ? theme.colors.ink : theme.colors.body,
131
+ ...typeScale.caption,
132
+ fontWeight: selected ? 500 : 400,
133
+ cursor: "pointer",
134
+ outline: "revert",
135
+ }, children: choice.label }, choice.mode));
136
+ }) })] }));
137
+ }
138
+ const DOCK_ROW = { top: 1, middle: 2, bottom: 3 };
139
+ const DOCK_COLUMN = {
140
+ left: 1,
141
+ center: 2,
142
+ right: 3,
143
+ };
144
+ const DOCK_BAND = {
145
+ top: "Top",
146
+ middle: "Middle",
147
+ bottom: "Bottom",
148
+ };
149
+ const DOCK_SIDE = {
150
+ left: "left",
151
+ center: "centre",
152
+ right: "right",
153
+ };
154
+ /**
155
+ * Where the bar sits, as a 3x3 map of the viewport with the middle cell
156
+ * missing - because the middle of the viewport is not an edge.
157
+ *
158
+ * This is issue #21's "the position is also settable from the toolbar's own
159
+ * menu, so it is not mouse-only", and it is a map rather than a list of eight
160
+ * names because the thing being chosen is a place: a cell whose dot sits where
161
+ * the bar would sit needs no label read to be understood. The accessible names
162
+ * are there for the reviewer who cannot see it.
163
+ *
164
+ * `ThemeChoice`'s chrome, deliberately: same `canvasSoft2` track, same 4px
165
+ * gutter inside an 8px radius leaving a concentric 4px inner corner, same
166
+ * Level 1 inset hairline on the selected cell. Two controls one above the
167
+ * other in the same popover that looked like two different systems would be
168
+ * worse than either.
169
+ *
170
+ * WIRED STRAIGHT TO THE STORE rather than through props, and that is an API
171
+ * decision. `AccountSurfaceProps` is exported from index.ts on the one package
172
+ * in this repo that publishes to npm, so a new required prop there is a
173
+ * breaking change to a shipped type for every host that renders this surface
174
+ * itself. The preference is module-scope state with a subscription already -
175
+ * which is also why there is no component copy of the dock to keep in step
176
+ * with what the drag writes.
177
+ *
178
+ * The route from a COLLAPSED bar is dot -> expand -> account -> here. Two
179
+ * keystrokes further than from the expanded bar, because the collapsed form is
180
+ * a single control by design; it is a longer path, not a dead end.
181
+ */
182
+ function DockChoice({ theme }) {
183
+ const { dock } = useSyncExternalStore(PREFERENCES_STORE.subscribe, PREFERENCES_STORE.getSnapshot,
184
+ // The same server snapshot the bar itself reads through, so this control
185
+ // and the dock it sets cannot render two different answers during
186
+ // hydration inside somebody else's application.
187
+ PREFERENCES_STORE.getServerSnapshot);
188
+ return (_jsxs("div", { role: "group", "aria-label": "Toolbar position", children: [_jsx("div", { style: {
189
+ marginBottom: spacing.xxs,
190
+ color: theme.colors.body,
191
+ ...typeScale.caption,
192
+ }, children: "Position" }), _jsx("div", { style: {
193
+ display: "grid",
194
+ // Square cells, so the block reads as a viewport rather than as
195
+ // three rows of segments. `fit-content` because a 3x3 map stretched
196
+ // to the popover's full 280px would be three 80x28 slabs.
197
+ gridTemplateColumns: `repeat(3, ${sizes.icon})`,
198
+ gap: spacing.xxs,
199
+ width: "fit-content",
200
+ padding: spacing.xxs,
201
+ background: theme.colors.canvasSoft2,
202
+ border: `1px solid ${theme.colors.hairline}`,
203
+ borderRadius: radii.md,
204
+ }, children: TOOLBAR_DOCKS.map((value) => {
205
+ const { vertical, horizontal, alignItems } = dockLayout(value);
206
+ const selected = value === dock;
207
+ return (_jsx("button", { type: "button", "data-testid": `toolbar-dock-${value}`, "aria-pressed": selected, "aria-label": `${DOCK_BAND[vertical]} ${DOCK_SIDE[horizontal]}`, title: `${DOCK_BAND[vertical]} ${DOCK_SIDE[horizontal]}`, onClick: () => writePreferences({ dock: value }), style: {
208
+ // Placed by its own axes rather than by source order, which is
209
+ // what leaves the middle cell empty with no spacer element to
210
+ // render and no list to keep in the right order.
211
+ gridColumn: DOCK_COLUMN[horizontal],
212
+ gridRow: DOCK_ROW[vertical],
213
+ display: "flex",
214
+ // The dot sits where the bar would sit. `alignItems` off
215
+ // `dockLayout` is the same value the real dock uses for its
216
+ // horizontal placement, so the map cannot drift from the thing
217
+ // it maps; the vertical half is the row above.
218
+ justifyContent: alignItems,
219
+ alignItems: vertical === "top"
220
+ ? "flex-start"
221
+ : vertical === "bottom"
222
+ ? "flex-end"
223
+ : "center",
224
+ height: sizes.icon,
225
+ padding: spacing.xxs,
226
+ boxSizing: "border-box",
227
+ border: "none",
228
+ // 4px, concentric inside the track's 8px. See ThemeChoice.
229
+ borderRadius: radii.xs,
230
+ background: selected ? theme.colors.canvas : "transparent",
231
+ boxShadow: selected ? theme.shadows.insetHairline : "none",
232
+ cursor: "pointer",
233
+ outline: "revert",
234
+ }, children: _jsx("span", { "aria-hidden": "true", style: {
235
+ width: spacing.xxs,
236
+ height: spacing.xxs,
237
+ borderRadius: radii.pill,
238
+ background: selected
239
+ ? theme.colors.ink
240
+ : theme.colors.hairlineStrong,
241
+ } }) }, value));
242
+ }) })] }));
243
+ }
244
+ /* ── Sign in ──────────────────────────────────────────────────────────── */
245
+ function SignIn({ theme, client, onSignedIn, }) {
246
+ const [email, setEmail] = useState("");
247
+ const [sending, setSending] = useState(false);
248
+ const [sent, setSent] = useState(false);
249
+ const [error, setError] = useState(null);
250
+ async function onSubmit(e) {
251
+ e.preventDefault();
252
+ setSending(true);
253
+ setError(null);
254
+ try {
255
+ await client.requestMagicLink(email);
256
+ setSent(true);
257
+ }
258
+ catch {
259
+ setError("Could not send the link. Check your connection and retry.");
260
+ }
261
+ finally {
262
+ setSending(false);
263
+ }
264
+ }
265
+ if (sent) {
266
+ return (_jsxs(Stack, { children: [_jsxs(Muted, { theme: theme, role: "status", children: ["If ", email, " has access, a sign-in link is on its way. Open it, then come back to this tab."] }), _jsx(Button, { theme: theme, onClick: onSignedIn, children: "I have signed in" })] }));
267
+ }
268
+ return (_jsx("form", { onSubmit: onSubmit, style: { ...resetBox }, children: _jsxs(Stack, { children: [_jsx(Muted, { theme: theme, children: "No password, just a link." }), _jsx("input", { type: "email", required: true, value: email, onChange: (e) => setEmail(e.target.value), placeholder: "you@bitvea.com", "aria-label": "Email", "data-testid": "toolbar-email", style: inputStyle(theme) }), error ? _jsx(ErrorText, { theme: theme, children: error }) : null, _jsx(Button, { theme: theme, type: "submit", disabled: sending || email.length === 0, children: sending ? "Sending…" : "Email me a link" })] }) }));
269
+ }
270
+ /* ── Submit ───────────────────────────────────────────────────────────── */
271
+ function SubmitFeedback({ theme, client, config, organizationId, onDone, }) {
272
+ const [body, setBody] = useState("");
273
+ const [state, setState] = useState("idle");
274
+ const [error, setError] = useState(null);
275
+ const trimmed = body.trim();
276
+ const tooLong = trimmed.length > FEEDBACK_BODY_MAX;
277
+ const canSend = trimmed.length > 0 && !tooLong && state !== "sending";
278
+ async function send() {
279
+ setState("sending");
280
+ setError(null);
281
+ // `body` is left alone on the failure path below: retyping a paragraph
282
+ // because the network blinked is the fastest way to lose a reviewer.
283
+ try {
284
+ const screenshot = await captureScreenshot();
285
+ await client.submit({
286
+ organizationId,
287
+ kind: "GENERAL",
288
+ body: trimmed,
289
+ metadata: captureMetadata(config),
290
+ ...(screenshot ? { screenshot } : {}),
291
+ });
292
+ setState("sent");
293
+ setBody("");
294
+ }
295
+ catch (err) {
296
+ setState("idle");
297
+ setError(messageFor(err));
298
+ }
299
+ }
300
+ if (state === "sent") {
301
+ return (_jsxs(Stack, { children: [_jsx("p", { style: {
302
+ ...resetBox,
303
+ color: theme.colors.success,
304
+ ...typeScale.bodySm,
305
+ }, role: "status", children: "Thanks, that reached the team." }), _jsxs("div", { style: { display: "flex", gap: spacing.xs }, children: [_jsx(Button, { theme: theme, onClick: () => setState("idle"), children: "Leave more feedback" }), onDone ? (_jsx(Button, { theme: theme, tone: "quiet", onClick: onDone, children: "Done" })) : null] })] }));
306
+ }
307
+ return (_jsx("form", { onSubmit: (e) => {
308
+ e.preventDefault();
309
+ if (canSend)
310
+ void send();
311
+ },
312
+ // See the sign-in form above: unstyled markup in somebody else's page is
313
+ // markup their element rules own.
314
+ style: { ...resetBox }, children: _jsxs(Stack, { children: [_jsx("textarea", { value: body, onChange: (e) => setBody(e.target.value), rows: 4, placeholder: "What is wrong with this page?", "aria-label": "Feedback", "data-testid": "toolbar-body", style: {
315
+ ...inputStyle(theme),
316
+ resize: "vertical",
317
+ minHeight: "84px",
318
+ } }), tooLong ? (_jsxs(ErrorText, { theme: theme, children: ["That is longer than ", FEEDBACK_BODY_MAX, " characters. Trim it and send again."] })) : null, error ? (_jsxs(Stack, { gap: spacing.xxs, children: [_jsx(ErrorText, { theme: theme, children: error }), _jsx(Muted, { theme: theme, children: "Your text is still here. Send it again when ready." })] })) : null, _jsx(Button, { theme: theme, type: "submit", disabled: !canSend, children: state === "sending" ? "Sending…" : "Send feedback" }), _jsx(Manifest, { theme: theme, config: config })] }) }));
319
+ }
320
+ function messageFor(err) {
321
+ if (!(err instanceof ToolbarApiError))
322
+ return "Could not send that.";
323
+ switch (err.code) {
324
+ case "not_invited_to_project":
325
+ return "You are not invited to this project any more.";
326
+ case "unauthorized":
327
+ return "Your session expired. Sign in again.";
328
+ case "network_error":
329
+ return "Could not reach the feedback service.";
330
+ case "screenshot_too_large":
331
+ return "The screenshot was too large to store.";
332
+ default:
333
+ return "Could not send that.";
334
+ }
335
+ }
336
+ /* ── Small presentational pieces ──────────────────────────────────────── */
337
+ /**
338
+ * Two letters from whatever the account has. Magic-link reviewers have no
339
+ * avatar image at all - there is no provider to fetch one from - so initials
340
+ * are the primary representation here rather than a fallback, and the pin
341
+ * markers use the same function so one person reads the same on both.
342
+ */
343
+ export function initialsFor(name, email) {
344
+ const source = (name ?? "").trim();
345
+ if (source.length > 0) {
346
+ const parts = source.split(/\s+/).filter((p) => p.length > 0);
347
+ const first = parts[0]?.[0] ?? "";
348
+ const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
349
+ return (first + last).toUpperCase();
350
+ }
351
+ return (email.trim()[0] ?? "?").toUpperCase();
352
+ }
353
+ /**
354
+ * What to PRINT for a person, as opposed to what to draw in their avatar.
355
+ *
356
+ * Better Auth's magic-link plugin stores `name: name || ""`, and a magic-link
357
+ * sign-up never asks for one - so every invited reviewer has an empty display
358
+ * name, which is most of the people whose words appear in a thread. Printing
359
+ * `author.name` raw therefore rendered an empty bold span followed by a reply
360
+ * body, and a two-person thread read as one unattributed run of text.
361
+ *
362
+ * The address is what identifies them, which is the same fallback the status
363
+ * attribution line and `initialsFor` already use. One helper so the three
364
+ * cannot drift.
365
+ */
366
+ export function displayNameFor(name, email) {
367
+ const trimmed = (name ?? "").trim();
368
+ return trimmed.length > 0 ? trimmed : email;
369
+ }
370
+ /**
371
+ * `YYYY-MM-DD HH:mm`, in the reviewer's own timezone.
372
+ *
373
+ * Not `toLocaleString()`: an ISO-shaped stamp sorts as it reads, is the same
374
+ * width on every row so a column of them lines up, and does not silently
375
+ * become `9/4/2026, 12:00:00 PM` for one reviewer and `04.09.2026, 12:00` for
376
+ * the next when they are looking at the same thread. The timezone is the
377
+ * reviewer's own because the question a stamp answers here is "was this before
378
+ * or after the thing I remember doing".
379
+ */
380
+ export function formatStamp(iso) {
381
+ const at = new Date(iso);
382
+ if (Number.isNaN(at.getTime()))
383
+ return iso;
384
+ const pad = (n) => String(n).padStart(2, "0");
385
+ return (`${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}` +
386
+ ` ${pad(at.getHours())}:${pad(at.getMinutes())}`);
387
+ }
388
+ /**
389
+ * "just now", "4m", "3h", "2d" - and `formatStamp` past a week.
390
+ *
391
+ * BESIDE `formatStamp` rather than instead of it, and the two are answering
392
+ * different questions. A thread is a conversation, and the useful fact about
393
+ * the reply above yours is how long ago it landed; that comment's own argument
394
+ * - "was this before or after the thing I remember doing" - is about a row in
395
+ * a list, and it still holds, which is why every relative stamp this renders
396
+ * carries the absolute one in a `title` (issue #20's message header).
397
+ *
398
+ * The relative units are deliberately ASCII-short and locale-free, for exactly
399
+ * the reason `formatStamp` refuses `toLocaleString`: `Intl.RelativeTimeFormat`
400
+ * would render "4 minutes ago" for one reviewer and "vor 4 Minuten" for the
401
+ * next, at two different widths, in a 12px mono cluster that has to stay one
402
+ * line.
403
+ *
404
+ * `now` is an argument rather than a `Date.now()` read so a test can state the
405
+ * moment instead of racing the clock. There is deliberately no ticking
406
+ * interval behind this: the thread re-reads its item after every reply and
407
+ * every status move, and a `setInterval` per open thread inside somebody
408
+ * else's page is a cost with no reader.
409
+ */
410
+ export function relativeStamp(iso, now = Date.now()) {
411
+ const at = new Date(iso).getTime();
412
+ if (Number.isNaN(at))
413
+ return iso;
414
+ const seconds = Math.round((now - at) / 1000);
415
+ // Also the answer for a stamp slightly in the future, which ordinary clock
416
+ // skew between the reviewer's machine and the API produces on every reply
417
+ // they have just posted. "-1m" would be a bug report.
418
+ if (seconds < 60)
419
+ return "just now";
420
+ const minutes = Math.floor(seconds / 60);
421
+ if (minutes < 60)
422
+ return `${minutes}m`;
423
+ const hours = Math.floor(minutes / 60);
424
+ if (hours < 24)
425
+ return `${hours}h`;
426
+ const days = Math.floor(hours / 24);
427
+ if (days < 7)
428
+ return `${days}d`;
429
+ // Weeks and months, out to a quarter. The ladder used to stop at seven days
430
+ // on the argument that "9d" is harder to read than the date it stands for -
431
+ // true of "9d", and it threw away the wrong half. A reviewer asking "have I
432
+ // dealt with this" wants an AGE, and "3w" answers that where "2026-08-28
433
+ // 11:12" makes them do the subtraction.
434
+ //
435
+ // A quarter is where it stops, because that is roughly where an age stops
436
+ // being the more useful fact: past it, "5mo" and "7mo" are the same thing to
437
+ // a reader, and the date is what they would go looking for anyway. Every one
438
+ // of these carries the absolute stamp in a `title`, so nothing is lost at any
439
+ // rung.
440
+ const weeks = Math.floor(days / 7);
441
+ if (days < 30)
442
+ return `${weeks}w`;
443
+ const months = Math.floor(days / 30);
444
+ if (days < 90)
445
+ return `${months}mo`;
446
+ return formatStamp(iso);
447
+ }
448
+ export function Avatar({ theme, name, email, size = 24, }) {
449
+ return (_jsx("span", { "aria-hidden": "true", style: {
450
+ display: "inline-flex",
451
+ alignItems: "center",
452
+ justifyContent: "center",
453
+ width: `${size}px`,
454
+ height: `${size}px`,
455
+ // Set rather than inherited: the ring is a border, and under a host
456
+ // page's default `content-box` a 22px avatar would render 24px and
457
+ // shove the row it sits in. See FeedbackToolbar.tsx's note.
458
+ boxSizing: "border-box",
459
+ flex: "0 0 auto",
460
+ borderRadius: radii.pill,
461
+ // Neutral, not the accent. An avatar is who somebody is, which is
462
+ // metadata; the accent is reserved for the one identity object that
463
+ // has to be findable over arbitrary host content, and that is the pin
464
+ // teardrop. Every avatar in the product being orange also made two
465
+ // different people look like the same person at a glance.
466
+ background: theme.colors.canvasSoft2,
467
+ color: theme.colors.body,
468
+ // 12px at every size: the sizes in use run 22-28px, and 12px is the
469
+ // smallest step this system has. The old `size * 0.42` produced 9px
470
+ // and 10px, which are on no step at all.
471
+ ...typeScale.caption,
472
+ fontWeight: 500,
473
+ border: `1px solid ${theme.colors.hairline}`,
474
+ }, children: initialsFor(name, email) }));
475
+ }
476
+ export function inputStyle(theme) {
477
+ return {
478
+ width: "100%",
479
+ boxSizing: "border-box",
480
+ padding: `${spacing.xs} ${spacing.sm}`,
481
+ border: `1px solid ${theme.colors.hairline}`,
482
+ borderRadius: radii.sm,
483
+ ...typeScale.bodySm,
484
+ color: theme.colors.ink,
485
+ // `canvas`, not `canvasSoft`. DESIGN.md's form-input specifies the canvas
486
+ // ground, and this field sits inside a canvas popover: on the soft tone it
487
+ // rendered DARKER than the surface holding it, which inverts the surface
488
+ // ladder at the most visible place in the product. The border is what
489
+ // makes the field a field.
490
+ background: theme.colors.canvas,
491
+ // Restores the platform focus ring on a host that suppressed it with
492
+ // `*:focus { outline: none }` - `revert` rolls this declaration back past
493
+ // every author rule to the UA's own.
494
+ outline: "revert",
495
+ };
496
+ }
497
+ export function Stack({ children, gap = spacing.sm, }) {
498
+ return (_jsx("div", { style: { display: "flex", flexDirection: "column", gap }, children: children }));
499
+ }
500
+ export function Muted({ children, role, theme, }) {
501
+ return (_jsx("p", { role: role, style: {
502
+ ...resetBox,
503
+ color: theme.colors.body,
504
+ ...typeScale.bodySm,
505
+ }, children: children }));
506
+ }
507
+ export function ErrorText({ children, theme, }) {
508
+ return (_jsx("p", { role: "alert", style: { ...resetBox, color: theme.colors.error, ...typeScale.bodySm }, children: children }));
509
+ }
510
+ export function Button({ children, onClick, type = "button", disabled, theme, tone = "solid", }) {
511
+ const quiet = tone === "quiet";
512
+ // See BarButton: an inline style cannot express `:focus-visible`, and a host
513
+ // `*:focus { outline: none }` would otherwise leave this control with no
514
+ // keyboard focus state at all. Hover and press are here for the same reason,
515
+ // and mirror `ThreadButton` in PinLayer deliberately - it is the same control
516
+ // on the other surface, and the two would otherwise drift.
517
+ const [focused, setFocused] = useState(false);
518
+ const [hovered, setHovered] = useState(false);
519
+ const [pressed, setPressed] = useState(false);
520
+ const reduced = usePrefersReducedMotion();
521
+ return (_jsx("button", { type: type, onClick: onClick, disabled: disabled, onFocus: () => setFocused(true), onBlur: () => setFocused(false), onPointerEnter: () => setHovered(true), onPointerLeave: () => {
522
+ setHovered(false);
523
+ setPressed(false);
524
+ }, onPointerDown: () => setPressed(true), onPointerUp: () => setPressed(false), onPointerCancel: () => setPressed(false), style: {
525
+ padding: `${spacing.xs} ${spacing.sm}`,
526
+ // Ink is the primary action's fill, as DESIGN.md's button-primary
527
+ // specifies. It used to be the brand orange, which put the accent on
528
+ // every button in the product and left nothing to mean "BitVea".
529
+ // `onInk` rather than white: `ink` flips to #ffffff in the dark
530
+ // palette, and white-on-white is what a literal would have shipped.
531
+ // The hover fill is the quiet tone's only - the faint shift toward
532
+ // `canvasSoft2` DESIGN.md's Known Gaps describes. There is no ink-hover
533
+ // step in this palette and inventing one belongs in a pull request of
534
+ // its own; the solid tone's feedback is the press below.
535
+ background: disabled
536
+ ? theme.colors.hairline
537
+ : quiet
538
+ ? hovered
539
+ ? theme.colors.canvasSoft2
540
+ : "transparent"
541
+ : theme.colors.ink,
542
+ // `mute` on `hairline` measures 3.0:1, and the disabled "Send
543
+ // feedback" is the first thing a reviewer sees every time the composer
544
+ // opens. `body` on the same fill is 7.1:1 light and 5.6:1 dark; the
545
+ // flat fill and the cursor are what say "disabled".
546
+ color: disabled
547
+ ? theme.colors.body
548
+ : quiet
549
+ ? theme.colors.body
550
+ : theme.colors.onInk,
551
+ border: quiet ? `1px solid ${theme.colors.hairline}` : "none",
552
+ // 6px, DESIGN.md's in-app button shape. The bar is the one control
553
+ // here that is deliberately pill-shaped; everything inside a popover
554
+ // is a form control and takes the form-control radius.
555
+ borderRadius: radii.sm,
556
+ ...typeScale.bodySmStrong,
557
+ cursor: disabled ? "not-allowed" : "pointer",
558
+ outline: "revert",
559
+ boxShadow: focused ? theme.shadows.focusRing : "none",
560
+ // A leaf control with no fixed descendants, so a transform is safe
561
+ // here. It is not safe on the toolbar ROOT, which is the ancestor
562
+ // every `position: fixed` pin marker resolves against and would become
563
+ // their containing block.
564
+ transform: motionTransform(pressed && !disabled ? `scale(${motion.pressScale})` : "none", reduced),
565
+ transition: transition(["background-color", "color", "transform"], motion.duration.instant, motion.easing.inOut, reduced),
566
+ }, children: children }));
567
+ }
568
+ /**
569
+ * What leaves with this report, printed rather than described.
570
+ *
571
+ * This used to be a sentence - "Sent with the page URL, viewport, browser,
572
+ * branch, commit and a screenshot" - which is a promise about facts the
573
+ * toolbar is holding in its hand. A reviewer filing against the wrong
574
+ * deployment cannot tell from a sentence; they can from the branch and the
575
+ * seven characters of SHA. So the four verifiable facts are shown as
576
+ * themselves, in the mono voice the rest of the product gives identifiers.
577
+ *
578
+ * The prose line stays for the two that cannot be shown: the browser string is
579
+ * long and uninteresting until it matters, and the screenshot does not exist
580
+ * yet. That sentence is doing CONSENT work on a tool that silently rasterises
581
+ * whatever is on a reviewer's screen on somebody else's staging site, so it is
582
+ * not decoration and does not get dropped for density.
583
+ */
584
+ function Manifest({ theme, config, }) {
585
+ // Read on every render rather than memoised: the reviewer can resize the
586
+ // window with the composer open, and a stale viewport here would be a claim
587
+ // about the report that is not true. This whole tree is client-only, so
588
+ // `window` is safe to read - see the portal note in FeedbackToolbar.tsx.
589
+ const meta = captureMetadata(config);
590
+ const facts = [
591
+ pathOf(meta.pageUrl),
592
+ `${meta.viewportWidth}×${meta.viewportHeight}`,
593
+ meta.gitBranch ?? meta.environment,
594
+ meta.gitCommit?.slice(0, 7),
595
+ ].filter((fact) => typeof fact === "string" && fact !== "");
596
+ return (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: spacing.xxs }, children: [_jsx("p", { "data-testid": "toolbar-manifest", style: {
597
+ ...resetBox,
598
+ color: theme.colors.body,
599
+ ...typeScale.captionMono,
600
+ overflowWrap: "anywhere",
601
+ }, children: facts.join(" · ") }), _jsx("p", { style: {
602
+ // `body`, not `mute`. This is the one sentence on the surface doing
603
+ // CONSENT work - it is what tells a reviewer that pressing Send
604
+ // rasterises whatever is on their screen on somebody else's staging
605
+ // site - and `mute` is #888888 on white, 3.5:1 at 12px, which is
606
+ // below AA and the same reading the rest of this file already
607
+ // refuses for far less important labels.
608
+ ...resetBox,
609
+ color: theme.colors.body,
610
+ ...typeScale.caption,
611
+ }, children: "Sent with your browser string and a screenshot of this viewport." })] }));
612
+ }
613
+ /** The path alone, since the origin is the page the reviewer is looking at. */
614
+ function pathOf(url) {
615
+ try {
616
+ return new URL(url).pathname;
617
+ }
618
+ catch {
619
+ return url;
620
+ }
621
+ }
622
+ //# sourceMappingURL=FeedbackPanel.js.map