@paramour-js/devtools-panel 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jason Paff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # @paramour-js/devtools-panel
2
+
3
+ A [TanStack Devtools](https://tanstack.com/devtools) panel for
4
+ [paramour](https://paramour.dev): watch your routes decode in real time.
5
+ Every hook call reports what it saw — which route, the raw wire values, the
6
+ decoded result, or the exact `issues[]` when a decode failed — with
7
+ defaults and `.catch()` recoveries attributed rather than silently blended
8
+ in, and editable search inputs that navigate the app to the URL you
9
+ compose.
10
+
11
+ ```sh
12
+ pnpm add @paramour-js/devtools-panel @tanstack/react-devtools
13
+ ```
14
+
15
+ You own the TanStack shell; paramour is a plugin in it. Mount it once,
16
+ dev-conditionally:
17
+
18
+ ```tsx
19
+ "use client";
20
+
21
+ import { paramourDevtoolsPlugin } from "@paramour-js/devtools-panel";
22
+ import { TanStackDevtools } from "@tanstack/react-devtools";
23
+
24
+ export function Devtools() {
25
+ if (process.env.NODE_ENV === "production") return null;
26
+ return <TanStackDevtools plugins={[paramourDevtoolsPlugin()]} />;
27
+ }
28
+ ```
29
+
30
+ ## Docs
31
+
32
+ - [Devtools guide](https://paramour.dev/docs/guides/devtools)
33
+ - [Devtools API reference](https://paramour.dev/docs/reference/devtools)
34
+
35
+ ## License
36
+
37
+ MIT © Jason Paff
@@ -0,0 +1,39 @@
1
+ import type { AnyCodec, CodecDescription } from "paramour";
2
+ import type { ReactNode } from "react";
3
+ export declare function CodecInput({ codec, dataKey, description, draftText, hasDraft, onCommit, onDraftChange, parsedValue, raw, }: {
4
+ readonly codec: AnyCodec;
5
+ readonly dataKey: string;
6
+ readonly description: CodecDescription;
7
+ readonly draftText: string;
8
+ /** True when a text draft exists — the draft then owns the widget state. */
9
+ readonly hasDraft: boolean;
10
+ /**
11
+ * Commit now; `immediateText` carries a same-event draft (the checkbox's
12
+ * toggle-and-commit) that React state cannot deliver to this commit —
13
+ * `setDrafts` lands next render, AFTER the commit would read it.
14
+ */
15
+ readonly onCommit: (immediateText?: string) => void;
16
+ readonly onDraftChange: (text: string) => void;
17
+ /** The decode's parsed value — the EFFECTIVE state when no draft exists. */
18
+ readonly parsedValue: unknown;
19
+ readonly raw: boolean;
20
+ }): ReactNode;
21
+ /**
22
+ * The kind → widget dispatch. The draft currency is always the WIRE
23
+ * string (`draftText`): structured widgets (toggle, select, date) convert
24
+ * their widget value to wire before reporting, so the live preview always
25
+ * reflects the true wire round-trip. Raw mode swaps in a mono text input
26
+ * holding the wire verbatim. Enter and blur commit (the deliberate commit
27
+ * point — no live-as-you-type navigation).
28
+ */
29
+ /**
30
+ * Which keys get the multi-line textarea (one wire value per line):
31
+ * repeated-key (arity "many") codecs in codec mode. Arity alone decides —
32
+ * element presence stopped implying csv when p.array gained `~element`
33
+ * (PP1); csv stays arity "single", so its one comma-joined wire value edits
34
+ * in the plain input below, and raw mode always edits one wire value in a
35
+ * mono input. Exported so the row's draft SEEDING can agree: only this
36
+ * widget may be seeded with a newline join — single-line inputs
37
+ * value-sanitize the `\n` away, fabricating a merged value.
38
+ */
39
+ export declare function isMultilineWidget(description: CodecDescription, raw: boolean): boolean;
@@ -0,0 +1,119 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { serializeValue } from "paramour";
3
+ export function CodecInput({ codec, dataKey, description, draftText, hasDraft, onCommit, onDraftChange, parsedValue, raw, }) {
4
+ const commitOnEnter = (key) => {
5
+ if (key === "Enter")
6
+ onCommit();
7
+ };
8
+ if (isMultilineWidget(description, raw)) {
9
+ // Repeated-key array codecs: one wire value per line (csv keeps its
10
+ // single comma-joined wire value and stays a plain input below).
11
+ return (_jsx("textarea", { "aria-label": `edit ${dataKey}`, className: "pmr-textarea", onBlur: () => {
12
+ onCommit();
13
+ }, onChange: (event) => {
14
+ onDraftChange(event.target.value);
15
+ }, placeholder: "one value per line", value: draftText }));
16
+ }
17
+ if (!raw && description.kind === "boolean") {
18
+ // Without a draft the box mirrors the EFFECTIVE parsed value — an
19
+ // absent wire with `.default(true)` must render checked, or unchecking
20
+ // it (committing `false`) is unreachable.
21
+ const checked = hasDraft
22
+ ? draftText === trueWire(codec, dataKey)
23
+ : parsedValue === true;
24
+ return (_jsx("input", { "aria-label": `edit ${dataKey}`, checked: checked, className: "pmr-input", onChange: (event) => {
25
+ const text = event.target.checked
26
+ ? trueWire(codec, dataKey)
27
+ : falseWire(codec, dataKey);
28
+ onDraftChange(text);
29
+ onCommit(text);
30
+ }, type: "checkbox" }));
31
+ }
32
+ if (!raw && description.enumMembers !== undefined) {
33
+ const members = description.enumMembers;
34
+ // No draft: show the effective member (a defaulted enum reads as its
35
+ // default), or the hidden placeholder when nothing parsed. A controlled
36
+ // value that matches NO option would make the browser display the first
37
+ // member while the draft stays "" — rendering that member unpickable
38
+ // (selecting it fires no change event).
39
+ const effectiveMember = typeof parsedValue === "string" && members.includes(parsedValue)
40
+ ? parsedValue
41
+ : "";
42
+ return (_jsxs("select", { "aria-label": `edit ${dataKey}`, className: "pmr-select", onBlur: () => {
43
+ onCommit();
44
+ }, onChange: (event) => {
45
+ onDraftChange(event.target.value);
46
+ }, onKeyDown: (event) => {
47
+ commitOnEnter(event.key);
48
+ }, value: draftText === "" ? effectiveMember : draftText, children: [_jsx("option", { disabled: true, hidden: true, value: "" }), members.map((member) => (_jsx("option", { value: member, children: member }, member)))] }));
49
+ }
50
+ const type = raw
51
+ ? "text"
52
+ : description.kind === "index" ||
53
+ description.kind === "integer" ||
54
+ description.kind === "number"
55
+ ? "number"
56
+ : description.kind === "isoDate"
57
+ ? "date"
58
+ : "text";
59
+ const placeholder = raw
60
+ ? "raw wire value"
61
+ : description.kind === "timestamp"
62
+ ? "2026-01-01T00:00:00.000Z"
63
+ : // An element here means csv's one comma-joined wire value — arrays
64
+ // also carry an element (PP1) but never reach this input: arity
65
+ // "many" takes the textarea above.
66
+ description.element !== undefined
67
+ ? `${description.element.kind},…`
68
+ : "";
69
+ return (_jsx("input", { "aria-label": raw ? `raw wire for ${dataKey}` : `edit ${dataKey}`, className: "pmr-input",
70
+ // The 1-based wire floor (PP5) — the draft currency is the WIRE string.
71
+ min: description.kind === "index" ? "1" : undefined, onBlur: () => {
72
+ onCommit();
73
+ }, onChange: (event) => {
74
+ onDraftChange(event.target.value);
75
+ }, onKeyDown: (event) => {
76
+ commitOnEnter(event.key);
77
+ }, placeholder: placeholder, step: description.kind === "index" || description.kind === "integer"
78
+ ? "1"
79
+ : "any", type: type, value: draftText }));
80
+ }
81
+ /**
82
+ * The kind → widget dispatch. The draft currency is always the WIRE
83
+ * string (`draftText`): structured widgets (toggle, select, date) convert
84
+ * their widget value to wire before reporting, so the live preview always
85
+ * reflects the true wire round-trip. Raw mode swaps in a mono text input
86
+ * holding the wire verbatim. Enter and blur commit (the deliberate commit
87
+ * point — no live-as-you-type navigation).
88
+ */
89
+ /**
90
+ * Which keys get the multi-line textarea (one wire value per line):
91
+ * repeated-key (arity "many") codecs in codec mode. Arity alone decides —
92
+ * element presence stopped implying csv when p.array gained `~element`
93
+ * (PP1); csv stays arity "single", so its one comma-joined wire value edits
94
+ * in the plain input below, and raw mode always edits one wire value in a
95
+ * mono input. Exported so the row's draft SEEDING can agree: only this
96
+ * widget may be seeded with a newline join — single-line inputs
97
+ * value-sanitize the `\n` away, fabricating a merged value.
98
+ */
99
+ export function isMultilineWidget(description, raw) {
100
+ return !raw && description.arity === "many";
101
+ }
102
+ /**
103
+ * The boolean wires via the codec's own serializer — exact even for a
104
+ * custom codec whose wire isn't literally "true"/"false".
105
+ */
106
+ function falseWire(codec, key) {
107
+ return safeWire(codec, key, false, "false");
108
+ }
109
+ function safeWire(codec, key, value, fallback) {
110
+ try {
111
+ return serializeValue(codec, key, value);
112
+ }
113
+ catch {
114
+ return fallback;
115
+ }
116
+ }
117
+ function trueWire(codec, key) {
118
+ return safeWire(codec, key, true, "true");
119
+ }
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from "react";
2
+ /**
3
+ * Non-invasive copy helpers: the session's URL (absent when a stale
4
+ * snapshot's URL cannot be rebuilt), the parsed values as JSON (absent when
5
+ * nothing parsed), and the `href()` reproduction snippet.
6
+ */
7
+ export declare function CopyToolbar({ parsedJson, snippet, url, }: {
8
+ readonly parsedJson: string | undefined;
9
+ readonly snippet: string;
10
+ readonly url: string | undefined;
11
+ }): ReactNode;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CopyButton } from "./primitives.js";
3
+ /**
4
+ * Non-invasive copy helpers: the session's URL (absent when a stale
5
+ * snapshot's URL cannot be rebuilt), the parsed values as JSON (absent when
6
+ * nothing parsed), and the `href()` reproduction snippet.
7
+ */
8
+ export function CopyToolbar({ parsedJson, snippet, url, }) {
9
+ return (_jsxs("span", { className: "pmr-toolbar", children: [url === undefined ? null : _jsx(CopyButton, { label: "copy url", text: url }), parsedJson === undefined ? null : (_jsx(CopyButton, { label: "copy json", text: parsedJson })), _jsx(CopyButton, { label: "copy href()", text: snippet })] }));
10
+ }
@@ -0,0 +1,15 @@
1
+ import type { Issue } from "paramour";
2
+ import type { ReactNode } from "react";
3
+ /**
4
+ * `issues[]` rendered prominently on decode failure — always in the
5
+ * single-scroll flow, never behind a tab, so a failing decode can never be
6
+ * hidden one click away. The raw-search root sentinel key `"<search>"`
7
+ * reads as the whole search string failing. `wire` and `expected` are the
8
+ * issue's structured halves: the wire value is JSON-quoted so an
9
+ * empty-string value stays visible, and an absent field renders an em dash
10
+ * (absence is meaningful — a missing key HAS no wire value; a rawSearch
11
+ * issue has no owning codec to name).
12
+ */
13
+ export declare function IssuesSection({ issues, }: {
14
+ readonly issues: readonly Issue[];
15
+ }): ReactNode;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * `issues[]` rendered prominently on decode failure — always in the
4
+ * single-scroll flow, never behind a tab, so a failing decode can never be
5
+ * hidden one click away. The raw-search root sentinel key `"<search>"`
6
+ * reads as the whole search string failing. `wire` and `expected` are the
7
+ * issue's structured halves: the wire value is JSON-quoted so an
8
+ * empty-string value stays visible, and an absent field renders an em dash
9
+ * (absence is meaningful — a missing key HAS no wire value; a rawSearch
10
+ * issue has no owning codec to name).
11
+ */
12
+ export function IssuesSection({ issues, }) {
13
+ if (issues.length === 0)
14
+ return null;
15
+ return (_jsxs("div", { className: "pmr-issues", children: [_jsx("div", { className: "pmr-section-title", children: "Issues" }), _jsxs("table", { className: "pmr-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "key" }), _jsx("th", { children: "wire" }), _jsx("th", { children: "expected" }), _jsx("th", { children: "message" })] }) }), _jsx("tbody", { children: issues.map((issue, index) => (_jsxs("tr", { children: [_jsx("td", { className: "pmr-mono", children: issue.key === "<search>" ? "(whole search)" : issue.key }), _jsx("td", { className: "pmr-mono", children: issue.wire === undefined ? "—" : JSON.stringify(issue.wire) }), _jsx("td", { className: "pmr-mono", children: issue.expected ?? "—" }), _jsx("td", { children: issue.message })] }, `${issue.key}:${String(index)}`))) })] })] }));
16
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+ export interface ParamourDevtoolsPanelProps {
3
+ /**
4
+ * Injected by the TanStack shell (the one contract it guarantees every
5
+ * panel, regardless of framework); selects the stylesheet's token set via
6
+ * a `data-theme` attribute — no theme context, no remount.
7
+ */
8
+ readonly theme?: "dark" | "light";
9
+ }
10
+ /**
11
+ * The paramour devtools panel: session sidebar on the left; the main pane
12
+ * auto-follows the route(s) whose observations match the current URL
13
+ * (layout and page may both report — both render, stacked), or shows a
14
+ * pinned session's last-known snapshot read-only with a stale marker.
15
+ */
16
+ export declare function ParamourDevtoolsPanel({ theme, }: ParamourDevtoolsPanelProps): ReactNode;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useSyncExternalStore } from "react";
3
+ import { getServerSnapshot, getSnapshot, subscribe } from "../store.js";
4
+ import { PANEL_CSS } from "../styles.js";
5
+ import { EmptyState } from "./primitives.js";
6
+ import { RouteView } from "./route-view.js";
7
+ import { Sidebar } from "./sidebar.js";
8
+ /**
9
+ * The paramour devtools panel: session sidebar on the left; the main pane
10
+ * auto-follows the route(s) whose observations match the current URL
11
+ * (layout and page may both report — both render, stacked), or shows a
12
+ * pinned session's last-known snapshot read-only with a stale marker.
13
+ */
14
+ export function ParamourDevtoolsPanel({ theme, }) {
15
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
16
+ const [selectedKey, setSelectedKey] = useState(null);
17
+ const visible = selectedKey === null
18
+ ? snapshot.sessions.filter((session) => snapshot.currentKeys.includes(session.key))
19
+ : snapshot.sessions.filter((session) => session.key === selectedKey);
20
+ return (_jsxs("div", { className: "pmr-root", "data-theme": theme ?? "light", children: [_jsx("style", { children: PANEL_CSS }), snapshot.sessions.length === 0 ? (_jsx(EmptyState, {})) : (_jsxs(_Fragment, { children: [_jsx(Sidebar, { currentKeys: snapshot.currentKeys, onSelect: setSelectedKey, selectedKey: selectedKey, sessions: snapshot.sessions }), _jsx("div", { className: "pmr-main", children: visible.length === 0 ? (_jsx("div", { className: "pmr-empty", children: "No observed route matches the current URL \u2014 select one from the sidebar to inspect its last-known snapshot." })) : (visible.map((session) => (_jsx(RouteView, { session: session, stale: !snapshot.currentKeys.includes(session.key) }, session.key)))) })] }))] }));
21
+ }
@@ -0,0 +1,13 @@
1
+ import type { ParamDescription } from "paramour";
2
+ import type { ReactNode } from "react";
3
+ import type { ParamourObservation } from "../seam.js";
4
+ /**
5
+ * The params half of the inspector. Read-only in v1: editing a path param
6
+ * navigates to a different RESOURCE — a bigger action than tweaking a
7
+ * filter, deferred behind `href()`.
8
+ */
9
+ export declare function ParamsTable({ changeStamps, descriptions, observation, }: {
10
+ readonly changeStamps: Readonly<Record<string, number>>;
11
+ readonly descriptions: Readonly<Record<string, ParamDescription>>;
12
+ readonly observation: ParamourObservation | undefined;
13
+ }): ReactNode;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { formatShape, formatWire, jsLiteral } from "../format.js";
3
+ import { ValueCell } from "./primitives.js";
4
+ /**
5
+ * The params half of the inspector. Read-only in v1: editing a path param
6
+ * navigates to a different RESOURCE — a bigger action than tweaking a
7
+ * filter, deferred behind `href()`.
8
+ */
9
+ export function ParamsTable({ changeStamps, descriptions, observation, }) {
10
+ const names = Object.keys(descriptions);
11
+ if (names.length === 0)
12
+ return null;
13
+ const wire = observation?.kind === "params" ? observation.wire : undefined;
14
+ const parsed = observation?.result.status === "success"
15
+ ? observation.result.data
16
+ : undefined;
17
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "pmr-section-title", children: "Params" }), _jsxs("table", { className: "pmr-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "key" }), _jsx("th", { children: "wire" }), _jsx("th", { children: "parsed" }), _jsx("th", { children: "shape" })] }) }), _jsx("tbody", { children: names.map((name) => {
18
+ const description = descriptions[name];
19
+ if (description === undefined)
20
+ return null;
21
+ return (_jsxs("tr", { children: [_jsx("td", { className: "pmr-mono", children: name }), _jsx("td", { children: _jsx(ValueCell, { stamp: 0, children: formatWire(wire?.[name]) }) }), _jsx("td", { children: _jsx(ValueCell, { stamp: changeStamps[name] ?? 0, children: parsed === undefined ? "—" : jsLiteral(parsed[name]) }) }), _jsxs("td", { className: "pmr-mono pmr-muted", children: [formatShape(description), segmentHint(description)] })] }, name));
22
+ }) })] })] }));
23
+ }
24
+ function segmentHint(description) {
25
+ switch (description.segmentKind) {
26
+ case "catchall":
27
+ return " [...]";
28
+ case "optional-catchall":
29
+ return " [[...]]";
30
+ case "single":
31
+ return "";
32
+ }
33
+ }
@@ -0,0 +1,38 @@
1
+ import type { RouterKind } from "paramour";
2
+ import type { ReactNode } from "react";
3
+ /** Shared leaf components. */
4
+ export type PanelStatus = "error" | "ok" | "pending" | "stale";
5
+ /** Neutral (never a status color) per-key attribution tag. */
6
+ export declare function AttributionTag({ kind, }: {
7
+ readonly kind: "catch" | "default";
8
+ }): ReactNode;
9
+ /**
10
+ * Inline micro-feedback — the label swaps to a checkmark briefly; no
11
+ * toasts. Clipboard failure (denied permission, no focus) is silently
12
+ * tolerated: a copy button that sometimes needs a second click beats a
13
+ * crashing panel.
14
+ */
15
+ export declare function CopyButton({ label, text, }: {
16
+ readonly label: string;
17
+ readonly text: string;
18
+ }): ReactNode;
19
+ /**
20
+ * The pre-observation state is EXPECTED, not a problem — styled as
21
+ * informational, never as an error.
22
+ */
23
+ export declare function EmptyState(): ReactNode;
24
+ export declare function RouterBadge({ router, }: {
25
+ readonly router: RouterKind;
26
+ }): ReactNode;
27
+ export declare function StatusDot({ status, }: {
28
+ readonly status: PanelStatus;
29
+ }): ReactNode;
30
+ /**
31
+ * Monospace value cell. The React `key` carries the store's change-stamp,
32
+ * so a parsed-value change remounts the span and restarts the flash
33
+ * animation; stamp 0 (first appearance) doesn't flash.
34
+ */
35
+ export declare function ValueCell({ children, stamp, }: {
36
+ readonly children: ReactNode;
37
+ readonly stamp: number;
38
+ }): ReactNode;
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ /**
4
+ * Colorblind-safe glyph shapes layered ON TOP of the color dots, not
5
+ * replacing them — the at-a-glance color read stays, the glyph carries the
6
+ * same information without color.
7
+ */
8
+ const STATUS_GLYPHS = {
9
+ error: "✕",
10
+ ok: "✓",
11
+ pending: "◷",
12
+ stale: "∅",
13
+ };
14
+ /** Neutral (never a status color) per-key attribution tag. */
15
+ export function AttributionTag({ kind, }) {
16
+ return _jsx("span", { className: "pmr-tag", children: kind });
17
+ }
18
+ /**
19
+ * Inline micro-feedback — the label swaps to a checkmark briefly; no
20
+ * toasts. Clipboard failure (denied permission, no focus) is silently
21
+ * tolerated: a copy button that sometimes needs a second click beats a
22
+ * crashing panel.
23
+ */
24
+ export function CopyButton({ label, text, }) {
25
+ const [copied, setCopied] = useState(false);
26
+ return (_jsx("button", { className: "pmr-icon-button", onClick: () => {
27
+ try {
28
+ void navigator.clipboard.writeText(text).catch(() => undefined);
29
+ }
30
+ catch {
31
+ // No clipboard API in this environment — the swap still signals.
32
+ }
33
+ setCopied(true);
34
+ setTimeout(() => {
35
+ setCopied(false);
36
+ }, 1200);
37
+ }, type: "button", children: copied ? "✓" : label }));
38
+ }
39
+ /**
40
+ * The pre-observation state is EXPECTED, not a problem — styled as
41
+ * informational, never as an error.
42
+ */
43
+ export function EmptyState() {
44
+ return (_jsx("div", { className: "pmr-empty", children: "No routes observed yet \u2014 navigate using a paramour hook to see it here." }));
45
+ }
46
+ export function RouterBadge({ router, }) {
47
+ return _jsx("span", { className: "pmr-badge", children: router });
48
+ }
49
+ export function StatusDot({ status, }) {
50
+ return (_jsx("span", { "aria-label": status, className: `pmr-dot pmr-dot--${status}`, role: "img", children: _jsx("span", { "aria-hidden": "true", className: "pmr-dot-glyph", children: STATUS_GLYPHS[status] }) }));
51
+ }
52
+ /**
53
+ * Monospace value cell. The React `key` carries the store's change-stamp,
54
+ * so a parsed-value change remounts the span and restarts the flash
55
+ * animation; stamp 0 (first appearance) doesn't flash.
56
+ */
57
+ export function ValueCell({ children, stamp, }) {
58
+ return (_jsx("span", { className: stamp > 0 ? "pmr-flash pmr-mono" : "pmr-mono", children: children }, stamp));
59
+ }
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RouteSession } from "../store.js";
3
+ /**
4
+ * One session's stacked single-scroll pane: route-match banner (path
5
+ * pattern, router kind, status, copy toolbar), params table, search table,
6
+ * then issues — rendered only when present, never behind a tab. A stale
7
+ * (non-current) session renders its last-known snapshot with editing
8
+ * disabled: its `navigate` belongs to a page that is no longer mounted.
9
+ *
10
+ * Sessions are immutable store records, so every derivation here is
11
+ * memoized on the session reference — a panel re-render for some OTHER
12
+ * session's observation must not re-run `describeRoute`, the literal
13
+ * printers, or the `href` serialization.
14
+ */
15
+ export declare function RouteView({ session, stale, }: {
16
+ readonly session: RouteSession;
17
+ readonly stale: boolean;
18
+ }): ReactNode;
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { describeRoute, href, isRawSearch } from "paramour";
3
+ import { useMemo } from "react";
4
+ import { jsLiteral, reproSnippet } from "../format.js";
5
+ import { parsedRecord } from "../store.js";
6
+ import { CopyToolbar } from "./copy-toolbar.js";
7
+ import { IssuesSection } from "./issues-section.js";
8
+ import { ParamsTable } from "./params-table.js";
9
+ import { RouterBadge, StatusDot } from "./primitives.js";
10
+ import { SearchTable } from "./search-table.js";
11
+ /**
12
+ * One session's stacked single-scroll pane: route-match banner (path
13
+ * pattern, router kind, status, copy toolbar), params table, search table,
14
+ * then issues — rendered only when present, never behind a tab. A stale
15
+ * (non-current) session renders its last-known snapshot with editing
16
+ * disabled: its `navigate` belongs to a page that is no longer mounted.
17
+ *
18
+ * Sessions are immutable store records, so every derivation here is
19
+ * memoized on the session reference — a panel re-render for some OTHER
20
+ * session's observation must not re-run `describeRoute`, the literal
21
+ * printers, or the `href` serialization.
22
+ */
23
+ export function RouteView({ session, stale, }) {
24
+ const derived = useMemo(() => {
25
+ const description = describeRoute(session.route);
26
+ const routerKind = session.params?.routerKind ?? session.search?.routerKind ?? "app";
27
+ const params = parsedRecord(session.params);
28
+ const search = parsedRecord(session.search);
29
+ const parsedJson = params === undefined && search === undefined
30
+ ? undefined
31
+ : jsLiteral({
32
+ ...(params === undefined ? {} : { params }),
33
+ ...(search === undefined ? {} : { search }),
34
+ });
35
+ const issues = [
36
+ ...issuesOf(session.params),
37
+ ...issuesOf(session.search),
38
+ ];
39
+ // The blessed-internal search config, needed for editing; `describeRoute`
40
+ // told us whether it's a codec map. Reading the internal slot here is
41
+ // sanctioned — it is the same read the hooks make.
42
+ const searchSlot = session.route["~search"];
43
+ const searchConfig = description.search.kind === "codecs" && !isRawSearch(searchSlot)
44
+ ? searchSlot
45
+ : undefined;
46
+ return {
47
+ description,
48
+ issues,
49
+ params,
50
+ parsedJson,
51
+ routerKind,
52
+ search,
53
+ searchConfig,
54
+ snippet: reproSnippet(description.path, routerKind, params, search),
55
+ staleHref: staleUrl(session.route, params, search),
56
+ wireKey: wireFingerprint(session.search),
57
+ };
58
+ }, [session]);
59
+ return (_jsxs("section", { children: [_jsxs("div", { className: "pmr-banner", children: [_jsx("span", { className: "pmr-banner-path", children: derived.description.path }), _jsx(RouterBadge, { router: derived.routerKind }), _jsx(StatusDot, { status: stale ? "stale" : session.status }), stale ? _jsx("span", { className: "pmr-stale-badge", children: "stale snapshot" }) : null, _jsx(CopyToolbar, { parsedJson: derived.parsedJson, snippet: derived.snippet, url: stale ? derived.staleHref : window.location.href })] }), _jsx(ParamsTable, { changeStamps: session.changeStamps.params, descriptions: derived.description.params, observation: session.params }), _jsx(SearchTable, { changeStamps: session.changeStamps.search, description: derived.description.search,
60
+ // The NEWEST observation's navigate (either half) — the store keeps
61
+ // it session-level so a stale half's closure can never shadow a
62
+ // fresher one.
63
+ navigate: stale ? undefined : session.navigate, observation: session.search, searchConfig: derived.searchConfig }, derived.wireKey), _jsx(IssuesSection, { issues: derived.issues })] }));
64
+ }
65
+ function issuesOf(observation) {
66
+ if (observation?.result.status !== "error")
67
+ return [];
68
+ return observation.result.error.issues;
69
+ }
70
+ /**
71
+ * A stale session's copy-url source: its snapshot belongs to a page that
72
+ * is NO LONGER the live location, so the URL is rebuilt from the
73
+ * parsed halves through core's `href` — the same serialization the user's
74
+ * own code would run. Unbuildable (errored params, exotic schema throw) →
75
+ * `undefined`, and the toolbar hides the button rather than copying the
76
+ * wrong page's URL.
77
+ */
78
+ function staleUrl(route, params, search) {
79
+ const buildHref = href;
80
+ try {
81
+ return buildHref(route, {
82
+ ...(params === undefined ? {} : { params }),
83
+ ...(search === undefined ? {} : { search }),
84
+ });
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
90
+ function wireFingerprint(observation) {
91
+ if (observation?.kind !== "search")
92
+ return "none";
93
+ return JSON.stringify(observation.wire);
94
+ }
@@ -0,0 +1,21 @@
1
+ import type { AnyCodec, SearchDescription } from "paramour";
2
+ import type { ReactNode } from "react";
3
+ import type { ParamourObservation } from "../seam.js";
4
+ /**
5
+ * The search half of the inspector and the panel's editing surface: per-key
6
+ * widgets validating live through the codec, a per-key RAW WIRE toggle for
7
+ * reproducing invalid values, clear-to-absent, and a commit-to-push flow —
8
+ * Enter/blur assembles the FULL pair list (untouched and unknown keys
9
+ * carried verbatim), serializes through `buildSearchString` (spaces as %20
10
+ * — S-rule fidelity), and navigates via the EMITTING hook's `navigate`
11
+ * capability. The parent remounts this component when the observed wire
12
+ * changes (its React key), which is the drafts-invalidation rule: an
13
+ * external navigation resets the edit session.
14
+ */
15
+ export declare function SearchTable({ changeStamps, description, navigate, observation, searchConfig, }: {
16
+ readonly changeStamps: Readonly<Record<string, number>>;
17
+ readonly description: SearchDescription;
18
+ readonly navigate: ((search: string) => void) | undefined;
19
+ readonly observation: ParamourObservation | undefined;
20
+ readonly searchConfig: Readonly<Record<string, AnyCodec>> | undefined;
21
+ }): ReactNode;