@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.
- package/README.md +262 -0
- package/dist/FeedbackPanel.d.ts +117 -0
- package/dist/FeedbackPanel.d.ts.map +1 -0
- package/dist/FeedbackPanel.js +622 -0
- package/dist/FeedbackPanel.js.map +1 -0
- package/dist/FeedbackToolbar.d.ts +54 -0
- package/dist/FeedbackToolbar.d.ts.map +1 -0
- package/dist/FeedbackToolbar.js +1149 -0
- package/dist/FeedbackToolbar.js.map +1 -0
- package/dist/InboxSheet.d.ts +80 -0
- package/dist/InboxSheet.d.ts.map +1 -0
- package/dist/InboxSheet.js +580 -0
- package/dist/InboxSheet.js.map +1 -0
- package/dist/PinLayer.d.ts +59 -0
- package/dist/PinLayer.d.ts.map +1 -0
- package/dist/PinLayer.js +1702 -0
- package/dist/PinLayer.js.map +1 -0
- package/dist/RichText.d.ts +6 -0
- package/dist/RichText.d.ts.map +1 -0
- package/dist/RichText.js +135 -0
- package/dist/RichText.js.map +1 -0
- package/dist/browser/entry.d.ts +3 -0
- package/dist/browser/entry.d.ts.map +1 -0
- package/dist/browser/entry.js +13 -0
- package/dist/browser/entry.js.map +1 -0
- package/dist/browser/mount.d.ts +37 -0
- package/dist/browser/mount.d.ts.map +1 -0
- package/dist/browser/mount.js +150 -0
- package/dist/browser/mount.js.map +1 -0
- package/dist/browser/toolbar.js +38 -0
- package/dist/browser/toolbar.js.map +7 -0
- package/dist/capture.d.ts +58 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +98 -0
- package/dist/capture.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +196 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/fs.d.ts +16 -0
- package/dist/cli/fs.d.ts.map +1 -0
- package/dist/cli/fs.js +33 -0
- package/dist/cli/fs.js.map +1 -0
- package/dist/cli/init.d.ts +75 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +266 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/register.d.ts +55 -0
- package/dist/cli/register.d.ts.map +1 -0
- package/dist/cli/register.js +84 -0
- package/dist/cli/register.js.map +1 -0
- package/dist/cli/registration.d.ts +37 -0
- package/dist/cli/registration.d.ts.map +1 -0
- package/dist/cli/registration.js +69 -0
- package/dist/cli/registration.js.map +1 -0
- package/dist/cli/strategies.d.ts +98 -0
- package/dist/cli/strategies.d.ts.map +1 -0
- package/dist/cli/strategies.js +198 -0
- package/dist/cli/strategies.js.map +1 -0
- package/dist/client.d.ts +59 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +113 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +92 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +65 -0
- package/dist/config.js.map +1 -0
- package/dist/deepLink.d.ts +86 -0
- package/dist/deepLink.d.ts.map +1 -0
- package/dist/deepLink.js +185 -0
- package/dist/deepLink.js.map +1 -0
- package/dist/fontFace.d.ts +70 -0
- package/dist/fontFace.d.ts.map +1 -0
- package/dist/fontFace.js +88 -0
- package/dist/fontFace.js.map +1 -0
- package/dist/gating.d.ts +32 -0
- package/dist/gating.d.ts.map +1 -0
- package/dist/gating.js +49 -0
- package/dist/gating.js.map +1 -0
- package/dist/icons.d.ts +68 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +97 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +68 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +59 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +130 -0
- package/dist/markdown.js.map +1 -0
- package/dist/motion.d.ts +178 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +302 -0
- package/dist/motion.js.map +1 -0
- package/dist/preferences.d.ts +71 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +195 -0
- package/dist/preferences.js.map +1 -0
- package/dist/rasterise.d.ts +45 -0
- package/dist/rasterise.d.ts.map +1 -0
- package/dist/rasterise.js +490 -0
- package/dist/rasterise.js.map +1 -0
- package/dist/selector.d.ts +19 -0
- package/dist/selector.d.ts.map +1 -0
- package/dist/selector.js +142 -0
- package/dist/selector.js.map +1 -0
- package/dist/session.d.ts +19 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +36 -0
- package/dist/session.js.map +1 -0
- package/dist/shortcut.d.ts +88 -0
- package/dist/shortcut.d.ts.map +1 -0
- package/dist/shortcut.js +149 -0
- package/dist/shortcut.js.map +1 -0
- package/dist/tokens.d.ts +709 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +749 -0
- package/dist/tokens.js.map +1 -0
- package/dist/vendor/types/account.d.ts +26 -0
- package/dist/vendor/types/account.d.ts.map +1 -0
- package/dist/vendor/types/account.js +23 -0
- package/dist/vendor/types/account.js.map +1 -0
- package/dist/vendor/types/apiKey.d.ts +38 -0
- package/dist/vendor/types/apiKey.d.ts.map +1 -0
- package/dist/vendor/types/apiKey.js +36 -0
- package/dist/vendor/types/apiKey.js.map +1 -0
- package/dist/vendor/types/auth.d.ts +10 -0
- package/dist/vendor/types/auth.d.ts.map +1 -0
- package/dist/vendor/types/auth.js +15 -0
- package/dist/vendor/types/auth.js.map +1 -0
- package/dist/vendor/types/entity.d.ts +74 -0
- package/dist/vendor/types/entity.d.ts.map +1 -0
- package/dist/vendor/types/entity.js +111 -0
- package/dist/vendor/types/entity.js.map +1 -0
- package/dist/vendor/types/feedback.d.ts +285 -0
- package/dist/vendor/types/feedback.d.ts.map +1 -0
- package/dist/vendor/types/feedback.js +286 -0
- package/dist/vendor/types/feedback.js.map +1 -0
- package/dist/vendor/types/index.d.ts +14 -0
- package/dist/vendor/types/index.d.ts.map +1 -0
- package/dist/vendor/types/index.js +16 -0
- package/dist/vendor/types/index.js.map +1 -0
- package/dist/vendor/types/invite.d.ts +42 -0
- package/dist/vendor/types/invite.d.ts.map +1 -0
- package/dist/vendor/types/invite.js +30 -0
- package/dist/vendor/types/invite.js.map +1 -0
- package/dist/vendor/types/mcpToken.d.ts +38 -0
- package/dist/vendor/types/mcpToken.d.ts.map +1 -0
- package/dist/vendor/types/mcpToken.js +39 -0
- package/dist/vendor/types/mcpToken.js.map +1 -0
- package/dist/vendor/types/organization.d.ts +13 -0
- package/dist/vendor/types/organization.d.ts.map +1 -0
- package/dist/vendor/types/organization.js +12 -0
- package/dist/vendor/types/organization.js.map +1 -0
- package/dist/vendor/types/project.d.ts +73 -0
- package/dist/vendor/types/project.d.ts.map +1 -0
- package/dist/vendor/types/project.js +117 -0
- package/dist/vendor/types/project.js.map +1 -0
- package/dist/vendor/types/role.d.ts +12 -0
- package/dist/vendor/types/role.d.ts.map +1 -0
- package/dist/vendor/types/role.js +20 -0
- package/dist/vendor/types/role.js.map +1 -0
- package/dist/vendor/types/roleDefinition.d.ts +61 -0
- package/dist/vendor/types/roleDefinition.d.ts.map +1 -0
- package/dist/vendor/types/roleDefinition.js +45 -0
- package/dist/vendor/types/roleDefinition.js.map +1 -0
- package/dist/vendor/types/team.d.ts +25 -0
- package/dist/vendor/types/team.d.ts.map +1 -0
- package/dist/vendor/types/team.js +17 -0
- package/dist/vendor/types/team.js.map +1 -0
- package/dist/vendor/types/util.d.ts +39 -0
- package/dist/vendor/types/util.d.ts.map +1 -0
- package/dist/vendor/types/util.js +31 -0
- package/dist/vendor/types/util.js.map +1 -0
- 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
|