UNPKG

@tanstack/devtools

Version:

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

1,611 lines (1,585 loc) 59.7 kB
import * as goober from 'goober' import { resolveSemanticTheme } from '@tanstack/devtools-ui/internal' import { createEffect, createSignal } from 'solid-js' import { createTheme } from '../context/use-devtools-context' import { PLUGINS_STRIP_HEIGHT, PLUGIN_GROUP_TAB_HEIGHT, WORKBENCH_GUTTER, WORKBENCH_GUTTER_NARROW, WORKBENCH_HEADER_HEIGHT, } from '../utils/constants' import type { TanStackDevtoolsConfig } from '../context/devtools-context' import type { Accessor } from 'solid-js' import type { DevtoolsStore } from '../context/devtools-store' const WORKBENCH_GEOMETRY_STYLE_ID = 'tanstack-devtools-workbench-geometry' export const ensureWorkbenchGeometryStyles = (targetDocument: Document) => { 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) } const fadeIn = goober.keyframes` from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } ` const slideInRight = goober.keyframes` from { transform: translateX(100%); } to { transform: translateX(0); } ` const slideUp = goober.keyframes` from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } ` const statusFadeIn = goober.keyframes` from { opacity: 0; } to { opacity: 1; } ` const spin = goober.keyframes` to { transform: rotate(360deg); } ` const stylesFactory = (theme: DevtoolsStore['settings']['theme']) => { const semantic = resolveSemanticTheme(theme) const css = goober.css return { seoWorkspace: css` display: flex; flex-direction: column; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; `, seoContent: css` flex: 1 1 auto; height: auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; `, seoPreviewSection: css` 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: css` 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: css` 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: css` 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: css` 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: css` 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: css` 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: css` color: ${semantic.color.text.secondary}; margin-bottom: 4px; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; `, seoPreviewUrl: css` color: ${semantic.color.text.muted}; font-family: ${semantic.font.mono}; font-size: 11px; margin-bottom: 0; word-break: break-all; `, seoMissingTagsSection: css` margin-top: 6px; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; color: ${semantic.color.status.error.text}; `, seoMissingTagsList: css` margin: 4px 0 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 4px; max-width: 240px; `, seoMissingTag: css` 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: css` margin-bottom: ${WORKBENCH_GUTTER}px; &:last-child { margin-bottom: 0; } `, serpPreviewLabel: css` 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: css` 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: css` 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: css` 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: css` display: flex; align-items: center; gap: 12px; margin-bottom: 8px; `, serpSnippetFavicon: css` width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0; object-fit: contain; overflow: hidden; display: flex; align-items: center; justify-content: center; `, serpSnippetDefaultFavicon: css` 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: css` display: flex; flex-direction: column; gap: 0; min-width: 0; `, serpSnippetSiteName: css` font-size: 0.875rem; color: ${semantic.color.text.primary}; line-height: 1.4; margin: 0; `, serpSnippetSiteUrl: css` font-size: 0.75rem; color: ${semantic.color.text.muted}; line-height: 1.4; margin: 0; `, serpSnippetTitle: css` font-size: 1.25rem; font-weight: 400; color: ${semantic.color.text.link}; margin: 0 0 4px 0; line-height: 1.3; `, serpSnippetDesc: css` font-size: 0.875rem; color: ${semantic.color.text.secondary}; margin: 0; line-height: 1.5; `, serpErrorList: css` margin: 4px 0 0 0; padding-left: 1.25rem; list-style-type: disc; `, serpReportItem: css` margin-top: 0.25rem; color: ${semantic.color.status.error.text}; font-size: 0.875rem; `, devtoolsPanelContainer: ( panelLocation: TanStackDevtoolsConfig['panelLocation'], isDetached: boolean, ) => css` 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: boolean) => { return css` visibility: ${isOpen ? 'visible' : 'hidden'}; height: ${isOpen ? 'auto' : '0'}; ` }, devtoolsPanelContainerResizing: (isResizing: Accessor<boolean>) => { if (isResizing()) { return css` transition: none; ` } return css` transition: transform 160ms ease-out; @media (prefers-reduced-motion: reduce) { transition-duration: 0ms; } ` }, devtoolsDrawerContent: css` width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; `, devtoolsPanel: css` 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: css` 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: css` 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: css` 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: css` 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: css` display: inline-flex; align-items: center; gap: ${semantic.gap.tight}; height: 100%; margin-left: auto; @media (max-width: 360px) { gap: 0; } `, workbenchActionButton: css` 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: boolean) => css` 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: css` 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: css` 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: (collapsed: boolean) => css` display: flex; align-items: center; gap: ${semantic.gap.tight}; min-width: 0; box-sizing: border-box; padding-block: ${collapsed ? '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: ${collapsed ? 0 : PLUGINS_STRIP_HEIGHT}px; min-height: 0; flex: 0 0 auto; opacity: ${collapsed ? 0 : 1}; /* Chrome band: the strip belongs to the header, not to the canvas. */ background: ${semantic.color.surface.brand}; border-bottom: ${collapsed ? '0' : '1px'} solid ${semantic.color.state.pressed}; overflow-x: ${collapsed ? '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: css` 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: css` 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: TanStackDevtoolsConfig['panelLocation']) => css` 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: css` 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: css` 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: css` /* 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: TanStackDevtoolsConfig['position']) => { const base = css` ${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: boolean, hideUntilHover: boolean) => { if (!isOpen) { return hideUntilHover ? css` opacity: 0; &:hover { opacity: 1; pointer-events: auto; visibility: visible; } ` : css` opacity: 1; pointer-events: auto; visibility: visible; ` } return css` opacity: 0; pointer-events: none; visibility: hidden; ` }, tabContent: css` 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: css` /* * 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: css` 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: css` 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: css` 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: css` 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: css` 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: css` 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: css` 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: css` 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: css` display: inline-flex; align-items: stretch; min-width: 0; `, pluginGroupTab: css` 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: css` 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: 'row' | 'col') => css` 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: css` 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: css` &, & * { cursor: grabbing !important; } `, /** The highlight that shows where a dragged tab would land. */ pluginDropOverlay: css` 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: css` 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: css` display: flex; flex-direction: column; gap: 0.75rem; `, /* An indented, ruled block reads as "this belongs to the switch above". */ conditionalSetting: css` 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: css` display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; @media (max-width: 768px) { grid-template-columns: 1fr; } `, settingsModifiers: css` display: flex; flex-wrap: wrap; gap: 6px; `, hotkeyTitle: css` 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: css` margin: 0; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; color: ${semantic.color.text.secondary}; `, hotkeyResult: css` display: flex; align-items: center; gap: 6px; margin: 0; font-size: ${semantic.type.bodyXs.size}; color: ${semantic.color.text.secondary}; `, hotkeyResultKeys: css` 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: css` display: flex; flex-direction: column; gap: 1rem; `, // No Plugins Fallback Styles /* Shell: positions the settings drawer and clips it to the workbench. */ pluginMarketplace: css` 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: css` 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: css` margin-bottom: ${WORKBENCH_GUTTER}px; padding-bottom: ${semantic.space[3]}; border-bottom: 1px solid ${semantic.color.border.decorative}; `, pluginMarketplaceTitleRow: css` 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: css` display: flex; flex-direction: column; gap: ${semantic.gap.tight}; min-width: 0; `, pluginMarketplaceControls: css` display: flex; align-items: center; flex: 1 1 320px; width: 100%; max-width: 448px; min-width: 0; margin-left: auto; `, pluginMarketplaceTitle: css` 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: css` font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; color: ${semantic.color.text.secondary}; margin: 0; max-width: 72ch; `, pluginMarketplaceSearchWrapper: css` 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: css` 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: css` display: flex; flex-wrap: wrap; gap: 6px; margin-top: ${semantic.space[3]}; padding: 0; background: transparent; border: 0; `, pluginMarketplaceTagButton: css` padding: 3px 10px; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodyXs.lineHeight}; font-weight: 500; background: ${semantic.color.surface.subtle}; border: 1px solid ${semantic.color.border.decorative}; border-radius: 999px; color: ${semantic.color.text.secondary}; cursor: pointer; transition: all 0.3s ease; &:hover { background: ${semantic.color.state.hover}; border-color: ${semantic.color.border.control}; color: ${semantic.color.text.primary}; } @media (prefers-reduced-motion: reduce) { transition: none; } `, pluginMarketplaceTagButtonActive: css` background: ${semantic.color.state.selectionFill} !important; border-color: ${semantic.color.state.selectionFill} !important; color: ${semantic.color.state.selectionText} !important; &:hover { background: ${semantic.color.state.selectionFill} !important; border-color: ${semantic.color.border.focus} !important; } `, pluginMarketplaceSettingsButton: css` display: flex; align-items: center; justify-content: center; flex: 0 0 auto; padding: 5px; background: ${semantic.color.surface.subtle}; border: 1px solid ${semantic.color.border.decorative}; border-radius: ${semantic.radius.control}; color: ${semantic.color.text.secondary}; cursor: pointer; transition: all 0.3s ease; margin-left: 6px; & svg { width: 14px; height: 14px; } &:hover { background: ${semantic.color.state.hover}; border-color: ${semantic.color.border.control}; color: ${semantic.color.text.primary}; } @media (prefers-reduced-motion: reduce) { transition: none; } `, /* Absolute, not fixed: the drawer belongs to the marketplace pane, so it must not cover the host page outside the workbench. */ pluginMarketplaceSettingsPanel: css` position: absolute; inset-block: 0; inset-inline-end: 0; width: 320px; max-width: 100%; box-sizing: border-box; background: ${semantic.color.surface.elevated}; border-inline-start: 1px solid ${semantic.color.border.decorative}; box-shadow: ${semantic.shadow.overlay}; z-index: 2; display: flex; flex-direction: column; animation: ${slideInRight} 0.3s ease; @media (prefers-reduced-motion: reduce) { animation: none; } `, pluginMarketplaceSettingsPanelHeader: css` display: flex; align-items: center; justify-content: space-between; gap: ${semantic.gap.control}; padding: ${semantic.space[3]} ${WORKBENCH_GUTTER}px; border-bottom: 1px solid ${semantic.color.border.decorative}; `, pluginMarketplaceSettingsPanelTitle: css` font-family: ${semantic.font.display}; font-size: ${semantic.type.headingCompact.size}; line-height: ${semantic.type.headingCompact.lineHeight}; font-weight: 700; color: ${semantic.color.text.primary}; margin: 0; `, pluginMarketplaceSettingsPanelClose: css` display: flex; align-items: center; justify-content: center; padding: 0.5rem; background: transparent; border: none; color: ${semantic.color.text.secondary}; cursor: pointer; border-radius: 0.375rem; transition: all 0.3s ease; &:hover { background: ${semantic.color.state.hover}; color: ${semantic.color.text.primary}; } `, pluginMarketplaceSettingsPanelContent: css` flex: 1; min-height: 0; padding: ${WORKBENCH_GUTTER}px; overflow-y: auto; overscroll-behavior: contain; `, pluginMarketplaceGrid: css` display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: ${semantic.gap.section}; animation: ${slideUp} 0.4s ease; @media (prefers-reduced-motion: reduce) { animation: none; } `, pluginMarketplaceCard: css` background: ${semantic.color.surface.elevated}; border: 1px solid ${semantic.color.border.decorative}; border-radius: ${semantic.radius.overlay}; padding: ${WORKBENCH_GUTTER}px; display: flex; flex-direction: column; gap: ${semantic.gap.section}; transition: all 0.3s ease; position: relative; overflow: hidden; /* Cards stay grounded: hover raises the shadow a step instead of lifting the card off the page. */ &:hover { border-color: ${semantic.color.border.control}; box-shadow: ${semantic.shadow.sm}; } @media (prefers-reduced-motion: reduce) { transition: none; } `, pluginMarketplaceCardIcon: css` width: 32px; height: 32px; flex: 0 0 32px; display: flex; align-items: center; justify-content: center; background: ${semantic.color.surface.subtle}; border: 1px solid ${semantic.color.border.decorative}; border-radius: ${semantic.radius.group}; color: ${semantic.color.text.secondary}; svg { width: 16px; height: 16px; } `, pluginMarketplaceCardHeader: css` flex: 1; `, pluginMarketplaceCardTitle: css` font-family: ${semantic.font.display}; font-size: ${semantic.type.bodySm.size}; line-height: ${semantic.type.bodySm.lineHeight}; font-weight: 700; color: ${semantic.color.text.primary}; /* Room on the trailing side so a long name never runs under the badge. */ margin: 0 72px 4px 0; `, pluginMarketplaceCardPackageBadge: css` margin-top: 4px; margin-bottom: 8px; font-size: 0.6875rem; font-family: ${semantic.font.mono}; color: ${semantic.color.text.muted}; padding: 0; word-break: break-all; display: inline-block; `, pluginMarketplaceCardDescriptionText: css` margin-top: 0; font-size: ${semantic.type.bodyXs.size}; line-height: ${semantic.type.bodySm.lineHeight}; color: ${semantic.color.text.secondary}; `, pluginMarketplaceCardVersionInfo: css`