@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 +21 -0
- package/README.md +37 -0
- package/dist/components/codec-input.d.ts +39 -0
- package/dist/components/codec-input.js +119 -0
- package/dist/components/copy-toolbar.d.ts +11 -0
- package/dist/components/copy-toolbar.js +10 -0
- package/dist/components/issues-section.d.ts +15 -0
- package/dist/components/issues-section.js +16 -0
- package/dist/components/panel.d.ts +16 -0
- package/dist/components/panel.js +21 -0
- package/dist/components/params-table.d.ts +13 -0
- package/dist/components/params-table.js +33 -0
- package/dist/components/primitives.d.ts +38 -0
- package/dist/components/primitives.js +59 -0
- package/dist/components/route-view.d.ts +18 -0
- package/dist/components/route-view.js +94 -0
- package/dist/components/search-table.d.ts +21 -0
- package/dist/components/search-table.js +177 -0
- package/dist/components/sidebar.d.ts +14 -0
- package/dist/components/sidebar.js +19 -0
- package/dist/edit.d.ts +50 -0
- package/dist/edit.js +81 -0
- package/dist/format.d.ts +36 -0
- package/dist/format.js +109 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/inference.d.ts +41 -0
- package/dist/inference.js +78 -0
- package/dist/match.d.ts +16 -0
- package/dist/match.js +49 -0
- package/dist/plugin.d.ts +20 -0
- package/dist/plugin.js +12 -0
- package/dist/seam.d.ts +16 -0
- package/dist/seam.js +16 -0
- package/dist/store.d.ts +70 -0
- package/dist/store.js +305 -0
- package/dist/styles.d.ts +16 -0
- package/dist/styles.js +278 -0
- package/package.json +72 -0
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;
|