From 85ac160c924777fb64790f2936e84c268c360356 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 23 Oct 2026 15:19:02 +0800 Subject: [PATCH 1/2] feat: show the devtools in a dock at the bottom of the page - The toolbar opens as a full-width dock at the bottom of the page. Its top edge can be dragged to change its height, and the page can scroll past it. - Closed, the dock is a small grip at the bottom center. It grows into a pull tab on hover or focus and turns red when there are errors. A click or a drag up opens the dock. - Alt+Shift+D opens and closes the dock. - A side nav holds the tabs, with counts of errors and calls. The header shows the version and the current route. - The dock keeps its open state, tab and height across reloads. - Selected rows and tabs use a rounded fill instead of a colored left border. Graph nodes show their kind with a dot and a tint. - The error code view scrolls to the frame inside the dock. Co-Authored-By: Claude Opus 5.5 --- .changeset/devtools-dock.md | 14 + README.md | 5 + src/dev-toolbar/error-viewer/index.tsx | 109 +++--- src/dev-toolbar/error-viewer/styles.css | 6 +- src/dev-toolbar/functions/index.tsx | 8 +- src/dev-toolbar/functions/styles.css | 6 +- src/dev-toolbar/icons.tsx | 20 + src/dev-toolbar/index.css | 361 ++++++++++++++++--- src/dev-toolbar/index.tsx | 461 ++++++++++++++++-------- src/dev-toolbar/ownership/index.tsx | 6 +- src/dev-toolbar/ownership/styles.css | 10 - src/dev-toolbar/reactivity/index.tsx | 8 +- src/dev-toolbar/reactivity/styles.css | 42 ++- src/ui/Section.css | 6 +- src/ui/Select.css | 23 +- tests/e2e/devtools.spec.ts | 108 +++++- 16 files changed, 850 insertions(+), 343 deletions(-) create mode 100644 .changeset/devtools-dock.md diff --git a/.changeset/devtools-dock.md b/.changeset/devtools-dock.md new file mode 100644 index 0000000..a6e4e9d --- /dev/null +++ b/.changeset/devtools-dock.md @@ -0,0 +1,14 @@ +--- +'@solidjs/start-devtools': minor +--- + +Show the devtools in a dock at the bottom of the page. + +- Closed, the dock is a small grip at the bottom center of the page. It grows into a pull tab on hover or focus, and turns red when there are errors. Click it, or drag it up, to open the dock. +- Alt+Shift+D opens and closes the dock. +- Drag the top edge of the dock to change its height. The page can scroll past it. +- A side nav holds the Errors, Server functions, Reactivity and Ownership tabs, with counts of errors and calls. +- The header shows the version and the current route. +- The dock remembers whether it was open, its tab and its height across reloads. +- An error opens the dock on the Errors tab. +- Selected tabs, calls and stack frames use a rounded fill instead of a colored left border. Graph nodes show their kind with a colored dot and tint instead of a left border. diff --git a/README.md b/README.md index 0c127d1..4a04885 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ Development error and server-function tooling for Solid Start mode. pnpm add @solidjs/start-devtools@next ``` +The toolbar is a dock at the bottom of the page. Closed, it is a small grip at the bottom +center. Click it, drag it up, or press Alt+Shift+D to open it. Drag its top edge to change +its height. It remembers whether it was open, its tab and its height across reloads, and an +error opens it on the Errors tab. + The Vite plugin mounts it automatically. Integrations can use the package directly: ```tsx diff --git a/src/dev-toolbar/error-viewer/index.tsx b/src/dev-toolbar/error-viewer/index.tsx index 98f7d7e..91f939d 100644 --- a/src/dev-toolbar/error-viewer/index.tsx +++ b/src/dev-toolbar/error-viewer/index.tsx @@ -4,7 +4,9 @@ import * as htmlToImage from 'html-to-image'; import type { JSX } from '@solidjs/web'; import { Errored, For, Loading, Show, createMemo, createSignal } from 'solid-js'; import IconButton from '../../ui/IconButton.js'; +import Placeholder from '../../ui/Placeholder.js'; import { Select, SelectOption } from '../../ui/Select.js'; +import { Text } from '../../ui/Text.js'; import { ArrowLeftIcon, ArrowRightIcon, @@ -276,55 +278,72 @@ export default function ErrorViewer(props: ErrorViewerProps): JSX.Element { return (
-
-
-
- 1}> -
- - - -
- {`${truncated()} of ${props.errors.length}`} + + No errors captured. + + } + > +
+
+
+ 1}> +
+ + + +
+ {`${truncated()} of ${props.errors.length}`} +
+ + +
- - - -
- -
-
- - - - - - - - - - - } - > - - - - - +
+
+ + + + + + + + + + + } + > + + + + + + +
+ + {(current) => ( +
+ + +
+ )} +
- - {(current) => ( -
- - -
- )} -
-
+
); diff --git a/src/dev-toolbar/error-viewer/styles.css b/src/dev-toolbar/error-viewer/styles.css index 394cd88..65037e0 100644 --- a/src/dev-toolbar/error-viewer/styles.css +++ b/src/dev-toolbar/error-viewer/styles.css @@ -170,11 +170,9 @@ } [data-solid-error-viewer-stack-frame][tc-active], -[data-solid-error-viewer-stack-frame][tc-selected], -[data-solid-error-viewer-stack-frame]:focus { +[data-solid-error-viewer-stack-frame][tc-selected] { color: var(--start-dt-text); background-color: var(--start-dt-accent-soft); - border-left-color: var(--start-dt-accent); } [data-solid-error-viewer-stack-frame-function] { @@ -241,6 +239,8 @@ } [data-solid-error-viewer-code-view] { + /* the offsetParent of the lines, so the view can scroll to the frame */ + position: relative; overflow: auto; min-width: 100%; height: 100%; diff --git a/src/dev-toolbar/functions/index.tsx b/src/dev-toolbar/functions/index.tsx index 93db2bd..a3e51a7 100644 --- a/src/dev-toolbar/functions/index.tsx +++ b/src/dev-toolbar/functions/index.tsx @@ -8,7 +8,7 @@ import { Section } from '../../ui/Section.js'; import { Select, SelectOption } from '../../ui/Select.js'; import { Tab, TabGroup, TabList, TabPanel } from '../../ui/Tabs.js'; import { Text } from '../../ui/Text.js'; -import { FunctionIcon, TrashIcon } from '../icons.js'; +import { TrashIcon } from '../icons.js'; import { BlobViewer } from './BlobViewer.js'; import { FormDataViewer } from './FormDataViewer.js'; import { HeadersViewer } from './HeadersViewer.js'; @@ -349,8 +349,10 @@ export function ServerFunctionViewer(props: ServerFunctionViewerProps): JSX.Elem {/* list of calls */}
- - Server functions + Calls + + {keys().length} +
}> diff --git a/src/dev-toolbar/functions/styles.css b/src/dev-toolbar/functions/styles.css index 377ada5..3887188 100644 --- a/src/dev-toolbar/functions/styles.css +++ b/src/dev-toolbar/functions/styles.css @@ -131,9 +131,9 @@ flex-shrink: 0; } -[data-solid-functions-nav] svg { - padding: 0.25rem; - color: var(--start-dt-accent); +[data-solid-functions-count] { + margin-left: auto; + color: var(--start-dt-text-muted); } [data-solid-function-instance-viewer-nav] > div:last-child { diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index 4f5f0f9..c4670ae 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -661,3 +661,23 @@ export function EyeIcon(props: JSX.IntrinsicElements['svg'] & { title: string }) ); } + +export function MinimizeIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} diff --git a/src/dev-toolbar/index.css b/src/dev-toolbar/index.css index 4ac7d93..b3d1c2f 100644 --- a/src/dev-toolbar/index.css +++ b/src/dev-toolbar/index.css @@ -15,118 +15,365 @@ --start-dt-shadow: 0 12px 32px oklch(0 0 0 / 0.4), 0 2px 8px oklch(0 0 0 / 0.25); position: fixed; - - bottom: 0px; - left: 0px; - - display: flex; - flex-direction: column-reverse; - align-items: flex-start; - + left: 0; + right: 0; + bottom: 0; z-index: 2147483647; + /* the container spans the page width, so only its children take clicks */ + pointer-events: none; + color: var(--start-dt-text); font-family: system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; } -[data-solid-dev-toolbar] > [tc-toolbar] { - cursor: grab; +[data-solid-dev-toolbar] > * { + pointer-events: auto; } -[data-solid-dev-toolbar] > [tc-toolbar]:active { - cursor: grabbing; +[data-solid-dev-toolbar] svg { + height: 1.25rem; + width: 1.25rem; + flex-shrink: 0; } -[data-solid-dev-toolbar] > [tc-toolbar] { - position: relative; +/* closed: a grip on the bottom edge that grows into a pull tab */ +[data-solid-dev-toolbar-handle] { + position: absolute; + left: 50%; + bottom: 0; + transform: translateX(-50%); + + /* the padding is the hit area around the grip */ + display: flex; + justify-content: center; + padding: 0.5rem 1rem 0; + + font: inherit; + color: var(--start-dt-text-muted); + cursor: pointer; + touch-action: none; + + background: none; + border: none; + outline: none; +} + +[data-solid-dev-toolbar-handle][hidden] { + display: none; +} + +[data-solid-dev-toolbar-handle-grip] { + width: 2.5rem; + height: 0.3125rem; + + border-radius: 0.25rem 0.25rem 0 0; background: var(--start-dt-bg-glass); - backdrop-filter: blur(16px) saturate(140%); - -webkit-backdrop-filter: blur(16px) saturate(140%); + /* a light ring keeps the grip visible on dark and light pages */ + box-shadow: 0 0 0 1px oklch(1 0 0 / 0.3); - margin: 1rem; - padding: 0.375rem 0.625rem; + transition: + opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), + width 150ms cubic-bezier(0.4, 0, 0.2, 1); +} - overflow: hidden; - border-radius: 9999px; +[data-solid-dev-toolbar-handle][data-errors] [data-solid-dev-toolbar-handle-grip] { + width: 3.5rem; + background: var(--start-dt-danger); +} + +[data-solid-dev-toolbar-handle-content] { + position: absolute; + left: 50%; + bottom: 0; + transform: translate(-50%, 100%); display: flex; - flex-direction: row; align-items: center; + gap: 0.5rem; - gap: 0.625rem; + padding: 0.3125rem 0.5rem 0.3125rem 0.75rem; + + font-size: 0.8125rem; + font-weight: 600; + white-space: nowrap; + color: var(--start-dt-text); + background: var(--start-dt-surface); border: var(--start-dt-border) 1px solid; - box-shadow: var(--start-dt-shadow); + border-bottom: none; + border-radius: 0.625rem 0.625rem 0 0; + box-shadow: 0 -4px 16px oklch(0 0 0 / 0.3); + + opacity: 0; + pointer-events: none; + + transition: + transform 150ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); } -[data-solid-dev-toolbar] > [tc-toolbar] > div { - display: flex; +[data-solid-dev-toolbar-handle-content] svg { + height: 1rem; + width: 1rem; +} - flex-direction: row; - align-items: center; - justify-content: center; +[data-solid-dev-toolbar-handle-chevron] { + color: var(--start-dt-text-muted); + transform: rotate(180deg); +} - gap: 0.125rem; +[data-solid-dev-toolbar-handle]:hover [data-solid-dev-toolbar-handle-content], +[data-solid-dev-toolbar-handle]:focus-visible [data-solid-dev-toolbar-handle-content], +[data-solid-dev-toolbar-handle]:active [data-solid-dev-toolbar-handle-content] { + transform: translate(-50%, 0); + opacity: 1; + pointer-events: auto; } -[data-solid-dev-toolbar] > [tc-toolbar] > div:last-child { - border-left: var(--start-dt-border) 1px solid; - padding-left: 0.625rem; +[data-solid-dev-toolbar-handle]:hover [data-solid-dev-toolbar-handle-grip], +[data-solid-dev-toolbar-handle]:focus-visible [data-solid-dev-toolbar-handle-grip], +[data-solid-dev-toolbar-handle]:active [data-solid-dev-toolbar-handle-grip] { + opacity: 0; +} - gap: 0.5rem; +[data-solid-dev-toolbar-handle]:focus-visible [data-solid-dev-toolbar-handle-content] { + border-color: var(--start-dt-accent); +} - color: var(--start-dt-text-muted); +[data-solid-dev-toolbar-handle-errors] { + min-width: 1.125rem; + padding: 0 0.3125rem; + + border-radius: 9999px; + background: var(--start-dt-danger); + color: white; + + font-size: 0.6875rem; + line-height: 1.125rem; + text-align: center; +} + +@media (prefers-reduced-motion: reduce) { + [data-solid-dev-toolbar-handle-grip], + [data-solid-dev-toolbar-handle-content] { + transition: none; + } } [data-solid-dev-toolbar-version] { border-radius: 9999px; color: var(--start-dt-accent); background: var(--start-dt-accent-soft); - padding: 0.125rem 0.5rem; + padding: 0.0625rem 0.5rem; - font-size: 0.875rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; line-height: 1.25rem; font-weight: 600; + white-space: nowrap; +} - border: none; +/* open: a dock along the bottom of the page */ + +[data-solid-dev-toolbar-dock] { + position: relative; + box-sizing: border-box; + + display: flex; + flex-direction: column; + + min-height: 10rem; + max-height: 90vh; + + background: var(--start-dt-bg); + border-top: var(--start-dt-border) 1px solid; + box-shadow: 0 -8px 32px oklch(0 0 0 / 0.35); } -[data-solid-dev-toolbar-version] > div { - height: 1.5rem; - width: 1.5rem; +[data-solid-dev-toolbar-dock][hidden] { + display: none; } -[data-solid-dev-toolbar] svg { - height: 1.25rem; - width: 1.25rem; +[data-solid-dev-toolbar-resize] { + position: absolute; + top: -4px; + left: 0; + right: 0; + height: 8px; + z-index: 2; + + cursor: ns-resize; + touch-action: none; } -[data-solid-dev-toolbar-panel][data-wide] { - width: 72rem; +[data-solid-dev-toolbar-resize]:hover, +[data-solid-dev-toolbar-resize]:active { + background: linear-gradient( + transparent 3px, + var(--start-dt-accent) 3px, + var(--start-dt-accent) 5px, + transparent 5px + ); } -[data-solid-dev-toolbar-panel] { - border: var(--start-dt-border) 1px solid; - background: var(--start-dt-bg-glass); - backdrop-filter: blur(16px) saturate(140%); - -webkit-backdrop-filter: blur(16px) saturate(140%); - box-shadow: var(--start-dt-shadow); +[data-solid-dev-toolbar-header] { + display: flex; + align-items: center; + gap: 0.75rem; - margin: 0rem 1rem; + padding: 0.25rem 0.5rem 0.25rem 0.75rem; + background: var(--start-dt-surface); + border-bottom: var(--start-dt-border-soft) 1px solid; + + white-space: nowrap; overflow: hidden; +} +[data-solid-dev-toolbar-brand] { + display: flex; + align-items: center; + gap: 0.5rem; + + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: -0.01em; +} + +[data-solid-dev-toolbar-route] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + + color: var(--start-dt-text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; +} + +[data-solid-dev-toolbar-spacer] { + flex: 1; +} + +[data-solid-dev-toolbar-main] { + flex: 1; + display: flex; + min-height: 0; +} + +[data-solid-dev-toolbar-nav] { display: flex; flex-direction: column; + gap: 0.125rem; + + width: 11rem; + flex-shrink: 0; + padding: 0.375rem; + overflow: auto; + + background: var(--start-dt-surface); + border-right: var(--start-dt-border-soft) 1px solid; +} + +[data-solid-dev-toolbar-tab] { + display: flex; + align-items: center; + gap: 0.5rem; + + padding: 0.375rem 0.5rem; + + font: inherit; + font-size: 0.8125rem; + text-align: left; + color: var(--start-dt-text-muted); + cursor: pointer; + + background: none; + border: none; + border-radius: 0.375rem; + + transition-property: color, background-color, box-shadow; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +[data-solid-dev-toolbar-tab] svg { + height: 1rem; + width: 1rem; +} + +[data-solid-dev-toolbar-tab]:hover { + color: var(--start-dt-text); + background: var(--start-dt-surface-hover); +} + +[data-solid-dev-toolbar-tab]:focus-visible { + outline: 2px solid var(--start-dt-accent); + outline-offset: -2px; +} + +[data-solid-dev-toolbar-tab][aria-selected='true'] { + color: var(--start-dt-text); + background: var(--start-dt-surface-active); +} + +[data-solid-dev-toolbar-tab][aria-selected='true'] svg { + color: var(--start-dt-accent); +} + +[data-solid-dev-toolbar-tab-label] { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} - width: 56rem; - height: min(40rem, calc(100vh - 4rem)); +[data-solid-dev-toolbar-tab-badge] { + min-width: 1.125rem; + padding: 0 0.3125rem; - max-width: calc(100vw - 10rem); - max-height: calc(100vh - 2rem); + border-radius: 9999px; + background: var(--start-dt-surface-hover); + color: var(--start-dt-text-muted); + + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; + line-height: 1.125rem; + text-align: center; +} + +[data-solid-dev-toolbar-tab-badge='failure'] { + background: oklch(0.63 0.21 25 / 0.2); + color: oklch(0.78 0.14 25); +} + +[data-solid-dev-toolbar-body] { + flex: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +[data-solid-dev-toolbar-panel] { + display: flex; + flex-direction: column; + + height: 100%; + overflow: hidden; +} - border-radius: 1rem; +/* narrow pages fold the nav down to its icons */ +@media (max-width: 640px) { + [data-solid-dev-toolbar-nav] { + width: auto; + } + + [data-solid-dev-toolbar-tab-label], + [data-solid-dev-toolbar-tab-badge], + [data-solid-dev-toolbar-route] { + display: none; + } } diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 797c512..7f04b19 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -1,13 +1,21 @@ import type { JSX } from '@solidjs/web'; import { clientOnly, httpStatus, isServer, Portal } from '@solidjs/web'; -import { createEffect, createSignal, Errored, getOwner, OBSERVE, onSettled } from 'solid-js'; -import { Toolbar } from 'terracotta/toolbar'; +import { + createEffect, + createMemo, + createSignal, + Errored, + For, + getOwner, + OBSERVE, + onSettled, + Show, +} from 'solid-js'; import version from '../version.js'; import IconButton from '../ui/IconButton.js'; -import { Text } from '../ui/Text.js'; import { type ServerFunctionInstance, ServerFunctionViewer } from './functions/index.js'; import { captureServerFunctionCall } from './functions/tracker.js'; -import { ErrorIcon, FunctionIcon, GraphIcon, SolidIcon, TreeIcon } from './icons.js'; +import { ErrorIcon, FunctionIcon, GraphIcon, MinimizeIcon, SolidIcon, TreeIcon } from './icons.js'; import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; import './index.css'; @@ -16,6 +24,54 @@ const ErrorViewer = clientOnly(() => import('./error-viewer/index.js'), { lazy: const ReactivityViewer = clientOnly(() => import('./reactivity/index.js'), { lazy: true }); const OwnershipViewer = clientOnly(() => import('./ownership/index.js'), { lazy: true }); +type Tab = 'err' | 'fn' | 'rx' | 'own'; + +const TABS: { value: Tab; label: string; icon: typeof ErrorIcon }[] = [ + { value: 'err', label: 'Errors', icon: ErrorIcon }, + { value: 'fn', label: 'Server functions', icon: FunctionIcon }, + { value: 'rx', label: 'Reactivity', icon: GraphIcon }, + { value: 'own', label: 'Ownership', icon: TreeIcon }, +]; + +// The dock remembers whether it was open, its tab and its height, so a full +// reload during development comes back to the same view. +const STORAGE_KEY = 'solid-start-devtools:dock'; +const MIN_HEIGHT = 160; +const DEFAULT_HEIGHT = 360; +// How far the pull tab moves up before a press becomes a drag that opens the dock. +const DRAG_THRESHOLD = 4; + +interface DockState { + open: boolean; + tab: Tab; + height: number; +} + +function readDockState(): Partial { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial; + } catch { + // Storage can be blocked. The defaults stay. + return {}; + } +} + +function writeDockState(state: DockState): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Storage can be blocked. + } +} + +function clampHeight(height: number): number { + return Math.round(Math.min(Math.max(height, MIN_HEIGHT), window.innerHeight * 0.9)); +} + +function currentRoute(): string { + return location.pathname + location.search; +} + export interface DevToolbarProps { children?: JSX.Element; } @@ -44,113 +100,117 @@ export function DevToolbar(props: DevToolbarProps) { excludeOwner(owner); if (owner) OBSERVE?.exclude(owner); - const [ref, setRef] = createSignal(); - - createEffect( - () => ref(), - (current) => { - if (!current) return; - - // Only the toolbar pill itself is the drag handle — panels/popovers are not draggable. - const handle = - (current.querySelector(':scope > [tc-toolbar]') as HTMLElement | null) ?? current; + const [open, setOpen] = createSignal(false); + const [tab, setTab] = createSignal('fn'); + const [height, setHeight] = createSignal(DEFAULT_HEIGHT); + const [route, setRoute] = createSignal(''); - let isDown = false; - - let offsetX = 0; - let offsetY = 0; - - let currentX = 0; - let currentY = 0; - - let centerX = 0; - let centerY = 0; - - const resetPosition = () => { - current.style.top = 'auto'; - current.style.left = 'auto'; - current.style.bottom = 'auto'; - current.style.right = 'auto'; - }; - - let isDirty = false; - - const ac = new AbortController(); - - handle.addEventListener( - 'mousedown', - (e) => { - isDown = true; - - const rect = current.getBoundingClientRect(); - - currentX = rect.left; - currentY = rect.top; - - offsetX = e.clientX - currentX; - offsetY = e.clientY - currentY; - - centerX = rect.width / 2; - centerY = rect.height / 2; - - isDirty = true; - }, - { - signal: ac.signal, - }, - ); - - window.addEventListener( - 'mouseup', - () => { - if (isDown && !isDirty) { - const preferredAnchorX = currentX + centerX < window.innerWidth / 2 ? 'left' : 'right'; - const preferredAnchorY = currentY + centerY < window.innerHeight / 2 ? 'top' : 'bottom'; + // Writes are batched, so a signal read right after its setter still returns + // the old value. The caller passes what it just changed. + function persist(next: Partial) { + writeDockState({ open: open(), tab: tab(), height: height(), ...next }); + } - resetPosition(); + function show(value: Tab) { + setTab(value); + setOpen(true); + persist({ open: true, tab: value }); + } - current.style[preferredAnchorX] = '0px'; - current.style[preferredAnchorY] = '0px'; + function close() { + setOpen(false); + persist({ open: false }); + } - current.style.flexDirection = - preferredAnchorY === 'bottom' ? 'column-reverse' : 'column'; - current.style.alignItems = preferredAnchorX === 'left' ? 'flex-start' : 'flex-end'; - } - isDown = false; - }, - { - signal: ac.signal, - }, - ); - - window.addEventListener( - 'mousemove', - (e) => { - if (isDown) { - if (isDirty) { - resetPosition(); - isDirty = false; - } - currentX = e.clientX - offsetX; - currentY = e.clientY - offsetY; + // A panel is live only while the dock is open on its tab, so the graph and + // the tree stop watching the app when nobody looks at them. + const visible = (value: Tab) => open() && tab() === value; - current.style.left = `${currentX}px`; - current.style.top = `${currentY}px`; - } - }, - { - signal: ac.signal, - passive: true, - }, - ); + onSettled(() => { + const saved = readDockState(); + if (TABS.some((entry) => entry.value === saved.tab)) setTab(saved.tab!); + if (typeof saved.height === 'number') setHeight(clampHeight(saved.height)); + if (saved.open) setOpen(true); + + // The Navigation API reports client-side navigations. Without it, only + // history traversal updates the route. + setRoute(currentRoute()); + const updateRoute = () => setRoute(currentRoute()); + const navigation = Reflect.get(window, 'navigation') as EventTarget | undefined; + window.addEventListener('popstate', updateRoute); + navigation?.addEventListener('currententrychange', updateRoute); + return () => { + window.removeEventListener('popstate', updateRoute); + navigation?.removeEventListener('currententrychange', updateRoute); + }; + }); + // The page can scroll past the open dock. + createEffect( + () => (open() ? height() : undefined), + (value) => { + if (value === undefined) return; + const root = document.documentElement; + const previous = root.style.paddingBottom; + root.style.paddingBottom = `${value}px`; return () => { - ac.abort(); + root.style.paddingBottom = previous; }; }, ); - const [content, setContent] = createSignal<'fn' | 'err' | 'rx' | 'own' | undefined>(undefined); + // Dragging the top edge of the dock sets its height. Dragging the pull tab up + // does the same once it passes the threshold, opening the dock under the + // pointer. A press on the tab that never moves is left to its click. + function onDragHeight(event: PointerEvent, opening: boolean) { + if (event.button !== 0) return; + event.preventDefault(); + const start = event.clientY; + let dragging = !opening; + let last = height(); + const move = (next: PointerEvent) => { + if (!dragging) { + if (start - next.clientY < DRAG_THRESHOLD) return; + dragging = true; + setOpen(true); + } + last = clampHeight(window.innerHeight - next.clientY); + setHeight(last); + }; + // The tab hides as the dock opens, so the pointer is followed on the window. + const end = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', end); + window.removeEventListener('pointercancel', end); + if (dragging) persist({ open: true, height: last }); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', end); + window.addEventListener('pointercancel', end); + } + + onSettled(() => { + const onKeyDown = (event: KeyboardEvent) => { + // `code`, because Alt changes the character `key` reports on macOS. + if (!event.altKey || !event.shiftKey || event.code !== 'KeyD') return; + event.preventDefault(); + if (open()) close(); + else show(tab()); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }); + + function onTabKeyDown(event: KeyboardEvent) { + const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; + if (!step) return; + event.preventDefault(); + const index = TABS.findIndex((entry) => entry.value === tab()); + const next = TABS[(index + step + TABS.length) % TABS.length]!; + show(next.value); + const list = event.currentTarget as HTMLElement; + list.querySelector(`[data-solid-dev-toolbar-tab="${next.value}"]`)?.focus(); + } // A panel can ask the graph to show a node. Each request is a new object, so // asking for the same node twice still moves the view. @@ -158,14 +218,6 @@ export function DevToolbar(props: DevToolbarProps) { // The graph can ask the ownership tree to show an owner the same way. const [ownershipFocus, setOwnershipFocus] = createSignal<{ owner: object }>(); - function toggleContent(value: 'fn' | 'err' | 'rx' | 'own') { - if (content() === value) { - setContent(undefined); - } else { - setContent(value); - } - } - const [errors, setErrors] = createSignal([]); function resetError() { @@ -176,7 +228,7 @@ export function DevToolbar(props: DevToolbarProps) { console.error(error); setErrors((current) => [error, ...current]); - setContent('err'); + show('err'); } onSettled(() => { @@ -218,62 +270,155 @@ export function DevToolbar(props: DevToolbarProps) { }), ); + const callCount = createMemo(() => Object.keys(instances()).length); + + const badge = (value: Tab): number => { + if (value === 'err') return errors().length; + if (value === 'fn') return callCount(); + return 0; + }; + return ( <> -
- -
- toggleContent('err')} disabled={errors().length === 0}> - - - toggleContent('fn')}> - - - toggleContent('rx')}> - - - toggleContent('own')}> - +
+ {/* The closed dock: a grip on the bottom edge that grows into a pull tab + on hover or focus, so it covers almost none of the page. */} + +