@tanstack/devtools
Version:
TanStack Devtools is a set of tools for building advanced devtools for your application.
1,552 lines (1,544 loc) • 217 kB
JavaScript
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, MIN_PANE_SIZE, resize, singleGroup, stackInto, splitAt, findGroupOfTab, zoneAt, PANE_DROP_EDGE_RATIO, canSplit, uppercaseFirstLetter } from '../chunk/TI24F65M.js';
import { keyboardModifiers } from '../chunk/CUZKAGZ3.js';
import { delegateEvents, createComponent, Portal, use, setAttribute, insert, memo, effect, className, style, setStyleProperty, addEventListener, template, classList, spread, 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.b