From b36ceb5b0a0aece06303937c744559f83355605d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Mon, 24 Aug 2026 13:35:53 +0800 Subject: [PATCH] fix: devtools redesign --- .changeset/toolbar-redesign.md | 5 + package.json | 4 +- pnpm-lock.yaml | 39 +- src/dev-toolbar/error-viewer/CodeView.tsx | 15 +- .../error-viewer/create-stack-frame.ts | 55 +- .../error-viewer/get-source-map.ts | 12 +- src/dev-toolbar/error-viewer/index.tsx | 123 ++- src/dev-toolbar/error-viewer/styles.css | 134 ++- src/dev-toolbar/functions/BlobViewer.css | 52 +- src/dev-toolbar/functions/BlobViewer.tsx | 33 +- src/dev-toolbar/functions/FormDataViewer.tsx | 18 +- src/dev-toolbar/functions/HeadersViewer.css | 4 - src/dev-toolbar/functions/HeadersViewer.tsx | 19 +- src/dev-toolbar/functions/HexViewer.css | 53 +- src/dev-toolbar/functions/HexViewer.tsx | 19 +- src/dev-toolbar/functions/SerovalValue.css | 21 + src/dev-toolbar/functions/SerovalValue.tsx | 12 +- src/dev-toolbar/functions/SerovalViewer.css | 134 ++- src/dev-toolbar/functions/SerovalViewer.tsx | 925 ++++++++++-------- .../functions/URLSearchParamsViewer.tsx | 18 +- src/dev-toolbar/functions/index.tsx | 218 +++-- src/dev-toolbar/functions/styles.css | 153 ++- src/dev-toolbar/index.css | 68 +- src/dev-toolbar/index.tsx | 13 +- src/ui/Badge.css | 31 +- src/ui/Button.css | 13 +- src/ui/Cascade.css | 29 - src/ui/Cascade.tsx | 8 - src/ui/IconButton.css | 26 +- src/ui/Placeholder.css | 2 + src/ui/Section.css | 45 +- src/ui/Section.tsx | 30 +- src/ui/Select.css | 15 +- src/ui/Tabs.css | 46 +- tests/e2e/devtools.spec.ts | 26 +- 35 files changed, 1585 insertions(+), 833 deletions(-) create mode 100644 .changeset/toolbar-redesign.md delete mode 100644 src/ui/Cascade.css delete mode 100644 src/ui/Cascade.tsx diff --git a/.changeset/toolbar-redesign.md b/.changeset/toolbar-redesign.md new file mode 100644 index 0000000..dfcac25 --- /dev/null +++ b/.changeset/toolbar-redesign.md @@ -0,0 +1,5 @@ +--- +'@solidjs/start-devtools': patch +--- + +Sync the dev toolbar redesign from Solid Start: panel layout with a call list beside a detail pane, collapsible sections, body-first content viewers, request timing, unhandled rejection capture, drag limited to the toolbar pill, and source map tracing through `@jridgewell/trace-mapping`. Stack parsing moves from `error-stack-parser` to `error-stack-parser-es/lite`, dropping the `stackframe` transitive dependency. diff --git a/package.json b/package.json index 55c4116..a7fd1d0 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,11 @@ "@arethetypeswrong/cli": "^0.18.5", "@changesets/cli": "^2.30.0", "@dom-expressions/compiler": "^0.50.0-next.43", + "@jridgewell/trace-mapping": "^0.3.31", "@playwright/test": "^1.62.1", "@solidjs/web": "^2.0.0-rc.0", "@types/node": "^24.0.0", - "error-stack-parser": "^2.1.4", + "error-stack-parser-es": "^2.0.1", "html-to-image": "^1.11.13", "oxfmt": "^0.64.0", "publint": "^0.3.23", @@ -69,7 +70,6 @@ "seroval": "^1.6.0", "shiki": "^4.3.1", "solid-js": "^2.0.0-rc.0", - "source-map-js": "^1.2.1", "terracotta": "2.0.0-next.6", "typescript": "^7.0.2", "vite": "^8.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c70b0ff..afec723 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@dom-expressions/compiler': specifier: ^0.50.0-next.43 version: 0.50.0-next.43 + '@jridgewell/trace-mapping': + specifier: ^0.3.31 + version: 0.3.31 '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -26,9 +29,9 @@ importers: '@types/node': specifier: ^24.0.0 version: 24.13.3 - error-stack-parser: - specifier: ^2.1.4 - version: 2.1.4 + error-stack-parser-es: + specifier: ^2.0.1 + version: 2.0.1 html-to-image: specifier: ^1.11.13 version: 1.11.13 @@ -53,9 +56,6 @@ importers: solid-js: specifier: ^2.0.0-rc.0 version: 2.0.0-rc.0 - source-map-js: - specifier: ^1.2.1 - version: 1.2.1 terracotta: specifier: 2.0.0-next.6 version: 2.0.0-next.6(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0) @@ -201,9 +201,16 @@ packages: '@types/node': optional: true + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} @@ -973,8 +980,8 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} - error-stack-parser@2.1.4: - resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + error-stack-parser-es@2.0.1: + resolution: {integrity: sha512-J36ntO+rMQVRuR/umlmxmfLi4TpWwtmTnHoJoXTiC2xDNazs0VDPKcX6pbhZMDd2HFtH9isMBJXMdbki+A++Pg==} es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -1534,9 +1541,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - stackframe@1.3.4: - resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} - std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -2007,8 +2011,15 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@loaderkit/resolve@1.0.6': dependencies: '@braidai/lang': 1.1.2 @@ -2541,9 +2552,7 @@ snapshots: environment@1.1.0: {} - error-stack-parser@2.1.4: - dependencies: - stackframe: 1.3.4 + error-stack-parser-es@2.0.1: {} es-module-lexer@2.3.2: {} @@ -3061,8 +3070,6 @@ snapshots: stackback@0.0.2: {} - stackframe@1.3.4: {} - std-env@4.2.0: {} string-width@4.2.3: diff --git a/src/dev-toolbar/error-viewer/CodeView.tsx b/src/dev-toolbar/error-viewer/CodeView.tsx index 0828257..f2f6a1d 100644 --- a/src/dev-toolbar/error-viewer/CodeView.tsx +++ b/src/dev-toolbar/error-viewer/CodeView.tsx @@ -29,7 +29,7 @@ export interface CodeViewProps { line: number; } -const RANGE = 8; +const RANGE = 15; export function CodeView(props: CodeViewProps): JSX.Element | null { const lines = () => @@ -50,9 +50,16 @@ export function CodeView(props: CodeViewProps): JSX.Element | null { .join('\n'); const highlighter = await loadHighlighter(); const fileExtension = props.fileName.split(/[#?]/)[0]!.split('.').pop()?.trim(); - let lang = fileExtension ?? 'text'; - if (fileExtension === 'mjs' || fileExtension === 'cjs') { - lang = 'js'; + // Only these grammars are loaded — anything else would make shiki + // throw. Fall back to plain JS highlighting for unknown sources. + let lang: 'js' | 'jsx' | 'ts' | 'tsx' = 'js'; + if ( + fileExtension === 'jsx' || + fileExtension === 'ts' || + fileExtension === 'tsx' || + fileExtension === 'js' + ) { + lang = fileExtension; } return highlighter.codeToHtml(value, { theme: 'dark-plus', diff --git a/src/dev-toolbar/error-viewer/create-stack-frame.ts b/src/dev-toolbar/error-viewer/create-stack-frame.ts index aac264f..1c7ac3e 100644 --- a/src/dev-toolbar/error-viewer/create-stack-frame.ts +++ b/src/dev-toolbar/error-viewer/create-stack-frame.ts @@ -1,3 +1,5 @@ +import { originalPositionFor, sourceContentFor } from '@jridgewell/trace-mapping'; +import type { StackFrameLite } from 'error-stack-parser-es/lite'; import { type Accessor, createMemo } from 'solid-js'; import getSourceMap from './get-source-map.js'; @@ -26,30 +28,38 @@ function getActualFileSource(path: string): string { return path; } -export function createStackFrame(stackframe: StackFrame, isCompiled: () => boolean) { +export function createStackFrame(stackframe: StackFrameLite, isCompiled: () => boolean) { const data = createMemo(async () => { const source = { - fileName: stackframe.fileName, - line: stackframe.lineNumber, - column: stackframe.columnNumber, - functionName: stackframe.functionName, + fileName: stackframe.file, + line: stackframe.line, + column: stackframe.col, + functionName: stackframe.function, }; if (!source.fileName) { return null; } - const response = await fetch(getActualFileSource(source.fileName)); - if (!response.ok) { + // Sources can be unreachable — node internals, extension scripts, + // files outside the dev server's allowlist. Treat any failure as + // "no source" instead of throwing into the error boundary. + try { + const url = getActualFileSource(source.fileName); + const response = await fetch(url); + if (!response.ok) { + return null; + } + const content = await response.text(); + const sourceMap = await getSourceMap(url, content); + return { + source, + content, + sourceMap, + isServer: isServerSource(source.fileName), + }; + } catch (error) { + console.warn('[solid dev toolbar] failed to load source for stack frame', error); return null; } - const content = await response.text(); - const url = getActualFileSource(source.fileName); - const sourceMap = await getSourceMap(url, content); - return { - source, - content, - sourceMap, - isServer: isServerSource(source.fileName), - }; }); const info = createMemo(() => { @@ -61,9 +71,12 @@ export function createStackFrame(stackframe: StackFrame, isCompiled: () => boole if (!isCompiled() && source.line && source.column && sourceMap) { if (isServer) { - const originalContent = sourceMap.sources.length - ? sourceMap.sourceContentFor(sourceMap.sources[0]!, true) - : null; + // The position is already original; only the original content needs + // to be pulled out of the source map. + const originalContent = + sourceMap.sources.length && sourceMap.sources[0] != null + ? sourceContentFor(sourceMap, sourceMap.sources[0]) + : null; if (originalContent) { return { source: source.fileName, @@ -74,14 +87,14 @@ export function createStackFrame(stackframe: StackFrame, isCompiled: () => boole } as StackFrameSource; } } else { - const result = sourceMap.originalPositionFor({ + const result = originalPositionFor(sourceMap, { line: source.line, column: source.column, }); if (result.source) { return { ...result, - content: sourceMap.sourceContentFor(result.source, true), + content: sourceContentFor(sourceMap, result.source), } as StackFrameSource; } } diff --git a/src/dev-toolbar/error-viewer/get-source-map.ts b/src/dev-toolbar/error-viewer/get-source-map.ts index 4107429..4499ad2 100644 --- a/src/dev-toolbar/error-viewer/get-source-map.ts +++ b/src/dev-toolbar/error-viewer/get-source-map.ts @@ -1,13 +1,10 @@ -import { type RawSourceMap, SourceMapConsumer } from 'source-map-js'; +import { AnyMap, type SourceMapInput, type TraceMap } from '@jridgewell/trace-mapping'; const INLINE_SOURCEMAP_REGEX = /^data:application\/json[^,]+base64,/; const SOURCEMAP_REGEX = /(?:\/\/[@#][ \t]+sourceMappingURL=([^\s'"]+?)[ \t]*$)|(?:\/\*[@#][ \t]+sourceMappingURL=([^*]+?)[ \t]*(?:\*\/)[ \t]*$)/; -export default async function getSourceMap( - url: string, - content: string, -): Promise { +export default async function getSourceMap(url: string, content: string): Promise { const lines = content.split('\n'); let sourceMapUrl: string | undefined; for (let i = lines.length - 1; i >= 0 && !sourceMapUrl; i--) { @@ -27,6 +24,7 @@ export default async function getSourceMap( sourceMapUrl = parsedURL.join('/'); } const response = await fetch(sourceMapUrl); - const rawSourceMap: RawSourceMap = await response.json(); - return new SourceMapConsumer(rawSourceMap); + const rawSourceMap: SourceMapInput = await response.json(); + // AnyMap also handles indexed ("sections") source maps + return new AnyMap(rawSourceMap); } diff --git a/src/dev-toolbar/error-viewer/index.tsx b/src/dev-toolbar/error-viewer/index.tsx index 2661f18..966950c 100644 --- a/src/dev-toolbar/error-viewer/index.tsx +++ b/src/dev-toolbar/error-viewer/index.tsx @@ -1,5 +1,5 @@ // @refresh skip -import ErrorStackParser from 'error-stack-parser'; +import { parse as parseErrorStack, type StackFrameLite } from 'error-stack-parser-es/lite'; import * as htmlToImage from 'html-to-image'; import type { JSX } from '@solidjs/web'; import { Errored, For, Loading, Show, createMemo, createSignal } from 'solid-js'; @@ -80,68 +80,59 @@ function CodeFallback(): JSX.Element { ); } +interface RawStackFrameOptionProps { + frame: StackFrameLite; + disabled?: boolean; +} + +// Frame rendered from the raw (unmapped) stack info — used while the source +// is loading or when it is unreachable, so the frame never vanishes from +// the list. +function RawStackFrameOption(props: RawStackFrameOptionProps): JSX.Element { + const fileName = () => props.frame.file; + return ( + + + {props.frame.function ?? ''} + + + {fileName() + ? getFilePath({ + source: fileName()!, + content: '', + line: props.frame.line!, + column: props.frame.col!, + name: props.frame.function, + }) + : ''} + + + ); +} + function StackFramesContent(props: StackFramesContentProps) { - const stackframes = ErrorStackParser.parse(props.error); + // An error without a usable stack yields no frames instead of throwing. + const stackframes = parseErrorStack(props.error, { allowEmpty: true }); - const [selectedFrame, setSelectedFrame] = createSignal(stackframes[0]!); + const [selectedFrame, setSelectedFrame] = createSignal( + stackframes[0], + ); return (
-
- - {(() => { - const data = createStackFrame(selectedFrame(), () => props.isCompiled); - return ( - }> - }> - {(source) => ( - <> - {source.source} -
- -
- - )} -
-
- ); - })()} -
-
- + data-solid-error-viewer-stack-frames value={selectedFrame()} onChange={setSelectedFrame} > {(current) => ( - - - {current.functionName ?? ''} - - - {getFilePath({ - source: current.getFileName()!, - content: '', - line: current.getLineNumber()!, - column: current.getColumnNumber()!, - name: current.getFunctionName(), - })} - - - } - > + }> {(() => { const data = createStackFrame(current, () => props.isCompiled); return ( - - + }> + }> {(source) => ( @@ -160,6 +151,40 @@ function StackFramesContent(props: StackFramesContentProps) { )} +
+ + {(frame) => ( + // Keyed on the frame so a failure inspecting one frame (an + // unreachable source, a failed highlight) resets when another + // frame is selected instead of leaving the pane stuck. + }> + {(() => { + const data = createStackFrame(frame, () => props.isCompiled); + return ( + }> + }> + {(source) => ( + <> + + {source.source} + +
+ +
+ + )} +
+
+ ); + })()} +
+ )} +
+
); } diff --git a/src/dev-toolbar/error-viewer/styles.css b/src/dev-toolbar/error-viewer/styles.css index cd557af..09f44e1 100644 --- a/src/dev-toolbar/error-viewer/styles.css +++ b/src/dev-toolbar/error-viewer/styles.css @@ -3,6 +3,8 @@ flex-direction: column; width: 100%; + height: 100%; + min-height: 0; } [data-solid-error-viewer-navbar] { @@ -11,6 +13,16 @@ align-items: center; justify-content: space-between; gap: 0.5rem; + + padding: 0.5rem 0.75rem; + + position: sticky; + top: 0; + z-index: 1; + + background: var(--start-dt-bg-glass); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); } [data-solid-error-viewer-pagination] { @@ -20,8 +32,8 @@ justify-content: center; gap: 0.5rem; border-radius: 9999px; - color: rgb(249 250 251); - background-color: rgb(17 24 39); + color: var(--start-dt-text); + background-color: var(--start-dt-surface); padding: 0.25rem; } @@ -30,23 +42,23 @@ } [data-solid-error-viewer-button]:hover { - color: rgb(229 231 235); - background-color: rgb(55 65 81); + color: var(--start-dt-text); + background-color: var(--start-dt-surface-hover); } [data-solid-error-viewer-button]:focus { outline: 2px solid transparent; outline-offset: 2px; - outline-color: rgb(229 231 235); + outline-color: var(--start-dt-accent); } [data-solid-error-viewer-button]:focus-visible { - box-shadow: 0 0 0 calc(3px) rgb(17 24 39 / 0.75); + box-shadow: 0 0 0 2px var(--start-dt-accent); } [data-solid-error-viewer-button]:active { - color: rgb(243 244 246); - background-color: rgb(31 41 55); + color: var(--start-dt-accent); + background-color: var(--start-dt-surface-active); } [data-solid-error-viewer-button] > svg { @@ -55,9 +67,11 @@ } [data-solid-error-viewer-page-counter] { - font-size: 0.875rem; + font-size: 0.8125rem; line-height: 1.25rem; font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--start-dt-text-muted); } [data-solid-error-viewer-navbar-left] { @@ -73,7 +87,7 @@ justify-content: center; gap: 0.25rem; border-radius: 9999px; - color: rgb(249 250 251); + color: var(--start-dt-text); padding: 0.25rem; } @@ -83,9 +97,13 @@ gap: 0.75rem; padding: 1rem; + flex: 1; + min-height: 0; + overflow: hidden; + border-top-width: 1px; border-top-style: solid; - border-top-color: oklch(87% 0.065 274.039); + border-top-color: var(--start-dt-border-soft); } [data-solid-error-viewer-error-info] { @@ -95,107 +113,137 @@ } [data-solid-error-viewer-error-info-name] { - font-weight: 700; - color: rgb(249 250 251); + font-weight: 600; + font-size: 0.8125rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--start-dt-text-muted); } [data-solid-error-viewer-error-info-message] { - font-size: 1.5rem; - line-height: 2rem; - color: rgb(239 68 68); + font-size: 1.25rem; + line-height: 1.75rem; + color: var(--start-dt-danger); font-weight: 700; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; } [data-solid-error-viewer-stack-frames-content] { display: flex; - flex-direction: column; + flex-direction: row; gap: 0.75rem; + + flex: 1; + min-height: 0; } [data-solid-error-viewer-stack-frames] { - flex: 1 1 0%; - max-height: 16rem; + width: 38%; + max-width: 20rem; + flex-shrink: 0; overflow-y: auto; + + border: 1px var(--start-dt-border-soft) solid; + border-radius: 0.5rem; } [data-solid-error-viewer-stack-frame] { display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 1rem; - color: rgb(249 250 251); + flex-direction: column; + align-items: flex-start; + gap: 0.125rem; + color: var(--start-dt-text); cursor: default; user-select: none; padding-top: 0.5rem; padding-bottom: 0.5rem; - padding-left: 1rem; - padding-right: 1rem; + padding-left: 0.75rem; + padding-right: 0.75rem; font-size: 0.875rem; line-height: 1.25rem; outline: none; + + min-width: 0; + max-width: 100%; } [data-solid-error-viewer-stack-frame][tc-active], [data-solid-error-viewer-stack-frame][tc-selected], [data-solid-error-viewer-stack-frame]:focus { - color: rgb(219 234 254); - background-color: rgb(30 58 138); + 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] { - font-weight: 700; + font-weight: 600; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; } [data-solid-error-viewer-stack-frame-file] { white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; font-size: 0.75rem; - /* 12px */ line-height: 1rem; - /* 16px */ + color: var(--start-dt-text-muted); } [data-solid-error-viewer-stack-frames-code] { - padding: 0.5rem 1rem; - gap: 0.25rem; + padding: 0.5rem 0.75rem; + gap: 0.375rem; flex: 1 1 0%; + min-width: 0; + min-height: 0; display: flex; flex-direction: column; - align-items: center; - justify-content: center; + align-items: stretch; + justify-content: flex-start; border-radius: 0.5rem; - background-color: rgb(17 24 39); + background-color: var(--start-dt-surface); + border: 1px var(--start-dt-border-soft) solid; } [data-solid-error-viewer-stack-frames-code-fallback] { display: flex; align-items: center; justify-content: center; - color: rgb(249 250 251); - min-height: 16rem; + color: var(--start-dt-text-muted); + flex: 1; + min-height: 8rem; font-size: 0.875rem; line-height: 1.25rem; } [data-solid-error-viewer-stack-frames-code-source] { - font-size: 0.875rem; + font-size: 0.8125rem; line-height: 1.25rem; - color: rgb(249 250 251); + color: var(--start-dt-text-muted); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; } [data-solid-error-viewer-stack-frames-code-container] { width: 100%; overflow: hidden; min-width: 100%; - max-height: 16rem; + flex: 1; + min-height: 0; border-top-width: 1px; border-top-style: solid; - border-top-color: oklch(87% 0.065 274.039); + border-top-color: var(--start-dt-border-soft); } [data-solid-error-viewer-code-view] { overflow: auto; min-width: 100%; + height: 100%; } [data-solid-error-viewer-code-view] > .shiki { @@ -220,5 +268,5 @@ } [data-solid-error-viewer-error-line] { - background-color: rgba(239, 68, 68, 0.4); + background-color: oklch(0.63 0.21 25 / 0.25); } diff --git a/src/dev-toolbar/functions/BlobViewer.css b/src/dev-toolbar/functions/BlobViewer.css index b7a95d5..5cca825 100644 --- a/src/dev-toolbar/functions/BlobViewer.css +++ b/src/dev-toolbar/functions/BlobViewer.css @@ -1,4 +1,52 @@ +[data-solid-blob-viewer] { + display: flex; + align-items: center; + gap: 0.625rem; + + align-self: flex-start; + + padding: 0.5rem 0.75rem; + text-align: left; +} + [data-solid-blob-viewer] svg { - width: 1rem; - height: 1rem; + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + + color: var(--start-dt-accent, currentColor); +} + +[data-solid-blob-viewer-info] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + + min-width: 0; +} + +[data-solid-blob-viewer-name] { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + font-size: 0.8125rem; + line-height: 1rem; + font-weight: 600; + + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-solid-blob-viewer-meta] { + display: flex; + align-items: center; + gap: 0.5rem; + + font-size: 0.75rem; + line-height: 1rem; + + color: var(--start-dt-text-muted, inherit); } diff --git a/src/dev-toolbar/functions/BlobViewer.tsx b/src/dev-toolbar/functions/BlobViewer.tsx index aed1920..c2968b8 100644 --- a/src/dev-toolbar/functions/BlobViewer.tsx +++ b/src/dev-toolbar/functions/BlobViewer.tsx @@ -29,6 +29,16 @@ interface BlobViewerInnerProps { source: File | Blob; } +function formatSize(size: number): string { + if (size < 1024) { + return `${size} B`; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + function BlobViewerInner(props: BlobViewerInnerProps): JSX.Element { const fileURL = createMemo(() => URL.createObjectURL(props.source)); @@ -46,16 +56,23 @@ function BlobViewerInner(props: BlobViewerInnerProps): JSX.Element { document.body.removeChild(link); } + const name = createMemo(() => { + if (props.source instanceof File) { + return props.source.name; + } + return props.source.type || 'Blob'; + }); + return ( ); } diff --git a/src/dev-toolbar/functions/FormDataViewer.tsx b/src/dev-toolbar/functions/FormDataViewer.tsx index 3b9a1a0..07cdad4 100644 --- a/src/dev-toolbar/functions/FormDataViewer.tsx +++ b/src/dev-toolbar/functions/FormDataViewer.tsx @@ -1,8 +1,8 @@ import { createMemo, For, Loading, Show } from 'solid-js'; import type { JSX } from '@solidjs/web'; import { Section } from '../../ui/Section.js'; +import { Text } from '../../ui/Text.js'; import { BlobViewer } from './BlobViewer.js'; -import { PropertySeparator, SerovalValue } from './SerovalValue.js'; interface FormDataViewerInnerProps { source: FormData; @@ -11,14 +11,20 @@ interface FormDataViewerInnerProps { function FormDataViewerInner(props: FormDataViewerInnerProps): JSX.Element { return (
-
+
{([key, value]) => ( -
- - +
+ + {key} + {typeof value === 'string' ? ( - + + {JSON.stringify(value)} + ) : ( )} diff --git a/src/dev-toolbar/functions/HeadersViewer.css b/src/dev-toolbar/functions/HeadersViewer.css index 41870b7..0cb58c2 100644 --- a/src/dev-toolbar/functions/HeadersViewer.css +++ b/src/dev-toolbar/functions/HeadersViewer.css @@ -2,7 +2,3 @@ font-size: 0.75rem; line-height: 1rem; } - -[data-solid-headers-viewer] > [data-solid-property] > *:first-child { - text-transform: capitalize; -} diff --git a/src/dev-toolbar/functions/HeadersViewer.tsx b/src/dev-toolbar/functions/HeadersViewer.tsx index 43fc25f..64cb37a 100644 --- a/src/dev-toolbar/functions/HeadersViewer.tsx +++ b/src/dev-toolbar/functions/HeadersViewer.tsx @@ -1,8 +1,7 @@ import { For } from 'solid-js'; -import { PropertySeparator, SerovalValue } from './SerovalValue.js'; +import { Text } from '../../ui/Text.js'; import './HeadersViewer.css'; -import { Text } from '../../ui/Text.js'; interface HeadersViewerProps { headers: Headers; @@ -10,13 +9,19 @@ interface HeadersViewerProps { export function HeadersViewer(props: HeadersViewerProps) { return ( -
+
{([key, value]) => ( -
- {key} - - +
+ + {key} + + + {value} +
)} diff --git a/src/dev-toolbar/functions/HexViewer.css b/src/dev-toolbar/functions/HexViewer.css index c1a0e31..e6ff8fb 100644 --- a/src/dev-toolbar/functions/HexViewer.css +++ b/src/dev-toolbar/functions/HexViewer.css @@ -1,34 +1,53 @@ [data-solid-hex-viewer] { - display: flex; - border: 1px oklch(87% 0.065 274.039) solid; - overflow: auto; -} - -[data-solid-hex-viewer-bytes] { - flex: 1; display: flex; flex-direction: column; - gap: 0.5rem; - padding: 0.5rem; -} -[data-solid-hex-viewer-text] { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 0.5rem; - border-left: 1px oklch(87% 0.065 274.039) solid; + border: 1px var(--start-dt-border-soft, oklch(87% 0.065 274.039)) solid; + border-radius: 0.5rem; + background-color: var(--start-dt-surface, transparent); + + overflow: auto; + + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; } [data-solid-hex-row] { display: flex; align-items: center; gap: 1rem; + + padding: 0.125rem 0.625rem; + + white-space: nowrap; +} + +[data-solid-hex-row]:nth-child(even) { + background-color: oklch(1 0 0 / 0.03); +} + +[data-solid-hex-row]:hover { + background-color: var(--start-dt-surface-hover, transparent); +} + +[data-solid-hex-offset] { + color: var(--start-dt-text-muted, inherit); +} + +[data-solid-hex-chunk] { + letter-spacing: 0.05em; + color: var(--start-dt-text, inherit); } [data-solid-hex-text] { display: flex; align-items: center; justify-content: start; + + margin-left: auto; + padding-left: 1rem; + border-left: 1px var(--start-dt-border-soft, oklch(87% 0.065 274.039)) solid; + + color: oklch(0.78 0.11 150); } diff --git a/src/dev-toolbar/functions/HexViewer.tsx b/src/dev-toolbar/functions/HexViewer.tsx index a398c49..2d49307 100644 --- a/src/dev-toolbar/functions/HexViewer.tsx +++ b/src/dev-toolbar/functions/HexViewer.tsx @@ -24,7 +24,11 @@ function HexChunk(props: HexViewerInnerProps) { ); } -function HexRow(props: HexViewerInnerProps) { +interface HexRowProps extends HexViewerInnerProps { + offset: number; +} + +function HexRow(props: HexRowProps) { const chunk1 = createMemo(() => props.bytes.subarray(0, 4)); const chunk2 = createMemo(() => props.bytes.subarray(4, 8)); const chunk3 = createMemo(() => props.bytes.subarray(8, 12)); @@ -32,10 +36,14 @@ function HexRow(props: HexViewerInnerProps) { return (
+ + {toHex(props.offset, 8)} + +
); } @@ -77,12 +85,9 @@ export function HexViewerInner(props: HexViewerInnerProps): JSX.Element { return (
-
- {(current) => } -
-
- {(current) => } -
+ + {(current, index) => } +
); } diff --git a/src/dev-toolbar/functions/SerovalValue.css b/src/dev-toolbar/functions/SerovalValue.css index f46abc3..3bc3a52 100644 --- a/src/dev-toolbar/functions/SerovalValue.css +++ b/src/dev-toolbar/functions/SerovalValue.css @@ -3,3 +3,24 @@ gap: 0.25rem; align-items: center; } + +[data-solid-seroval-value='key'] { + color: oklch(0.78 0.1 305); +} + +[data-solid-seroval-value='string'] { + color: oklch(0.78 0.11 150); +} + +[data-solid-seroval-value='number'] { + color: oklch(0.8 0.11 80); +} + +[data-solid-seroval-value='keyword'] { + color: oklch(0.74 0.1 250); + font-style: italic; +} + +[data-solid-seroval-separator] { + color: var(--start-dt-text-muted); +} diff --git a/src/dev-toolbar/functions/SerovalValue.tsx b/src/dev-toolbar/functions/SerovalValue.tsx index ce5516c..ab9b5cc 100644 --- a/src/dev-toolbar/functions/SerovalValue.tsx +++ b/src/dev-toolbar/functions/SerovalValue.tsx @@ -3,16 +3,24 @@ import './SerovalValue.css'; interface SerovalValueProps { value: string | number | boolean | undefined | null; + kind?: 'key' | 'string' | 'number' | 'keyword'; } export function SerovalValue(props: SerovalValueProps) { return ( - + {`${props.value}`} ); } export function PropertySeparator() { - return :; + return ( + + : + + ); } diff --git a/src/dev-toolbar/functions/SerovalViewer.css b/src/dev-toolbar/functions/SerovalViewer.css index 0d2d081..dce2671 100644 --- a/src/dev-toolbar/functions/SerovalViewer.css +++ b/src/dev-toolbar/functions/SerovalViewer.css @@ -1,70 +1,132 @@ [data-solid-seroval-viewer] { - display: grid; - grid-template-columns: 1fr 2fr; + height: 100%; + min-height: 12rem; + + overflow: auto; + + padding: 0.375rem 0.5rem; + + border: 1px var(--start-dt-border-soft, oklch(87% 0.065 274.039)) solid; + border-radius: 0.5rem; + background-color: var(--start-dt-surface, transparent); + + color: var(--start-dt-text, rgb(249 250 251)); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; } -[data-solid-seroval-promise] { +[data-solid-seroval-tree-node] { display: flex; - gap: 0.5rem; - align-items: center; + flex-direction: column; } -[data-solid-seroval-renderer] { +[data-solid-seroval-tree-row] { display: flex; - border-top: 1px oklch(87% 0.065 274.039) solid; - border-bottom: 1px oklch(87% 0.065 274.039) solid; + align-items: center; + gap: 0.375rem; + + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + + min-width: 0; + width: 100%; + + border: none; + background: none; + color: inherit; + font: inherit; + text-align: left; + outline: none; } -[data-solid-seroval-renderer] > * { - border-left: 1px oklch(87% 0.065 274.039) solid; +button[data-solid-seroval-tree-row] { + cursor: pointer; } -[data-solid-seroval-renderer]:last-child { - border-right: 1px oklch(87% 0.065 274.039) solid; +button[data-solid-seroval-tree-row]:hover, +button[data-solid-seroval-tree-row]:focus-visible { + background-color: var(--start-dt-surface-hover, transparent); } -[data-solid-seroval-node] { - display: flex; - flex-direction: column; - gap: 0.25rem; - padding: 0.25rem; +[data-solid-seroval-tree-chevron] { + display: inline-flex; + align-items: center; + justify-content: center; - width: 100%; - max-width: 16rem; + width: 0.875rem; + flex-shrink: 0; + + color: var(--start-dt-text-muted, inherit); } -[data-solid-seroval-node-header] { - position: sticky; - top: 0; - display: flex; - align-items: center; - justify-content: space-between; - color: rgb(249 250 251); +[data-solid-seroval-tree-chevron]::before { + content: ''; + + width: 0.3125rem; + height: 0.3125rem; + + border-right: 1.5px currentColor solid; + border-bottom: 1.5px currentColor solid; + + transform: rotate(-45deg); + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +[data-solid-seroval-tree-chevron][data-leaf]::before { + content: none; +} + +[data-solid-seroval-tree-row][data-expanded] > [data-solid-seroval-tree-chevron]::before { + transform: rotate(45deg); } -[data-solid-seroval-node-content] { +[data-solid-seroval-tree-children] { display: flex; flex-direction: column; - gap: 0.25rem; -} -[data-solid-seroval-value] { - color: rgb(249 250 251); + margin-left: 0.6875rem; + padding-left: 0.625rem; + + border-left: 1px var(--start-dt-border-soft, oklch(87% 0.065 274.039)) solid; } -[data-solid-seroval-value-wrapper] { +[data-solid-seroval-tree-key] { display: inline-flex; - gap: 0.25rem; align-items: center; + gap: 0.125rem; + + flex-shrink: 0; } -[data-solid-seroval-link] { +[data-solid-seroval-tree-preview] { + color: var(--start-dt-text-muted, inherit); + font-size: 0.75rem; + line-height: 1rem; + + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-solid-seroval-tree-circular] { display: inline-flex; - gap: 0.25rem; align-items: center; + gap: 0.25rem; } -[data-solid-seroval-link] > svg { +[data-solid-seroval-tree-circular] > svg { width: 1rem; height: 1rem; + + color: var(--start-dt-accent, currentColor); +} + +[data-solid-seroval-tree-raw] { + padding: 0.25rem 0; +} + +[data-solid-seroval-value='plain'] { + color: var(--start-dt-text, rgb(249 250 251)); } diff --git a/src/dev-toolbar/functions/SerovalViewer.tsx b/src/dev-toolbar/functions/SerovalViewer.tsx index 4ad8866..9e1d7bb 100644 --- a/src/dev-toolbar/functions/SerovalViewer.tsx +++ b/src/dev-toolbar/functions/SerovalViewer.tsx @@ -1,11 +1,9 @@ import type { SerovalNode } from 'seroval'; -import { createEffect, createSignal, For, omit, Show } from 'solid-js'; +import { createEffect, createSignal, For, Show } from 'solid-js'; import type { JSX } from '@solidjs/web'; import { ChunkReader } from '@solidjs/web/server-functions/client'; import { Badge } from '../../ui/Badge.js'; -import { Cascade, CascadeOption } from '../../ui/Cascade.js'; -import { Section } from '../../ui/Section.js'; import { HexViewer } from './HexViewer.js'; import { PropertySeparator, SerovalValue } from './SerovalValue.js'; @@ -146,7 +144,7 @@ function getNodeType(node: SerovalNode) { case 35: return 'Sequence'; } - throw new Error('unsupported node type'); + return 'unknown'; } function traverse(node: SerovalNode, handler: (node: SerovalNode) => void): void { @@ -396,272 +394,416 @@ function getStreamKeyword(t: 32 | 33 | 34): string { } } -function renderSerovalNode( - ctx: RenderContext, - node: SerovalNode, - onSelect: (index: number | undefined) => void, - inner?: boolean, -): JSX.Element { - if ( - node.t >= 4 && - (inner || node.t === 4) && - node.i != null && - !(node.t === 5 || node.t === 6 || node.t === 17) - ) { +const PREVIEW_LENGTH = 32; +const PREVIEW_KEYS = 3; + +function truncate(value: string): string { + if (value.length > PREVIEW_LENGTH) { + return `${value.slice(0, PREVIEW_LENGTH)}…`; + } + return value; +} + +function previewNode(ctx: RenderContext, node: SerovalNode, depth: number): string { + switch (node.t) { + case 0: + return `${node.s}`; + case 1: + return truncate(`"${node.s}"`); + case 2: + return getConstantValue(node.s); + case 3: + return `${node.s}n`; + case 4: { + if (depth >= 1) { + return `#${node.i}`; + } + const target = ctx.getNode(node.i); + return target ? previewNode(ctx, target, depth + 1) : `#${node.i}`; + } + case 5: + return truncate(`${node.s}`); + case 6: + return truncate(`/${node.c}/${node.m}`); + case 7: + return `Set(${node.a.length})`; + case 8: + return `Map(${node.e.k.length})`; + case 9: + return `Array(${node.a.length})`; + case 10: + case 11: { + if (depth >= 1) { + return '{…}'; + } + const keys = node.p.k.filter((key) => typeof key === 'string').slice(0, PREVIEW_KEYS); + const rest = node.p.k.length > keys.length ? ', …' : ''; + return keys.length ? `{${keys.join(', ')}${rest}}` : '{}'; + } + case 12: + return 'Promise'; + case 13: + case 14: + return truncate(`${getNodeType(node)}: ${node.m}`); + case 15: + case 16: + return `${node.c}(${node.l})`; + case 17: + return getSymbolValue(node.s); + case 19: + return 'ArrayBuffer'; + case 20: + return `DataView(${node.l})`; + case 21: + return previewNode(ctx, node.f, depth); + case 22: { + const result = ctx.getPromise(node.s); + if (result) { + return `Promise<${result.t === 23 ? 'success' : 'failure'}>`; + } + return 'Promise'; + } + case 25: + return `Plugin(${node.c})`; + case 28: + return 'Iterator'; + case 30: + return 'AsyncIterator'; + case 31: + return `Stream(${(ctx.getStream(node.i) || []).length})`; + case 35: + return `Sequence(${node.a.length})`; + default: + return getNodeType(node); + } +} + +interface EntryKeyProps { + value: string | number; + kind?: 'key' | 'number' | 'keyword'; +} + +function EntryKey(props: EntryKeyProps): JSX.Element { + return ( + + + + + ); +} + +interface LeafRowProps { + label?: JSX.Element; + children: JSX.Element; +} + +function LeafRow(props: LeafRowProps): JSX.Element { + return ( +
+
+ + {props.label} + {props.children} +
+
+ ); +} + +interface ExpandableRowProps { + label?: JSX.Element; + badges?: JSX.Element; + preview: JSX.Element; + open?: boolean; + children: JSX.Element; +} + +function ExpandableRow(props: ExpandableRowProps): JSX.Element { + const [open, setOpen] = createSignal(props.open ?? false); + return ( +
+ + +
{props.children}
+
+
+ ); +} + +interface TreeValueProps { + ctx: RenderContext; + node: SerovalNode; + seen: number[]; + label?: JSX.Element; + open?: boolean; +} + +function TreeValue(props: TreeValueProps): JSX.Element { + const ctx = props.ctx; + const node = props.node; + + // Indexed reference: resolve reactively, guard cycles + if (node.t === 4) { const index = node.i; - const description = `id: ${index}`; - const lookup = ctx.getNode(index)!; + if (props.seen.includes(index)) { + return ( + + + + {`circular #${index}`} + + + ); + } return ( - - - {getNodeType(lookup)} - {description} - + + {`#${index} pending`} + + } + > + {(target) => ( + + )} + ); } + + const seen = node.i != null ? [...props.seen, node.i] : props.seen; + const flag = 'o' in node ? getObjectFlag(node.o ?? 0) : 'none'; + const badges = ( + <> + {node.i != null && {`#${node.i}`}} + {flag !== 'none' && {flag}} + + ); + switch (node.t) { // Number = 0, case 0: - return ; + return ( + + + + ); // String = 1, case 1: - return ; + return ( + + + + ); // Constant = 2, case 2: - return ; + return ( + + + + ); // BigInt = 3, case 3: return ( -
- bigint - -
+ + + ); // Date = 5, case 5: return ( -
+ Date - -
+ + ); // RegExp = 6, case 6: return ( -
+ RegExp - -
+ + + ); + // WKSymbol = 17, + case 17: + return ( + + + ); // Set = 7, case 7: return ( - <> -
-
- - - -
-
-
- - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > - [index, node] as const)}> - {([key, value]) => ( -
- - - {renderSerovalNode(ctx, value, onSelect, true)} -
- )} -
- -
- + + + {(child, index) => ( + } + /> + )} + + ); // Map = 8, case 8: return ( - <> -
-
- - - -
-
-
- - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > - }> - {([key, value]) => ( -
- {renderSerovalNode(ctx, key, onSelect, true)} - - {renderSerovalNode(ctx, value, onSelect, true)} -
- )} -
- -
- + + + {([key, value], index) => ( + } + preview={`{${previewNode(ctx, key, 1)} => ${previewNode(ctx, value, 1)}}`} + > + } + /> + } + /> + + )} + + ); // Array = 9, case 9: return ( - <> -
-
- - - -
-
- - - {getObjectFlag(node.o)} -
-
-
- - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > - [index, node] as const)} - fallback={} - > - {([key, value]) => ( -
- - - {value === 0 ? ( - - ) : ( - renderSerovalNode(ctx, value, onSelect, true) - )} -
- )} -
- -
- + + + {(child, index) => + child === 0 ? ( + }> + + + ) : ( + } + /> + ) + } + + ); // Object = 10, case 10: // NullConstructor = 11, case 11: return ( - <> -
-
- - - -
-
- - - {getObjectFlag(node.o)} -
-
-
- - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > - }> - {([key, value]) => ( -
- {typeof key === 'string' ? ( - - ) : ( - renderSerovalNode(ctx, key, onSelect, true) - )} - - {renderSerovalNode(ctx, value, onSelect, true)} -
- )} -
- -
- + + + {([key, value]) => ( + + } + /> + )} + + ); // Promise = 12, case 12: return ( - - data-solid-properties - defaultValue={undefined} - onChange={onSelect} + - {renderSerovalNode(ctx, node.f, onSelect, true)} - + } + /> + ); // Error = 13, case 13: // AggregateError = 14, case 14: return ( - <> -
-
- - - -
-
+ + }> + + - {(current) => ( -
- - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > - } - > - {([key, value]) => ( -
- {typeof key === 'string' ? ( - - ) : ( - renderSerovalNode(ctx, key, onSelect, true) - )} - - {renderSerovalNode(ctx, value, onSelect, true)} -
- )} -
- -
+ {(properties) => ( + + {([key, value]) => ( + + } + /> + )} + )}
- +
); - // WKSymbol = 17, - case 17: - return ; - // Reference = 18, - case 18: - break; - // ArrayBuffer = 19, - case 19: { - const data = atob(node.s); - const result = new TextEncoder().encode(data); - return ; - } // TypedArray = 15, case 15: // BigIntTypedArray = 16, @@ -669,209 +811,200 @@ function renderSerovalNode( // DataView = 20, case 20: return ( - <> -
-
- - - -
-
- - - -
-
-
- - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > - {renderSerovalNode(ctx, node.f, onSelect, true)} - -
- + + }> + + + }> + + + } + /> + + ); + // ArrayBuffer = 19, + case 19: + return ( + +
+ {(() => { + const data = atob(node.s); + const result = new TextEncoder().encode(data); + return ; + })()} +
+
); // Boxed = 21, case 21: return ( - - data-solid-properties - defaultValue={undefined} - onChange={onSelect} + - {renderSerovalNode(ctx, node.f, onSelect, true)} - + } + /> + ); + // PromiseConstructor = 22, case 22: return ( - <> - {(() => { - const result = ctx.getPromise(node.s); - if (result) { - const status = result.t === 23 ? 'success' : ('failure' as const); - return ( - - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > -
- - - {status} -
- - - - {renderSerovalNode(ctx, result.a[1], onSelect, true)} - - - ); + {previewNode(ctx, node, 0)}} + open={props.open} + > + }> + pending + } - return pending; - })()} - + > + {(result) => ( + <> + }> + + {result.t === 23 ? 'success' : 'failure'} + + + } + /> + + )} + + ); - // Plugin = 25 + // Plugin = 25, case 25: return ( - <> -
-
- - - -
-
-
- - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > - }> - {([key, value]) => ( -
- - - {renderSerovalNode(ctx, value, onSelect, true)} -
- )} -
- -
- + + + {([key, value]) => ( + } /> + )} + + ); - // IteratorFactory = 27, - case 27: - break; // IteratorFactoryInstance = 28, case 28: - return renderSerovalNode(ctx, node.a[1], onSelect, true); - // AsyncIteratorFactory = 29, - case 29: - break; // AsyncIteratorFactoryInstance = 30, case 30: - return renderSerovalNode(ctx, node.a[1], onSelect, true); + return ( + + } + /> + + ); // StreamConstructor = 31, case 31: return ( - <> - {(() => { - const result = ctx.getStream(node.i) || []; - return ( - - data-solid-properties - defaultValue={undefined} - onChange={onSelect} - > - }> - {(current) => ( -
- - - {renderSerovalNode(ctx, current.f, onSelect, true)} -
- )} -
- - ); - })()} - + {previewNode(ctx, node, 0)}} + open={props.open} + > + }> + waiting + + } + > + {(chunk) => ( + } + /> + )} + + ); case 35: return ( - - data-solid-properties - defaultValue={undefined} - onChange={onSelect} + - }> - {(current, index) => ( -
- - - {renderSerovalNode(ctx, current, onSelect, true)} -
+ + {(child, index) => ( + + {(current) => ( + + } + /> + )} + )} - +
+ ); + default: + return ( + + {getNodeType(node)} + ); } } -interface SerovalNodeRendererProps extends RenderContext { - node: SerovalNode; -} - -function SerovalNodeRenderer(props: SerovalNodeRendererProps): JSX.Element { - const rest = omit(props, 'node'); - const [next, setNext] = createSignal(); - - function onSelect(index: number | undefined) { - if (index == null) { - setNext(undefined); - } else { - setNext(props.getNode(index)); - } - } - - return ( - <> -
-
- {getNodeType(props.node)} - {props.node.i != null && {`id: ${props.node.i}`}} -
-
{renderSerovalNode(props, props.node, onSelect)}
-
- - {(current) => } - - - ); -} - -interface SerovalRendererProps extends Omit { - node?: SerovalNode; -} - -function SerovalRenderer(props: SerovalRendererProps): JSX.Element { - const rest = omit(props, 'node'); - return ( -
- - {(current) => } - -
- ); -} - function createSimpleStore>(initial: T) { const [state, setState] = createSignal(initial as Exclude); @@ -993,12 +1126,20 @@ export function SerovalViewer(props: SerovalViewerProps): JSX.Element { return (
- references.read(index)} - getPromise={(index) => promises.read(index)} - getStream={(index) => streams.read(index)} - /> + + {(root) => ( + references.read(index), + getPromise: (index) => promises.read(index), + getStream: (index) => streams.read(index), + }} + node={root} + seen={[]} + open + /> + )} +
); } diff --git a/src/dev-toolbar/functions/URLSearchParamsViewer.tsx b/src/dev-toolbar/functions/URLSearchParamsViewer.tsx index f901f5a..6f603e6 100644 --- a/src/dev-toolbar/functions/URLSearchParamsViewer.tsx +++ b/src/dev-toolbar/functions/URLSearchParamsViewer.tsx @@ -1,7 +1,7 @@ import { createMemo, For, Loading, Show } from 'solid-js'; import type { JSX } from '@solidjs/web'; import { Section } from '../../ui/Section.js'; -import { PropertySeparator, SerovalValue } from './SerovalValue.js'; +import { Text } from '../../ui/Text.js'; interface URLSearchParamsViewerInnerProps { source: URLSearchParams; @@ -10,13 +10,19 @@ interface URLSearchParamsViewerInnerProps { function URLSearchParamsViewerInner(props: URLSearchParamsViewerInnerProps): JSX.Element { return (
-
+
{([key, value]) => ( -
- - - +
+ + {key} + + + {JSON.stringify(value)} +
)} diff --git a/src/dev-toolbar/functions/index.tsx b/src/dev-toolbar/functions/index.tsx index e5c61ce..0e071e0 100644 --- a/src/dev-toolbar/functions/index.tsx +++ b/src/dev-toolbar/functions/index.tsx @@ -8,12 +8,11 @@ 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 { ArrowLeftIcon, FunctionIcon, TrashIcon } from '../icons.js'; +import { FunctionIcon, TrashIcon } from '../icons.js'; import { BlobViewer } from './BlobViewer.js'; import { FormDataViewer } from './FormDataViewer.js'; import { HeadersViewer } from './HeadersViewer.js'; import { HexViewer } from './HexViewer.js'; -import { PropertySeparator, SerovalValue } from './SerovalValue.js'; import { SerovalViewer } from './SerovalViewer.js'; import './styles.css'; import { type ServerFunctionRequest, type ServerFunctionResponse } from './tracker.js'; @@ -66,40 +65,47 @@ interface ContentViewerProps { } function ContentViewer(props: ContentViewerProps): JSX.Element { + const body = createMemo(() => { + const original = props.source.source; + if (!original.body) { + return undefined; + } + const source = original.clone(); + const startType = source.headers.get(BODY_FORMAT_KEY); + const contentType = source.headers.get('Content-Type'); + switch (true) { + case startType === BodyFormat.Serialized: + return ; + case startType === BodyFormat.Json: + case contentType?.startsWith('application/json'): + return ; + case startType === BodyFormat.String: + return ; + case startType === BodyFormat.File: + return ; + case startType === BodyFormat.FormData: + case contentType?.startsWith('multipart/form-data'): + return ; + case startType === BodyFormat.URLSearchParams: + case contentType?.startsWith('application/x-www-form-urlencoded'): + return ; + case startType === BodyFormat.Blob: + return ; + case startType === BodyFormat.ArrayBuffer: + case startType === BodyFormat.Uint8Array: + return ; + } + return undefined; + }); + return ( <> -
+ +
{body()}
+
+
-
- {(() => { - const source = props.source.source.clone(); - const startType = source.headers.get(BODY_FORMAT_KEY); - const contentType = source.headers.get('Content-Type'); - switch (true) { - case startType === BodyFormat.Serialized: - return ; - case startType === BodyFormat.Json: - case contentType?.startsWith('application/json'): - return ; - case startType === BodyFormat.String: - return ; - case startType === BodyFormat.File: - return ; - case startType === BodyFormat.FormData: - case contentType?.startsWith('multipart/form-data'): - return ; - case startType === BodyFormat.URLSearchParams: - case contentType?.startsWith('application/x-www-form-urlencoded'): - return ; - case startType === BodyFormat.Blob: - return ; - case startType === BodyFormat.ArrayBuffer: - case startType === BodyFormat.Uint8Array: - return ; - } - })()} -
); } @@ -126,18 +132,26 @@ function convertRequestToEntries(request: Request) { function RequestViewer(props: RequestViewerProps): JSX.Element { return ( -
- - {([key, value]) => ( -
- {key} - - -
- )} -
-
+
+
+ + {([key, value]) => ( +
+ + {key} + + + {`${value}`} + +
+ )} +
+
+
); } @@ -161,28 +175,50 @@ function convertResponseToEntries(response: Response) { function ResponseViewer(props: ResponseViewerProps): JSX.Element { return ( - + + Waiting for response. + + } + > {(instance) => ( <> -
- - {([key, value]) => ( -
- {key} - - -
- )} -
-
- Timing - - + +
+
+ + {([key, value]) => ( +
+ + {key} + + + {`${value}`} + +
+ )} +
+
+ + Timing + + + {`${((instance().time - props.request.time) / 1000).toFixed(2)}s`} + +
- )} @@ -223,7 +259,6 @@ function ServerFunctionInstanceDetail(props: ServerFunctionInstanceDetailProps) interface ServerFunctionInstanceViewerProps { instance: ServerFunctionInstance; onDelete: () => void; - onReturn: () => void; } function ServerFunctionInstanceViewer(props: ServerFunctionInstanceViewerProps): JSX.Element { @@ -232,14 +267,20 @@ function ServerFunctionInstanceViewer(props: ServerFunctionInstanceViewerProps):
- - -
+ + {(response) => ( + + + {`${((response().time - props.instance.request.time) / 1000).toFixed(2)}s`} + + + )} + @@ -282,24 +323,8 @@ export function ServerFunctionViewer(props: ServerFunctionViewerProps): JSX.Elem
- - {(value) => ( - - {(instance) => ( - { - setCurrentInstance(undefined); - }} - onDelete={() => { - props.onDeleteInstance(value()); - }} - /> - )} - - )} - - + {/* list of calls */} +
Server functions @@ -324,7 +349,32 @@ export function ServerFunctionViewer(props: ServerFunctionViewerProps): JSX.Elem
- +
+ {/* request/response viewer */} +
+ + Select a server function call. + + } + > + {(value) => ( + + {(instance) => ( + { + setCurrentInstance(undefined); + props.onDeleteInstance(value()); + }} + /> + )} + + )} + +
diff --git a/src/dev-toolbar/functions/styles.css b/src/dev-toolbar/functions/styles.css index 9f410bc..377ada5 100644 --- a/src/dev-toolbar/functions/styles.css +++ b/src/dev-toolbar/functions/styles.css @@ -2,21 +2,51 @@ display: flex; align-items: center; gap: 0.5rem; + + min-width: 0; +} + +[data-solid-functions-instance-detail] > [data-solid-text-size] { + overflow: hidden; + text-overflow: ellipsis; } [data-solid-functions-viewer] { - color: rgb(249 250 251); + color: var(--start-dt-text); + + display: flex; + flex-direction: row; + + height: 100%; +} +[data-solid-functions-sidebar] { display: flex; flex-direction: column; - min-height: 100%; + width: 17rem; + flex-shrink: 0; + min-height: 0; + + border-right: var(--start-dt-border-soft) 1px solid; +} + +[data-solid-functions-detail] { + display: flex; + flex-direction: column; + + flex: 1; + min-width: 0; + min-height: 0; + + overflow: auto; } [data-solid-functions-instances-container] { - color: rgb(249 250 251); + color: var(--start-dt-text); width: 100%; - min-height: 100%; + flex: 1; + min-height: 0; overflow: auto; } @@ -31,8 +61,12 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.5rem; - color: rgb(249 250 251); + gap: 0.5rem; + padding: 0.5rem 0.75rem; + color: var(--start-dt-text); + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; } [data-solid-headers] { @@ -60,16 +94,21 @@ gap: 0.25rem; } +[data-solid-property] > [data-solid-text-weight='semibold'] { + color: var(--start-dt-text-muted); +} + [data-solid-function-instance-viewer] { display: flex; flex-direction: column; width: 100%; + min-height: 100%; } [data-solid-function-instance-viewer-nav], [data-solid-functions-nav] { - padding: 0.5rem; + padding: 0.5rem 0.75rem; display: flex; align-items: center; gap: 0.5rem; @@ -78,32 +117,126 @@ position: sticky; top: 0; + z-index: 1; + + border-bottom: var(--start-dt-border-soft) 1px solid; - border-bottom: oklch(87% 0.065 274.039) 1px solid; + background: var(--start-dt-bg-glass); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); +} - background: oklch(25.7% 0.09 281.288); +[data-solid-functions-nav] { + justify-content: flex-start; + flex-shrink: 0; } [data-solid-functions-nav] svg { padding: 0.25rem; + color: var(--start-dt-accent); +} + +[data-solid-function-instance-viewer-nav] > div:last-child { + display: flex; + align-items: center; + gap: 0.5rem; + + flex-shrink: 0; +} + +[data-solid-function-instance-timing] { + display: inline-flex; + align-items: center; + + color: var(--start-dt-text-muted); + background: var(--start-dt-surface); + border-radius: 9999px; + padding: 0.125rem 0.5rem; +} + +/* Body section (the only non-collapsible
section in a tab panel) fills the pane; + the minimum keeps it inspectable even when Headers/Information are expanded — the + tab panel scrolls instead of squeezing the body. */ +[data-solid-tab-panel] > div[data-solid-section]:first-child { + flex: 1 1 auto; + min-height: 18rem; +} + +[data-solid-tab-panel] > div[data-solid-section]:first-child > [data-solid-section-content] { + flex: 1; + min-height: 0; } [data-solid-function-instance-viewer-nav-left] { display: flex; align-items: center; gap: 0.5rem; + + min-width: 0; } [data-solid-function-instance-viewer-nav-left] > div { display: flex; align-items: center; gap: 0.5rem; + + min-width: 0; } [data-solid-function-instance-viewer-content] { - height: 100%; + flex: 1; + min-height: 0; } [data-solid-function-instance-viewer-content] > [tc-tab-group] { height: 100%; + + display: flex; + flex-direction: column; +} + +/* shared key-value table (headers, form data, url search params) */ +[data-solid-kv-table] { + display: flex; + flex-direction: column; + gap: 0; + + border: 1px var(--start-dt-border-soft) solid; + border-radius: 0.5rem; + overflow: hidden; +} + +[data-solid-kv-row] { + display: flex; + align-items: baseline; + gap: 0.75rem; + + padding: 0.375rem 0.625rem; +} + +[data-solid-kv-row]:nth-child(even) { + background-color: oklch(1 0 0 / 0.03); +} + +[data-solid-kv-row]:hover { + background-color: var(--start-dt-surface-hover); +} + +[data-solid-kv-key] { + flex: 0 0 11rem; + min-width: 0; + + overflow: hidden; + text-overflow: ellipsis; + + color: oklch(0.78 0.1 305); +} + +[data-solid-kv-value] { + flex: 1; + min-width: 0; + + word-break: break-all; + + color: var(--start-dt-text); } diff --git a/src/dev-toolbar/index.css b/src/dev-toolbar/index.css index d657f96..3c44a7b 100644 --- a/src/dev-toolbar/index.css +++ b/src/dev-toolbar/index.css @@ -1,4 +1,19 @@ [data-solid-dev-toolbar] { + /* design tokens — inherited by every panel and ui component inside */ + --start-dt-bg: oklch(0.16 0.015 265); + --start-dt-bg-glass: oklch(0.17 0.015 265 / 0.88); + --start-dt-surface: oklch(0.22 0.02 265); + --start-dt-surface-hover: oklch(0.27 0.025 265); + --start-dt-surface-active: oklch(0.3 0.03 265); + --start-dt-border: oklch(0.34 0.02 265); + --start-dt-border-soft: oklch(0.27 0.02 265); + --start-dt-text: oklch(0.93 0.005 265); + --start-dt-text-muted: oklch(0.68 0.015 265); + --start-dt-accent: oklch(0.68 0.13 245); + --start-dt-accent-soft: oklch(0.68 0.13 245 / 0.16); + --start-dt-danger: oklch(0.63 0.21 25); + --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; @@ -8,19 +23,31 @@ flex-direction: column-reverse; align-items: flex-start; + z-index: 2147483647; + + 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]:active { +[data-solid-dev-toolbar] > [tc-toolbar]:active { cursor: grabbing; } [data-solid-dev-toolbar] > [tc-toolbar] { position: relative; - background: oklch(25.7% 0.09 281.288); + background: var(--start-dt-bg-glass); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + margin: 1rem; - padding: 0.25rem 0.5rem; + padding: 0.375rem 0.625rem; overflow: hidden; border-radius: 9999px; @@ -29,9 +56,10 @@ flex-direction: row; align-items: center; - gap: 0.5rem; + gap: 0.625rem; - border: oklch(87% 0.065 274.039) 1px solid; + border: var(--start-dt-border) 1px solid; + box-shadow: var(--start-dt-shadow); } [data-solid-dev-toolbar] > [tc-toolbar] > div { @@ -40,25 +68,30 @@ flex-direction: row; align-items: center; justify-content: center; + + gap: 0.125rem; } [data-solid-dev-toolbar] > [tc-toolbar] > div:last-child { - border-left: rgb(249 250 251) 1px solid; - padding-left: 0.5rem; + border-left: var(--start-dt-border) 1px solid; + padding-left: 0.625rem; gap: 0.5rem; + + color: var(--start-dt-text-muted); } [data-solid-dev-toolbar-version] { border-radius: 9999px; - color: rgb(249 250 251); + color: var(--start-dt-accent); + background: var(--start-dt-accent-soft); padding: 0.125rem 0.5rem; font-size: 0.875rem; line-height: 1.25rem; font-weight: 600; - border: oklch(87% 0.065 274.039) 1px solid; + border: none; } [data-solid-dev-toolbar-version] > div { @@ -72,14 +105,21 @@ } [data-solid-dev-toolbar-panel] { - border: oklch(87% 0.065 274.039) 1px solid; - background: oklch(25.7% 0.09 281.288); + 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); + margin: 0rem 1rem; - overflow: auto; + overflow: hidden; + + display: flex; + flex-direction: column; - width: 50rem; - height: 30rem; + width: 56rem; + height: min(40rem, calc(100vh - 4rem)); max-width: calc(100vw - 10rem); max-height: calc(100vh - 2rem); diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 5a27cc2..ce62fb6 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -23,6 +23,11 @@ export function DevToolbar(props: DevToolbarProps) { () => 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; + let isDown = false; let offsetX = 0; @@ -45,7 +50,7 @@ export function DevToolbar(props: DevToolbarProps) { const ac = new AbortController(); - current.addEventListener( + handle.addEventListener( 'mousedown', (e) => { isDown = true; @@ -149,10 +154,16 @@ export function DevToolbar(props: DevToolbarProps) { pushError(error.error ?? error); }; + const onRejectionEvent = (event: PromiseRejectionEvent) => { + pushError(event.reason); + }; + window.addEventListener('error', onErrorEvent); + window.addEventListener('unhandledrejection', onRejectionEvent); return () => { window.removeEventListener('error', onErrorEvent); + window.removeEventListener('unhandledrejection', onRejectionEvent); }; }); diff --git a/src/ui/Badge.css b/src/ui/Badge.css index 7f3468c..4272b9e 100644 --- a/src/ui/Badge.css +++ b/src/ui/Badge.css @@ -5,29 +5,40 @@ gap: 0.25rem; border-radius: 0.375rem; - padding: 0.125rem 0.25rem; + padding: 0.125rem 0.375rem; + + font-size: 0.75rem; + line-height: 1rem; + font-weight: 600; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; background-color: var(--background); - border: 1px var(--foreground) solid; + border: 1px var(--border) solid; color: var(--foreground); } [data-solid-badge='success'] { - --background: oklch(98.2% 0.018 155.826); - --foreground: oklch(52.7% 0.154 150.069); + --background: oklch(0.63 0.15 150 / 0.14); + --border: oklch(0.63 0.15 150 / 0.4); + --foreground: oklch(0.8 0.14 152); } [data-solid-badge='failure'] { - --background: oklch(97.1% 0.013 17.38); - --foreground: oklch(50.5% 0.213 27.518); + --background: oklch(0.63 0.21 25 / 0.14); + --border: oklch(0.63 0.21 25 / 0.4); + --foreground: oklch(0.78 0.15 22); } [data-solid-badge='warning'] { - --background: oklch(98.7% 0.026 102.212); - --foreground: oklch(47.6% 0.114 61.907); + --background: oklch(0.75 0.15 85 / 0.14); + --border: oklch(0.75 0.15 85 / 0.4); + --foreground: oklch(0.85 0.13 88); } [data-solid-badge='info'] { - --background: oklch(97% 0.014 254.604); - --foreground: oklch(48.8% 0.243 264.376); + --background: oklch(0.68 0.13 245 / 0.14); + --border: oklch(0.68 0.13 245 / 0.4); + --foreground: oklch(0.78 0.11 245); } diff --git a/src/ui/Button.css b/src/ui/Button.css index 3941b5a..e20d94a 100644 --- a/src/ui/Button.css +++ b/src/ui/Button.css @@ -7,13 +7,13 @@ border-radius: 0.5rem; - color: oklch(97% 0.014 254.604); + color: var(--start-dt-text, oklch(97% 0.014 254.604)); - background-color: rgb(17 24 39); - padding: 0.25rem 0.5rem; + background-color: var(--start-dt-surface, rgb(17 24 39)); + padding: 0.25rem 0.625rem; font-size: 0.875rem; line-height: 1.25rem; - border: none; + border: 1px var(--start-dt-border, transparent) solid; outline: none; transition-property: color, background-color, border-color, box-shadow; @@ -27,6 +27,7 @@ [data-solid-button][tc-selected], [data-solid-button]:hover, [data-solid-button]:focus { - color: rgb(219 234 254); - background-color: rgb(30 58 138); + color: var(--start-dt-text, rgb(219 234 254)); + background-color: var(--start-dt-surface-hover, rgb(30 58 138)); + border-color: var(--start-dt-accent, transparent); } diff --git a/src/ui/Cascade.css b/src/ui/Cascade.css deleted file mode 100644 index 2c4c762..0000000 --- a/src/ui/Cascade.css +++ /dev/null @@ -1,29 +0,0 @@ -[data-solid-cascade] { - display: flex; - flex-direction: column; - margin: 0; - margin-block: 0; - list-style-type: none; - padding-inline: 0; -} - -[data-solid-cascade-option] { - border-radius: 0.25rem; - font-size: 0.875rem; - line-height: 1.25rem; - outline: none; - cursor: pointer; - - transition-property: color, background-color, border-color, box-shadow; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; - - white-space: nowrap; -} - -[data-solid-cascade-option][tc-active], -[data-solid-cascade-option][tc-selected], -[data-solid-cascade-option]:focus { - background-color: rgb(219 234 254); - color: rgb(30 58 138); -} diff --git a/src/ui/Cascade.tsx b/src/ui/Cascade.tsx deleted file mode 100644 index a787cb1..0000000 --- a/src/ui/Cascade.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { Select as BaseSelect, SelectOption as BaseSelectOption } from 'terracotta/select'; - -import './Cascade.css'; - -export const Cascade: typeof BaseSelect = (props) => ; -export const CascadeOption: typeof BaseSelectOption = (props) => ( - -); diff --git a/src/ui/IconButton.css b/src/ui/IconButton.css index 064de79..01da27d 100644 --- a/src/ui/IconButton.css +++ b/src/ui/IconButton.css @@ -1,36 +1,42 @@ [data-solid-icon-button] { display: flex; - padding: 0.25rem; - border-radius: 9999px; + padding: 0.375rem; + border-radius: 0.5rem; border-width: 0px; align-items: center; justify-content: center; cursor: pointer; - color: rgb(249 250 251); + color: var(--start-dt-text, rgb(249 250 251)); background: none; - transition-property: color, background-color, border-color, box-shadow; + transition-property: color, background-color, border-color, box-shadow, opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } [data-solid-icon-button]:hover { - color: rgb(229 231 235); - background-color: rgb(55 65 81); + color: var(--start-dt-text, rgb(229 231 235)); + background-color: var(--start-dt-surface-hover, rgb(55 65 81)); } [data-solid-icon-button]:focus { outline: 2px solid transparent; - outline-color: rgb(249 250 251); + outline-color: var(--start-dt-accent, rgb(249 250 251)); outline-offset: 2px; } [data-solid-icon-button]:focus-visible { - box-shadow: 0 0 0 calc(3px) rgb(17 24 39 / 0.75); + box-shadow: 0 0 0 2px var(--start-dt-accent, rgb(17 24 39 / 0.75)); } [data-solid-icon-button]:active { - color: rgb(243 244 246); - background-color: rgb(31 41 55); + color: var(--start-dt-accent, rgb(243 244 246)); + background-color: var(--start-dt-surface-active, rgb(31 41 55)); +} + +[data-solid-icon-button]:disabled { + opacity: 0.35; + cursor: default; + pointer-events: none; } [data-solid-icon-button] > svg { diff --git a/src/ui/Placeholder.css b/src/ui/Placeholder.css index 24c19d9..5f207d8 100644 --- a/src/ui/Placeholder.css +++ b/src/ui/Placeholder.css @@ -5,6 +5,8 @@ width: 100%; height: 100%; + + color: var(--start-dt-text-muted, inherit); } [data-solid-placeholder] > span { diff --git a/src/ui/Section.css b/src/ui/Section.css index 395c96b..154d0f6 100644 --- a/src/ui/Section.css +++ b/src/ui/Section.css @@ -1,16 +1,55 @@ [data-solid-section] { display: flex; flex-direction: column; + gap: 0.375rem; } [data-solid-section-title] { text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--start-dt-text-muted, inherit); +} + +/* collapsible variant */ +summary[data-solid-section-title] { + display: flex; + align-items: center; + gap: 0.375rem; + + cursor: pointer; + list-style: none; + user-select: none; +} + +summary[data-solid-section-title]::-webkit-details-marker { + display: none; +} + +summary[data-solid-section-title]::before { + content: ''; + + width: 0.375rem; + height: 0.375rem; + + border-right: 1.5px currentColor solid; + border-bottom: 1.5px currentColor solid; + + transform: rotate(-45deg); + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +details[open] > summary[data-solid-section-title]::before { + transform: rotate(45deg); +} + +summary[data-solid-section-title]:hover { + color: var(--start-dt-text, inherit); } [data-solid-section-content] { - margin-left: 0.5rem; - padding-left: 0.5rem; - border-left: 1px oklch(87% 0.065 274.039) solid; + margin-left: 0.25rem; + padding-left: 0.625rem; + border-left: 2px var(--start-dt-border-soft, oklch(87% 0.065 274.039)) solid; width: calc(100% - 2rem); diff --git a/src/ui/Section.tsx b/src/ui/Section.tsx index 82b1f75..5c64df0 100644 --- a/src/ui/Section.tsx +++ b/src/ui/Section.tsx @@ -1,4 +1,5 @@ import type { JSX } from '@solidjs/web'; +import { Show } from 'solid-js'; import { Text, type TextProps } from './Text.js'; import './Section.css'; @@ -6,16 +7,33 @@ import './Section.css'; export interface SectionProps { title: string; options?: TextProps<'span'>['options']; + collapsible?: boolean; + defaultOpen?: boolean; children: JSX.Element; } export function Section(props: SectionProps): JSX.Element { return ( -
- - {props.title} - -
{props.children}
-
+ + + {props.title} + +
{props.children}
+
+ } + > +
+ + {props.title} + +
{props.children}
+
+ ); } diff --git a/src/ui/Select.css b/src/ui/Select.css index 5728449..6ee2d0e 100644 --- a/src/ui/Select.css +++ b/src/ui/Select.css @@ -8,11 +8,11 @@ } [data-solid-select] > * + * { - border-top: 1px oklch(87% 0.065 274.039) solid; + border-top: 1px var(--start-dt-border-soft, oklch(87% 0.065 274.039)) solid; } [data-solid-select-option] { - color: rgb(249 250 251); + color: var(--start-dt-text, rgb(249 250 251)); padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; @@ -22,6 +22,8 @@ outline: none; cursor: pointer; + border-left: 2px solid transparent; + transition-property: color, background-color, border-color, box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; @@ -29,9 +31,14 @@ white-space: nowrap; } +[data-solid-select-option]:hover { + background-color: var(--start-dt-surface-hover, rgb(55 65 81)); +} + [data-solid-select-option][tc-active], [data-solid-select-option][tc-selected], [data-solid-select-option]:focus { - color: rgb(229 231 235); - background-color: rgb(55 65 81); + color: var(--start-dt-text, rgb(229 231 235)); + background-color: var(--start-dt-accent-soft, rgb(55 65 81)); + border-left-color: var(--start-dt-accent, transparent); } diff --git a/src/ui/Tabs.css b/src/ui/Tabs.css index 8c4c11c..4d4b2d3 100644 --- a/src/ui/Tabs.css +++ b/src/ui/Tabs.css @@ -3,49 +3,63 @@ align-items: center; justify-content: center; - border-radius: 0.5rem; + border-radius: 0.375rem; cursor: pointer; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 1rem; - padding-right: 1rem; - font-size: 0.875rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.875rem; + padding-right: 0.875rem; + font-size: 0.8125rem; line-height: 1.25rem; + font-weight: 500; border: none; outline: none; + color: var(--start-dt-text-muted, rgb(229 231 235)); + background: none; + transition-property: color, background-color, border-color, box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } -[data-solid-tab][tc-active], -[data-solid-tab][tc-selected], [data-solid-tab]:hover, [data-solid-tab]:focus { - color: rgb(229 231 235); - background-color: rgb(55 65 81); + color: var(--start-dt-text, rgb(229 231 235)); +} + +[data-solid-tab][tc-active], +[data-solid-tab][tc-selected] { + color: var(--start-dt-text, rgb(229 231 235)); + background-color: var(--start-dt-surface-hover, rgb(55 65 81)); + box-shadow: 0 1px 2px oklch(0 0 0 / 0.25); } [data-solid-tab-group] { } [data-solid-tab-list] { - display: flex; - gap: 0.5rem; + display: inline-flex; + gap: 0.25rem; margin: 0.5rem; + padding: 0.25rem; + + border-radius: 0.5rem; + background-color: var(--start-dt-surface, rgb(17 24 39)); + align-self: flex-start; } [data-solid-tab-panel] { - height: 100%; + flex: 1 1 0%; + min-height: 0; overflow: auto; display: flex; flex-direction: column; - gap: 0.5rem; - padding: 0.5rem; + gap: 0.75rem; + padding: 0.75rem; - border-top: oklch(87% 0.065 274.039) 1px solid; + border-top: var(--start-dt-border-soft, oklch(87% 0.065 274.039)) 1px solid; } diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index bfbf8fc..0f63476 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -37,6 +37,10 @@ test('captures client errors', async ({ page }) => { await expect(page.locator('[data-solid-error-viewer-error-info-message]')).toHaveText( 'client boom', ); + + // Frames come from the parsed stack, so an empty list means the parser broke. + const frames = page.locator('[data-solid-error-viewer-stack-frame]'); + await expect(frames.first()).toContainText('app.tsx'); }); test('shows server-function calls', async ({ page }) => { @@ -46,15 +50,31 @@ test('shows server-function calls', async ({ page }) => { }); await page.goto('/'); - await page.locator('#emit-server-function-request').click(); - await page.getByRole('button', { name: 'View Server Functions' }).click(); - + const toggle = page.getByRole('button', { name: 'View Server Functions' }); const instances = page.locator('[data-solid-functions-instances]'); + + // The panel overlays the page, so the fixture buttons are only clickable + // while it is closed. + await page.locator('#emit-server-function-request').click(); + await toggle.click(); await expect(instances).toContainText('loadUser'); + + await toggle.click(); await page.locator('#emit-server-function-response').click(); + await toggle.click(); await expect(instances).toContainText('201'); + + await toggle.click(); await page.locator('#emit-server-function-response').click(); + await toggle.click(); await expect(instances).toContainText('500'); + + // The detail pane starts empty and fills in once a call is selected. + const detail = page.locator('[data-solid-functions-detail]'); + await expect(detail).toContainText('Select a server function call.'); + await page.locator('[data-solid-functions-instances] [data-solid-select-option]').first().click(); + await expect(detail.locator('[data-solid-function-instance-viewer]')).toBeVisible(); + expect(warnings).not.toContainEqual(expect.stringContaining('STRICT_READ_UNTRACKED')); });