UNPKG

@tanstack/devtools

Version:

TanStack Devtools is a set of tools for building advanced devtools for your application.

1,554 lines (1,546 loc) 185 kB
import { PLUGIN_TITLE_CONTAINER_ID, PLUGIN_CONTAINER_ID } from '../chunk/A767CXXU.js'; import { createPiPWindow, TANSTACK_DEVTOOLS, PANEL_CLOSE_THRESHOLD, getAllPermutations, PANEL_MAX_VIEWPORT_RATIO, DevtoolsContext, flattenTabs, WORKBENCH_GUTTER, WORKBENCH_GUTTER_NARROW, PLUGIN_GROUP_TAB_HEIGHT, PLUGINS_STRIP_HEIGHT, WORKBENCH_HEADER_HEIGHT, layoutRects, PLUGIN_SPLITTER_SIZE, splitterHandles, allGroups, MAX_ACTIVE_PLUGINS, closeTab, activateTab, moveTab, setTabs, appendPane, singleGroup, stackInto, splitAt, findGroupOfTab, zoneAt, PANE_DROP_EDGE_RATIO, canSplit, MIN_PANE_SIZE, uppercaseFirstLetter } from '../chunk/TI24F65M.js'; import { keyboardModifiers } from '../chunk/CUZKAGZ3.js'; import { createComponent, Portal, ssr, ssrAttribute, escape, ssrStyle, ssrStyleProperty, ssrElement, mergeProps } from 'solid-js/web'; import { createSignal, onMount, createEffect, onCleanup, Show, createMemo, untrack, For, useContext, Index } from 'solid-js'; import { createShortcut, useKeyDownList } from '@solid-primitives/keyboard'; import { ThemeContextProvider, ChevronDownIcon, MainPanel as MainPanel$1, Section, SectionTitle, SectionIcon, SectionDescription, Checkbox, Select, Input, CloseIcon, SearchIcon, SettingsIcon, Button, PackageIcon as PackageIcon$1, ExternalLinkIcon, CheckCircleIcon, XCircleIcon } from '@tanstack/devtools-ui'; import { devtoolsEventClient } from '@tanstack/devtools-client'; import clsx from 'clsx'; import * as goober from 'goober'; import { resolveSemanticTheme } from '@tanstack/devtools-ui/internal'; import { Cogs, PiP, X, PackageIcon, SettingsCog, Link, Keyboard, GeoTag } from '@tanstack/devtools-ui/icons'; import { createSortable } from '@neodrag/solid/sortable'; import { createStore } from 'solid-js/store'; import { createElementSize } from '@solid-primitives/resize-observer'; import { createEventListener } from '@solid-primitives/event-listener'; var createDevtoolsContext = () => { const context = useContext(DevtoolsContext); if (context === void 0) { throw new Error( "createDevtoolsContext must be used within a ShellContextProvider" ); } return context; }; function createTheme() { const { settings, setSettings } = createDevtoolsSettings(); const theme = createMemo(() => settings().theme); return { theme, setTheme: (theme2) => setSettings({ theme: theme2 }) }; } var createPlugins = () => { const { store, setStore } = createDevtoolsContext(); const plugins = createMemo(() => store.plugins); const activePlugins = createMemo(() => flattenTabs(store.state.layout), [], { equals: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]) }); const layout = createMemo(() => store.state.layout); const setLayout = (next) => { setStore((previous) => ({ ...previous, state: { ...previous.state, layout: next } })); }; const toggleActivePlugins = (pluginId) => { const current = store.state.layout; const isActive = flattenTabs(current).includes(pluginId); if (isActive) { setLayout(closeTab(current, pluginId)); return; } if (flattenTabs(current).length >= MAX_ACTIVE_PLUGINS) return; setLayout(appendPane(current, pluginId)); }; return { plugins, toggleActivePlugins, activePlugins, layout, setLayout }; }; var createDevtoolsState = () => { const { store, setStore } = createDevtoolsContext(); const state = createMemo(() => store.state); const setState = (newState) => { setStore((previous) => ({ ...previous, state: { ...previous.state, ...newState } })); }; return { state, setState }; }; var createDevtoolsSettings = () => { const { store, setStore } = createDevtoolsContext(); const settings = createMemo(() => store.settings); const setSettings = (newSettings) => { setStore((previous) => ({ ...previous, settings: { ...previous.settings, ...newSettings } })); }; return { setSettings, settings }; }; var createPersistOpen = () => { const { state, setState } = createDevtoolsState(); const persistOpen = createMemo(() => state().persistOpen); const setPersistOpen = (value) => { setState({ persistOpen: value }); }; return { persistOpen, setPersistOpen }; }; var [collapsed, setCollapsed] = createSignal(false); var createCollapsed = () => ({ isCollapsed: collapsed, toggleCollapsed: () => setCollapsed((previous) => !previous), setCollapsed }); var createStripDrag = () => { const { paneDragBridge } = createDevtoolsContext(); return { /** Called by the workspace during setup; cleared when it goes away. */ acceptStripDrags: (handler) => { paneDragBridge.handler = handler; onCleanup(() => { if (paneDragBridge.handler === handler) paneDragBridge.handler = null; }); }, /** Called by the strip once a press has been held long enough to be a drag. */ beginStripDrag: (pluginId, point) => paneDragBridge.handler?.(pluginId, point) }; }; var createHeight = () => { const { state, setState } = createDevtoolsState(); const height = createMemo(() => state().height); const setHeight = (newHeight) => { setState({ height: newHeight }); }; return { height, setHeight }; }; var recursivelyChangeTabIndex = (node, remove = true) => { if (remove) { node.setAttribute("tabIndex", "-1"); } else { node.removeAttribute("tabIndex"); } for (const child of node.children) { recursivelyChangeTabIndex(child, remove); } }; var createDisableTabbing = (isOpen) => { createEffect(() => { const el = document.getElementById(TANSTACK_DEVTOOLS); if (!el) return; recursivelyChangeTabIndex(el, !isOpen()); }); }; // src/utils/hotkey.ts var normalizeHotkey = (keys) => { if (!keys.includes("CtrlOrMeta")) { return [keys]; } return [ keys.map((key) => key === "CtrlOrMeta" ? "Control" : key), keys.map((key) => key === "CtrlOrMeta" ? "Meta" : key) ]; }; var getHotkeyPermutations = (hotkey) => { const normalizedHotkeys = normalizeHotkey(hotkey); return normalizedHotkeys.flatMap((normalizedHotkey) => { const modifiers = normalizedHotkey.filter( (key) => keyboardModifiers.includes(key) ); const nonModifiers = normalizedHotkey.filter( (key) => !keyboardModifiers.includes(key) ); if (modifiers.length === 0) { return [nonModifiers]; } const allModifierCombinations = getAllPermutations(modifiers); return allModifierCombinations.map((combo) => [...combo, ...nonModifiers]); }); }; var isHotkeyCombinationPressed = (keys, hotkey) => { const permutations = getHotkeyPermutations(hotkey); const pressedKeys = keys.map((key) => key.toUpperCase()); return permutations.some( (combo) => ( // every key in the combo must be pressed combo.every((key) => pressedKeys.includes(String(key).toUpperCase())) && // and no extra keys beyond the combo pressedKeys.every( (key) => combo.map((k) => String(k).toUpperCase()).includes(key) ) ) ); }; var WORKBENCH_GEOMETRY_STYLE_ID = "tanstack-devtools-workbench-geometry"; var ensureWorkbenchGeometryStyles = (targetDocument) => { if (targetDocument.getElementById(WORKBENCH_GEOMETRY_STYLE_ID)) return; const style = targetDocument.createElement("style"); style.id = WORKBENCH_GEOMETRY_STYLE_ID; style.textContent = ` @media (max-width: 360px) { .tsd-workbench-wordmark { display: none; } } @media (prefers-reduced-motion: reduce) { .tsd-workbench-secondary-tabs, .tsd-workbench-secondary-tabs > * { transition: none !important; } .tsd-motion-safe { animation: none !important; transition: none !important; transform: none !important; } /* Core controls and surfaces animate on hover/active by default; drop all of it in one place. Both markers are stamped by core only, so this never reaches inside a plugin's own markup. */ [data-tsd-control], [data-tsd-surface] { transition: none !important; } }`; targetDocument.head.appendChild(style); }; var fadeIn = goober.keyframes` from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } `; var slideInRight = goober.keyframes` from { transform: translateX(100%); } to { transform: translateX(0); } `; var slideUp = goober.keyframes` from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } `; var statusFadeIn = goober.keyframes` from { opacity: 0; } to { opacity: 1; } `; var spin = goober.keyframes` to { transform: rotate(360deg); } `; var stylesFactory = (theme) => { const semantic = resolveSemanticTheme(theme); const css2 = goober.css; return { seoWorkspace: css2` display: flex; flex-direction: column; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; `, seoContent: css2` flex: 1 1 auto; height: auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; `, seoPreviewSection: css2` display: flex; flex-direction: row; gap: 16px; margin-bottom: 0; justify-content: flex-start; align-items: flex-start; overflow-x: auto; flex-wrap: wrap; padding-bottom: 0.5rem; `, seoPreviewCard: css2` border: 1px solid ${semantic.color.border.decorative}; border-radius: ${semantic.radius.overlay}; padding: 12px; background: ${semantic.color.surface.elevated}; margin-bottom: 0; box-shadow: ${semantic.shadow.xs}; display: flex; flex-direction: column; align-items: stretch; min-width: 200px; max-width: 240px; font-size: ${semantic.type.bodySm.size}; gap: ${semantic.gap.tight}; `, seoPreviewHeader: css2` display: flex; align-items: center; gap: 6px; font-size: ${semantic.type.bodyXs.size}; font-weight: 600; letter-spacing: ${semantic.type.labelSm.tracking}; text-transform: uppercase; margin-bottom: 0; color: ${semantic.color.text.secondary}; `, /** * The network's own brand colour survives as a small dot so the card can * still be identified at a glance, without giving every card a different * coloured outline. */ seoPreviewNetworkDot: css2` width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; box-shadow: inset 0 0 0 1px ${semantic.color.state.hover}; @media (forced-colors: active) { forced-color-adjust: none; } `, seoPreviewImage: css2` width: 100%; max-width: 100%; box-sizing: border-box; border-radius: ${semantic.radius.group}; margin-bottom: 6px; background: ${semantic.color.surface.subtle}; height: 120px; object-fit: cover; `, seoPreviewImagePlaceholder: css2` display: flex; align-items: center; justify-content: center; color: ${semantic.color.text.muted}; font-size: ${semantic.type.bodyXs.size}; border: 1px dashed ${semantic.color.border.decorative}; `, seoPreviewTitle: css2` font-family: ${semantic.font.display}; font-size: ${semantic.type.bodySm.size}; line-height: ${semantic.type.bodySm.lineHeight}; font-weight: 700; margin-bottom: 2px; color: ${semantic.color.text.primary}; `, seoPreviewDesc: css2` color: ${semantic.color.text.secondary}; margin-bottom: 4px; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; `, seoPreviewUrl: css2` color: ${semantic.color.text.muted}; font-family: ${semantic.font.mono}; font-size: 11px; margin-bottom: 0; word-break: break-all; `, seoMissingTagsSection: css2` margin-top: 6px; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; color: ${semantic.color.status.error.text}; `, seoMissingTagsList: css2` margin: 4px 0 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 4px; max-width: 240px; `, seoMissingTag: css2` background: ${semantic.color.status.error.subtleFill}; color: ${semantic.color.status.error.text}; border-radius: ${semantic.radius.control}; padding: 2px 6px; font-family: ${semantic.font.mono}; font-size: 11px; font-weight: 500; `, /* No box of its own: the section already provides one, and the snippet below provides another. A label plus spacing is enough. */ serpPreviewBlock: css2` margin-bottom: ${WORKBENCH_GUTTER}px; &:last-child { margin-bottom: 0; } `, serpPreviewLabel: css2` font-size: ${semantic.type.bodyXs.size}; font-weight: 600; letter-spacing: ${semantic.type.labelSm.tracking}; text-transform: uppercase; margin-bottom: 6px; color: ${semantic.color.text.secondary}; `, serpSnippet: css2` border: 1px solid ${semantic.color.border.decorative}; border-radius: 8px; padding: 1rem 1.25rem; background: ${semantic.color.surface.elevated}; max-width: 600px; font-family: ${semantic.font.body}; box-shadow: ${semantic.shadow.xs}; `, serpSnippetMobile: css2` border: 1px solid ${semantic.color.border.decorative}; border-radius: 8px; padding: 1rem 1.25rem; background: ${semantic.color.surface.elevated}; max-width: 380px; font-family: ${semantic.font.body}; box-shadow: ${semantic.shadow.xs}; `, serpSnippetDescMobile: css2` font-size: 0.875rem; color: ${semantic.color.text.secondary}; margin: 0; line-height: 1.5; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; `, serpSnippetTopRow: css2` display: flex; align-items: center; gap: 12px; margin-bottom: 8px; `, serpSnippetFavicon: css2` width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0; object-fit: contain; overflow: hidden; display: flex; align-items: center; justify-content: center; `, serpSnippetDefaultFavicon: css2` width: 28px; height: 28px; background-color: ${semantic.color.surface.subtle}; border-radius: 50%; flex-shrink: 0; object-fit: contain; overflow: hidden; display: flex; align-items: center; justify-content: center; `, serpSnippetSiteColumn: css2` display: flex; flex-direction: column; gap: 0; min-width: 0; `, serpSnippetSiteName: css2` font-size: 0.875rem; color: ${semantic.color.text.primary}; line-height: 1.4; margin: 0; `, serpSnippetSiteUrl: css2` font-size: 0.75rem; color: ${semantic.color.text.muted}; line-height: 1.4; margin: 0; `, serpSnippetTitle: css2` font-size: 1.25rem; font-weight: 400; color: ${semantic.color.text.link}; margin: 0 0 4px 0; line-height: 1.3; `, serpSnippetDesc: css2` font-size: 0.875rem; color: ${semantic.color.text.secondary}; margin: 0; line-height: 1.5; `, serpErrorList: css2` margin: 4px 0 0 0; padding-left: 1.25rem; list-style-type: disc; `, serpReportItem: css2` margin-top: 0.25rem; color: ${semantic.color.status.error.text}; font-size: 0.875rem; `, devtoolsPanelContainer: (panelLocation, isDetached) => css2` direction: ltr; position: fixed; overflow: visible; ${panelLocation}: 0; inset-inline: 0; z-index: 99999; inline-size: 100%; max-inline-size: 100%; box-sizing: border-box; ${isDetached ? "" : "max-height: 90%;"} border: 0; box-shadow: none; transition: transform 160ms ease-out; @media (prefers-reduced-motion: reduce) { transition-duration: 0ms; } `, devtoolsPanelContainerVisibility: (isOpen) => { return css2` visibility: ${isOpen ? "visible" : "hidden"}; height: ${isOpen ? "auto" : "0"}; `; }, devtoolsPanelContainerResizing: (isResizing) => { if (isResizing()) { return css2` transition: none; `; } return css2` transition: transform 160ms ease-out; @media (prefers-reduced-motion: reduce) { transition-duration: 0ms; } `; }, devtoolsDrawerContent: css2` width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; `, devtoolsPanel: css2` display: grid; font-size: ${semantic.type.bodySm.size}; font-family: ${semantic.font.body}; background-color: ${semantic.color.surface.workspace}; color: ${semantic.color.text.primary}; width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; grid-template-rows: ${WORKBENCH_HEADER_HEIGHT}px minmax(0, 1fr); /* The strip row is auto-sized so the strip's own animated height drives it — a fixed 44px row would snap instead of sliding. */ &:has([data-testid='plugins-strip']) { grid-template-rows: ${WORKBENCH_HEADER_HEIGHT}px auto minmax(0, 1fr); } overflow-x: hidden; overflow-y: hidden; height: 100%; `, workbenchHeader: css2` display: flex; align-items: center; gap: ${semantic.gap.control}; min-width: 0; height: ${WORKBENCH_HEADER_HEIGHT}px; /* No trailing gutter: the action icons run to the panel edge. */ padding: 0 0 0 ${WORKBENCH_GUTTER}px; box-sizing: border-box; background: ${semantic.color.surface.brand}; color: ${semantic.color.text.mutedOnBrand}; /* A translucent ink rule, not border.decorative — decorative *is* the cream brand surface, so it disappears on the chrome band itself. */ border-bottom: 1px solid ${semantic.color.state.pressed}; & button { min-width: 28px; height: 100%; box-sizing: border-box; border: 0; border-radius: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; } & button { transition: all 0.3s ease; } & button:hover:not([data-tsd-selected='true']) { background: ${semantic.color.state.hover}; } @media (prefers-reduced-motion: reduce) { & button { transition: none; } } & button:focus-visible { outline: 2px solid ${semantic.color.border.focus}; outline-offset: 2px; } @media (max-width: 430px) { gap: ${semantic.gap.tight}; padding-inline-start: ${WORKBENCH_GUTTER_NARROW}px; & button { min-width: 24px; } } @media (max-width: 360px) { gap: 2px; padding-inline-start: 4px; & button { padding-inline: 3px; font-size: 11px; } } `, workbenchLogo: css2` display: inline-flex; align-items: center; width: 16px; height: 21px; flex: 0 0 16px; color: ${semantic.color.text.primary}; & > svg { width: 100%; height: 100%; } @media (max-width: 360px) { width: 14px; height: 18px; flex-basis: 14px; } `, workbenchDestinations: css2` display: inline-flex; align-items: stretch; align-self: stretch; gap: 0; /* The destinations are the part that must survive a narrow panel: let them scroll rather than letting flex squeeze the labels together. */ min-width: 0; flex: 0 1 auto; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; &::-webkit-scrollbar { display: none; } margin: 0; padding: 0; & > button { flex: 0 0 auto; } `, workbenchNavButton: css2` margin: 0; padding-inline: 10px; font-size: ${semantic.type.bodyXs.size}; font-weight: ${semantic.type.labelSm.weight}; letter-spacing: ${semantic.type.labelSm.tracking}; color: ${semantic.color.text.mutedOnBrand}; &[data-tsd-selected='true'] { background: ${semantic.color.state.pressed}; color: ${semantic.color.text.primary}; font-weight: 700; } @media (max-width: 361px) { padding-inline: 4px; } `, workbenchActions: css2` display: inline-flex; align-items: center; gap: ${semantic.gap.tight}; height: 100%; margin-left: auto; @media (max-width: 360px) { gap: 0; } `, workbenchActionButton: css2` display: inline-flex; align-items: center; justify-content: center; width: ${WORKBENCH_HEADER_HEIGHT}px; min-width: ${WORKBENCH_HEADER_HEIGHT}px; height: ${WORKBENCH_HEADER_HEIGHT}px; flex: 0 0 ${WORKBENCH_HEADER_HEIGHT}px; padding: 0; color: ${semantic.color.text.mutedOnBrand}; & svg { width: 20px; height: 20px; } &[data-tsd-selected='true'] { background: ${semantic.color.state.pressed}; color: ${semantic.color.text.primary}; } @media (max-width: 360px) { width: 32px; min-width: 32px; flex-basis: 32px; } `, /** * A pull tab protruding from the bottom edge of the lowest chrome band — * below the secondary strip when one is on screen, below the header when * not. It is positioned against the panel rather than nested inside the * strip, because the strip scrolls horizontally and would clip it. * * Collapsed is the exception: the panel is then only as tall as the header * and sits flush against the viewport edge, so a downward tab would be off * screen. There it flips to the panel's outer edge instead. */ /** * A pull tab protruding from the bottom edge of the subheader, dropping back * to the header's bottom edge once the subheader is folded away — so it * always hangs off whatever chrome band is lowest, always inside the panel. * * It is positioned against the panel rather than nested inside the strip, * because the strip scrolls horizontally and would clip it. Both bands are * border-box, so their hairlines already sit inside these heights. */ workbenchCollapseToggle: (isCollapsed) => css2` position: absolute; top: ${isCollapsed ? WORKBENCH_HEADER_HEIGHT : WORKBENCH_HEADER_HEIGHT + PLUGINS_STRIP_HEIGHT}px; inset-inline-end: 7%; z-index: 10; display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 20px; box-sizing: border-box; padding: 0; /* Open at the top so it reads as attached to the band above it. */ border: 1px solid ${semantic.color.state.pressed}; border-top: 0; border-radius: 0 0 ${semantic.radius.group} ${semantic.radius.group}; background: ${semantic.color.surface.brand}; color: ${semantic.color.text.mutedOnBrand}; cursor: pointer; transition: all 0.3s ease; &:hover { height: 24px; color: ${semantic.color.text.primary}; background: ${semantic.color.surface.subtle}; } &:focus-visible { outline: 2px solid ${semantic.color.border.focus}; outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { transition: none; &:hover { height: 20px; } } `, workbenchCollapseIcon: css2` display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; transition: transform 0.3s ease; & svg { width: 18px; height: 18px; } @media (prefers-reduced-motion: reduce) { transition-duration: 0ms; } `, workbenchWordmark: css2` white-space: nowrap; /* A wordmark is display type, not body copy. */ font-family: ${semantic.font.display}; font-size: ${semantic.type.headingCompact.size}; font-weight: ${semantic.type.headingCompact.weight}; line-height: ${semantic.type.headingCompact.lineHeight}; letter-spacing: -0.01em; color: ${semantic.color.text.primary}; margin-inline-end: ${semantic.space[2]}; /* Give up the wordmark before the destination labels start colliding — the emblem still carries the branding. */ @media (max-width: 560px) { display: none; } `, /** * The subheader slides rather than disappearing: it stays mounted and * animates its own height to zero, and the panel's strip row is auto-sized * so the row follows it. Folded it is `inert` so nothing inside stays * focusable behind a zero-height band. */ workbenchSecondaryTabs: (collapsed2) => css2` display: flex; align-items: center; gap: ${semantic.gap.tight}; min-width: 0; box-sizing: border-box; padding-block: ${collapsed2 ? "0px" : "6px"}; padding-inline-start: ${WORKBENCH_GUTTER}px; padding-inline-end: ${WORKBENCH_GUTTER}px; scroll-padding-inline-start: ${WORKBENCH_GUTTER}px; scroll-padding-inline-end: ${WORKBENCH_GUTTER}px; height: ${collapsed2 ? 0 : PLUGINS_STRIP_HEIGHT}px; min-height: 0; flex: 0 0 auto; opacity: ${collapsed2 ? 0 : 1}; /* Chrome band: the strip belongs to the header, not to the canvas. */ background: ${semantic.color.surface.brand}; border-bottom: ${collapsed2 ? "0" : "1px"} solid ${semantic.color.state.pressed}; overflow-x: ${collapsed2 ? "hidden" : "auto"}; overflow-y: hidden; white-space: nowrap; scrollbar-width: thin; transition: all 0.3s ease; /* Tabs must not shift as the strip scrolls, but they still animate their own hover and selected states. */ & > * { transform: none; } & > :last-child { scroll-margin-inline-end: ${WORKBENCH_GUTTER}px; } @media (max-width: 430px) { padding-inline: ${WORKBENCH_GUTTER_NARROW}px; } @media (prefers-reduced-motion: reduce) { transition: none; } `, workbenchSecondaryTab: css2` display: inline-flex; align-items: center; justify-content: center; /* A plugin entry can be dragged down into the workspace to place its pane, so it advertises that rather than looking like a plain button. */ &[data-plugin-title-control] { cursor: grab; } min-height: 32px; padding: ${semantic.padding.controlBlock} ${semantic.padding.controlInline}; border: 1px solid transparent; border-radius: ${semantic.radius.control}; background: transparent; color: ${semantic.color.text.secondary}; font-family: ${semantic.font.body}; font-size: ${semantic.type.labelSm.size}; font-weight: ${semantic.type.labelSm.weight}; line-height: ${semantic.type.labelSm.lineHeight}; letter-spacing: ${semantic.type.labelSm.tracking}; cursor: pointer; flex: 0 0 auto; appearance: none; transition: all 0.3s ease; &:hover { background: ${semantic.color.state.hover}; color: ${semantic.color.text.primary}; } &[data-tsd-selected='true'] { background: ${semantic.color.state.selectionFill}; border-color: ${semantic.color.state.selectionFill}; color: ${semantic.color.state.selectionText}; } &:focus-visible { outline: 2px solid ${semantic.color.border.focus}; outline-offset: 2px; } `, pluginTitleText: css2` margin: 0; color: inherit; font-family: ${semantic.font.body}; font-size: inherit; font-weight: inherit; line-height: inherit; letter-spacing: inherit; `, /** * A thin bar sitting on the panel's own edge. It must NOT be grown into a * fat hit area: at 24px tall it covered the top of the 36px header, so a * press aimed at a header button started a resize instead — which is what * made dragging feel like click, drag, click. */ dragHandle: (panelLocation) => css2` position: absolute; left: 0; ${panelLocation === "bottom" ? "top" : "bottom"}: 0; width: 100%; height: 5px; cursor: row-resize; user-select: none; touch-action: none; z-index: 100000; background-color: transparent; transition: all 0.3s ease; &:hover, &:focus-visible { background-color: ${semantic.color.border.control}; } @media (prefers-reduced-motion: reduce) { transition: none; } `, mainCloseBtn: css2` background: transparent; position: fixed; z-index: 99999; display: inline-flex; width: fit-content; cursor: pointer; appearance: none; border: 0; align-items: center; padding: 0; font-size: ${semantic.type.bodyXs.size}; cursor: pointer; transition: opacity 0.25s ease-out; &:hide-until-hover { opacity: 0; pointer-events: none; visibility: hidden; } &:hide-until-hover:hover { opacity: 1; pointer-events: auto; visibility: visible; } `, mainCloseBtnDefault: css2` background: ${semantic.color.surface.brand}; color: ${semantic.color.text.primary}; width: 60px; height: 60px; justify-content: center; border-radius: 14px; /* * Two inset layers carry the chip's finish so both can animate: * a 1px edge ring, transparent at rest so it fades in on hover, and a * full-bleed tint that lifts the brand fill off pitch black in dark mode * (and off flat cream in light) without replacing it. */ box-shadow: inset 0 0 0 1px transparent, inset 0 0 0 999px ${semantic.color.state.hover}, ${semantic.shadow.sm}; transition: all 0.3s ease; /* Sized by height with width following the emblem's own tall aspect, so the mark fills the chip instead of being letterboxed inside a square. */ & > svg { width: auto; height: 48px; outline: none; transition: all 0.3s ease; } /* * Hover keeps the brand fill: this chip floats over the user's page, so * replacing the fill with a translucent state colour would make it * vanish. Hover deepens the resting tint one step, brings in the edge * ring, and scales the chip up a touch. * * It animates the scale property rather than a transform: floating mode * sets transform inline to drive the drag, so a transform here would be * overridden and never apply. */ &:hover { box-shadow: inset 0 0 0 1px ${semantic.color.border.control}, inset 0 0 0 999px ${semantic.color.state.pressed}, ${semantic.shadow.overlay}; scale: 1.06; } &:hover > svg { scale: 1.04; } &:active { scale: 0.98; } @media (prefers-reduced-motion: reduce) { transition-property: opacity; & > svg { transition: none; } &:hover, &:hover > svg, &:active { scale: 1; } } &:focus-visible { outline: 2px solid ${semantic.color.border.focus}; outline-offset: 2px; } `, mainCloseBtnFloating: css2` /* Floating placement is driven by inline left/top, so don't animate position (would fight the drag/throw rAF loop). The hover treatment uses box-shadow and scale, both of which are safe to keep. */ transition: opacity 0.3s ease, box-shadow 0.3s ease, scale 0.3s ease, background-color 0.3s ease, color 0.3s ease; /* Stays a pointer even though it is draggable: the trigger reads as a button first, and a grab cursor made it look like a handle. */ cursor: pointer; touch-action: none; user-select: none; `, mainCloseBtnPosition: (position) => { const base = css2` ${position === "top-left" ? `top: ${semantic.space[2]}; left: ${semantic.space[2]};` : ""} ${position === "top-right" ? `top: ${semantic.space[2]}; right: ${semantic.space[2]};` : ""} ${position === "middle-left" ? `top: 50%; left: ${semantic.space[2]}; transform: translateY(-50%);` : ""} ${position === "middle-right" ? `top: 50%; right: ${semantic.space[2]}; transform: translateY(-50%);` : ""} ${position === "bottom-left" ? `bottom: ${semantic.space[2]}; left: ${semantic.space[2]};` : ""} ${position === "bottom-right" ? `bottom: ${semantic.space[2]}; right: ${semantic.space[2]};` : ""} `; return base; }, mainCloseBtnAnimation: (isOpen, hideUntilHover) => { if (!isOpen) { return hideUntilHover ? css2` opacity: 0; &:hover { opacity: 1; pointer-events: auto; visibility: visible; } ` : css2` opacity: 1; pointer-events: auto; visibility: visible; `; } return css2` opacity: 0; pointer-events: none; visibility: hidden; `; }, tabContent: css2` transition: all 0.2s ease-in-out; width: 100%; max-width: 100%; min-width: 0; height: 100%; box-sizing: border-box; overflow-x: hidden; `, /** * A plugin's mount target, and the scroll boundary between that plugin and * the host page. * * `overscroll-behavior: contain` belongs HERE and on the other outermost * destination scrollers only — never on their descendants. Plugins nest * several `overflow: auto` wrappers that often have nothing to scroll; a * wheel over one of those is meant to chain up to this element. Containing * every descendant turns each empty wrapper into a dead end and the pane * stops scrolling altogether. */ pluginsTabContent: css2` /* * A positioning context per pane. Plugins position their own chrome * absolutely and assume their own root is the containing block, but a * plugin root is often statically positioned — without this, a top-zero * offset resolves against the whole Workbench and the plugin paints its * controls over our header. With three panes open they would all pile * into the same corner. */ position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; `, pluginsEmptyState: css2` display: flex; flex: 1 1 auto; flex-direction: column; align-items: center; justify-content: center; gap: ${semantic.gap.control}; min-width: 0; padding: ${WORKBENCH_GUTTER}px; text-align: center; background: ${semantic.color.surface.workspace}; `, pluginsEmptyStateIcon: css2` display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; margin-bottom: ${semantic.space[1]}; border-radius: 50%; background: ${semantic.color.surface.subtle}; color: ${semantic.color.text.muted}; & svg { width: 22px; height: 22px; } `, pluginsEmptyStateTitle: css2` margin: 0; font-family: ${semantic.font.display}; font-size: ${semantic.type.headingPane.size}; font-weight: ${semantic.type.headingPane.weight}; line-height: ${semantic.type.headingPane.lineHeight}; color: ${semantic.color.text.primary}; `, pluginsEmptyStateHint: css2` margin: 0; max-width: 42ch; font-size: ${semantic.type.bodySm.size}; font-weight: ${semantic.type.bodySm.weight}; line-height: ${semantic.type.bodySm.lineHeight}; color: ${semantic.color.text.secondary}; `, /** * The panes' permanent home. Every pane is a direct child for its whole * life and is placed with inline offsets computed from the layout tree, so * a drag never re-parents it. That is what stops an iframe plugin reloading * and a canvas plugin losing its context every time the layout changes. */ pluginWorkspace: css2` position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; background: ${semantic.color.surface.workspace}; `, /** * Off-screen but still announced. Used for the live region that narrates * picking a pane up and putting it down, which is the only feedback a * screen-reader user gets from a move. */ pluginSrOnly: css2` position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; `, /** A group's tab bar, sitting along the top edge of the group's rect. */ pluginGroupTabs: css2` display: flex; align-items: stretch; gap: 2px; height: ${PLUGIN_GROUP_TAB_HEIGHT}px; min-width: 0; padding-inline: 4px; box-sizing: border-box; overflow-x: auto; overflow-y: hidden; white-space: nowrap; scrollbar-width: thin; background: ${semantic.color.surface.brand}; border-bottom: 1px solid ${semantic.color.state.pressed}; `, /** * Presentational wrapper holding the two sibling controls of one tab. They * are siblings rather than nested because a button inside a button is * invalid, and the inner one would be unreachable by keyboard. */ pluginGroupTabItem: css2` position: relative; display: inline-flex; align-items: stretch; flex: 0 0 auto; max-width: 200px; background: transparent; transition: background 0.2s ease; &[data-tsd-selected='true'] { background: ${semantic.color.surface.workspace}; } &:hover:not([data-tsd-selected='true']) { background: ${semantic.color.state.hover}; } &[data-tsd-held='true'] { outline: 2px solid ${semantic.color.border.focus}; outline-offset: -2px; } @media (prefers-reduced-motion: reduce) { transition: none; } `, /** The sortable item: exactly the draggable part of a tab, nothing else. */ pluginGroupTabRow: css2` display: inline-flex; align-items: stretch; min-width: 0; `, pluginGroupTab: css2` display: inline-flex; align-items: center; min-width: 0; /* Room at the end for the close button, which sits over this one. */ padding-inline: 8px 28px; border: 0; border-radius: 0; background: transparent; color: ${semantic.color.text.mutedOnBrand}; font-family: ${semantic.font.body}; font-size: ${semantic.type.bodyXs.size}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: grab; &[aria-pressed='true'] { color: ${semantic.color.text.primary}; cursor: default; } `, /** * 24px square, the smallest target WCAG 2.5.8 accepts, laid over the right end * of the tab. Positioned rather than in flow so it is a sibling of the * sortable row: the drag engine finds its target by walking up the tree, so a * close button nested inside the row would always be a drag surface. */ pluginGroupTabClose: css2` position: absolute; inset-inline-end: 2px; top: 50%; transform: translateY(-50%); z-index: 1; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 2px; background: transparent; color: ${semantic.color.text.mutedOnBrand}; cursor: pointer; &:hover { background: ${semantic.color.state.pressed}; } & svg { width: 10px; height: 10px; } `, /** * A gutter between two panes of one split. It is a real focusable separator * so it can be driven from the keyboard, matching the whole-panel resizer. */ pluginSplitter: (dir) => css2` position: absolute; z-index: 2; background-color: transparent; transition: background-color 0.2s ease; cursor: ${dir === "row" ? "col-resize" : "row-resize"}; touch-action: none; &:hover, &:focus-visible { background-color: ${semantic.color.border.focus}; } @media (prefers-reduced-motion: reduce) { transition: none; } @media (forced-colors: active) { &:hover, &:focus-visible { background-color: Highlight; } } `, /** * The tab that follows the cursor while dragging, so it is obvious which pane * is being carried. Pointer-transparent, or it would sit between the pointer * and the drop zone being aimed at. */ pluginDragPreview: css2` position: fixed; z-index: 2147483646; display: inline-flex; align-items: center; gap: 6px; max-width: 220px; height: ${PLUGIN_GROUP_TAB_HEIGHT}px; padding-inline: 10px; box-sizing: border-box; pointer-events: none; border: 1px solid ${semantic.color.border.focus}; border-radius: 3px; background: ${semantic.color.surface.brand}; color: ${semantic.color.text.primary}; font-family: ${semantic.font.body}; font-size: ${semantic.type.bodyXs.size}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; opacity: 0.95; /* Sits just off the cursor so it never covers the pointer itself. */ transform: translate(12px, 12px); @media (forced-colors: active) { border-color: Highlight; } `, /** * While a pane is being carried, every surface *inside the panel* shows the * grabbing cursor — the pointer travels well outside the tab it started on. * Applied to the panel, never to `<html>`: the host page's cursor is not ours * to change. */ pluginDraggingCursor: css2` &, & * { cursor: grabbing !important; } `, /** The highlight that shows where a dragged tab would land. */ pluginDropOverlay: css2` position: absolute; z-index: 3; pointer-events: none; box-sizing: border-box; border: 2px solid ${semantic.color.border.focus}; background: ${semantic.color.state.hover}; @media (forced-colors: active) { border-color: Highlight; } `, pluginPaneSeparator: css2` flex: 0 0 1px; align-self: stretch; /* Plugins paint their own surface, which may be lighter or darker than ours, so this rule needs a mid tone that shows against both. */ background: ${semantic.color.border.control}; @media (forced-colors: active) { background: CanvasText; } `, settingsGroup: css2` display: flex; flex-direction: column; gap: 0.75rem; `, /* An indented, ruled block reads as "this belongs to the switch above". */ conditionalSetting: css2` margin-top: ${semantic.space[2]}; margin-inline-start: ${WORKBENCH_GUTTER}px; padding: ${semantic.space[3]}; border-inline-start: 2px solid ${semantic.color.border.decorative}; background-color: ${semantic.color.surface.subtle}; border-start-end-radius: ${semantic.radius.group}; border-end-end-radius: ${semantic.radius.group}; `, settingRow: css2` display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; @media (max-width: 768px) { grid-template-columns: 1fr; } `, settingsModifiers: css2` display: flex; flex-wrap: wrap; gap: 6px; `, hotkeyTitle: css2` margin: 0; font-family: ${semantic.font.display}; font-size: ${semantic.type.headingCompact.size}; line-height: ${semantic.type.headingCompact.lineHeight}; font-weight: ${semantic.type.headingCompact.weight}; color: ${semantic.color.text.primary}; `, hotkeyDescription: css2` margin: 0; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; color: ${semantic.color.text.secondary}; `, hotkeyResult: css2` display: flex; align-items: center; gap: 6px; margin: 0; font-size: ${semantic.type.bodyXs.size}; color: ${semantic.color.text.secondary}; `, hotkeyResultKeys: css2` padding: 1px 6px; border: 1px solid ${semantic.color.border.decorative}; border-radius: ${semantic.radius.control}; background: ${semantic.color.surface.subtle}; color: ${semantic.color.text.primary}; font-family: ${semantic.font.mono}; font-size: 11px; `, settingsStack: css2` display: flex; flex-direction: column; gap: 1rem; `, // No Plugins Fallback Styles /* Shell: positions the settings drawer and clips it to the workbench. */ pluginMarketplace: css2` position: relative; display: flex; flex-direction: column; font-family: ${semantic.font.body}; color: ${semantic.color.text.primary}; width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; height: 100%; min-height: 0; overflow: hidden; background: ${semantic.color.surface.workspace}; animation: ${fadeIn} 0.3s ease; @media (prefers-reduced-motion: reduce) { animation: none; } `, pluginMarketplaceScroll: css2` flex: 1 1 auto; min-height: 0; min-width: 0; box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; padding: ${WORKBENCH_GUTTER}px; @media (max-width: 430px) { padding: ${WORKBENCH_GUTTER_NARROW}px; } `, pluginMarketplaceHeader: css2` margin-bottom: ${WORKBENCH_GUTTER}px; padding-bottom: ${semantic.space[3]}; border-bottom: 1px solid ${semantic.color.border.decorative}; `, pluginMarketplaceTitleRow: css2` display: flex; align-items: flex-end; justify-content: space-between; gap: ${WORKBENCH_GUTTER}px; margin-bottom: 0; flex-wrap: wrap; `, /** Title + description stay together so the search box can't split them. */ pluginMarketplaceTitleBlock: css2` display: flex; flex-direction: column; gap: ${semantic.gap.tight}; min-width: 0; `, pluginMarketplaceControls: css2` display: flex; align-items: center; flex: 1 1 320px; width: 100%; max-width: 448px; min-width: 0; margin-left: auto; `, pluginMarketplaceTitle: css2` font-family: ${semantic.font.display}; font-size: 1.125rem; line-height: 1.3; font-weight: 700; color: ${semantic.color.text.primary}; margin: 0; letter-spacing: -0.02em; `, pluginMarketplaceDescription: css2` font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; color: ${semantic.color.text.secondary}; margin: 0; max-width: 72ch; `, pluginMarketplaceSearchWrapper: css2` position: relative; display: flex; align-items: center; flex: 1 1 0%; width: auto; max-width: 400px; min-width: 0; @media (max-width: 430px) { width: 100%; max-width: none; } svg { position: absolute; left: 8px; width: 14px; height: 14px; color: ${semantic.color.text.muted}; pointer-events: none; } `, pluginMarketplaceSearch: css2` width: 100%; box-sizing: border-box; padding: 5px 10px 5px 28px; background: ${semantic.color.surface.app}; border: 1px solid ${semantic.color.border.decorative}; border-radius: ${semantic.radius.control}; color: ${semantic.color.text.primary}; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; font-family: ${semantic.font.body}; transition: all 0.3s ease; &::placeholder { color: ${semantic.color.text.muted}; } &:hover { border-color: ${semantic.color.border.control}; } &:focus { outline: none; border-color: ${semantic.color.border.focus}; background: ${semantic.color.surface.elevated}; box-shadow: 0 0 0 2px ${semantic.color.state.pressed}; } @media (prefers-reduced-motion: reduce) { transition: none; } `, pluginMarketplaceTagsContainer: css2` display: flex; flex-wrap: wrap; gap: 6px; margin-top: ${semantic.space[3]}; padding: 0; background: transparent; border: 0; `, pluginMarketplaceTagButton: css2` padding: 3px 10px; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; font-we