diff --git a/.changeset/persist-subheader-fold.md b/.changeset/persist-subheader-fold.md new file mode 100644 index 000000000..fb9434aeb --- /dev/null +++ b/.changeset/persist-subheader-fold.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': patch +--- + +Persist the folded plugin strip across reloads so it does not steal height back on refresh. diff --git a/.changeset/rainbow-trigger-mark.md b/.changeset/rainbow-trigger-mark.md new file mode 100644 index 000000000..6037de566 --- /dev/null +++ b/.changeset/rainbow-trigger-mark.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': patch +--- + +Use the rainbow palm favicon as the default trigger mark, default the trigger to floating, and make pane gutters follow the pointer instead of compounding each drag move. diff --git a/.changeset/tab-gutters-scrollbars.md b/.changeset/tab-gutters-scrollbars.md new file mode 100644 index 000000000..9e7be47b3 --- /dev/null +++ b/.changeset/tab-gutters-scrollbars.md @@ -0,0 +1,6 @@ +--- +'@tanstack/devtools': patch +'@tanstack/devtools-ui': patch +--- + +Give plugin tabs a small inset in a rounded card, keep pane gutters visible at rest, and theme the panel scrollbars. diff --git a/docs/architecture.md b/docs/architecture.md index 0d0797116..545654cd6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,7 +146,7 @@ The devtools shell is a Solid.js application that renders the entire devtools UI - **`setConfig(config)`** -- Updates configuration and plugins at runtime. Plugins are reactive: adding or removing them updates the tab bar immediately. The shell renders: -- A **trigger button** (the floating devtools toggle, customizable or replaceable) +- A **trigger button** (the floating rainbow palm mark, customizable or replaceable) - A **resizable Workbench panel** (docked to the top or bottom of the viewport, resizable via pointer or keyboard) - A compact **36px TanStack Devtools header** with Plugins, Marketplace, SEO, and Settings destinations. The palm emblem is inline SVG, so it stays sharp and takes its colour from the theme. - A fixed-height **44px secondary strip** for plugin and SEO navigation. It scrolls horizontally when space is limited, and a pull tab on its bottom edge folds it away behind the header. Folding changes nothing else — the panel keeps its height and the destination content keeps running — so the tab is rendered only on the destinations that have a strip. @@ -154,7 +154,7 @@ The shell renders: - A **settings panel** for theme, hotkeys, position, and other preferences - Up to three simultaneous **plugin mount frames**, divided into equal widths by static separators -Settings and UI state (panel size, position, active tab, theme) are persisted in `localStorage` so they survive page reloads. +Settings and UI state (panel size, position, active tab, theme, and whether the secondary strip is folded) are persisted in `localStorage` so they survive page reloads. The core shell owns the Workbench header, navigation, mount-frame geometry, separators, and surrounding light/dark surfaces. Each external plugin owns everything inside its mount target; core styling deliberately does not reach into plugin descendants. Detaching the Workbench uses a fixed `100vh` Picture-in-Picture layout and restores the stored docked height when reattached. @@ -162,10 +162,10 @@ The core shell owns the Workbench header, navigation, mount-frame geometry, sepa Two rules keep the shell reading as one surface: -- **Chrome versus canvas.** The header and every secondary strip paint the brand surface (cream in light mode, near-black in dark mode) and close with a translucent ink hairline. Destination content and plugin mount frames paint the workspace surface. A plugin pane can paint any colour it likes, so the separator between panes uses a mid-tone border that stays visible against both. +- **Chrome versus canvas.** The header and every secondary strip paint the brand surface (cream in light mode, near-black in dark mode) and close with a translucent ink hairline. Destination content and plugin mount frames paint the workspace surface. A plugin pane can paint any colour it likes. The resize gutter between panes is invisible at rest and paints a line on hover or keyboard focus. - **One gutter.** `WORKBENCH_GUTTER` (16px, or `WORKBENCH_GUTTER_NARROW` at 12px below 430px) is the single inline gutter. The header, the strips, and each destination's content all start there, so the left edge is one column instead of three. `MainPanel withPadding` uses the same value. -Colour comes from the semantic theme only. Raw hex values in core-owned source are rejected by `tests/semantic-color-usage.test.ts` unless they carry a narrow, path-scoped `semantic-color-exempt` marker — currently only third-party network marks and the source-inspector highlight alpha. Status colours (success, warning, error, info) mark real state; identity accents do not compete with them, so a card that is both featured and active keeps the neutral outline and lets its badge say which it is. +Colour comes from the semantic theme only. Raw hex values in core-owned source are rejected by `tests/semantic-color-usage.test.ts` unless they carry a narrow, path-scoped `semantic-color-exempt` marker. The current exemptions are third-party network marks, the source-inspector highlight alpha, and the trigger rainbow mark. Status colours (success, warning, error, info) mark real state; identity accents do not compete with them, so a card that is both featured and active keeps the neutral outline and lets its badge say which it is. ### @tanstack/devtools-ui -- Component Library diff --git a/docs/configuration.md b/docs/configuration.md index 8f8c7b6d9..bfbf04baf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,7 +35,7 @@ The `config` object is mainly focused around user interaction with the devtools { position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle-left' | 'middle-right' } ``` -- `triggerMode` - How the trigger is placed. `'fixed'` anchors it to `position`; `'floating'` lets you drag the trigger anywhere on screen (and throw it — it glides with momentum and springs back off the edges). The floating spot is persisted in local storage. +- `triggerMode` - How the trigger is placed. `'floating'` (the default) lets you drag the trigger anywhere on screen, and throw it: it glides with momentum and springs back off the edges. `'fixed'` anchors it to `position`. The floating spot is persisted in local storage. ```ts { triggerMode: 'fixed' | 'floating' } diff --git a/docs/plugin-workspace.md b/docs/plugin-workspace.md index 79e0f8efb..9e3ec7783 100644 --- a/docs/plugin-workspace.md +++ b/docs/plugin-workspace.md @@ -18,7 +18,8 @@ close it, so there is only ever one control for it. same zones as below. Dropping onto an empty workspace gives it the whole area. The strip folds itself away when everything is open, and comes back on its own the -moment a plugin closes and returns to it. +moment a plugin closes and returns to it. A fold you make with the pull tab is +kept across reloads. ## Arranging panes @@ -50,8 +51,9 @@ or bottom edge stack instead. Drag the panel taller first if you want rows. ## Resizing -Drag the gutter between two panes. One pane grows by exactly what the other loses, -and neither can shrink past a readable minimum. +Each pane sits in a rounded card with a small gutter from the workspace edge. +Drag between cards to resize: the resize line appears on hover. One pane grows +by exactly what the other loses, and neither can shrink past a readable minimum. ## Keyboard diff --git a/e2e/apps/react-vite/tests/plugin-workspace.spec.ts b/e2e/apps/react-vite/tests/plugin-workspace.spec.ts index b5c7fb355..a4beb876a 100644 --- a/e2e/apps/react-vite/tests/plugin-workspace.spec.ts +++ b/e2e/apps/react-vite/tests/plugin-workspace.spec.ts @@ -80,9 +80,13 @@ test('dragging the gutter moves width from one pane to the other', async ({ const afterDemo = (await dt.pane(DEMO).boundingBox())!.width const afterProbe = (await dt.pane(PROBE).boundingBox())!.width - expect(afterDemo).toBeGreaterThan(beforeDemo + 50) + const moved = afterDemo - beforeDemo + // The gutter must follow the pointer (~120px), not compound each move into + // a spring that flings the pane across the workspace. + expect(moved).toBeGreaterThan(80) + expect(moved).toBeLessThan(160) // One grows by exactly what the other loses. - expect(afterDemo - beforeDemo).toBeCloseTo(beforeProbe - afterProbe, 0) + expect(moved).toBeCloseTo(beforeProbe - afterProbe, 0) expect(afterDemo + afterProbe).toBeCloseTo(beforeDemo + beforeProbe, 0) }) diff --git a/packages/devtools-ui/src/styles/use-styles.ts b/packages/devtools-ui/src/styles/use-styles.ts index 790f65c74..2aea1cb8a 100644 --- a/packages/devtools-ui/src/styles/use-styles.ts +++ b/packages/devtools-ui/src/styles/use-styles.ts @@ -683,6 +683,27 @@ const stylesFactory = (theme: TanStackDevtoolsTheme) => { /* Keep a scroll gesture inside the devtools instead of chaining it on to the host page once this panel hits its end. */ overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: ${semantic.color.border.control} transparent; + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background-color: ${semantic.color.border.control}; + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; + } + &::-webkit-scrollbar-thumb:hover { + background-color: ${semantic.color.text.muted}; + } + &::-webkit-scrollbar-corner { + background: transparent; + } height: 100%; &::selection, & *::selection { diff --git a/packages/devtools/skills/devtools-app-setup/SKILL.md b/packages/devtools/skills/devtools-app-setup/SKILL.md index 0097c8eff..80d9a8a5f 100644 --- a/packages/devtools/skills/devtools-app-setup/SKILL.md +++ b/packages/devtools/skills/devtools-app-setup/SKILL.md @@ -159,7 +159,7 @@ Pass a `config` prop to `TanStackDevtools` to set initial shell behavior. These Storage keys used internally: - `tanstack_devtools_settings` -- persisted settings -- `tanstack_devtools_state` -- persisted UI state (active tab, panel height, active plugins, persistOpen) +- `tanstack_devtools_state` -- persisted UI state (active tab, panel height, layout, persistOpen, subheaderCollapsed) All config properties are optional. Defaults shown below: @@ -168,7 +168,8 @@ All config properties are optional. Defaults shown below: config={{ defaultOpen: false, // open panel on mount hideUntilHover: false, // hide trigger until mouse hover - position: 'bottom-right', // trigger position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle-left' | 'middle-right' + position: 'bottom-right', // used when triggerMode is 'fixed': 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle-left' | 'middle-right' + triggerMode: 'floating', // 'floating' (default, draggable) | 'fixed' panelLocation: 'bottom', // panel position: 'top' | 'bottom' openHotkey: ['Control', '~'], inspectHotkey: ['Shift', 'Alt', 'CtrlOrMeta'], diff --git a/packages/devtools/src/components/plugin-workspace.tsx b/packages/devtools/src/components/plugin-workspace.tsx index 996753abf..9f630e5e0 100644 --- a/packages/devtools/src/components/plugin-workspace.tsx +++ b/packages/devtools/src/components/plugin-workspace.tsx @@ -21,6 +21,7 @@ import { TANSTACK_DEVTOOLS } from '../utils/storage' import { MAX_ACTIVE_PLUGINS, MIN_PANE_SIZE, + PANE_CARD_INSET, PANE_DROP_EDGE_RATIO, PLUGIN_GROUP_TAB_HEIGHT, PLUGIN_SPLITTER_SIZE, @@ -34,6 +35,7 @@ import { layoutRects, moveTab, resize, + resizeFromPointer, setTabs, singleGroup, splitAt, @@ -44,6 +46,27 @@ import { import type { DropZone, Rect, SplitterHandle } from '../utils/layout-tree' type Box = { w: number; h: number } + +const insetRect = (rect: Rect, inset: number): Rect => ({ + left: rect.left + inset, + top: rect.top + inset, + width: Math.max(rect.width - inset * 2, 0), + height: Math.max(rect.height - inset * 2, 0), +}) + +/** Widen the splitter so it fills the chrome between two cards. */ +const expandSplitterRect = (handle: SplitterHandle, extra: number): Rect => + handle.dir === 'row' + ? { + ...handle.rect, + left: handle.rect.left - extra, + width: handle.rect.width + extra * 2, + } + : { + ...handle.rect, + top: handle.rect.top - extra, + height: handle.rect.height + extra * 2, + } /** What the pointer or keyboard is currently carrying. */ type Held = { tabId: string } | null /** @@ -83,7 +106,7 @@ const GroupTabBar = (props: { groupId: string tabs: Array activeIndex: number - rect: Rect | undefined + rect: Rect | null heldTabId: string | null titleOf: (id: string) => string moveHintId: string @@ -243,11 +266,30 @@ export const PluginWorkspace = (props: { measure() }) - const groupRects = createMemo(() => - layoutRects(layout(), box(), PLUGIN_SPLITTER_SIZE), - ) + const paddedBox = createMemo(() => ({ + w: Math.max(box().w - PANE_CARD_INSET * 2, 0), + h: Math.max(box().h - PANE_CARD_INSET * 2, 0), + })) + + const shift = (rect: Rect): Rect => ({ + ...rect, + left: rect.left + PANE_CARD_INSET, + top: rect.top + PANE_CARD_INSET, + }) + + const groupRects = createMemo(() => { + const raw = layoutRects(layout(), paddedBox(), PLUGIN_SPLITTER_SIZE) + return Object.fromEntries( + Object.entries(raw).map(([id, rect]) => [id, shift(rect)]), + ) + }) const handles = createMemo(() => - splitterHandles(layout(), box(), PLUGIN_SPLITTER_SIZE), + splitterHandles(layout(), paddedBox(), PLUGIN_SPLITTER_SIZE).map( + (handle) => ({ + ...handle, + rect: shift(handle.rect), + }), + ), ) const groups = createMemo(() => allGroups(layout())) @@ -265,15 +307,25 @@ export const PluginWorkspace = (props: { equals: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]), }) + /** The rounded card a group sits in, inset from the workspace chrome. */ + const cardRect = (groupId: string): Rect | null => + groupRects()[groupId] ?? null + + const tabBarRect = (groupId: string): Rect | null => { + const card = cardRect(groupId) + if (!card) return null + return { ...card, height: PLUGIN_GROUP_TAB_HEIGHT } + } + /** Panes sit under their group's tab bar, so the bar's height comes off the top. */ const paneRect = (groupId: string): Rect | null => { - const rect = groupRects()[groupId] - if (!rect) return null + const card = cardRect(groupId) + if (!card) return null return { - left: rect.left, - top: rect.top + PLUGIN_GROUP_TAB_HEIGHT, - width: rect.width, - height: Math.max(rect.height - PLUGIN_GROUP_TAB_HEIGHT, 0), + left: card.left, + top: card.top + PLUGIN_GROUP_TAB_HEIGHT, + width: card.width, + height: Math.max(card.height - PLUGIN_GROUP_TAB_HEIGHT, 0), } } @@ -347,7 +399,7 @@ export const PluginWorkspace = (props: { if (point.y <= rect.top + PLUGIN_GROUP_TAB_HEIGHT) { return { groupId, zone: 'center', willStack: true } } - const zone = zoneAt(point, rect, PANE_DROP_EDGE_RATIO) + const zone = zoneAt(point, paneRect(groupId) ?? rect, PANE_DROP_EDGE_RATIO) // A pane too small to split takes the tab as a stacked tab instead, so the // gesture always does something sensible rather than being refused. const willStack = @@ -357,7 +409,7 @@ export const PluginWorkspace = (props: { groupId, zone, MIN_PANE_SIZE, - box(), + paddedBox(), PLUGIN_SPLITTER_SIZE, ) return { groupId, zone, willStack } @@ -505,26 +557,42 @@ export const PluginWorkspace = (props: { const startSplitterDrag = (handle: SplitterHandle, event: PointerEvent) => { if (event.button !== 0) return + event.preventDefault() + const target = event.currentTarget + if (target instanceof HTMLElement) { + target.setPointerCapture(event.pointerId) + } const start = handle.dir === 'row' ? event.clientX : event.clientY + const original = layout() const minFraction = handle.extent > 0 ? (handle.dir === 'row' ? MIN_PANE_SIZE.w : MIN_PANE_SIZE.h) / handle.extent : 0 + const previousUserSelect = document.body.style.userSelect + document.body.style.userSelect = 'none' const move = (moveEvent: PointerEvent) => { + moveEvent.preventDefault() const now = handle.dir === 'row' ? moveEvent.clientX : moveEvent.clientY - if (handle.extent <= 0) return setLayout( - resize( - layout(), + resizeFromPointer( + original, handle.path, handle.gutterIndex, - (now - start) / handle.extent, + now - start, + handle.extent, minFraction, ), ) } - const up = () => { + const up = (upEvent: PointerEvent) => { + if ( + target instanceof HTMLElement && + target.hasPointerCapture(upEvent.pointerId) + ) { + target.releasePointerCapture(upEvent.pointerId) + } + document.body.style.userSelect = previousUserSelect document.removeEventListener('pointermove', move) document.removeEventListener('pointerup', up) } @@ -650,9 +718,12 @@ export const PluginWorkspace = (props: { if (target === null) return null // The empty workspace: the pane will fill it, so highlight all of it. if (target.groupId === null) { - return { left: 0, top: 0, width: box().w, height: box().h } + return insetRect( + { left: 0, top: 0, width: box().w, height: box().h }, + PANE_CARD_INSET, + ) } - const rect = groupRects()[target.groupId] + const rect = cardRect(target.groupId) if (!rect) return null if (target.willStack) return rect const half = (value: number) => value / 2 @@ -691,7 +762,7 @@ export const PluginWorkspace = (props: { groupId={group.id} tabs={group.tabs} activeIndex={group.active} - rect={groupRects()[group.id]} + rect={tabBarRect(group.id)} heldTabId={held()?.tabId ?? null} titleOf={titleOf} moveHintId={moveHintId} @@ -760,39 +831,46 @@ export const PluginWorkspace = (props: { mid-resize and left stale element references behind. Keying by position keeps the elements alive and just updates their values. */} - {(handle) => ( -