From 1e653e212ecedd81f7daa0b48a14a348673bdbfb Mon Sep 17 00:00:00 2001 From: Parikshit Deshmukh Date: Tue, 18 Aug 2026 19:57:51 +0530 Subject: [PATCH 1/2] Split devtools into Inspect and Debug trays, rework the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the widget's two tools — OpenUI Devtools becomes Inspect, OpenUI Paste becomes Debug — and give them independent trays rather than one drawer that grows. Closing either leaves the other where it was. Layout: both trays fill an 85% block anchored bottom right (capped at 3456x2234). Inspect keeps a fixed width; Debug takes the remainder, and its editor/panels split is now draggable, opening at a quarter. Cards: overview stats read as a count chip plus a label, actions move above the diagnostics, and level chips are soft squares. Hover gives a card a stronger stroke and a shadow. Theme: dark mode is rebuilt around a single neutral ramp with five foreground steps and desaturated accent surfaces, all contrast checked. Adds card, control-*, level-* and tray-ring tokens so raised and inset surfaces are described rather than hand-picked per component. Tests cover the independent trays and returning an ejected window to the tray; the entry point into Debug is now a stream row's Debug button. Co-Authored-By: Claude Opus 5 --- packages/devtools/README.md | 4 +- packages/devtools/src/IconButton.tsx | 63 ++ packages/devtools/src/LevelIcon.tsx | 30 +- packages/devtools/src/OpenUIDevtools.test.ts | 200 ++++- packages/devtools/src/OpenUIDevtools.tsx | 842 ++++++++++-------- packages/devtools/src/QuotaErrorRow.tsx | 108 ++- .../devtools/src/ReactLangStreamEventRow.tsx | 239 +++-- packages/devtools/src/ThemeToggle.tsx | 82 ++ packages/devtools/src/paste/HelpDialog.tsx | 155 ++-- packages/devtools/src/paste/LangEditor.tsx | 1 - packages/devtools/src/paste/PasteUI.tsx | 207 ++++- packages/devtools/src/paste/StreamToolbar.tsx | 19 +- packages/devtools/src/paste/eject.ts | 2 +- packages/devtools/src/paste/mockTools.ts | 2 +- packages/devtools/src/paste/styles.ts | 21 +- packages/devtools/src/theme.ts | 148 +-- packages/react-lang/src/devtoolsBootstrap.ts | 2 +- packages/react-lang/src/index.ts | 2 +- 18 files changed, 1403 insertions(+), 724 deletions(-) create mode 100644 packages/devtools/src/IconButton.tsx create mode 100644 packages/devtools/src/ThemeToggle.tsx diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 6a582ae94..28d23b669 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -21,7 +21,9 @@ The widget renders nothing in production builds (`NODE_ENV === "production"`) un `@openuidev/react-lang` ships with this package and auto-mounts the widget in development — no manual `` needed. Mounting it manually still works (e.g. to customize props): only one instance ever renders, and a manually mounted instance takes precedence over the auto-mounted one. -In development, `createLibrary()` registers the live library with the widget. The **OpenUI Paste** banner at the bottom of the drawer widens the drawer into an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. A stream event's **Debug** button opens its response the same way. Eject moves the view into a separate window. The first visit opens a short step-by-step guide (also on **Help**); dismissing it is remembered. +**OpenUI Inspect** (the event drawer) and **OpenUI Debug** are independent tools on independent trays. Debug docks beside Inspect rather than growing out of it, and each closes on its own: dismissing Debug leaves the event list where it was, and vice versa. + +In development, `createLibrary()` registers the live library with the widget. The **OpenUI Debug** banner at the bottom of the Inspect tray opens an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. A stream event's **Debug** button opens its response the same way. Eject moves the view into a separate window. The first visit opens a short step-by-step guide (also on **Help**); dismissing it is remembered. Display filters ("auto-open on error", "errors only") and the theme live behind the gear in the drawer header. The theme is Light or Dark, chosen manually and remembered across reloads: nothing is auto-detected from the host page or the OS, and it styles the devtools chrome only — never your app. The floating Shiro toggle stays dark so the branded mark stays readable. diff --git a/packages/devtools/src/IconButton.tsx b/packages/devtools/src/IconButton.tsx new file mode 100644 index 000000000..5d542b90b --- /dev/null +++ b/packages/devtools/src/IconButton.tsx @@ -0,0 +1,63 @@ +import { useState, type ButtonHTMLAttributes, type CSSProperties } from "react"; + +/** + * The widget's square icon buttons — header actions, close crosses, the + * settings trigger. They sit on their tray with no chrome until pointed at, + * then take a fill; keeping that in one component means every one of them + * behaves the same without threading hover state through each call site. + */ +export function IconButton({ + active = false, + outlined = false, + style, + ...props +}: ButtonHTMLAttributes & { + /** Held open, e.g. the settings trigger while its menu shows. */ + active?: boolean; + /** Carries its own border, for buttons that sit on a busy surface. */ + outlined?: boolean; +}) { + const [hovered, setHovered] = useState(false); + return ( + - ) : ( - - + + + ) : ( + <> + setEvents([])} + aria-label="Reset events" + title="Reset events" + > + + + + + )} + + + + + + + {selected ? ( +
+ {selectedStack.split("\n").map((line, index) => ( +
+ {index + 1} + + {line || " "} - )} - {headerTitle} -
-
- {selected ? ( - <> - - - - ) : ( - <> - - - - )} -
+ ) : ( + <> + -
- - {selected ? ( -
- {selectedStack.split("\n").map((line, index) => ( -
- {index + 1} - - {line || " "} +
+ {visibleEvents.length === 0 ? ( +
+ + + No events captured yet.
- ))} -
- ) : ( - <> -
- {visibleEvents.length === 0 ? ( -
No events captured yet.
- ) : ( - visibleEvents.map((event, index) => { - const key = - typeof event.detail["id"] === "string" - ? event.detail["id"] - : `${event.timestamp}-${index}`; - const quotaError = getQuotaError(event); - if (quotaError) return ; - const stream = getReactLangStreamDetail(event); - if (stream) { - return ( - 0} - onOpenInPaste={(response) => { - setCode(response); - openPaste(); - }} - /> - ); - } - - const error = getErrorInfo(event); - const detail = asRecord(event.detail); - const kind = asString(detail["kind"]); - const status = - typeof detail["status"] === "number" - ? String(detail["status"]) - : undefined; - const message = error?.message ?? asString(detail["message"]); + ) : ( + visibleEvents.map((event, index) => { + const key = + typeof event.detail["id"] === "string" + ? event.detail["id"] + : `${event.timestamp}-${index}`; + const quotaError = getQuotaError(event); + if (quotaError) return ; + const stream = getReactLangStreamDetail(event); + if (stream) { return ( -
-
-
- - {kind ? {kind} : null} - {status ? ( - - {status} - - ) : null} -
-
- - {new Date(event.timestamp).toLocaleTimeString()} + 0} + onOpenInPaste={(response) => { + setCode(response); + openPaste(); + }} + /> + ); + } + + const error = getErrorInfo(event); + const detail = asRecord(event.detail); + const kind = asString(detail["kind"]); + const status = + typeof detail["status"] === "number" ? String(detail["status"]) : undefined; + const message = error?.message ?? asString(detail["message"]); + return ( +
+
+
+ + {kind ? {kind} : null} + {status ? ( + + {status} - -
+ ) : null} +
+
+ + {new Date(event.timestamp).toLocaleTimeString()} + +
- {message ? ( -
{message}
- ) : kind ? null : ( -
{summarize(event)}
- )} - {error?.stack ? ( - - ) : null}
- ); - }) - )} -
- - - )} - - )} + {message ? ( +
{message}
+ ) : kind ? null : ( +
{summarize(event)}
+ )} + {error?.stack ? ( + + ) : null} +
+ ); + }) + )} +
+ + )} + + + + +
{popupRoot ? createPortal(paste, popupRoot) : null} @@ -436,6 +503,36 @@ export function OpenUIDevtools({ ); } +/** + * A real checkbox painted as a switch — the input stays in the tree (hidden but + * clickable and focusable) so keyboard, form semantics, and screen readers get + * the native control rather than a div pretending to be one. + */ +function SettingSwitch({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} + /** * Header dropdown for the display filters and the widget theme, so the list is * all list. Escape is handled in the capture phase so closing the menu doesn't @@ -471,8 +568,8 @@ function SettingsMenu({ return (
- + {open ? (
- - + {/* Every row reads the same way: name on the left, control on the + right, hairline between. */} + onChange({ autoOpen })} + /> +
+ onChange({ onlyErrors })} + />
-
Theme
- onChange({ theme })} /> + Theme + onChange({ theme })} />
) : null} @@ -510,51 +604,6 @@ function SettingsMenu({ ); } -const THEME_OPTIONS: { - id: ColorScheme; - label: string; - icon: typeof Sun; -}[] = [ - { id: "light", label: "Light", icon: Sun }, - { id: "dark", label: "Dark", icon: Moon }, -]; - -function ThemeToggle({ - value, - onChange, -}: { - value: ColorScheme; - onChange: (value: ColorScheme) => void; -}) { - return ( -
- {THEME_OPTIONS.map((option, index) => { - const active = value === option.id; - const Icon = option.icon; - return ( - - ); - })} -
- ); -} - function asRecord(detail: unknown): Record { return typeof detail === "object" && detail !== null ? (detail as Record) : {}; } @@ -633,17 +682,29 @@ const styles = { boxShadow: "var(--oui-dt-toggle-shadow)", fontFamily: FONT, padding: 0, - transition: "background 150ms ease, box-shadow 150ms ease", + transition: "background 150ms ease, box-shadow 150ms ease, transform 150ms ease", }, - // Errors turn the whole button red and the count replaces the mark, rather - // than hiding the number in a corner badge. + toggleHover: { + transform: "scale(1.08)", + }, + // Errors swap the mark for a count on a red disc, held inside a light puck so + // the number reads as a badge rather than flooding the whole button red. toggleError: { - background: "var(--oui-dt-toggle-error)", - borderColor: "var(--oui-dt-toggle-error)", - color: "#fff", + background: "var(--oui-dt-toggle-error-surface)", + borderColor: "var(--oui-dt-toggle-error-ring)", }, toggleCount: { - fontSize: 15, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + minWidth: 24, + height: 24, + padding: "0 6px", + borderRadius: 999, + background: "var(--oui-dt-toggle-error)", + color: "#fff", + fontSize: 13, fontWeight: 700, lineHeight: 1, }, @@ -664,33 +725,31 @@ const styles = { pointerEvents: "auto", transition: "opacity 200ms ease, visibility 0s", }, + // Geometry (right/width/transform) is per-tray and set inline; this is the + // shared chrome. Each tray hides itself when closed so the other can be open + // over the same scrim without a retracted tray staying focusable. drawer: { position: "fixed", - top: 12, - right: 12, - bottom: 12, boxSizing: "border-box", - width: "min(420px, calc(100vw - 24px))", display: "flex", flexDirection: "column", - border: "1px solid var(--oui-dt-border)", + borderWidth: 1, + borderStyle: "solid", + borderColor: "var(--oui-dt-tray-ring)", borderRadius: 16, background: "var(--oui-dt-bg)", color: "var(--oui-dt-fg)", fontFamily: FONT, fontSize: 13, - boxShadow: "var(--oui-dt-shadow)", - transform: "translateX(calc(100% + 12px))", + visibility: "hidden", transition: - "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1)", + "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s linear 220ms", overflow: "hidden", }, drawerOpen: { - transform: "translateX(0)", - }, - // Paste isn't a separate dialog: the same drawer grows into it. - drawerWide: { - width: "min(1180px, calc(100vw - 24px))", + visibility: "visible", + transition: + "transform 220ms cubic-bezier(0.32, 0.72, 0, 1), right 260ms cubic-bezier(0.32, 0.72, 0, 1), width 260ms cubic-bezier(0.32, 0.72, 0, 1), visibility 0s", }, pasteHost: { flex: 1, @@ -711,11 +770,6 @@ const styles = { gap: 6, minWidth: 0, }, - headerLogo: { - display: "inline-flex", - alignItems: "center", - flexShrink: 0, - }, title: { overflow: "hidden", textOverflow: "ellipsis", @@ -731,10 +785,10 @@ const styles = { display: "inline-flex", alignItems: "center", gap: 4, - border: "1px solid var(--oui-dt-border)", + border: "1px solid var(--oui-dt-control-border)", borderRadius: 8, - background: "var(--oui-dt-bg)", - color: "var(--oui-dt-fg-tertiary)", + background: "var(--oui-dt-control-bg)", + color: "var(--oui-dt-fg)", cursor: "pointer", fontFamily: FONT, fontSize: 12, @@ -746,28 +800,6 @@ const styles = { borderColor: "var(--oui-dt-inverted)", color: "var(--oui-dt-inverted-fg)", }, - iconButton: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 26, - height: 26, - border: "none", - borderRadius: 8, - background: "transparent", - color: "var(--oui-dt-fg-muted)", - cursor: "pointer", - padding: 0, - }, - iconButtonActive: { - background: "var(--oui-dt-bg-subtle)", - color: "var(--oui-dt-fg)", - }, - iconButtonOutlined: { - boxSizing: "border-box", - border: "1px solid var(--oui-dt-border)", - background: "var(--oui-dt-bg)", - }, menuWrap: { position: "relative", display: "inline-flex", @@ -776,7 +808,8 @@ const styles = { position: "absolute", top: "calc(100% + 6px)", right: 0, - zIndex: 1, + // Above the banner group, which lifts itself over the list for its fade. + zIndex: 2, boxSizing: "border-box", width: 236, display: "flex", @@ -789,14 +822,54 @@ const styles = { padding: 12, fontWeight: 400, }, + // Every row is the height of the tallest control (the theme toggle), so the + // dividers land on an even rhythm no matter what each row holds. menuCheckbox: { display: "flex", alignItems: "center", - gap: 8, - color: "var(--oui-dt-fg-secondary)", - fontSize: 12, + justifyContent: "space-between", + gap: 12, + minHeight: MENU_ROW_HEIGHT, + cursor: "pointer", + }, + switchTrack: { + position: "relative", + boxSizing: "border-box", + flexShrink: 0, + width: 30, + height: 18, + borderRadius: 999, + background: "var(--oui-dt-border)", + transition: "background 150ms ease", + }, + switchTrackOn: { + background: "var(--oui-dt-inverted)", + }, + // Covers the whole track so the hit area and focus ring stay on the input. + switchInput: { + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + margin: 0, + borderRadius: 999, + opacity: 0, cursor: "pointer", - accentColor: "var(--oui-dt-fg)", + }, + switchKnob: { + position: "absolute", + top: 2, + left: 2, + width: 14, + height: 14, + borderRadius: "50%", + background: "var(--oui-dt-bg)", + boxShadow: "var(--oui-dt-shadow-subtle)", + transition: "transform 150ms ease", + pointerEvents: "none", + }, + switchKnobOn: { + transform: "translateX(12px)", }, menuDivider: { height: 1, @@ -807,69 +880,71 @@ const styles = { alignItems: "center", justifyContent: "space-between", gap: 12, + minHeight: MENU_ROW_HEIGHT, }, menuLabel: { fontSize: 12, - fontWeight: 600, + fontWeight: 500, color: "var(--oui-dt-fg)", }, - themeToggle: { - display: "inline-flex", - alignItems: "stretch", + // One border on the track, none on the segments — collapsing per-segment + // borders with a negative margin made whichever one was active paint a pixel + // wider than its neighbour. + // Whole card is the button; the chevron only signals where it leads. + // Inset from the drawer edges, matching the list's own 12px gutter. The top + // margin keeps a clear gap even when the list scrolls right up to the banners. + // Padding, not margin, so the tray background travels with the banner and + // rows scrolling underneath are hidden rather than peeking through the gap. + bannerGroup: { + position: "relative", + zIndex: 1, + display: "flex", + flexDirection: "column", + gap: 8, flexShrink: 0, - }, - themeOption: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - boxSizing: "border-box", - width: 34, - height: 28, - border: "1px solid var(--oui-dt-border)", background: "var(--oui-dt-bg)", - color: "var(--oui-dt-fg-tertiary)", - cursor: "pointer", - padding: 0, - marginLeft: -1, - }, - themeOptionFirst: { - marginLeft: 0, - borderTopLeftRadius: 8, - borderBottomLeftRadius: 8, + padding: "12px 12px 18px", }, - themeOptionLast: { - borderTopRightRadius: 8, - borderBottomRightRadius: 8, + // Mirrors bannerFade at the tray's bottom edge, so rows dissolve into the + // drawer instead of meeting the border mid-row. Pinned to the tray rather + // than the list, so it covers the stack-trace view too. + trayFade: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + height: 28, + background: "linear-gradient(to top, var(--oui-dt-bg), transparent)", + pointerEvents: "none", }, - themeOptionActive: { - background: "var(--oui-dt-inverted)", - borderColor: "var(--oui-dt-inverted)", - color: "var(--oui-dt-inverted-fg)", - zIndex: 1, + // Hangs below the banner, spanning the tray's full width, so list rows + // dissolve as they scroll up under it rather than being clipped mid-row. + // Sized to the list's own top padding so it dissolves the gap without + // washing over the first card. + bannerFade: { + position: "absolute", + left: 0, + right: 0, + top: "100%", + height: 12, + background: "linear-gradient(to bottom, var(--oui-dt-bg), transparent)", + pointerEvents: "none", }, - // Whole card is the button; the chevron only signals where it leads. pasteBanner: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexShrink: 0, - // Inset from the drawer edges, matching the list's own 12px gutter. The top - // margin keeps a clear gap even when the list scrolls right up to the banner. - margin: 12, - border: "1px solid var(--oui-dt-border)", + textDecoration: "none", borderRadius: 12, - background: "var(--oui-dt-bg-muted)", + background: "var(--oui-dt-promo-bg)", color: "var(--oui-dt-fg)", cursor: "pointer", fontFamily: FONT, textAlign: "left", padding: "12px 14px", }, - pasteBannerDisabled: { - opacity: 0.5, - cursor: "not-allowed", - }, pasteBannerText: { display: "flex", flexDirection: "column", @@ -878,26 +953,26 @@ const styles = { }, pasteBannerTitle: { fontSize: 12, - fontWeight: 600, - }, - pasteBannerHint: { - color: "var(--oui-dt-fg-muted)", - fontSize: 11, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", + fontWeight: 500, }, - pasteBannerChevron: { + // The whole banner is the hit target; this just reads as the button on it, so + // it stays a span rather than nesting a control inside a control. + pasteBannerAction: { display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, - width: 26, - height: 26, - border: "1px solid var(--oui-dt-border)", + border: "1px solid var(--oui-dt-control-border)", borderRadius: 8, - background: "var(--oui-dt-bg)", - color: "var(--oui-dt-fg-muted)", + background: "var(--oui-dt-control-bg)", + color: "var(--oui-dt-fg)", + boxShadow: "var(--oui-dt-shadow-subtle)", + fontSize: 11, + padding: "5px 12px", + transition: "transform 150ms ease", + }, + pasteBannerActionHover: { + transform: "scale(0.96)", }, list: { flex: 1, @@ -908,11 +983,24 @@ const styles = { flexDirection: "column", gap: 10, }, + // Fills the list so the message sits in the middle of the tray, not pinned + // under the header. empty: { + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 12, color: "var(--oui-dt-fg-faint)", - padding: "32px 0", textAlign: "center", }, + emptyIcon: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + }, row: { border: "1px solid var(--oui-dt-border)", borderRadius: 12, @@ -920,7 +1008,7 @@ const styles = { display: "flex", flexDirection: "column", gap: 6, - background: "var(--oui-dt-bg)", + background: "var(--oui-dt-card)", }, badgeCredits: { background: "var(--oui-dt-credits-bg)", @@ -955,7 +1043,7 @@ const styles = { kind: { color: "var(--oui-dt-fg)", fontSize: 12, - fontWeight: 700, + fontWeight: 600, wordBreak: "break-word", }, badgeNeutral: { diff --git a/packages/devtools/src/QuotaErrorRow.tsx b/packages/devtools/src/QuotaErrorRow.tsx index 8a74cf55d..78343e072 100644 --- a/packages/devtools/src/QuotaErrorRow.tsx +++ b/packages/devtools/src/QuotaErrorRow.tsx @@ -1,6 +1,7 @@ import { type ObservabilityEvent } from "@openuidev/observability"; import { CreditCard, KeyRound } from "lucide-react"; -import type { CSSProperties } from "react"; +import { useState, type CSSProperties } from "react"; +import { LevelIcon } from "./LevelIcon"; export interface QuotaErrorInfo { title: string; @@ -35,40 +36,56 @@ export function getQuotaError(event: ObservabilityEvent): QuotaErrorInfo | undef /** Billing/rate-limit list entry — the highlighted card a known 429 code renders as. */ export function QuotaErrorRow({ info }: { info: QuotaErrorInfo }) { + const [hoveredCta, setHoveredCta] = useState(null); + return ( -
+
-
{info.title}
+
+ +
{info.title}
+

{info.message}

- {info.showPurchaseCta || info.showByokCta ? ( -
- {info.showPurchaseCta ? ( - - ) : null} - {info.showByokCta ? ( - - ) : null} -
- ) : null}
+ {info.showPurchaseCta || info.showByokCta ? ( +
+ {info.showPurchaseCta ? ( + + ) : null} + {info.showByokCta ? ( + + ) : null} +
+ ) : null}
); } @@ -91,19 +108,25 @@ const styles = { display: "flex", flexDirection: "column", gap: 6, - background: "var(--oui-dt-bg)", - }, - rowCredits: { - border: "1px solid var(--oui-dt-credits-border)", - background: "var(--oui-dt-credits-gradient)", + background: "var(--oui-dt-card)", }, + // The card matches every other row; the chip and title colour carry the + // billing/rate-limit signal. creditsNote: { display: "flex", flexDirection: "column", + gap: 12, + }, + creditsHeader: { + display: "flex", + alignItems: "center", gap: 6, + minWidth: 0, }, + // Identical to the `kind` title on every other row; the chip alone carries + // the billing/rate-limit signal. creditsTitle: { - fontSize: 13, + fontSize: 12, fontWeight: 600, color: "var(--oui-dt-fg)", }, @@ -116,7 +139,7 @@ const styles = { actions: { display: "flex", gap: 8, - marginTop: 2, + marginTop: 10, }, action: { display: "inline-flex", @@ -131,10 +154,15 @@ const styles = { fontSize: 12, fontWeight: 500, cursor: "pointer", + transition: "transform 150ms ease", + }, + actionHover: { + transform: "scale(0.96)", }, actionSecondary: { - background: "var(--oui-dt-bg)", + background: "var(--oui-dt-control-bg)", color: "var(--oui-dt-fg)", - border: "1px solid var(--oui-dt-border)", + border: "1px solid var(--oui-dt-control-border)", + boxShadow: "var(--oui-dt-shadow-subtle)", }, } satisfies Record; diff --git a/packages/devtools/src/ReactLangStreamEventRow.tsx b/packages/devtools/src/ReactLangStreamEventRow.tsx index 9eca44ff2..2715b21b2 100644 --- a/packages/devtools/src/ReactLangStreamEventRow.tsx +++ b/packages/devtools/src/ReactLangStreamEventRow.tsx @@ -1,6 +1,6 @@ import { type ObservabilityEvent } from "@openuidev/observability"; import { Bug, Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; -import { useMemo, useState, type CSSProperties } from "react"; +import { useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { LevelIcon } from "./LevelIcon"; import { TOKEN_COLOR, tokenizeLang } from "./paste/highlight"; @@ -60,6 +60,8 @@ export function ReactLangStreamEventRow({ canOpenInPaste?: boolean; }) { const [expanded, setExpanded] = useState(false); + const [hovered, setHovered] = useState(false); + const [hoveredAction, setHoveredAction] = useState(null); const [responseCopied, setResponseCopied] = useState(false); // Collapsed rows skip tokenizing: a live stream re-renders this on every chunk. const responseTokens = useMemo( @@ -92,7 +94,11 @@ export function ReactLangStreamEventRow({ const openInPasteDisabled = isStreaming || !stream.response || !canOpenInPaste; return ( -
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + >
+ {visibleErrors.length > 0 ? ( + + error{visibleErrors.length === 1 ? "" : "s"} + + ) : null} {statementCount !== undefined ? ( - - {statementCount} statement{statementCount === 1 ? "" : "s"} - + statement{statementCount === 1 ? "" : "s"} ) : null} {orphaned.length > 0 ? ( - - {orphaned.length} orphaned statement{orphaned.length === 1 ? "" : "s"} - - ) : null} - {visibleErrors.length > 0 ? ( - - {visibleErrors.length} error{visibleErrors.length === 1 ? "" : "s"} - + + orphaned statement{orphaned.length === 1 ? "" : "s"} + ) : null}
{expanded ? (
+
+ Actions +
+ + +
+
+ {visibleErrors.length > 0 ? (
-
Errors ({visibleErrors.length})
+
+ Errors {visibleErrors.length} +
{visibleErrors.map((error, index) => ( -
- - -
) : null} @@ -205,6 +227,29 @@ export function ReactLangStreamEventRow({ ); } +/** + * One overview stat: the number in a circle, then what it counts. Errors get + * the danger tint so severity still reads at a glance. + */ +function Tally({ + count, + danger = false, + children, +}: { + count: number; + danger?: boolean; + children: ReactNode; +}) { + return ( + + + {count} + + {children} + + ); +} + function Diagnostic({ error }: { error: Record }) { const source = asString(error["source"]); const code = asString(error["code"]); @@ -242,14 +287,24 @@ const FONT = '"Inter", system-ui, sans-serif'; const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace"; const styles = { + // Longhands, not the `border` shorthand: rowHover overrides borderColor, and + // React blanks a shorthand's longhands when a later style touches one of them. row: { - border: "1px solid var(--oui-dt-border)", + borderWidth: 1, + borderStyle: "solid", + borderColor: "var(--oui-dt-border)", borderRadius: 12, padding: 12, display: "flex", flexDirection: "column", gap: 6, - background: "var(--oui-dt-bg)", + background: "var(--oui-dt-card)", + transition: "border-color 150ms ease, box-shadow 150ms ease", + }, + // Only this row expands, so hover is the affordance that says so. + rowHover: { + borderColor: "var(--oui-dt-border-strong)", + boxShadow: "var(--oui-dt-shadow-subtle)", }, rowHeader: { display: "flex", @@ -264,11 +319,23 @@ const styles = { flexShrink: 0, }, // Width is fixed to match the empty slot plain rows reserve, so timestamps align. + // Sized like an icon button so it can take the same fill; the card's own + // hover drives it, since the whole card is the toggle. chevron: { display: "inline-flex", - width: 14, + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + width: 22, + height: 22, flexShrink: 0, + borderRadius: 6, color: "var(--oui-dt-fg-muted)", + transition: "background 150ms ease, color 150ms ease", + }, + chevronHover: { + background: "var(--oui-dt-bg-subtle)", + color: "var(--oui-dt-fg)", }, badgeGroup: { display: "flex", @@ -292,7 +359,7 @@ const styles = { kind: { color: "var(--oui-dt-fg)", fontSize: 12, - fontWeight: 700, + fontWeight: 600, }, badgeStreaming: { background: "var(--oui-dt-success-bg)", @@ -322,15 +389,33 @@ const styles = { marginTop: 7, paddingLeft: 24, }, - errorSummary: { + tally: { + display: "inline-flex", + alignItems: "center", + gap: 4, + }, + tallyCount: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + minWidth: 14, + height: 14, + padding: "0 3px", + borderRadius: 999, + background: "var(--oui-dt-bg-subtle)", + color: "var(--oui-dt-fg-secondary)", + fontSize: 10, + lineHeight: 1, + }, + tallyCountDanger: { + background: "var(--oui-dt-danger-bg)", color: "var(--oui-dt-danger)", - fontWeight: 600, }, streamExpanded: { display: "flex", flexDirection: "column", gap: 14, - borderTop: "1px solid var(--oui-dt-border-subtle)", marginTop: 4, paddingTop: 12, }, @@ -340,31 +425,62 @@ const styles = { gap: 6, }, streamSectionTitle: { + display: "flex", + alignItems: "center", + gap: 6, color: "var(--oui-dt-fg-secondary)", fontSize: 11, - fontWeight: 600, - textTransform: "uppercase", - letterSpacing: "0.04em", }, + streamSectionCount: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minWidth: 16, + height: 16, + borderRadius: 999, + padding: "0 5px", + boxSizing: "border-box", + background: "var(--oui-dt-bg-subtle)", + color: "var(--oui-dt-fg-muted)", + fontSize: 10, + lineHeight: 1, + }, + // Label and buttons share one unstroked panel: label left, buttons right. responseActions: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 10, + borderRadius: 8, + background: "var(--oui-dt-bg-muted)", + padding: "8px 8px 8px 14px", + }, + responseActionsLabel: { + color: "var(--oui-dt-fg-secondary)", + fontSize: 11, + }, + responseActionsButtons: { display: "flex", alignItems: "center", gap: 6, - marginTop: 2, }, responseButton: { display: "inline-flex", alignItems: "center", gap: 4, - border: "1px solid var(--oui-dt-border)", + border: "1px solid var(--oui-dt-control-border)", borderRadius: 8, - background: "var(--oui-dt-bg)", - color: "var(--oui-dt-fg-secondary)", + background: "var(--oui-dt-control-bg)", + color: "var(--oui-dt-fg)", cursor: "pointer", fontFamily: FONT, fontSize: 11, - fontWeight: 500, padding: "4px 9px", + boxShadow: "var(--oui-dt-shadow-subtle)", + transition: "transform 150ms ease", + }, + responseButtonHover: { + transform: "scale(0.96)", }, responseButtonDisabled: { opacity: 0.45, @@ -374,7 +490,6 @@ const styles = { maxHeight: 260, overflow: "auto", margin: 0, - border: "1px solid var(--oui-dt-border)", borderRadius: 8, background: "var(--oui-dt-bg-muted)", color: "var(--oui-dt-fg-tertiary)", diff --git a/packages/devtools/src/ThemeToggle.tsx b/packages/devtools/src/ThemeToggle.tsx new file mode 100644 index 000000000..3e74198ad --- /dev/null +++ b/packages/devtools/src/ThemeToggle.tsx @@ -0,0 +1,82 @@ +import { Moon, Sun } from "lucide-react"; +import type { CSSProperties } from "react"; +import { IconButton } from "./IconButton"; +import type { ColorScheme } from "./theme"; + +const OPTIONS: { id: ColorScheme; label: string; icon: typeof Sun }[] = [ + { id: "light", label: "Light", icon: Sun }, + { id: "dark", label: "Dark", icon: Moon }, +]; + +/** + * Two icon buttons for the Inspect settings menu, where the row has a label + * and room to show both choices. The selected one is filled; the other is + * chrome-less until pointed at. + */ +export function ThemeSegmented({ + value, + onChange, +}: { + value: ColorScheme; + onChange: (value: ColorScheme) => void; +}) { + return ( +
+ {OPTIONS.map((option) => { + const active = value === option.id; + const Icon = option.icon; + return ( + onChange(option.id)} + aria-label={option.label} + title={option.label} + > + + + ); + })} +
+ ); +} + +/** + * Single-button flip for the Debug header, which is a row of icon buttons with + * no room for a labelled pair. It shows the theme you would get, not the one + * you are in — the icon is the outcome of pressing it. + */ +export function ThemeToggle({ + value, + onChange, +}: { + value: ColorScheme; + onChange: (value: ColorScheme) => void; +}) { + const next: ColorScheme = value === "dark" ? "light" : "dark"; + const label = next === "dark" ? "Switch to dark theme" : "Switch to light theme"; + const Icon = next === "dark" ? Moon : Sun; + + return ( + onChange(next)} aria-label={label} title={label}> + + + ); +} + +const styles = { + segmented: { + display: "inline-flex", + alignItems: "center", + gap: 2, + flexShrink: 0, + }, + selected: { + background: "var(--oui-dt-inverted)", + color: "var(--oui-dt-inverted-fg)", + borderColor: "var(--oui-dt-inverted)", + }, +} satisfies Record; diff --git a/packages/devtools/src/paste/HelpDialog.tsx b/packages/devtools/src/paste/HelpDialog.tsx index ac9371575..f6dbc6d38 100644 --- a/packages/devtools/src/paste/HelpDialog.tsx +++ b/packages/devtools/src/paste/HelpDialog.tsx @@ -1,25 +1,25 @@ -import { ArrowDown, ClipboardPaste, ListChecks, MonitorPlay, Play, X } from "lucide-react"; +import { X } from "lucide-react"; import { useEffect, useState, type CSSProperties } from "react"; +import { IconButton } from "../IconButton"; import { pasteStyles as s } from "./styles"; -const STEPS: { icon: typeof Play; title: string; text: string }[] = [ +const SUMMARY = + "OpenUI Debug is a scratchpad for OpenUI Lang: paste a model response and see how it parses, validates, and renders against this app's own components."; + +const STEPS: { title: string; text: string }[] = [ { - icon: ClipboardPaste, title: "Paste OpenUI Lang", text: "Drop in a model response, or write Lang by hand, in the editor on the left.", }, { - icon: MonitorPlay, title: "Watch it render", text: "Render uses the host app's real createLibrary() components and CSS. Query() and Mutation() resolve with mocked data.", }, { - icon: ListChecks, title: "Read the diagnostics", text: "Validation groups parse errors by code and lists unresolved refs; Tree and JSON show the parsed result.", }, { - icon: Play, title: "Replay it as a stream", text: "Stream re-emits the editor chunk by chunk with LLM-like jitter. Pause, step, or fix the Seed to reproduce a run.", }, @@ -60,7 +60,7 @@ export function HelpDialog({ style={styles.trigger} onClick={() => setOpen(true)} aria-haspopup="dialog" - aria-label="How to use OpenUI Paste" + aria-label="How to use OpenUI Debug" > Help @@ -70,37 +70,35 @@ export function HelpDialog({ style={s.helpDialog} role="dialog" aria-modal="true" - aria-label="How to use OpenUI Paste" + aria-label="How to use OpenUI Debug" onClick={(event) => event.stopPropagation()} >
- How to use OpenUI Paste - + How to use OpenUI Debug + + +
- {STEPS.map((step, index) => { - const Icon = step.icon; - return ( -
-
- - - -
-
{step.title}
-

{step.text}

-
+

{SUMMARY}

+
    + {STEPS.map((step, index) => ( +
  1. + + {index + 1} + +
    +
    {step.title}
    +

    {step.text}

    - {index < STEPS.length - 1 ? ( -
    - -
    - ) : null} -
- ); - })} + + ))} + +
+
+
@@ -109,73 +107,96 @@ export function HelpDialog({ ); } -const TILE = 38; +const BADGE = 18; const styles = { + // Matches the Inspect header's text buttons (Wrap / Copy). trigger: { display: "inline-flex", alignItems: "center", - height: 26, - border: "1px solid var(--oui-dt-border)", + gap: 4, + border: "1px solid var(--oui-dt-control-border)", borderRadius: 8, - background: "var(--oui-dt-bg)", - color: "var(--oui-dt-fg-tertiary)", + background: "var(--oui-dt-control-bg)", + color: "var(--oui-dt-fg)", cursor: "pointer", fontFamily: "inherit", fontSize: 12, fontWeight: 500, - padding: "0 10px", + padding: "4px 10px", }, - closeButton: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 26, - height: 26, - border: "1px solid var(--oui-dt-border)", - borderRadius: 8, - background: "var(--oui-dt-bg)", - color: "var(--oui-dt-fg-muted)", - cursor: "pointer", + // What the tool is, before the how. + summary: { + margin: "0 0 16px", + maxWidth: "62ch", + fontSize: 12, + fontWeight: 400, + lineHeight: 1.55, + color: "var(--oui-dt-fg-secondary)", + }, + steps: { + listStyle: "none", + display: "flex", + flexDirection: "column", + gap: 20, + margin: 0, padding: 0, }, step: { display: "flex", alignItems: "flex-start", - gap: 12, + gap: 10, }, - tile: { + // Same chip proportions as LevelIcon, so the dialog matches the event rows. + badge: { display: "inline-flex", alignItems: "center", justifyContent: "center", flexShrink: 0, boxSizing: "border-box", - width: TILE, - height: TILE, - border: "1px solid var(--oui-dt-border)", - borderRadius: 10, + width: BADGE, + height: BADGE, + borderRadius: 6, background: "var(--oui-dt-bg-subtle)", color: "var(--oui-dt-fg-secondary)", + fontSize: 11, + fontWeight: 500, + lineHeight: 1, + }, + stepBody: { + minWidth: 0, }, + // Medium title over regular body. stepTitle: { color: "var(--oui-dt-fg)", - fontSize: 13, - fontWeight: 700, - // Optically centers the title against the tile's first line. - paddingTop: 2, + fontSize: 12, + fontWeight: 500, }, stepText: { - margin: "2px 0 0", + margin: "3px 0 0", + maxWidth: "52ch", fontSize: 12, - lineHeight: 1.5, + fontWeight: 400, + lineHeight: 1.55, color: "var(--oui-dt-fg-muted)", }, - // Sits under the tile column so the tiles read as one flow. - arrow: { + // Second way out, for anyone who reads to the end rather than reaching for + // the cross. + footer: { display: "flex", - justifyContent: "center", - width: TILE, - padding: "6px 0", - color: "var(--oui-dt-fg-faint)", + justifyContent: "flex-end", + padding: "0 20px 16px", + }, + dismiss: { + border: "1px solid var(--oui-dt-border)", + borderRadius: 8, + background: "var(--oui-dt-bg)", + color: "var(--oui-dt-fg-secondary)", + boxShadow: "var(--oui-dt-shadow-subtle)", + cursor: "pointer", + fontFamily: "inherit", + fontSize: 12, + fontWeight: 500, + padding: "6px 14px", }, } satisfies Record; diff --git a/packages/devtools/src/paste/LangEditor.tsx b/packages/devtools/src/paste/LangEditor.tsx index 30b480871..7c0ee5e39 100644 --- a/packages/devtools/src/paste/LangEditor.tsx +++ b/packages/devtools/src/paste/LangEditor.tsx @@ -110,7 +110,6 @@ const styles = { minHeight: 0, height: "100%", background: "var(--oui-dt-bg-muted)", - borderRight: "1px solid var(--oui-dt-border-subtle)", }, highlight: { ...shared, diff --git a/packages/devtools/src/paste/PasteUI.tsx b/packages/devtools/src/paste/PasteUI.tsx index cf56a499e..3e388b60a 100644 --- a/packages/devtools/src/paste/PasteUI.tsx +++ b/packages/devtools/src/paste/PasteUI.tsx @@ -1,7 +1,9 @@ -import { ArrowLeft, Maximize2, X } from "lucide-react"; -import { Component, useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { ArrowLeft, Maximize2, Minimize2, X } from "lucide-react"; +import { Component, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { IconButton } from "../IconButton"; import { type RegisteredLibrary } from "../libraryRegistry"; -import { themeVars, useDevtoolsScheme } from "../theme"; +import { themeVars, useDevtoolsScheme, type ColorScheme } from "../theme"; +import { ThemeToggle } from "../ThemeToggle"; import type { ChunkStrategy } from "./chunker"; import { HelpDialog } from "./HelpDialog"; import { LangEditor } from "./LangEditor"; @@ -19,6 +21,12 @@ import { useValidation } from "./useValidation"; const CHAR_STRATEGY_LIMIT = 50 * 1024; +/** Editor's share of the split, as a percentage. The panels get the rest. */ +const DEFAULT_EDITOR_PCT = 25; +const MIN_EDITOR_PCT = 12; +const MAX_EDITOR_PCT = 75; +const SPLITTER = 14; + type Tab = "render" | "validation" | "tree" | "json" | "stream"; const TABS: { id: Tab; label: string }[] = [ @@ -59,10 +67,14 @@ export interface PasteUIProps { onCodeChange: (code: string) => void; ejected?: boolean; onEject?: () => void; + /** Returns an ejected window to the tray. */ + onMinimize?: () => void; /** Dismisses the whole widget (or the ejected window). */ onClose: () => void; /** Narrows the drawer back to the event list. Omitted when ejected. */ onBack?: () => void; + theme: ColorScheme; + onThemeChange: (theme: ColorScheme) => void; /** False on the first ever visit, which opens the help guide unprompted. */ helpSeen?: boolean; onHelpSeen?: () => void; @@ -84,8 +96,11 @@ export function PasteUI({ onCodeChange, ejected = false, onEject, + onMinimize, onClose, onBack, + theme, + onThemeChange, helpSeen = true, onHelpSeen, popupBlocked = false, @@ -97,6 +112,10 @@ export function PasteUI({ const outcome = useValidation(code, lang, schema, rootName); const playback = usePlayback(code, lang, schema, rootName); const [tab, setTab] = useState("render"); + const [editorPct, setEditorPct] = useState(DEFAULT_EDITOR_PCT); + const [resizing, setResizing] = useState(false); + const [splitHover, setSplitHover] = useState(false); + const bodyRef = useRef(null); const [strategy, setStrategy] = useState("llm"); const [seed, setSeed] = useState(42); const scheme = useDevtoolsScheme(); @@ -116,6 +135,36 @@ export function PasteUI({ const renderedCode = playbackActive ? playback.state.prefix : code; const streamDisabled = !lang || !code.trim() || schema == null; + const clampPct = (pct: number) => Math.min(MAX_EDITOR_PCT, Math.max(MIN_EDITOR_PCT, pct)); + + // Listeners go on the captured handle, not on `window` — when Debug is + // ejected the tray is portaled into the popup's document, and the + // module-scope window never sees those pointer events. Pointer capture also + // keeps the drag alive once the cursor leaves the handle. + const startResize = (event: React.PointerEvent) => { + const body = bodyRef.current; + if (!body) return; + const handle = event.currentTarget; + handle.setPointerCapture(event.pointerId); + setResizing(true); + + const onMove = (move: PointerEvent) => { + const bounds = body.getBoundingClientRect(); + if (bounds.width === 0) return; + setEditorPct(clampPct(((move.clientX - bounds.left) / bounds.width) * 100)); + }; + const onEnd = () => { + setResizing(false); + handle.removeEventListener("pointermove", onMove); + handle.removeEventListener("pointerup", onEnd); + handle.removeEventListener("pointercancel", onEnd); + }; + + handle.addEventListener("pointermove", onMove); + handle.addEventListener("pointerup", onEnd); + handle.addEventListener("pointercancel", onEnd); + }; + const changeCode = (next: string) => { if (playback.state.status !== "idle") playback.reset(); onCodeChange(next); @@ -126,15 +175,11 @@ export function PasteUI({
{onBack ? ( - + ) : null} - OpenUI Paste + OpenUI Debug {lang?.langCoreVersion ? ( lang-core {lang.langCoreVersion} @@ -143,26 +188,27 @@ export function PasteUI({
+ {!ejected && onEject ? ( - + ) : null} - +
{popupBlocked ? ( -
Allow popups for this origin to eject OpenUI Paste.
+
Allow popups for this origin to eject OpenUI Debug.
) : null} CHAR_STRATEGY_LIMIT} disabled={streamDisabled} /> -
+
{playbackActive ? Streaming… : null}
+
setSplitHover(true)} + onMouseLeave={() => setSplitHover(false)} + onKeyDown={(event) => { + if (event.key === "ArrowLeft") setEditorPct((pct) => clampPct(pct - 2)); + if (event.key === "ArrowRight") setEditorPct((pct) => clampPct(pct + 2)); + }} + role="separator" + aria-orientation="vertical" + aria-label="Resize editor" + aria-valuenow={Math.round(editorPct)} + aria-valuemin={MIN_EDITOR_PCT} + aria-valuemax={MAX_EDITOR_PCT} + tabIndex={0} + > + + + + + + +
{TABS.map((item) => { @@ -201,7 +289,7 @@ export function PasteUI({
Loading renderer…
) : lang === null ? (
- Install @openuidev/react-lang to use OpenUI Paste. + Install @openuidev/react-lang to use OpenUI Debug.
) : selected ? ( <> @@ -278,24 +366,6 @@ const styles = { gap: 6, flexShrink: 0, }, - iconButton: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - width: 26, - height: 26, - border: "none", - borderRadius: 8, - background: "transparent", - color: "var(--oui-dt-fg-muted)", - cursor: "pointer", - padding: 0, - }, - iconButtonOutlined: { - boxSizing: "border-box", - border: "1px solid var(--oui-dt-border)", - background: "var(--oui-dt-bg)", - }, banner: { padding: "8px 16px", background: "var(--oui-dt-warning-bg)", @@ -303,16 +373,69 @@ const styles = { fontSize: 12, borderBottom: "1px solid var(--oui-dt-warning-border)", }, + // Columns are set inline so the drag can move them. body: { flex: 1, minHeight: 0, display: "grid", - gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)", + }, + // A hairline that reads as a divider, inside a wider transparent strip so + // it is still easy to grab. The handle only shows once you are on it. + splitter: { + position: "relative", + cursor: "col-resize", + background: "transparent", + touchAction: "none", + }, + splitterLine: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + width: 1, + background: "var(--oui-dt-border-subtle)", + transition: "width 150ms ease, background 150ms ease", + pointerEvents: "none", + }, + splitterLineOn: { + width: 3, + background: "var(--oui-dt-border-strong)", + }, + // Thin rounded chip with three stacked dots, centred on the divider. + splitterGrip: { + position: "absolute", + top: "50%", + left: 0, + transform: "translate(-50%, -50%)", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 3, + boxSizing: "border-box", + width: 11, + height: 30, + borderRadius: 999, + border: "1px solid var(--oui-dt-control-border)", + background: "var(--oui-dt-control-bg)", + opacity: 0, + transition: "opacity 150ms ease", + pointerEvents: "none", + }, + splitterGripOn: { + opacity: 1, + }, + splitterDot: { + width: 2, + height: 2, + borderRadius: "50%", + background: "var(--oui-dt-fg)", }, editorWrap: { position: "relative", minWidth: 0, minHeight: 0, + overflow: "hidden", }, output: { display: "flex", diff --git a/packages/devtools/src/paste/StreamToolbar.tsx b/packages/devtools/src/paste/StreamToolbar.tsx index 18a500080..2c72e491d 100644 --- a/packages/devtools/src/paste/StreamToolbar.tsx +++ b/packages/devtools/src/paste/StreamToolbar.tsx @@ -5,6 +5,7 @@ import { pasteStyles as s } from "./styles"; import type { PlaybackControls } from "./usePlayback"; const SPEEDS = ["0.25", "0.5", "1", "2", "4", "8"]; +// Every control in the toolbar draws its icon at one size and one stroke. const ICON = { size: 14 }; export interface StreamSettings { @@ -14,15 +15,9 @@ export interface StreamSettings { onSeedChange: (n: number) => void; } -function groupStyle( - primary: boolean, - disabled: boolean, - first: boolean, - last: boolean, -): CSSProperties { +function groupStyle(disabled: boolean, first: boolean, last: boolean): CSSProperties { return { ...s.groupButton, - ...(primary ? s.groupButtonPrimary : null), ...(disabled ? s.groupButtonDisabled : null), borderTopLeftRadius: first ? 8 : 0, borderBottomLeftRadius: first ? 8 : 0, @@ -53,7 +48,7 @@ export function StreamToolbar({
{state.status === "playing" ? ( )}