@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/dist/match.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Does `pathname` (from `window.location`) match this route's pattern?
3
+ * Powers the "current URL" grouping: static segments compare against the
4
+ * percent-DECODED path part; `single` consumes exactly one part; `catchall`
5
+ * one-plus; `optional-catchall` zero-plus (both are terminal by Next's
6
+ * grammar, mirrored by core's tokenizer). Trailing slashes are
7
+ * normalization noise (`/shop/` matches `/shop`).
8
+ */
9
+ export function matchesPathname(segments, pathname) {
10
+ const parts = pathname.split("/").filter((part) => part !== "");
11
+ let index = 0;
12
+ for (const segment of segments) {
13
+ switch (segment.kind) {
14
+ case "catchall": {
15
+ if (parts.length - index < 1)
16
+ return false;
17
+ index = parts.length;
18
+ break;
19
+ }
20
+ case "optional-catchall": {
21
+ index = parts.length;
22
+ break;
23
+ }
24
+ case "single": {
25
+ if (parts[index] === undefined)
26
+ return false;
27
+ index += 1;
28
+ break;
29
+ }
30
+ case "static": {
31
+ const part = parts[index];
32
+ if (part === undefined || decodePart(part) !== segment.raw)
33
+ return false;
34
+ index += 1;
35
+ break;
36
+ }
37
+ }
38
+ }
39
+ return index === parts.length;
40
+ }
41
+ /** A malformed escape in a URL the panel merely OBSERVES must not throw. */
42
+ function decodePart(part) {
43
+ try {
44
+ return decodeURIComponent(part);
45
+ }
46
+ catch {
47
+ return part;
48
+ }
49
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactElement } from "react";
2
+ /**
3
+ * The TanStack Devtools plugin entry: the user owns the shell — install
4
+ * `@tanstack/react-devtools`, mount
5
+ * `<TanStackDevtools plugins={[paramourDevtoolsPlugin()]} />`, done. The
6
+ * entry type is declared STRUCTURALLY (the shell's contract is just
7
+ * name/render/id/defaultOpen) so this module never imports the shell at
8
+ * runtime; assignability to the real plugin type is certified by the
9
+ * package's type tests. The shell clones `render` and injects `theme`.
10
+ */
11
+ export interface ParamourDevtoolsPluginEntry {
12
+ readonly defaultOpen?: boolean;
13
+ readonly id: string;
14
+ readonly name: string;
15
+ readonly render: ReactElement;
16
+ }
17
+ export interface ParamourDevtoolsPluginOptions {
18
+ readonly defaultOpen?: boolean;
19
+ }
20
+ export declare function paramourDevtoolsPlugin(options?: ParamourDevtoolsPluginOptions): ParamourDevtoolsPluginEntry;
package/dist/plugin.js ADDED
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ParamourDevtoolsPanel } from "./components/panel.js";
3
+ export function paramourDevtoolsPlugin(options) {
4
+ return {
5
+ ...(options?.defaultOpen === undefined
6
+ ? {}
7
+ : { defaultOpen: options.defaultOpen }),
8
+ id: "paramour-devtools",
9
+ name: "Paramour",
10
+ render: _jsx(ParamourDevtoolsPanel, {}),
11
+ };
12
+ }
package/dist/seam.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { ParamourDevtoolsSeam } from "@paramour-js/next/devtools-seam";
2
+ /**
3
+ * The panel's half of the observation seam. The contract of record lives in
4
+ * `@paramour-js/next`'s `src/devtools-seam.ts`; this module consumes it
5
+ * TYPES-ONLY via the `./devtools-seam` subpath export (which has no runtime
6
+ * condition — a runtime import of it fails module resolution by
7
+ * construction, enforcing "attach via the global only" mechanically). Both
8
+ * sides are create-if-absent on the same `Symbol.for` key, so mount order —
9
+ * hooks first or panel first — is irrelevant and pre-mount observations
10
+ * replay from the buffer.
11
+ */
12
+ export type { ParamourDevtoolsSeam, ParamourHookId, ParamourNavigate, ParamourObservation, ParamourObservationResult, ParamourParamsObservation, ParamourSearchObservation, ParamourSearchWire, } from "@paramour-js/next/devtools-seam";
13
+ /** Same realm-global registry key the hooks use; see the contract of record. */
14
+ export declare const SEAM_KEY: unique symbol;
15
+ /** The slot, created on first touch by whichever side runs first. */
16
+ export declare function getOrCreateSeam(): ParamourDevtoolsSeam;
package/dist/seam.js ADDED
@@ -0,0 +1,16 @@
1
+ /** Same realm-global registry key the hooks use; see the contract of record. */
2
+ export const SEAM_KEY = Symbol.for("paramour.devtools.seam");
3
+ const globalSlots = globalThis;
4
+ /** The slot, created on first touch by whichever side runs first. */
5
+ export function getOrCreateSeam() {
6
+ const existing = globalSlots[SEAM_KEY];
7
+ if (existing !== undefined)
8
+ return existing;
9
+ const created = {
10
+ buffer: [],
11
+ listeners: new Set(),
12
+ version: 1,
13
+ };
14
+ globalSlots[SEAM_KEY] = created;
15
+ return created;
16
+ }
@@ -0,0 +1,70 @@
1
+ import type { AnyRoute } from "paramour";
2
+ import type { ParamourNavigate, ParamourObservation } from "./seam.js";
3
+ /**
4
+ * The panel's session store: a module-level singleton shaped for
5
+ * `useSyncExternalStore`. It outlives panel mounts, so the session history
6
+ * survives closing/reopening the shell; it attaches to the seam on the
7
+ * FIRST subscriber (replaying the buffer) and detaches at zero.
8
+ */
9
+ /**
10
+ * Per-data-key counters, incremented when that key's PARSED value changes
11
+ * between observations — feeds the row-flash highlight. Kept per HALF: an
12
+ * App route may legally declare a search key named like a path param (the
13
+ * collision is forbidden only for pages routes), and a shared record would
14
+ * cross-flash the other table's row.
15
+ */
16
+ export interface ChangeStamps {
17
+ readonly params: Readonly<Record<string, number>>;
18
+ readonly search: Readonly<Record<string, number>>;
19
+ }
20
+ export interface DevtoolsSnapshot {
21
+ /** Session keys classified as the page currently on screen. */
22
+ readonly currentKeys: readonly string[];
23
+ /** Every route observed this session, first-observed order. */
24
+ readonly sessions: readonly RouteSession[];
25
+ }
26
+ /**
27
+ * One observed route, keyed `${routerKind}:${path}` rather than by object
28
+ * reference — HMR re-mints route objects on every save, and the composite
29
+ * key keeps one session across re-mints while the latest route object wins
30
+ * (its codecs are the live ones).
31
+ */
32
+ export interface RouteSession {
33
+ readonly changeStamps: ChangeStamps;
34
+ readonly key: string;
35
+ /**
36
+ * The NEWEST observation's navigate, regardless of half: the other half's
37
+ * closure can be older — its fingerprint may not have moved since — and
38
+ * edits must always route through the freshest resolution base.
39
+ */
40
+ readonly navigate: ParamourNavigate;
41
+ readonly params?: ParamourObservation;
42
+ /** The NEWEST observation's basePath-relative pathname. */
43
+ readonly pathname: string;
44
+ readonly route: AnyRoute;
45
+ readonly search?: ParamourObservation;
46
+ /** error if any half errored, else pending if any half pends, else ok. */
47
+ readonly status: "error" | "ok" | "pending";
48
+ }
49
+ export declare function getServerSnapshot(): DevtoolsSnapshot;
50
+ export declare function getSnapshot(): DevtoolsSnapshot;
51
+ /**
52
+ * The parsed record a change-stamp comparison keys on — and the route
53
+ * view's table data (shared so the two can never diverge): the success
54
+ * arm's data object; error/pending halves contribute nothing (a row that
55
+ * stops parsing shows status, not a flash).
56
+ */
57
+ export declare function parsedRecord(observation: ParamourObservation | undefined): Readonly<Record<string, unknown>> | undefined;
58
+ /**
59
+ * Test hook: drop all sessions, cached snapshots, and any live seam
60
+ * attachment (a component left mounted by a previous test must not pin the
61
+ * store to a stale seam). Not in the barrel.
62
+ */
63
+ export declare function resetStoreForTests(): void;
64
+ /**
65
+ * `useSyncExternalStore`-shaped subscribe. First subscriber: replay the
66
+ * seam buffer through the reducer, then attach the live seam listener and
67
+ * a `popstate` backup (a back/forward that changes no decode still moves
68
+ * `currentKeys`). Last unsubscribe detaches both.
69
+ */
70
+ export declare function subscribe(listener: () => void): () => void;
package/dist/store.js ADDED
@@ -0,0 +1,305 @@
1
+ import { matchesPathname } from "./match.js";
2
+ import { getOrCreateSeam } from "./seam.js";
3
+ const EMPTY_SNAPSHOT = { currentKeys: [], sessions: [] };
4
+ const state = {
5
+ consumed: new WeakSet(),
6
+ detach: undefined,
7
+ listeners: new Set(),
8
+ notifyScheduled: false,
9
+ prefix: undefined,
10
+ sessions: [],
11
+ snapshot: EMPTY_SNAPSHOT,
12
+ snapshotHref: "",
13
+ };
14
+ export function getServerSnapshot() {
15
+ return EMPTY_SNAPSHOT;
16
+ }
17
+ export function getSnapshot() {
18
+ return state.snapshot;
19
+ }
20
+ /**
21
+ * The parsed record a change-stamp comparison keys on — and the route
22
+ * view's table data (shared so the two can never diverge): the success
23
+ * arm's data object; error/pending halves contribute nothing (a row that
24
+ * stops parsing shows status, not a flash).
25
+ */
26
+ export function parsedRecord(observation) {
27
+ if (observation === undefined)
28
+ return undefined;
29
+ if (observation.result.status !== "success")
30
+ return undefined;
31
+ const { data } = observation.result;
32
+ if (typeof data !== "object" || data === null)
33
+ return undefined;
34
+ return data;
35
+ }
36
+ /**
37
+ * Test hook: drop all sessions, cached snapshots, and any live seam
38
+ * attachment (a component left mounted by a previous test must not pin the
39
+ * store to a stale seam). Not in the barrel.
40
+ */
41
+ export function resetStoreForTests() {
42
+ if (state.detach !== undefined) {
43
+ state.detach();
44
+ state.detach = undefined;
45
+ }
46
+ state.consumed = new WeakSet();
47
+ state.listeners.clear();
48
+ state.notifyScheduled = false;
49
+ state.prefix = undefined;
50
+ state.sessions = [];
51
+ state.snapshot = EMPTY_SNAPSHOT;
52
+ state.snapshotHref = "";
53
+ }
54
+ /**
55
+ * `useSyncExternalStore`-shaped subscribe. First subscriber: replay the
56
+ * seam buffer through the reducer, then attach the live seam listener and
57
+ * a `popstate` backup (a back/forward that changes no decode still moves
58
+ * `currentKeys`). Last unsubscribe detaches both.
59
+ */
60
+ export function subscribe(listener) {
61
+ if (state.listeners.size === 0)
62
+ attach();
63
+ state.listeners.add(listener);
64
+ return () => {
65
+ state.listeners.delete(listener);
66
+ if (state.listeners.size === 0 && state.detach !== undefined) {
67
+ state.detach();
68
+ state.detach = undefined;
69
+ }
70
+ };
71
+ }
72
+ function attach() {
73
+ const seam = getOrCreateSeam();
74
+ for (const observation of seam.buffer)
75
+ reduce(observation);
76
+ recomputeSnapshot();
77
+ const onObservation = (observation) => {
78
+ reduce(observation);
79
+ recomputeSnapshot();
80
+ // Hooks emit during render, so a synchronous notify here would
81
+ // setState the panel while the EMITTING component is still rendering —
82
+ // React's "cannot update a component while rendering" violation. The
83
+ // snapshot updates eagerly; only the React wake-up defers a microtask.
84
+ scheduleNotify();
85
+ };
86
+ const onPopstate = () => {
87
+ recomputeSnapshot();
88
+ notify();
89
+ };
90
+ // Next commits client navigations via history.pushState/replaceState —
91
+ // no popstate fires — and the hooks emit render-phase BEFORE that commit,
92
+ // so the emit-time recompute reads the OLD pathname. Wrapping both is the
93
+ // only signal that the URL actually moved; without it the freshly
94
+ // navigated page classifies as a stale snapshot (editing disabled) until
95
+ // some unrelated event.
96
+ // eslint-disable-next-line @typescript-eslint/unbound-method -- captured to restore on detach (identity preserved); only ever invoked via .call(window.history)
97
+ const { pushState: originalPushState, replaceState: originalReplaceState } = window.history;
98
+ const wrapCommit = (original) => (data, unused, url) => {
99
+ original.call(window.history, data, unused, url);
100
+ recomputeSnapshot();
101
+ // The commit runs inside React's own commit phase (an insertion
102
+ // effect) — defer the wake-up exactly like an observation's.
103
+ scheduleNotify();
104
+ };
105
+ const wrappedPushState = wrapCommit(originalPushState);
106
+ const wrappedReplaceState = wrapCommit(originalReplaceState);
107
+ window.history.pushState = wrappedPushState;
108
+ window.history.replaceState = wrappedReplaceState;
109
+ seam.listeners.add(onObservation);
110
+ window.addEventListener("popstate", onPopstate);
111
+ state.detach = () => {
112
+ seam.listeners.delete(onObservation);
113
+ window.removeEventListener("popstate", onPopstate);
114
+ // Restore ONLY what is still ours: a library that patched history
115
+ // AFTER the panel attached (lazy-loaded analytics, another devtools
116
+ // plugin) holds our wrapper inside its own — a blind restore of the
117
+ // attach-time functions would silently sever it for the rest of the
118
+ // tab session. Its patch keeps working either way: our wrapper stays
119
+ // in its chain and merely recomputes against a detached store.
120
+ if (window.history.pushState === wrappedPushState) {
121
+ window.history.pushState = originalPushState;
122
+ }
123
+ if (window.history.replaceState === wrappedReplaceState) {
124
+ window.history.replaceState = originalReplaceState;
125
+ }
126
+ };
127
+ }
128
+ /** A malformed escape in a URL the panel merely OBSERVES must not throw. */
129
+ function decodePathname(pathname) {
130
+ try {
131
+ return decodeURIComponent(pathname);
132
+ }
133
+ catch {
134
+ return pathname;
135
+ }
136
+ }
137
+ /**
138
+ * Is this session the page currently on screen? Two conditions: the live
139
+ * location must still correspond to the session's last OBSERVED pathname —
140
+ * a URL that moved with no fresh observation means the retained
141
+ * `navigate` belongs to a page no longer mounted, so pattern-matching the
142
+ * location alone would enable editing through a stale closure
143
+ * (`/product/1` → `/product/2` with no re-emit) — and the route's own
144
+ * pattern must match that observed pathname, so a session whose route
145
+ * object mismatches where its hook actually rendered never claims currency.
146
+ */
147
+ function isCurrent(locationPathname, session) {
148
+ return (locationCovers(locationPathname, session.pathname) &&
149
+ matchesPathname(session.route["~segments"], session.pathname));
150
+ }
151
+ /**
152
+ * Does the live location correspond to `observed`? Observed pathnames are
153
+ * basePath-/locale-relative while `window.location.pathname` carries the
154
+ * prefix, so the comparison is suffix-based — observed pathnames start
155
+ * with "/", which makes plain `endsWith` boundary-safe. Percent-encoding
156
+ * may differ between the two sources, so a failed raw comparison retries
157
+ * decoded. Trailing slashes are normalization noise.
158
+ */
159
+ function locationCovers(locationPathname, observed) {
160
+ const location = normalizePathname(locationPathname);
161
+ const target = normalizePathname(observed);
162
+ if (target === "/") {
163
+ // No suffix to align on: only a prefix learned from an earlier
164
+ // non-root match (or a prefix-less app) can verify a root observation.
165
+ return location === "/" || location === state.prefix;
166
+ }
167
+ if (location.endsWith(target)) {
168
+ state.prefix = location.slice(0, location.length - target.length);
169
+ return true;
170
+ }
171
+ const decodedLocation = decodePathname(location);
172
+ const decodedTarget = decodePathname(target);
173
+ if (decodedLocation.endsWith(decodedTarget)) {
174
+ state.prefix = decodedLocation.slice(0, decodedLocation.length - decodedTarget.length);
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ function normalizePathname(pathname) {
180
+ return pathname.length > 1 && pathname.endsWith("/")
181
+ ? pathname.slice(0, -1)
182
+ : pathname;
183
+ }
184
+ function notify() {
185
+ for (const listener of state.listeners)
186
+ listener();
187
+ }
188
+ function recomputeSnapshot() {
189
+ const { href, pathname } = window.location;
190
+ const currentKeys = state.sessions
191
+ .filter((session) => isCurrent(pathname, session))
192
+ .map((session) => session.key);
193
+ // Bail out when nothing observable moved: Next apps churn `replaceState`
194
+ // (scroll restoration etc.), and minting a fresh snapshot every commit
195
+ // defeats useSyncExternalStore's Object.is bailout — the whole panel
196
+ // would re-render per commit. `href` participates because the route view
197
+ // reads `location.href` (copy-URL) during render.
198
+ if (href === state.snapshotHref &&
199
+ state.snapshot.sessions === state.sessions &&
200
+ sameKeys(state.snapshot.currentKeys, currentKeys)) {
201
+ return;
202
+ }
203
+ state.snapshotHref = href;
204
+ // `reduce` reassigns `state.sessions` immutably, so the array can be
205
+ // shared with the snapshot by reference.
206
+ state.snapshot = { currentKeys, sessions: state.sessions };
207
+ }
208
+ function reduce(observation) {
209
+ // Attach-time replays walk the WHOLE buffer; anything the retained
210
+ // sessions already consumed must not re-apply.
211
+ if (state.consumed.has(observation))
212
+ return;
213
+ state.consumed.add(observation);
214
+ const key = `${observation.routerKind}:${observation.route.path}`;
215
+ const index = state.sessions.findIndex((session) => session.key === key);
216
+ const previous = index === -1 ? undefined : state.sessions[index];
217
+ const half = observation.kind === "params" ? "params" : "search";
218
+ const previousHalf = previous?.[half];
219
+ const halfStamps = { ...previous?.changeStamps[half] };
220
+ const before = parsedRecord(previousHalf);
221
+ const after = parsedRecord(observation);
222
+ if (after !== undefined) {
223
+ for (const [dataKey, value] of Object.entries(after)) {
224
+ const previousValue = before?.[dataKey];
225
+ if (before !== undefined && !sameValue(previousValue, value)) {
226
+ halfStamps[dataKey] = (halfStamps[dataKey] ?? 0) + 1;
227
+ }
228
+ }
229
+ }
230
+ const changeStamps = {
231
+ params: half === "params" ? halfStamps : (previous?.changeStamps.params ?? {}),
232
+ search: half === "search" ? halfStamps : (previous?.changeStamps.search ?? {}),
233
+ };
234
+ const merged = {
235
+ changeStamps,
236
+ key,
237
+ navigate: observation.navigate,
238
+ ...(half === "params"
239
+ ? { params: observation }
240
+ : previous?.params === undefined
241
+ ? {}
242
+ : { params: previous.params }),
243
+ pathname: observation.pathname,
244
+ route: observation.route,
245
+ ...(half === "search"
246
+ ? { search: observation }
247
+ : previous?.search === undefined
248
+ ? {}
249
+ : { search: previous.search }),
250
+ status: "ok",
251
+ };
252
+ const withStatus = { ...merged, status: statusOf(merged) };
253
+ if (index === -1) {
254
+ state.sessions = [...state.sessions, withStatus];
255
+ }
256
+ else {
257
+ state.sessions = state.sessions.map((session, sessionIndex) => sessionIndex === index ? withStatus : session);
258
+ }
259
+ }
260
+ function sameKeys(a, b) {
261
+ return a.length === b.length && a.every((key, index) => key === b[index]);
262
+ }
263
+ /** Cheap stable comparison: JSON with Date-safe replacer, sorted keys. */
264
+ function sameValue(a, b) {
265
+ return stableStringify(a) === stableStringify(b);
266
+ }
267
+ function scheduleNotify() {
268
+ if (state.notifyScheduled)
269
+ return;
270
+ state.notifyScheduled = true;
271
+ queueMicrotask(() => {
272
+ state.notifyScheduled = false;
273
+ notify();
274
+ });
275
+ }
276
+ function stableStringify(value) {
277
+ if (value === undefined)
278
+ return "undefined";
279
+ if (value instanceof Date)
280
+ return `date:${String(value.getTime())}`;
281
+ if (Array.isArray(value)) {
282
+ return `[${value.map((element) => stableStringify(element)).join(",")}]`;
283
+ }
284
+ if (typeof value === "object" && value !== null) {
285
+ const entries = Object.entries(value)
286
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
287
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
288
+ return `{${entries.join(",")}}`;
289
+ }
290
+ if (typeof value === "bigint")
291
+ return `${String(value)}n`;
292
+ if (typeof value === "function" || typeof value === "symbol") {
293
+ return `[${typeof value}]`;
294
+ }
295
+ return JSON.stringify(value);
296
+ }
297
+ function statusOf(session) {
298
+ const halves = [session.params, session.search];
299
+ if (halves.some((half) => half?.result.status === "error"))
300
+ return "error";
301
+ if (halves.some((half) => half?.result.status === "pending")) {
302
+ return "pending";
303
+ }
304
+ return "ok";
305
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The panel's entire stylesheet, rendered as an inline `<style>` element by
3
+ * the panel root — no stylesheet import, no CSS-in-JS dependency, zero
4
+ * runtime deps. All selectors are `.pmr-` prefixed, so duplicate injection
5
+ * (two panels) is harmless. CSP note: an inline `<style>` needs
6
+ * `style-src 'unsafe-inline'` or a nonce — acceptable for a dev-only tool,
7
+ * documented rather than solved in v1.
8
+ *
9
+ * Theming: the shell's `theme` prop lands as a `data-theme` attribute on
10
+ * `.pmr-root`; the custom-property token set below flips with it — no theme
11
+ * context, no remount. Violet/indigo accent is paramour's own; the four
12
+ * status colors follow devtool traffic-light convention. Density and the
13
+ * monospace wire stack keep the tables scannable; the only motion is the
14
+ * ~400ms row flash plus the copy-button glyph swap.
15
+ */
16
+ export declare const PANEL_CSS = "\n.pmr-root {\n --pmr-accent: #6d5ae6;\n --pmr-accent-soft: #6d5ae62e;\n --pmr-bg: #ffffff;\n --pmr-bg-raised: #f6f5fb;\n --pmr-border: #e2e0ef;\n --pmr-flash-duration: 400ms;\n --pmr-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n --pmr-font-ui: system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --pmr-status-error: #e5484d;\n --pmr-status-ok: #30a46c;\n --pmr-status-pending: #f5a524;\n --pmr-status-stale: #8f8f8f;\n --pmr-text: #232130;\n --pmr-text-muted: #6f6c85;\n\n background: var(--pmr-bg);\n color: var(--pmr-text);\n display: flex;\n font-family: var(--pmr-font-ui);\n font-size: 12px;\n height: 100%;\n line-height: 1.45;\n min-height: 0;\n overflow: hidden;\n width: 100%;\n}\n.pmr-root[data-theme=\"dark\"] {\n --pmr-accent: #8b7cf0;\n --pmr-accent-soft: #8b7cf03d;\n --pmr-bg: #17151f;\n --pmr-bg-raised: #201d2b;\n --pmr-border: #322e42;\n --pmr-status-error: #ff6369;\n --pmr-status-ok: #3dd68c;\n --pmr-status-pending: #ffb224;\n --pmr-status-stale: #7c7c7c;\n --pmr-text: #eceaf4;\n --pmr-text-muted: #9b97ad;\n}\n\n.pmr-sidebar {\n border-right: 1px solid var(--pmr-border);\n flex: 0 0 190px;\n overflow-y: auto;\n padding: 6px 0;\n}\n.pmr-sidebar-entry {\n align-items: center;\n background: none;\n border: none;\n color: var(--pmr-text);\n cursor: pointer;\n display: flex;\n font: inherit;\n gap: 6px;\n padding: 4px 10px;\n text-align: left;\n width: 100%;\n}\n.pmr-sidebar-entry:hover {\n background: var(--pmr-bg-raised);\n}\n.pmr-sidebar-entry[data-selected=\"true\"] {\n background: var(--pmr-accent-soft);\n}\n.pmr-sidebar-entry[data-stale=\"true\"] {\n opacity: 0.55;\n}\n.pmr-sidebar-path {\n font-family: var(--pmr-font-mono);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.pmr-main {\n flex: 1 1 auto;\n min-width: 0;\n overflow-y: auto;\n padding: 8px 12px 16px;\n}\n\n.pmr-banner {\n align-items: center;\n background: var(--pmr-bg-raised);\n border: 1px solid var(--pmr-border);\n border-radius: 6px;\n display: flex;\n gap: 8px;\n margin: 8px 0 6px;\n padding: 6px 10px;\n}\n.pmr-banner-path {\n font-family: var(--pmr-font-mono);\n font-size: 13px;\n font-weight: 600;\n}\n.pmr-badge {\n border: 1px solid var(--pmr-border);\n border-radius: 999px;\n color: var(--pmr-text-muted);\n font-size: 10px;\n padding: 0 6px;\n text-transform: uppercase;\n}\n.pmr-stale-badge {\n color: var(--pmr-status-stale);\n font-size: 10px;\n text-transform: uppercase;\n}\n\n.pmr-dot {\n align-items: center;\n display: inline-flex;\n flex: none;\n gap: 3px;\n}\n.pmr-dot::before {\n border-radius: 50%;\n content: \"\";\n display: inline-block;\n height: 8px;\n width: 8px;\n}\n.pmr-dot--error::before { background: var(--pmr-status-error); }\n.pmr-dot--ok::before { background: var(--pmr-status-ok); }\n.pmr-dot--pending::before { background: var(--pmr-status-pending); }\n.pmr-dot--stale::before { background: var(--pmr-status-stale); }\n.pmr-dot-glyph {\n font-size: 10px;\n}\n.pmr-dot--error .pmr-dot-glyph { color: var(--pmr-status-error); }\n.pmr-dot--ok .pmr-dot-glyph { color: var(--pmr-status-ok); }\n.pmr-dot--pending .pmr-dot-glyph { color: var(--pmr-status-pending); }\n.pmr-dot--stale .pmr-dot-glyph { color: var(--pmr-status-stale); }\n\n.pmr-section-title {\n color: var(--pmr-text-muted);\n font-size: 10px;\n font-weight: 600;\n letter-spacing: 0.08em;\n margin: 10px 0 2px;\n text-transform: uppercase;\n}\n.pmr-table {\n border-collapse: collapse;\n width: 100%;\n}\n.pmr-table th {\n border-bottom: 1px solid var(--pmr-border);\n color: var(--pmr-text-muted);\n font-size: 10px;\n font-weight: 600;\n padding: 2px 8px 2px 0;\n text-align: left;\n text-transform: uppercase;\n}\n.pmr-table td {\n border-bottom: 1px solid var(--pmr-border);\n height: 22px;\n padding: 2px 8px 2px 0;\n vertical-align: middle;\n}\n.pmr-mono {\n font-family: var(--pmr-font-mono);\n}\n.pmr-muted {\n color: var(--pmr-text-muted);\n}\n@keyframes pmr-flash {\n from { background-color: var(--pmr-accent-soft); }\n to { background-color: transparent; }\n}\n.pmr-flash {\n animation: pmr-flash var(--pmr-flash-duration) ease-out;\n}\n.pmr-tag {\n border: 1px solid var(--pmr-border);\n border-radius: 3px;\n color: var(--pmr-text-muted);\n font-size: 10px;\n padding: 0 4px;\n}\n\n.pmr-input,\n.pmr-select,\n.pmr-textarea {\n background: var(--pmr-bg);\n border: 1px solid var(--pmr-border);\n border-radius: 4px;\n color: var(--pmr-text);\n font-family: var(--pmr-font-mono);\n font-size: 11px;\n max-width: 180px;\n padding: 1px 4px;\n width: 100%;\n}\n.pmr-textarea {\n min-height: 34px;\n resize: vertical;\n}\n.pmr-input:focus,\n.pmr-select:focus,\n.pmr-textarea:focus {\n border-color: var(--pmr-accent);\n outline: none;\n}\n.pmr-icon-button {\n background: none;\n border: 1px solid var(--pmr-border);\n border-radius: 4px;\n color: var(--pmr-text-muted);\n cursor: pointer;\n font: inherit;\n font-size: 10px;\n padding: 1px 6px;\n}\n.pmr-icon-button:hover {\n border-color: var(--pmr-accent);\n color: var(--pmr-text);\n}\n.pmr-icon-button[data-active=\"true\"] {\n border-color: var(--pmr-accent);\n color: var(--pmr-accent);\n}\n.pmr-preview {\n font-family: var(--pmr-font-mono);\n font-size: 10px;\n}\n.pmr-preview--error {\n color: var(--pmr-status-error);\n}\n.pmr-preview--ok {\n color: var(--pmr-text-muted);\n}\n\n.pmr-issues {\n border: 1px solid var(--pmr-status-error);\n border-radius: 6px;\n margin-top: 10px;\n padding: 6px 10px;\n}\n.pmr-issues .pmr-section-title {\n color: var(--pmr-status-error);\n margin-top: 0;\n}\n\n.pmr-empty {\n color: var(--pmr-text-muted);\n font-style: italic;\n margin: auto;\n padding: 24px;\n text-align: center;\n}\n\n.pmr-toolbar {\n display: flex;\n gap: 4px;\n margin-left: auto;\n}\n";