diff --git a/packages/entity-graph-react/src/devtools/components/command-feedback.test.tsx b/packages/entity-graph-react/src/devtools/components/command-feedback.test.tsx new file mode 100644 index 00000000..e631222c --- /dev/null +++ b/packages/entity-graph-react/src/devtools/components/command-feedback.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CommandFeedback } from "./command-feedback"; + +/** + * One element used to flip between role="alert" and role="status" (with aria-live="polite" on + * both, which contradicts alert's implicit assertive live region). Assistive tech does not + * reliably announce a role change, so notices and errors now live in two stable regions. + */ +describe("CommandFeedback", () => { + afterEach(cleanup); + + it("announces pending work and notices in a polite status region", () => { + render( {}} />); + const status = screen.getByRole("status"); + expect(status.getAttribute("aria-live")).toBe("polite"); + expect(status.textContent).toContain("Rewinding"); + expect(screen.getByRole("alert").textContent).toBe(""); + }); + + it("announces errors in an alert that is always present in the tree", () => { + const { rerender } = render( {}} />); + expect(screen.getByRole("alert").textContent).toBe(""); + rerender( {}} />); + expect(screen.getByRole("alert").textContent).toContain("Restore refused"); + expect(screen.getByRole("status").textContent).toBe(""); + }); + + it("offers a dismiss control only when there is something to dismiss", () => { + const onDismiss = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull(); + rerender(); + fireEvent.click(screen.getByRole("button", { name: /dismiss/i })); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/entity-graph-react/src/devtools/components/command-feedback.tsx b/packages/entity-graph-react/src/devtools/components/command-feedback.tsx new file mode 100644 index 00000000..944b96af --- /dev/null +++ b/packages/entity-graph-react/src/devtools/components/command-feedback.tsx @@ -0,0 +1,29 @@ +export interface CommandFeedbackProps { + readonly pending: string | null; + readonly error: string | null; + readonly notice: string | null; + readonly onDismiss: () => void; +} + +/** + * Two stable live regions: a polite status region for progress and notices, and an alert for + * errors. Both stay mounted so assistive technology announces content changes; a single element + * that swaps its role between "status" and "alert" is not announced reliably. + */ +export function CommandFeedback({ pending, error, notice, onDismiss }: CommandFeedbackProps) { + const state = error ? "error" : notice ? "success" : pending ? "pending" : "idle"; + return ( +
+
+ {pending && !error && Working: {pending}…} + {notice && !error && {notice}} +
+
+ {error && {error}} +
+ {(error || notice) && ( + + )} +
+ ); +} diff --git a/packages/entity-graph-react/src/devtools/components/graph-pulse.test.tsx b/packages/entity-graph-react/src/devtools/components/graph-pulse.test.tsx new file mode 100644 index 00000000..ce97b5ca --- /dev/null +++ b/packages/entity-graph-react/src/devtools/components/graph-pulse.test.tsx @@ -0,0 +1,22 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { GraphPulse } from "./graph-pulse"; + +describe("GraphPulse", () => { + afterEach(cleanup); + + it("does not reference the segment list while collapsed, and does once expanded", () => { + const { rerender } = render( + {}} onSelect={() => {}} />, + ); + const toggle = screen.getByRole("button", { name: /graph pulse/i }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(toggle.hasAttribute("aria-controls")).toBe(false); + + rerender( {}} onSelect={() => {}} />); + const controls = toggle.getAttribute("aria-controls"); + expect(controls).toBeTruthy(); + expect(document.getElementById(controls!)).not.toBeNull(); + }); +}); diff --git a/packages/entity-graph-react/src/devtools/components/graph-pulse.tsx b/packages/entity-graph-react/src/devtools/components/graph-pulse.tsx index af4e0880..6c4de684 100644 --- a/packages/entity-graph-react/src/devtools/components/graph-pulse.tsx +++ b/packages/entity-graph-react/src/devtools/components/graph-pulse.tsx @@ -24,7 +24,7 @@ export function GraphPulse({ type="button" className="pem-pulse-toggle" aria-expanded={!collapsed} - aria-controls="pem-pulse-segments" + aria-controls={collapsed ? undefined : "pem-pulse-segments"} onClick={onToggleCollapsed} > Graph Pulse diff --git a/packages/entity-graph-react/src/devtools/components/value-inspector.test.tsx b/packages/entity-graph-react/src/devtools/components/value-inspector.test.tsx new file mode 100644 index 00000000..2477557b --- /dev/null +++ b/packages/entity-graph-react/src/devtools/components/value-inspector.test.tsx @@ -0,0 +1,51 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ENTITY_GRAPH_DEVTOOLS_STYLES } from "../styles"; +import { InspectorDiff } from "./value-inspector"; + +/** + * The devtools stylesheet is a string injected into a Shadow DOM, so jsdom never applies it. + * This test instead proves the contract between markup and stylesheet: every rule that styles a + * diff-row cell must actually match the cells InspectorDiff renders. Before the fix the rules + * targeted `.pem-diff-row > span` while body cells were ``, so body rows rendered with no + * padding, no mono font, no wrapping and no change colour. + */ +function cellSelectors(): string[] { + const selectors = new Set(); + for (const match of ENTITY_GRAPH_DEVTOOLS_STYLES.matchAll(/^([^{}]*\.pem-diff-row[^{}]*)\{/gm)) { + for (const selector of match[1].split(",")) { + const trimmed = selector.trim(); + if (trimmed.includes(">")) selectors.add(trimmed); + } + } + return [...selectors]; +} + +describe("InspectorDiff markup matches the stylesheet", () => { + const rows = [ + { path: "status", kind: "changed", original: "pending", live: "approved" }, + { path: "owner", kind: "added", original: undefined, live: "ada" }, + ] as const; + + it("styles every body cell, not only the header cells", () => { + const { container } = render(); + const bodyRows = [...container.querySelectorAll('.pem-diff-row:not(.pem-diff-head)')]; + expect(bodyRows).toHaveLength(2); + const cellRules = cellSelectors().filter((selector) => !selector.includes("[data-kind")); + expect(cellRules.length).toBeGreaterThan(0); + for (const row of bodyRows) { + for (const cell of row.children) { + expect(cellRules.some((selector) => cell.matches(selector)), `${cell.tagName} matches ${cellRules.join(" | ")}`).toBe(true); + } + } + }); + + it("applies the change colour rule to the field cell of changed rows", () => { + const { container } = render(); + const changedField = container.querySelector('.pem-diff-row[data-kind="changed"] > :first-child'); + const changeRules = cellSelectors().filter((selector) => selector.includes('[data-kind="changed"]')); + expect(changeRules.length).toBeGreaterThan(0); + expect(changeRules.some((selector) => changedField?.matches(selector))).toBe(true); + }); +}); diff --git a/packages/entity-graph-react/src/devtools/components/virtual-list.test.tsx b/packages/entity-graph-react/src/devtools/components/virtual-list.test.tsx new file mode 100644 index 00000000..9abeb7ad --- /dev/null +++ b/packages/entity-graph-react/src/devtools/components/virtual-list.test.tsx @@ -0,0 +1,29 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { InspectorVirtualList } from "./virtual-list"; +import { ENTITY_GRAPH_DEVTOOLS_STYLES } from "../styles"; + +describe("InspectorVirtualList", () => { + afterEach(cleanup); + + it("passes the item index to renderItem so rows never search the array for their position", () => { + const items = ["a", "b", "c"]; + render( + item} + ariaLabel="letters" + renderItem={(item, index) => {`${index + 1}:${item}`}} + />, + ); + expect(screen.getByRole("list", { name: "letters" }).textContent).toBe("1:a2:b3:c"); + }); + + it("bounds the membership scroll list so the virtualizer has a viewport", () => { + // jsdom applies no Shadow DOM stylesheet, so assert the rule exists in the sheet itself. + const rule = ENTITY_GRAPH_DEVTOOLS_STYLES.match(/\.pem-membership\s+\.pem-scroll-list\s*\{([^}]*)\}/); + expect(rule, "a .pem-membership .pem-scroll-list rule").not.toBeNull(); + expect(rule?.[1]).toMatch(/max-height\s*:/); + }); +}); diff --git a/packages/entity-graph-react/src/devtools/components/virtual-list.tsx b/packages/entity-graph-react/src/devtools/components/virtual-list.tsx index 30c3c934..4ed8065a 100644 --- a/packages/entity-graph-react/src/devtools/components/virtual-list.tsx +++ b/packages/entity-graph-react/src/devtools/components/virtual-list.tsx @@ -7,7 +7,7 @@ export interface InspectorVirtualListProps { estimateSize?: number; ariaLabel: string; className?: string; - renderItem(item: T): ReactNode; + renderItem(item: T, index: number): ReactNode; } export function InspectorVirtualList({ @@ -35,7 +35,7 @@ export function InspectorVirtualList({ if (items.length <= 50) { return (
- {items.map((item) =>
{renderItem(item)}
)} + {items.map((item, index) =>
{renderItem(item, index)}
)}
); } @@ -54,7 +54,7 @@ export function InspectorVirtualList({ className="pem-virtual-row" style={{ transform: `translateY(${virtualItem.start}px)` }} > - {renderItem(item)} + {renderItem(item, virtualItem.index)} ); })} diff --git a/packages/entity-graph-react/src/devtools/event-format.test.ts b/packages/entity-graph-react/src/devtools/event-format.test.ts new file mode 100644 index 00000000..d2bb6e22 --- /dev/null +++ b/packages/entity-graph-react/src/devtools/event-format.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import type { GraphDevtoolsChange, GraphDevtoolsEvent } from "@prometheus-ags/entity-graph-core/devtools"; + +import { eventCorrelationLabel, eventTitle } from "./event-format"; + +type MutationEvent = Extract; + +const counts = { + entityTypes: 1, entities: 1, patchedEntities: 0, entityStates: 0, syncMetadata: 0, + lists: 0, listMemberships: 0, fetching: 0, stale: 0, errors: 0, +}; + +function mutation(changes: GraphDevtoolsChange[], affected?: MutationEvent["payload"]["affectedEntities"]): MutationEvent { + return { + protocol: "prometheus.entity-graph.devtools", + version: 1, + storeId: "graph-1", + eventId: "graph-1:7", + sequence: 7, + correlationId: "graph-1:7", + observedAt: "2026-10-08T12:00:00.000Z", + type: "mutation", + payload: { + snapshot: { status: "retained", cursor: 3, eventSequence: 7, bytes: 10, capturedAt: "2026-10-08T12:00:00.000Z" }, + changes, + ...(affected ? { affectedEntities: affected } : {}), + before: counts, + after: counts, + projectionDurationMs: 0.5, + valuesTruncated: false, + changesOmitted: 0, + }, + } as MutationEvent; +} + +describe("eventTitle for mutations", () => { + it("names the first affected identity and its patch operation, listing fields when values are included", () => { + const event = mutation([ + { category: "patch", action: "added", key: "Order", id: "o-1042", valueState: "included", after: { status: "paid" } }, + ]); + expect(eventTitle(event)).toBe("Order/o-1042 · patch status"); + }); + + it("falls back to a bare patch operation under the metadata-only policy", () => { + const event = mutation([ + { category: "patch", action: "updated", key: "Order", id: "o-1042", valueState: "hidden-by-policy" }, + ]); + expect(eventTitle(event)).toBe("Order/o-1042 · patch"); + }); + + it("describes entity upserts and removals", () => { + expect(eventTitle(mutation([ + { category: "entity", action: "updated", key: "Order", id: "o-1042", valueState: "hidden-by-policy" }, + ]))).toBe("Order/o-1042 · upsert"); + expect(eventTitle(mutation([ + { category: "entity", action: "removed", key: "Order", id: "o-1042", valueState: "hidden-by-policy" }, + ]))).toBe("Order/o-1042 · remove"); + }); + + it("counts the other affected identities", () => { + const event = mutation([ + { category: "entity", action: "added", key: "Order", id: "o-1", valueState: "hidden-by-policy" }, + { category: "entity", action: "added", key: "Order", id: "o-2", valueState: "hidden-by-policy" }, + { category: "entity", action: "added", key: "Order", id: "o-3", valueState: "hidden-by-policy" }, + ]); + expect(eventTitle(event)).toBe("Order/o-1 · upsert · +2 more"); + }); + + it("keeps the change-count form only when no identity is known", () => { + expect(eventTitle(mutation([ + { category: "list", action: "updated", key: "orders:all", valueState: "hidden-by-policy", beforeCount: 1, afterCount: 2 }, + ]))).toBe("1 graph change"); + expect(eventTitle(mutation([ + { category: "list", action: "updated", key: "a", valueState: "hidden-by-policy" }, + { category: "sync", action: "updated", key: "b", valueState: "hidden-by-policy" }, + ]))).toBe("2 graph changes"); + }); +}); + +describe("eventCorrelationLabel", () => { + it("shows the last colon-separated segment rather than the store prefix", () => { + expect(eventCorrelationLabel(mutation([]))).toBe("7"); + expect(eventCorrelationLabel({ ...mutation([]), correlationId: "store:req:abc123" })).toBe("abc123"); + }); +}); diff --git a/packages/entity-graph-react/src/devtools/event-format.ts b/packages/entity-graph-react/src/devtools/event-format.ts index 6f36eb37..27f8cc45 100644 --- a/packages/entity-graph-react/src/devtools/event-format.ts +++ b/packages/entity-graph-react/src/devtools/event-format.ts @@ -1,4 +1,5 @@ -import type { GraphDevtoolsEvent } from "@prometheus-ags/entity-graph-core/devtools"; +import type { GraphDevtoolsChange, GraphDevtoolsEvent } from "@prometheus-ags/entity-graph-core/devtools"; +import { affectedEntitiesForEvent } from "./causality"; const timeFormatter = new Intl.DateTimeFormat(undefined, { hour: "2-digit", @@ -11,12 +12,50 @@ export function formatEventTime(event: GraphDevtoolsEvent): string { return timeFormatter.format(new Date(event.observedAt)); } +function patchedFieldNames(change: GraphDevtoolsChange): string[] { + const after = change.after; + return typeof after === "object" && after !== null && !Array.isArray(after) + ? Object.keys(after) + : []; +} + +/** Human operation for the change that targets one identity, e.g. "patch status" or "upsert". */ +function changeOperation(change: GraphDevtoolsChange | undefined): string { + if (!change) return "change"; + if (change.category === "patch") { + if (change.action === "removed") return "clear patch"; + const fields = patchedFieldNames(change); + return fields.length > 0 ? `patch ${fields.join(", ")}` : "patch"; + } + if (change.category === "entity") return change.action === "removed" ? "remove" : "upsert"; + return change.category === "entity-state" ? "state" : change.category; +} + +function mutationTitle(event: Extract): string { + const affected = affectedEntitiesForEvent(event); + const first = affected[0]; + if (!first) { + const count = event.payload.changes.length; + return count === 1 ? "1 graph change" : `${count} graph changes`; + } + const targeting = event.payload.changes.filter((change) => change.key === first.type && change.id === first.id); + const change = targeting.find((candidate) => candidate.category === "patch") + ?? targeting.find((candidate) => candidate.category === "entity") + ?? targeting[0]; + const more = affected.length - 1; + return `${first.type}/${first.id} · ${changeOperation(change)}${more > 0 ? ` · +${more} more` : ""}`; +} + +/** The request-scoped suffix of a correlation id; the prefix is only the store id. */ +export function eventCorrelationLabel(event: Pick): string { + const segments = event.correlationId.split(":"); + return segments[segments.length - 1] || event.correlationId; +} + export function eventTitle(event: GraphDevtoolsEvent): string { switch (event.type) { - case "mutation": { - const count = event.payload.changes.length; - return count === 1 ? "1 graph change" : `${count} graph changes`; - } + case "mutation": + return mutationTitle(event); case "view": return `${event.payload.state.replaceAll("-", " ")} · ${event.payload.viewId}`; case "time-travel": diff --git a/packages/entity-graph-react/src/devtools/host.tsx b/packages/entity-graph-react/src/devtools/host.tsx index 57df9203..9479d976 100644 --- a/packages/entity-graph-react/src/devtools/host.tsx +++ b/packages/entity-graph-react/src/devtools/host.tsx @@ -24,6 +24,7 @@ import { DEFAULT_ENTITY_GRAPH_DEVTOOLS_SHORTCUT, ENTITY_GRAPH_DEVTOOLS_PREFERENCE_KEY, entityGraphDevtoolsAriaShortcut, + isEditableShortcutTarget, matchesEntityGraphDevtoolsShortcut, readEntityGraphDevtoolsPreferences, writeEntityGraphDevtoolsPreferences, @@ -190,13 +191,14 @@ function EntityGraphDevtoolsSurface({ useEffect(() => { if (!resolvedShortcut) return; const onKeyDown = (event: KeyboardEvent) => { + if (isEditableShortcutTarget(event.target)) return; if (!matchesEntityGraphDevtoolsShortcut(event, resolvedShortcut)) return; event.preventDefault(); if (panelOpen && visible) closePanel(); else openPanel(); }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); }, [closePanel, openPanel, panelOpen, resolvedShortcut, visible]); useEffect(() => { diff --git a/packages/entity-graph-react/src/devtools/inspector-shell.test.tsx b/packages/entity-graph-react/src/devtools/inspector-shell.test.tsx new file mode 100644 index 00000000..6cbcaf22 --- /dev/null +++ b/packages/entity-graph-react/src/devtools/inspector-shell.test.tsx @@ -0,0 +1,249 @@ +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { createGraphStore, type GraphStore } from "@prometheus-ags/entity-graph-core"; + +import { EntityGraphDevtoolsProvider } from "./provider"; +import { EntityGraphInspectorShell } from "./inspector-shell"; +import type { EntityGraphInspectorStateAdapter } from "./state"; + +/** Let the controller publish and the model store flush its animation-frame projection. */ +const settle = () => act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); +}); + +function seedStore({ dirty = true, error = false } = {}): GraphStore { + const store = createGraphStore(); + const state = store.getState(); + state.upsertEntity("Order", "o-1", { id: "o-1", status: "new", total: 1 }); + state.upsertEntity("Order", "o-2", { id: "o-2", status: "new", total: 2 }); + state.upsertEntity("Order", "o-3", { id: "o-3", status: "new", total: 3 }); + if (dirty) state.patchEntity("Order", "o-2", { status: "paid", total: 9 }); + if (error) state.setEntityError("Order", "o-3", "fetch failed"); + return store; +} + +async function renderInspector(store: GraphStore, stateAdapter?: EntityGraphInspectorStateAdapter) { + const view = render( + + + , + ); + await settle(); + return view; +} + +const shell = () => document.querySelector(".pem-inspector") as HTMLElement; +const workspaceTab = (label: RegExp) => + within(document.querySelector(".pem-workspace-tabs") as HTMLElement).getByRole("tab", { name: label }); +const entitiesWorkspace = () => document.querySelector(".pem-entity-workspace") as HTMLElement; +const activityWorkspace = () => document.querySelector(".pem-activity-workspace") as HTMLElement; +const pressedFilter = () => + (entitiesWorkspace().querySelector('.pem-filter-row button[aria-pressed="true"]') as HTMLElement).textContent; +const selectedEntityRow = () => + entitiesWorkspace().querySelector('.pem-entity-row[data-selected="true"]') as HTMLElement | null; +const selectedValueTab = () => + (entitiesWorkspace().querySelector('.pem-value-tabs [role="tab"][aria-selected="true"]') as HTMLElement).textContent; + +async function rewindThroughActivity(pick: "baseline" | "latest" = "latest") { + fireEvent.click(workspaceTab(/^Activity/)); + const select = activityWorkspace().querySelector(".pem-time-travel select") as HTMLSelectElement; + const option = pick === "baseline" ? select.options[0] : select.options[select.options.length - 1]; + fireEvent.change(select, { target: { value: option.value } }); + fireEvent.click(within(activityWorkspace()).getByRole("button", { name: "Rewind graph" })); + await settle(); +} + +describe("inspector landing defaults", () => { + afterEach(cleanup); + + it("opens on Entities with the dirty filter, the first dirty entity and the diff tab when something is dirty", async () => { + await renderInspector(seedStore({ dirty: true })); + expect(workspaceTab(/^Entities/).getAttribute("aria-selected")).toBe("true"); + expect(pressedFilter()).toBe("dirty"); + expect(selectedEntityRow()?.getAttribute("title")).toBe("Order / o-2"); + expect(selectedValueTab()).toMatch(/^diff/); + }); + + it("keeps Overview and the original tab for a clean snapshot", async () => { + await renderInspector(seedStore({ dirty: false })); + expect(workspaceTab(/^Overview/).getAttribute("aria-selected")).toBe("true"); + expect(pressedFilter()).toBe("all"); + expect(selectedValueTab()).toBe("original"); + }); + + it("lets an explicit value-tab choice win over the dirty default", async () => { + const store = seedStore({ dirty: true }); + await renderInspector(store); + fireEvent.click(within(entitiesWorkspace()).getByRole("tab", { name: "original" })); + expect(selectedValueTab()).toBe("original"); + act(() => { store.getState().patchEntity("Order", "o-2", { note: "x" }); }); + await settle(); + expect(selectedValueTab()).toBe("original"); + }); + + it("does not re-land once the user has moved on, even when new entities turn dirty", async () => { + const store = seedStore({ dirty: false }); + await renderInspector(store); + fireEvent.click(workspaceTab(/^Views/)); + act(() => { store.getState().patchEntity("Order", "o-1", { status: "late" }); }); + await settle(); + expect(workspaceTab(/^Views/).getAttribute("aria-selected")).toBe("true"); + expect(pressedFilter()).toBe("all"); + }); + + it("respects a host state adapter that names a workspace", async () => { + const adapter: EntityGraphInspectorStateAdapter = { + read: () => ({ version: 1, workspace: "overview" }), + write: () => undefined, + }; + await renderInspector(seedStore({ dirty: true }), adapter); + expect(workspaceTab(/^Overview/).getAttribute("aria-selected")).toBe("true"); + expect(pressedFilter()).toBe("all"); + }); +}); + +describe("actionable status", () => { + afterEach(cleanup); + + it("hides the errors chip at zero and exposes dirty/error chips as buttons", async () => { + await renderInspector(seedStore({ dirty: true, error: false })); + const status = document.querySelector(".pem-shell-status") as HTMLElement; + expect(status.textContent).not.toMatch(/errors/); + const dirtyChip = within(status).getByRole("button", { name: /1 dirty/ }); + expect(dirtyChip.getAttribute("data-tone")).toBe("dirty"); + }); + + it("switches to Entities filtered by errors with the first erroring entity selected from the header chip", async () => { + await renderInspector(seedStore({ dirty: false, error: true })); + const status = document.querySelector(".pem-shell-status") as HTMLElement; + const errorsChip = within(status).getByRole("button", { name: /1 errors/ }); + expect(errorsChip.getAttribute("data-tone")).toBe("error"); + fireEvent.click(errorsChip); + expect(workspaceTab(/^Entities/).getAttribute("aria-selected")).toBe("true"); + expect(pressedFilter()).toBe("errors"); + expect(selectedEntityRow()?.getAttribute("title")).toBe("Order / o-3"); + }); + + it("makes the Overview Dirty and Errors metrics buttons that focus the matching entities", async () => { + const store = seedStore({ dirty: true, error: true }); + await renderInspector(store); + fireEvent.click(workspaceTab(/^Overview/)); + const metrics = document.querySelector(".pem-metric-grid") as HTMLElement; + const dirtyMetric = within(metrics).getByRole("button", { name: /Dirty/ }); + expect(dirtyMetric.getAttribute("data-tone")).toBe("dirty"); + const errorsMetric = within(metrics).getByRole("button", { name: /Errors/ }); + expect(errorsMetric.getAttribute("data-tone")).toBe("error"); + expect(within(metrics).queryByRole("button", { name: /^Entities/ })).toBeNull(); + + fireEvent.click(errorsMetric); + expect(workspaceTab(/^Entities/).getAttribute("aria-selected")).toBe("true"); + expect(pressedFilter()).toBe("errors"); + expect(selectedEntityRow()?.getAttribute("title")).toBe("Order / o-3"); + + fireEvent.click(workspaceTab(/^Overview/)); + fireEvent.click(dirtyMetric); + expect(pressedFilter()).toBe("dirty"); + expect(selectedEntityRow()?.getAttribute("title")).toBe("Order / o-2"); + }); +}); + +describe("entity detail summary", () => { + afterEach(cleanup); + + it("summarises locally patched fields in one line", async () => { + await renderInspector(seedStore({ dirty: true })); + const summary = entitiesWorkspace().querySelector(".pem-dirty-summary") as HTMLElement; + expect(summary.textContent).toBe("2 fields locally patched · status, +1 more"); + }); + + it("uses the singular form and omits the line for clean entities", async () => { + const store = seedStore({ dirty: false }); + store.getState().patchEntity("Order", "o-1", { status: "late" }); + await renderInspector(store); + expect((entitiesWorkspace().querySelector(".pem-dirty-summary") as HTMLElement).textContent) + .toBe("1 field locally patched · status"); + fireEvent.click(within(entitiesWorkspace()).getByRole("button", { name: "all" })); + fireEvent.click(within(entitiesWorkspace()).getByTitle("Order / o-3")); + expect(entitiesWorkspace().querySelector(".pem-dirty-summary")).toBeNull(); + }); +}); + +describe("activity copy", () => { + afterEach(cleanup); + + it("shows the correlation suffix instead of the store prefix and drops the duplicate Observed row", async () => { + const store = seedStore({ dirty: false }); + await renderInspector(store); + act(() => { store.getState().upsertEntity("Order", "o-1", { id: "o-1", status: "shipped", total: 1 }); }); + await settle(); + fireEvent.click(workspaceTab(/^Activity/)); + const rows = activityWorkspace().querySelectorAll(".pem-event-row"); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + const correlation = (row.querySelector(".pem-correlation") as HTMLElement).textContent ?? ""; + expect(correlation).not.toContain("graph-1"); + expect(correlation).toMatch(/^\d+$/); + } + expect((rows[0] as HTMLElement).textContent).toContain("Order/o-1 · upsert"); + const terms = [...activityWorkspace().querySelectorAll(".pem-event-readouts dt")].map((dt) => dt.textContent); + expect(terms).not.toContain("Observed"); + }); +}); + +describe("rewound state", () => { + afterEach(cleanup); + + it("frames the inspector, shows a sticky bar in every workspace and returns to live from it", async () => { + const store = seedStore({ dirty: false }); + await renderInspector(store); + act(() => { store.getState().upsertEntity("Order", "o-1", { id: "o-1", status: "shipped", total: 1 }); }); + await settle(); + expect(shell().getAttribute("data-rewound")).toBe("false"); + expect(document.querySelector(".pem-rewound-bar")).toBeNull(); + + await rewindThroughActivity(); + expect(shell().getAttribute("data-rewound")).toBe("true"); + const bar = shell().querySelector(":scope > .pem-rewound-bar") as HTMLElement; + expect(bar.textContent).toMatch(/^Viewing snapshot \d+ \(event \d+\) · /); + + fireEvent.click(workspaceTab(/^Overview/)); + expect(shell().querySelector(":scope > .pem-rewound-bar")).not.toBeNull(); + + fireEvent.click(within(bar).getByRole("button", { name: "Return to live" })); + await settle(); + expect(shell().getAttribute("data-rewound")).toBe("false"); + expect(document.querySelector(".pem-rewound-bar")).toBeNull(); + }); + + it("omits the event reference for the baseline snapshot", async () => { + const store = seedStore({ dirty: false }); + await renderInspector(store); + act(() => { store.getState().upsertEntity("Order", "o-1", { id: "o-1", status: "shipped", total: 1 }); }); + await settle(); + await rewindThroughActivity("baseline"); + const bar = shell().querySelector(":scope > .pem-rewound-bar") as HTMLElement; + expect(bar.textContent).toMatch(/^Viewing snapshot \d+ · Return to live$/); + }); +}); + +describe("pause copy", () => { + afterEach(cleanup); + + it("counts events received since pausing on the Resume control", async () => { + const store = seedStore({ dirty: false }); + await renderInspector(store); + fireEvent.click(workspaceTab(/^Activity/)); + const pause = within(activityWorkspace()).getByRole("button", { name: /Pause/ }); + fireEvent.click(pause); + expect(pause.textContent).toMatch(/Resume$/); + act(() => { + store.getState().upsertEntity("Order", "o-1", { id: "o-1", status: "a", total: 1 }); + store.getState().upsertEntity("Order", "o-1", { id: "o-1", status: "b", total: 1 }); + }); + await settle(); + expect(pause.textContent).toMatch(/Resume · 2 new$/); + fireEvent.click(pause); + expect(pause.textContent).toMatch(/Pause$/); + }); +}); diff --git a/packages/entity-graph-react/src/devtools/inspector-shell.tsx b/packages/entity-graph-react/src/devtools/inspector-shell.tsx index 8586d47f..57ec09ae 100644 --- a/packages/entity-graph-react/src/devtools/inspector-shell.tsx +++ b/packages/entity-graph-react/src/devtools/inspector-shell.tsx @@ -6,6 +6,7 @@ import { EntitiesWorkspace } from "./workspaces/entities"; import { ViewsWorkspace } from "./workspaces/views"; import { ActivityWorkspace } from "./workspaces/activity"; import { GraphPulse } from "./components/graph-pulse"; +import { CommandFeedback } from "./components/command-feedback"; import type { EntityGraphInspectorStateAdapter } from "./state"; type ActivityBoundaryProps = { @@ -72,9 +73,14 @@ export function EntityGraphInspectorShell({ const dirty = model.entities.filter((entity) => entity.dirty).length; const errors = model.entities.filter((entity) => entity.entityState.error).length; + const rewound = viewModel.rewoundSnapshot; return ( -
+
@@ -84,8 +90,19 @@ export function EntityGraphInspectorShell({ {model.snapshot.snapshots.mode === "live" ? "● Live" : `◉ Rewound ${model.snapshot.snapshots.cursor}`} - ◆ {dirty} dirty - ! {errors} errors + + {errors > 0 && ( + + )}
@@ -110,19 +127,29 @@ export function EntityGraphInspectorShell({ ))} -
- {viewModel.command.pending && Working: {viewModel.command.pending}…} - {viewModel.command.error && {viewModel.command.error}} - {viewModel.command.notice && {viewModel.command.notice}} - {(viewModel.command.error || viewModel.command.notice) && ( - - )} -
+ {rewound && ( +
+ + Viewing snapshot {rewound.cursor} + {rewound.eventSequence !== null ? ` (event ${rewound.eventSequence})` : ""} + + {" · "} + +
+ )} + +
@@ -204,6 +232,7 @@ export function EntityGraphInspectorShell({ filter={viewModel.activityFilter} onFilter={viewModel.setActivityFilter} paused={viewModel.paused} + pausedNewCount={viewModel.pausedNewCount} onTogglePaused={viewModel.togglePaused} onSelect={viewModel.selectEvent} snapshots={model.snapshot.snapshots} diff --git a/packages/entity-graph-react/src/devtools/preferences.test.ts b/packages/entity-graph-react/src/devtools/preferences.test.ts new file mode 100644 index 00000000..21d0b2c4 --- /dev/null +++ b/packages/entity-graph-react/src/devtools/preferences.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_ENTITY_GRAPH_DEVTOOLS_SHORTCUT, + entityGraphDevtoolsAriaShortcut, + isEditableShortcutTarget, + matchesEntityGraphDevtoolsShortcut, +} from "./preferences"; + +function keyEvent(init: KeyboardEventInit & { target?: Element | null }): KeyboardEvent { + const event = new KeyboardEvent("keydown", init); + if (init.target !== undefined) Object.defineProperty(event, "target", { value: init.target }); + return event; +} + +/** + * Mod+Shift+G was the previous default: on macOS that is the browser's "Find previous", and the + * host blocked it globally with preventDefault, even while the user typed in a form field. + * The shortcut defaults to Alt+Shift+E and never fires from an editable element. + */ +describe("DevTools shortcut", () => { + it("defaults to Alt+Shift+E", () => { + expect(DEFAULT_ENTITY_GRAPH_DEVTOOLS_SHORTCUT).toEqual({ key: "e", modifier: "alt", shiftKey: true }); + }); + + it("matches the alt modifier and still rejects Mod+Shift+G", () => { + const shortcut = DEFAULT_ENTITY_GRAPH_DEVTOOLS_SHORTCUT; + expect(matchesEntityGraphDevtoolsShortcut(keyEvent({ key: "E", altKey: true, shiftKey: true }), shortcut)).toBe(true); + expect(matchesEntityGraphDevtoolsShortcut(keyEvent({ key: "e", altKey: true, shiftKey: true, metaKey: true }), shortcut)).toBe(false); + expect(matchesEntityGraphDevtoolsShortcut(keyEvent({ key: "g", metaKey: true, shiftKey: true }), shortcut)).toBe(false); + }); + + it("keeps the legacy mod shortcut working when an app configured it", () => { + const legacy = { key: "g", modifier: "mod", shiftKey: true } as const; + expect(matchesEntityGraphDevtoolsShortcut(keyEvent({ key: "g", metaKey: true, shiftKey: true }), legacy)).toBe(true); + expect(matchesEntityGraphDevtoolsShortcut(keyEvent({ key: "g", ctrlKey: true, shiftKey: true }), legacy)).toBe(true); + }); + + it("formats the alt shortcut for aria-keyshortcuts", () => { + expect(entityGraphDevtoolsAriaShortcut(DEFAULT_ENTITY_GRAPH_DEVTOOLS_SHORTCUT)).toBe("Alt+Shift+E"); + }); + + it("recognises editable targets so the host can ignore them", () => { + const input = document.createElement("input"); + const textarea = document.createElement("textarea"); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + const button = document.createElement("button"); + expect(isEditableShortcutTarget(input)).toBe(true); + expect(isEditableShortcutTarget(textarea)).toBe(true); + expect(isEditableShortcutTarget(editable)).toBe(true); + expect(isEditableShortcutTarget(button)).toBe(false); + expect(isEditableShortcutTarget(null)).toBe(false); + }); +}); diff --git a/packages/entity-graph-react/src/devtools/preferences.ts b/packages/entity-graph-react/src/devtools/preferences.ts index 1bf183a1..67902187 100644 --- a/packages/entity-graph-react/src/devtools/preferences.ts +++ b/packages/entity-graph-react/src/devtools/preferences.ts @@ -78,30 +78,43 @@ export function writeEntityGraphDevtoolsPreferences( export interface EntityGraphDevtoolsShortcut { key?: string; - modifier?: "mod" | "control" | "meta"; + modifier?: "mod" | "control" | "meta" | "alt"; shiftKey?: boolean; } -/** Default cross-platform restore/toggle shortcut: Ctrl/Cmd+Shift+G. */ +/** + * Default cross-platform restore/toggle shortcut: Alt+Shift+E. + * The previous Ctrl/Cmd+Shift+G default collided with the browser's "Find previous" on macOS. + */ export const DEFAULT_ENTITY_GRAPH_DEVTOOLS_SHORTCUT: Required = { - key: "g", - modifier: "mod", + key: "e", + modifier: "alt", shiftKey: true, }; +/** Whether a keyboard event originates from an element that consumes typed keys. */ +export function isEditableShortcutTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + const tag = target.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + const editable = target.closest("[contenteditable]")?.getAttribute("contenteditable"); + return editable !== undefined && editable !== null && editable !== "false"; +} + /** Return whether a keyboard event matches the configured DevTools shortcut. */ export function matchesEntityGraphDevtoolsShortcut( event: KeyboardEvent, shortcut: Required, ): boolean { - const modifierMatches = shortcut.modifier === "control" - ? event.ctrlKey && !event.metaKey - : shortcut.modifier === "meta" - ? event.metaKey && !event.ctrlKey - : event.ctrlKey !== event.metaKey; + const modifierMatches = shortcut.modifier === "alt" + ? event.altKey && !event.ctrlKey && !event.metaKey + : shortcut.modifier === "control" + ? event.ctrlKey && !event.metaKey && !event.altKey + : shortcut.modifier === "meta" + ? event.metaKey && !event.ctrlKey && !event.altKey + : event.ctrlKey !== event.metaKey && !event.altKey; return modifierMatches && event.shiftKey === shortcut.shiftKey && - !event.altKey && event.key.toLocaleLowerCase() === shortcut.key.toLocaleLowerCase(); } @@ -109,11 +122,13 @@ export function matchesEntityGraphDevtoolsShortcut( export function entityGraphDevtoolsAriaShortcut( shortcut: Required, ): string { - const prefix = shortcut.modifier === "control" - ? "Control" - : shortcut.modifier === "meta" - ? "Meta" - : "Control"; + const prefix = shortcut.modifier === "alt" + ? "Alt" + : shortcut.modifier === "control" + ? "Control" + : shortcut.modifier === "meta" + ? "Meta" + : "Control"; const ariaShortcut = [prefix, ...(shortcut.shiftKey ? ["Shift"] : []), shortcut.key.toLocaleUpperCase()].join("+"); if (shortcut.modifier !== "mod") return ariaShortcut; return `${ariaShortcut} ${ariaShortcut.replace("Control", "Meta")}`; diff --git a/packages/entity-graph-react/src/devtools/styles.test.ts b/packages/entity-graph-react/src/devtools/styles.test.ts new file mode 100644 index 00000000..98743a34 --- /dev/null +++ b/packages/entity-graph-react/src/devtools/styles.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from "vitest"; + +import { ENTITY_GRAPH_DEVTOOLS_STYLES as SHEET } from "./styles"; + +/** + * Stylesheet contract for the "Instrument Dark" inspector refinement. + * + * The sheet is a string injected into a Shadow DOM, so jsdom never applies it. These tests parse + * the string and assert the token, scale, hit-target, layout, contrast and motion contracts that + * the refinement commits to (docs/devtools-design-notes.md §2 and §4). + */ + +interface Rule { + selector: string; + body: string; + /** Enclosing at-rule preludes, outermost first (e.g. ["@container (width < 640px)"]). */ + context: string[]; +} + +/** Minimal CSS splitter: top-level rules plus rules nested inside at-rules. */ +function parseRules(css: string, context: string[] = []): Rule[] { + const rules: Rule[] = []; + let i = 0; + while (i < css.length) { + const open = css.indexOf("{", i); + if (open === -1) break; + const prelude = css.slice(i, open).trim(); + let depth = 1; + let j = open + 1; + while (j < css.length && depth > 0) { + if (css[j] === "{") depth += 1; + else if (css[j] === "}") depth -= 1; + j += 1; + } + const body = css.slice(open + 1, j - 1); + if (prelude.startsWith("@")) { + if (prelude.startsWith("@keyframes")) rules.push({ selector: prelude, body, context }); + else rules.push(...parseRules(body, [...context, prelude])); + } else { + rules.push({ selector: prelude, body, context }); + } + i = j; + } + return rules; +} + +const RULES = parseRules(SHEET.replace(/\/\*[\s\S]*?\*\//g, "")); +const HOST_RULE = RULES.find((rule) => rule.selector === ":host" && rule.context.length === 0); +const OUTSIDE_HOST = SHEET.replace(HOST_RULE?.body ?? "", ""); + +function declarations(body: string): Record { + const out: Record = {}; + for (const part of body.split(";")) { + const idx = part.indexOf(":"); + if (idx === -1) continue; + out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim(); + } + return out; +} + +/** Rules whose comma-separated selector list contains `selector` exactly. */ +function rulesFor(selector: string, predicate: (rule: Rule) => boolean = () => true): Rule[] { + return RULES.filter( + (rule) => rule.selector.split(",").map((s) => s.trim()).includes(selector) && predicate(rule), + ); +} + +function px(value: string | undefined): number | undefined { + const match = value?.match(/^(\d+(?:\.\d+)?)px$/); + return match ? Number(match[1]) : undefined; +} + +function maxDeclared(selector: string, property: string, predicate: (rule: Rule) => boolean): number { + const values = rulesFor(selector, predicate) + .map((rule) => px(declarations(rule.body)[property])) + .filter((value): value is number => value !== undefined); + return values.length ? Math.max(...values) : 0; +} + +// ---------- WCAG 2.x contrast ---------- + +function channel(hex: string, offset: number): number { + const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +function luminance(hex: string): number { + const h = hex.replace("#", ""); + return 0.2126 * channel(h, 0) + 0.7152 * channel(h, 2) + 0.0722 * channel(h, 4); +} + +export function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +/** `--pem-` → fallback hex as declared in the :host token block. */ +function tokenHex(name: string): string { + const host = HOST_RULE?.body ?? ""; + const match = host.match(new RegExp(`--pem-${name}\\s*:\\s*var\\([^;]*?(#[0-9a-fA-F]{6})\\)`)); + if (!match) throw new Error(`token --pem-${name} has no hex fallback in :host`); + return match[1].toLowerCase(); +} + +const TOKENS: Record = { + shell: "#0d1117", + surface: "#161b22", + elevated: "#1c2333", + text: "#e6edf3", + muted: "#8b949e", + code: "#cdd9e5", + accent: "#f0a500", + add: "#3fb950", + mod: "#d29922", + del: "#f85149", + line: "#30363d", + focus: "#f0a500", +}; + +const ALLOWED_FONT_SIZES = [10, 11, 12, 13, 15, 18, 24]; +const ALLOWED_FONT_WEIGHTS = [400, 500, 600, 700]; +const SPACING_SCALE = [0, 2, 4, 6, 8, 12, 16, 20, 24]; + +const INTERACTIVE = [ + ".pem-panel-toolbar button", + ".pem-launcher-settings", + ".pem-primary-action", + ".pem-secondary-action", + ".pem-detail-actions button", + ".pem-time-travel-controls button", + ".pem-rail-collapse", + ".pem-rail-restore", + ".pem-filter-row button", + ".pem-pause", + ".pem-workspace-tabs button", + ".pem-value-tabs button", + ".pem-shell-status > button", + ".pem-rewound-bar button", + ".pem-command-feedback button", + ".pem-causal-path button", + ".pem-pulse-segments button", + ".pem-pulse-toggle", + ".pem-store-select select", + ".pem-settings fieldset button", + ".pem-settings-hide button", + ".pem-settings header button", + ".pem-settings select", + ".pem-trace-list button", + ".pem-compact-list button", + ".pem-mobile-back", +]; +const SQUARE = [".pem-panel-toolbar button", ".pem-rail-collapse", ".pem-rail-restore", ".pem-settings header button"]; + +const isDesktop = (rule: Rule) => rule.context.length === 0; +const isMobile = (rule: Rule) => rule.context.some((c) => /@media[^{]*max-width/.test(c)); + +describe("devtools stylesheet — tokens", () => { + it("declares every palette token once in :host with a --pem-devtools-* override form", () => { + expect(HOST_RULE).toBeDefined(); + const host = HOST_RULE!.body; + for (const [name, hex] of Object.entries(TOKENS)) { + const re = new RegExp(`--pem-${name}\\s*:\\s*var\\(--pem-devtools-${name}\\s*,`); + expect(host, `--pem-${name} override form`).toMatch(re); + expect(tokenHex(name), `--pem-${name} fallback`).toBe(hex); + const declaredCount = SHEET.match(new RegExp(`--pem-${name}\\s*:`, "g"))?.length ?? 0; + expect(declaredCount, `--pem-${name} declared once`).toBe(1); + } + }); + + it("keeps honouring the pre-existing host override names", () => { + const host = HOST_RULE!.body; + for (const legacy of [ + "--pem-devtools-color-text", + "--pem-devtools-color-shell", + "--pem-devtools-color-border", + "--pem-devtools-color-focus", + "--pem-devtools-radius-panel", + "--pem-devtools-font-body", + "--pem-devtools-font-mono", + ]) { + expect(host, legacy).toContain(`var(${legacy},`); + } + // The legacy names map onto the new tokens rather than living in a second cascade. + expect(host).toMatch(/--pem-text\s*:\s*var\(--pem-devtools-text\s*,\s*var\(--pem-devtools-color-text\s*,/); + expect(host).toMatch(/--pem-shell\s*:\s*var\(--pem-devtools-shell\s*,\s*var\(--pem-devtools-color-shell\s*,/); + expect(host).toMatch(/--pem-line\s*:\s*var\(--pem-devtools-line\s*,\s*var\(--pem-devtools-color-border\s*,/); + expect(host).toMatch(/--pem-focus\s*:\s*var\(--pem-devtools-focus\s*,\s*var\(--pem-devtools-color-focus\s*,/); + }); + + it("uses no colour literals outside the :host token block", () => { + const hexes = OUTSIDE_HOST.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []; + expect(hexes, `hex literals: ${hexes.join(", ")}`).toHaveLength(0); + const withoutShadows = OUTSIDE_HOST.replace(/box-shadow\s*:[^;]+;/g, ""); + expect(withoutShadows).not.toMatch(/\b(rgb|rgba|hsl|hsla)\(/); + // Shadows may only use the black-alpha form for their blur. + for (const shadow of OUTSIDE_HOST.match(/box-shadow\s*:[^;]+;/g) ?? []) { + const colours = shadow.match(/\b(rgb|rgba|hsl|hsla)\([^)]*\)/g) ?? []; + for (const colour of colours) expect(colour).toMatch(/^rgb\(0 0 0 \/ \d+%\)$/); + } + }); + + it("retires --pem-attention and --pem-panel", () => { + expect(SHEET).not.toContain("--pem-attention"); + expect(SHEET).not.toContain("--pem-panel:"); + }); +}); + +describe("devtools stylesheet — semantic colours", () => { + it("maps dirty to mod, errors to del and inserts to add", () => { + const dirty = rulesFor('[data-tone="dirty"]'); + const error = rulesFor('[data-tone="error"]'); + expect(dirty.length).toBeGreaterThan(0); + expect(error.length).toBeGreaterThan(0); + expect(dirty.map((r) => r.body).join("")).toContain("var(--pem-mod)"); + expect(error.map((r) => r.body).join("")).toContain("var(--pem-del)"); + expect(rulesFor('[data-tone="attention"]')).toHaveLength(0); + }); + + it("colours diff rows by kind", () => { + const kind = (name: string) => + RULES.filter((r) => r.selector.includes(`.pem-diff-row[data-kind="${name}"]`)).map((r) => r.body).join(""); + expect(kind("changed")).toContain("var(--pem-mod)"); + expect(kind("added")).toContain("var(--pem-add)"); + expect(kind("removed")).toContain("var(--pem-del)"); + }); +}); + +describe("devtools stylesheet — type scale", () => { + it("uses only the font-size scale, nothing below 10px", () => { + const sizes = new Set(); + for (const m of SHEET.matchAll(/font-size\s*:\s*(\d+(?:\.\d+)?)px/g)) sizes.add(Number(m[1])); + for (const m of SHEET.matchAll(/font\s*:\s*(?:\d{3}\s+)?(\d+(?:\.\d+)?)px/g)) sizes.add(Number(m[1])); + const sorted = [...sizes].sort((a, b) => a - b); + expect(sorted[0]).toBeGreaterThanOrEqual(10); + for (const size of sorted) expect(ALLOWED_FONT_SIZES, `font-size ${size}px`).toContain(size); + }); + + it("uses only the 400/500/600/700 weights", () => { + const weights = new Set(); + for (const m of SHEET.matchAll(/font-weight\s*:\s*(\d{3})/g)) weights.add(Number(m[1])); + for (const m of SHEET.matchAll(/font\s*:\s*(\d{3})\s/g)) weights.add(Number(m[1])); + for (const weight of weights) expect(ALLOWED_FONT_WEIGHTS, `font-weight ${weight}`).toContain(weight); + }); + + it("keeps labels and chrome at 11px or larger", () => { + for (const selector of [".pem-panel-toolbar", ".pem-settings label", ".pem-store-select", ".pem-coverage-note", ".pem-pulse-segments small"]) { + for (const rule of rulesFor(selector)) { + const size = px(declarations(rule.body)["font-size"]); + if (size !== undefined) expect(size, selector).toBeGreaterThanOrEqual(11); + } + } + }); + + it("sets tabular numerals where data is tabular", () => { + for (const selector of [".pem-devtools-surface", ".pem-metric strong", ".pem-diff-row > *", ".pem-value"]) { + const bodies = rulesFor(selector).map((r) => r.body).join(""); + expect(bodies, selector).toContain("font-variant-numeric: tabular-nums"); + } + }); +}); + +describe("devtools stylesheet — spacing scale", () => { + it("restricts gap/padding/margin px values to the 4-step scale", () => { + const values = new Set(); + for (const m of SHEET.matchAll(/(?:gap|padding|margin)(?:-[a-z]+)?\s*:\s*([^;{}]+);/g)) { + for (const v of m[1].matchAll(/-?(\d+(?:\.\d+)?)px/g)) values.add(Number(v[1])); + } + for (const value of [...values].sort((a, b) => a - b)) { + expect(SPACING_SCALE, `spacing ${value}px`).toContain(value); + } + }); +}); + +describe("devtools stylesheet — hit targets", () => { + it.each(INTERACTIVE)("%s is at least 32px tall on desktop", (selector) => { + expect(maxDeclared(selector, "min-height", isDesktop)).toBeGreaterThanOrEqual(32); + }); + + it.each(SQUARE)("%s is at least 32px wide on desktop", (selector) => { + expect(maxDeclared(selector, "min-width", isDesktop)).toBeGreaterThanOrEqual(32); + }); + + it.each(INTERACTIVE)("%s grows to 44px at the mobile breakpoint", (selector) => { + expect(maxDeclared(selector, "min-height", isMobile)).toBeGreaterThanOrEqual(44); + }); +}); + +describe("devtools stylesheet — container-query layout", () => { + it("makes the panel content the layout container", () => { + const body = rulesFor(".pem-panel-content").map((r) => r.body).join(""); + expect(body).toContain("container-type: inline-size"); + }); + + it("collapses the causal rail below 900px of container width", () => { + const rail = rulesFor(".pem-causal-rail", (r) => r.context.some((c) => c.startsWith("@container") && c.includes("900px"))); + expect(rail.length).toBeGreaterThan(0); + expect(rail.map((r) => r.body).join("")).toContain("display: none"); + const workspace = rulesFor(".pem-entity-workspace", (r) => r.context.some((c) => c.startsWith("@container") && c.includes("900px"))); + expect(workspace.length).toBeGreaterThan(0); + }); + + it("stacks navigator and detail below 640px of container width", () => { + const narrow = (c: string) => c.startsWith("@container") && c.includes("640px"); + const hidden = RULES.filter((r) => r.context.some(narrow) && r.selector.includes('[data-narrow-detail="false"]')); + expect(hidden.length).toBeGreaterThan(0); + expect(hidden.map((r) => r.body).join("")).toContain("display: none"); + const back = rulesFor(".pem-mobile-back", (r) => r.context.some(narrow)); + expect(back.map((r) => r.body).join("")).toContain("display: inline-flex"); + }); + + it("keeps the viewport media query for the frame, safe-area and touch-target rules only", () => { + const viewport = RULES.filter(isMobile); + expect(viewport.length).toBeGreaterThan(0); + const selectors = viewport.flatMap((r) => r.selector.split(",").map((s) => s.trim())); + for (const selector of selectors) { + const allowed = + selector.startsWith(".pem-panel-frame") || + selector.startsWith(".pem-settings") || + selector === ".pem-panel-toolbar" || + INTERACTIVE.includes(selector); + expect(allowed, `viewport rule leaked layout: ${selector}`).toBe(true); + } + expect(SHEET).not.toMatch(/@media \(max-width: 719px\)[^{]*\{[\s\S]*?\.pem-entity-workspace/); + }); +}); + +describe("devtools stylesheet — contrast (WCAG 2.x)", () => { + const t = (name: string) => tokenHex(name); + + it.each(["shell", "surface", "elevated"])("text, muted and code reach 4.5:1 on %s", (surface) => { + expect(contrast(t("text"), t(surface))).toBeGreaterThanOrEqual(4.5); + expect(contrast(t("muted"), t(surface))).toBeGreaterThanOrEqual(4.5); + expect(contrast(t("code"), t(surface))).toBeGreaterThanOrEqual(4.5); + }); + + it("launcher badge text reaches 4.5:1 on the badge background", () => { + const badge = declarations(rulesFor(".pem-launcher-badge")[0]!.body); + const token = (value: string) => value.match(/var\(--pem-([a-z]+)\)/)![1]; + expect(contrast(t(token(badge.color)), t(token(badge.background)))).toBeGreaterThanOrEqual(4.5); + }); + + it("pulse segment underline reaches 3:1 against the pulse surface", () => { + const segment = declarations(rulesFor(".pem-pulse-segments button")[0]!.body); + const underline = segment["border-bottom"].match(/var\(--pem-([a-z]+)\)/)![1]; + const surface = segment.background.match(/var\(--pem-([a-z]+)\)/)![1]; + expect(contrast(t(underline), t(surface))).toBeGreaterThanOrEqual(3); + }); + + it("mod, del and add text reach 4.5:1 on the selected-row surface", () => { + const selected = declarations(rulesFor('.pem-entity-row[data-selected="true"]')[0]!.body); + const surface = selected.background.match(/var\(--pem-([a-z]+)\)/)![1]; + for (const tone of ["mod", "del", "add", "accent"]) { + expect(contrast(t(tone), t(surface)), `${tone} on ${surface}`).toBeGreaterThanOrEqual(4.5); + } + }); +}); + +describe("devtools stylesheet — motion and focus", () => { + it("animates only the panel reveal, 200ms ease-out", () => { + const keyframes = RULES.filter((r) => r.selector.startsWith("@keyframes")); + expect(keyframes.map((r) => r.selector)).toEqual(["@keyframes pem-panel-reveal"]); + const animated = RULES.filter((r) => !r.context.some((c) => c.includes("prefers-reduced-motion")) && /animation\s*:/.test(r.body)); + expect(animated.map((r) => r.selector)).toEqual([".pem-panel-frame"]); + expect(declarations(animated[0]!.body).animation).toMatch(/pem-panel-reveal 200ms ease-out/); + expect(SHEET).not.toContain("pem-pulse-arrival"); + }); + + it("disables the reveal and transitions under prefers-reduced-motion", () => { + const reduced = RULES.filter((r) => r.context.some((c) => c.includes("prefers-reduced-motion: reduce"))); + const body = reduced.map((r) => r.body).join(""); + expect(body).toMatch(/animation(-duration)?\s*:\s*[^;]*!important/); + expect(body).toMatch(/transition(-duration)?\s*:\s*[^;]*!important/); + }); + + it("keeps the 2px focus ring with 2px offset on :focus-visible", () => { + const focus = RULES.filter((r) => r.selector.includes(":focus-visible")); + expect(focus.length).toBeGreaterThan(0); + const body = focus.map((r) => r.body).join(""); + expect(body).toContain("outline: 2px solid var(--pem-focus)"); + expect(body).toContain("outline-offset: 2px"); + }); + + it("themes selection and scrollbars from the palette", () => { + expect(rulesFor("::selection").map((r) => r.body).join("")).toContain("var(--pem-accent)"); + expect(SHEET).toMatch(/scrollbar-color:\s*var\(--pem-line\)\s+var\(--pem-(shell|surface)\)/); + }); + + it("adds no gradients, glass or hard offset shadows", () => { + expect(SHEET).not.toMatch(/gradient\(/); + expect(SHEET).not.toMatch(/backdrop-filter/); + for (const shadow of SHEET.match(/box-shadow\s*:\s*([^;]+);/g) ?? []) { + if (shadow.includes("inset") || shadow.includes("none")) continue; + // offset-x offset-y blur: blur must be present and non-zero (soft), never a hard offset. + const parts = shadow.replace(/box-shadow\s*:\s*/, "").split(/\s+/); + expect(parts.length, shadow).toBeGreaterThanOrEqual(4); + expect(px(parts[2])).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/entity-graph-react/src/devtools/styles.ts b/packages/entity-graph-react/src/devtools/styles.ts index 54252a7e..26f30611 100644 --- a/packages/entity-graph-react/src/devtools/styles.ts +++ b/packages/entity-graph-react/src/devtools/styles.ts @@ -1,14 +1,37 @@ -/** Isolated Shadow DOM contract for the shared forensic inspector workspace. */ +/** + * Isolated Shadow DOM contract for the shared forensic inspector workspace. + * + * Palette "Instrument Dark" (docs/devtools-design-notes.md §2). Every colour in this sheet is a + * `--pem-*` token declared once on `:host`; each token accepts a `--pem-devtools-` host + * override and the pre-existing `--pem-devtools-color-*` / `-radius-panel` / `-font-*` names. + * Type scale 11/12/13/15/18/24, spacing scale 2/4/6/8/12/16/20/24, hit targets 32px (44px on + * small viewports). Layout breakpoints are container queries on `.pem-panel-content`. + */ export const ENTITY_GRAPH_DEVTOOLS_STYLES = ` :host { all: initial; + --pem-shell: var(--pem-devtools-shell, var(--pem-devtools-color-shell, #0D1117)); + --pem-surface: var(--pem-devtools-surface, #161B22); + --pem-elevated: var(--pem-devtools-elevated, #1C2333); + --pem-text: var(--pem-devtools-text, var(--pem-devtools-color-text, #E6EDF3)); + --pem-muted: var(--pem-devtools-muted, #8B949E); + --pem-code: var(--pem-devtools-code, #CDD9E5); + --pem-accent: var(--pem-devtools-accent, #F0A500); + --pem-add: var(--pem-devtools-add, #3FB950); + --pem-mod: var(--pem-devtools-mod, #D29922); + --pem-del: var(--pem-devtools-del, #F85149); + --pem-line: var(--pem-devtools-line, var(--pem-devtools-color-border, #30363D)); + --pem-focus: var(--pem-devtools-focus, var(--pem-devtools-color-focus, #F0A500)); + --pem-radius-panel: var(--pem-devtools-radius-panel, 8px); + --pem-font-body: var(--pem-devtools-font-body, ui-sans-serif, system-ui, sans-serif); + --pem-font-mono: var(--pem-devtools-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); color-scheme: dark; position: fixed; z-index: 2147483000; inset: 0; box-sizing: border-box; - font-family: var(--pem-devtools-font-body, ui-sans-serif, system-ui, sans-serif); - color: var(--pem-devtools-color-text, #e8e6df); + font-family: var(--pem-font-body); + color: var(--pem-text); pointer-events: none; } @@ -17,41 +40,37 @@ button, input, select, textarea { font: inherit; } button { color: inherit; touch-action: manipulation; } h1, h2, h3, p, dl, dd, ol, ul { margin: 0; } ol, ul { padding: 0; list-style: none; } -code, pre, .pem-mono { - font-family: var(--pem-devtools-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); -} +code, pre, .pem-mono { font-family: var(--pem-font-mono); } +::selection { background: var(--pem-accent); color: var(--pem-shell); } button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex="0"]:focus-visible, [tabindex="-1"]:focus-visible { - outline: 2px solid var(--pem-devtools-color-focus, #6ed8c9); + outline: 2px solid var(--pem-focus); outline-offset: 2px; } .pem-devtools-surface { - --pem-line: #344047; - --pem-muted: #92a0a6; - --pem-accent: #63c7a8; position: fixed; inset: 0; pointer-events: none; - color: var(--pem-devtools-color-text, #e8e6df); - font: 13px/1.4 var(--pem-devtools-font-body, ui-sans-serif, system-ui, sans-serif); + color: var(--pem-text); + font: 13px/1.4 var(--pem-font-body); font-variant-numeric: tabular-nums; } .pem-launcher-slot { position: fixed; display: flex; align-items: center; - gap: 5px; + gap: 4px; pointer-events: auto; } -.pem-launcher-slot[data-position="top-left"] { top: max(14px, env(safe-area-inset-top)); left: max(14px, env(safe-area-inset-left)); } -.pem-launcher-slot[data-position="top-right"] { top: max(14px, env(safe-area-inset-top)); right: max(14px, env(safe-area-inset-right)); } -.pem-launcher-slot[data-position="bottom-left"] { bottom: max(14px, env(safe-area-inset-bottom)); left: max(14px, env(safe-area-inset-left)); } -.pem-launcher-slot[data-position="bottom-right"] { right: max(14px, env(safe-area-inset-right)); bottom: max(14px, env(safe-area-inset-bottom)); } +.pem-launcher-slot[data-position="top-left"] { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); } +.pem-launcher-slot[data-position="top-right"] { top: max(16px, env(safe-area-inset-top)); right: max(16px, env(safe-area-inset-right)); } +.pem-launcher-slot[data-position="bottom-left"] { bottom: max(16px, env(safe-area-inset-bottom)); left: max(16px, env(safe-area-inset-left)); } +.pem-launcher-slot[data-position="bottom-right"] { right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); } .pem-launcher, .pem-launcher-settings, .pem-panel-toolbar button { - border: 1px solid #3c494f; - background: #111719; - color: #e8e6df; + border: 1px solid var(--pem-line); + background: var(--pem-surface); + color: var(--pem-text); box-shadow: 0 12px 36px rgb(0 0 0 / 36%); cursor: pointer; } @@ -62,23 +81,20 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- align-items: center; justify-content: center; gap: 8px; - border-color: #478e82; border-radius: 999px; - padding: 7px 14px 7px 7px; - background: #142320; - color: #c6f4ec; - font-weight: 720; + padding: 6px 12px 6px 6px; + font-weight: 600; } -.pem-launcher:hover { border-color: #79d7c9; background: #19302b; } +.pem-launcher:hover { border-color: var(--pem-accent); background: var(--pem-elevated); } .pem-launcher-mark { display: grid; width: 36px; height: 36px; place-items: center; - border: 1px solid #79d7c9; + border: 1px solid var(--pem-accent); border-radius: 50%; - color: #79d7c9; - font: 800 13px/1 var(--pem-devtools-font-mono, ui-monospace, monospace); + color: var(--pem-accent); + font: 700 13px/1 var(--pem-font-mono); } .pem-launcher-badge { display: grid; @@ -86,38 +102,44 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- min-height: 20px; place-items: center; border-radius: 10px; - padding: 0 5px; - background: #e85d32; - color: #fff8f2; - font: 800 10px/1 var(--pem-devtools-font-mono, ui-monospace, monospace); + padding: 0 4px; + background: var(--pem-accent); + color: var(--pem-shell); + font: 700 11px/1 var(--pem-font-mono); + font-variant-numeric: tabular-nums; } .pem-launcher-settings { - width: 36px; - height: 36px; + min-width: 36px; + min-height: 36px; border-radius: 50%; } .pem-launcher-slot[data-form="edge-tab"] .pem-launcher { min-width: 38px; min-height: 48px; border-radius: 5px; - padding: 5px; + padding: 4px; } .pem-launcher-slot[data-form="edge-tab"] .pem-launcher-label { display: none; } .pem-launcher-slot[data-form="edge-tab"] .pem-launcher-mark { width: 28px; height: 34px; border: 0; } .pem-launcher-slot[data-form="edge-tab"][data-position$="left"] { left: 0; } .pem-launcher-slot[data-form="edge-tab"][data-position$="right"] { right: 0; } -.pem-launcher-slot[data-position^="bottom"] .pem-settings { top: auto; bottom: calc(100% + 7px); } +.pem-launcher-slot[data-position^="bottom"] .pem-settings { top: auto; bottom: calc(100% + 8px); } .pem-panel-frame { position: fixed; display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: visible; - border: 1px solid #303a40; - border-radius: 9px; - background: #101416; + border: 1px solid var(--pem-line); + border-radius: var(--pem-radius-panel); + background: var(--pem-shell); box-shadow: 0 28px 80px rgb(0 0 0 / 52%); pointer-events: auto; + animation: pem-panel-reveal 200ms ease-out both; +} +@keyframes pem-panel-reveal { + from { opacity: 0; translate: 0 8px; } + to { opacity: 1; translate: 0 0; } } .pem-panel-frame[data-layout="floating"] { top: max(20px, env(safe-area-inset-top)); @@ -135,86 +157,89 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- inset: auto 0 0; width: 100vw; height: min(680px, 72vh); - border-radius: 9px 9px 0 0; + border-radius: var(--pem-radius-panel) var(--pem-radius-panel) 0 0; } .pem-panel-toolbar { position: relative; z-index: 4; display: flex; - min-height: 38px; + min-height: 40px; align-items: center; justify-content: space-between; - border-bottom: 1px solid #303a40; - padding: 4px 7px 4px 12px; - background: #0d1113; - color: #92a0a6; + border-bottom: 1px solid var(--pem-line); + padding: 4px 8px 4px 12px; + background: var(--pem-shell); + color: var(--pem-muted); font-size: 11px; - font-weight: 700; + font-weight: 600; letter-spacing: .04em; text-transform: uppercase; } -.pem-panel-toolbar > div { display: flex; gap: 5px; } +.pem-panel-toolbar > div { display: flex; gap: 4px; } .pem-panel-toolbar button { - width: 30px; - height: 30px; + min-width: 32px; + min-height: 32px; border-radius: 4px; box-shadow: none; } -.pem-panel-content { min-width: 0; min-height: 0; overflow: hidden; } +.pem-panel-content { min-width: 0; min-height: 0; overflow: hidden; container-type: inline-size; } .pem-settings { position: absolute; z-index: 7; - top: calc(100% + 7px); + top: calc(100% + 8px); right: 0; display: grid; - width: min(310px, calc(100vw - 28px)); + width: min(310px, calc(100vw - 32px)); gap: 12px; - border: 1px solid #3c494f; - border-radius: 7px; - padding: 13px; - background: #151a1d; + border: 1px solid var(--pem-line); + border-radius: var(--pem-radius-panel); + padding: 12px; + background: var(--pem-elevated); box-shadow: 0 18px 52px rgb(0 0 0 / 50%); - color: #e8e6df; + color: var(--pem-text); text-transform: none; } -.pem-panel-frame > .pem-settings { top: 42px; right: 7px; } +.pem-panel-frame > .pem-settings { top: 44px; right: 8px; } .pem-launcher-slot[data-position$="left"] .pem-settings { right: auto; left: 0; } .pem-settings header { display: flex; align-items: center; justify-content: space-between; } -.pem-settings header button { border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 18px; } -.pem-settings label { display: grid; gap: 5px; color: #92a0a6; font-size: 10px; } +.pem-settings header button { + min-width: 32px; + min-height: 32px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 18px; +} +.pem-settings label { display: grid; gap: 4px; color: var(--pem-muted); font-size: 11px; } .pem-settings select { width: 100%; min-height: 36px; - border: 1px solid #303a40; + border: 1px solid var(--pem-line); border-radius: 4px; padding: 6px 8px; - background: #0d1113; - color: #e8e6df; + background: var(--pem-shell); + color: var(--pem-text); } -.pem-settings fieldset { display: flex; gap: 5px; margin: 0; border: 0; padding: 0; } -.pem-settings legend { margin-bottom: 5px; color: #92a0a6; font-size: 10px; } +.pem-settings fieldset { display: flex; gap: 4px; margin: 0; border: 0; padding: 0; } +.pem-settings legend { margin-bottom: 4px; color: var(--pem-muted); font-size: 11px; } .pem-settings fieldset button, .pem-settings-hide button { - min-height: 34px; - border: 1px solid #303a40; + min-height: 32px; + border: 1px solid var(--pem-line); border-radius: 4px; - padding: 5px 8px; - background: #171e21; - color: #d9dfdc; + padding: 4px 8px; + background: var(--pem-surface); + color: var(--pem-text); cursor: pointer; - font-size: 10px; + font-size: 11px; text-transform: capitalize; } -.pem-settings fieldset button[aria-pressed="true"] { border-color: #478e82; background: #1c3b36; color: #a7eee2; } -.pem-settings p { color: #92a0a6; font-size: 10px; } -.pem-settings kbd { border: 1px solid #303a40; border-radius: 3px; padding: 2px 4px; background: #0d1113; color: #d7e4e1; } -.pem-settings-hide { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; border-top: 1px solid #303a40; padding-top: 11px; } +.pem-settings fieldset button[aria-pressed="true"] { border-color: var(--pem-accent); background: var(--pem-shell); color: var(--pem-accent); } +.pem-settings p { color: var(--pem-muted); font-size: 11px; } +.pem-settings kbd { border: 1px solid var(--pem-line); border-radius: 3px; padding: 2px 4px; background: var(--pem-shell); color: var(--pem-code); } +.pem-settings-hide { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; border-top: 1px solid var(--pem-line); padding-top: 12px; } .pem-inspector { - --pem-panel: #1b2227; - --pem-line: #344047; - --pem-muted: #92a0a6; - --pem-accent: #63c7a8; - --pem-attention: #e85d32; display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; width: 100%; @@ -222,28 +247,33 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- overflow: hidden; border: 0; border-radius: 0; - background: var(--pem-devtools-color-shell, #101416); + background: var(--pem-shell); box-shadow: none; - color: var(--pem-devtools-color-text, #e8e6df); + color: var(--pem-text); font-size: 13px; line-height: 1.4; } +.pem-inspector[data-rewound="true"] { + grid-template-rows: auto auto auto auto minmax(0, 1fr) auto; + outline: 2px solid var(--pem-mod); + outline-offset: -2px; +} .pem-shell-header { display: flex; - min-height: 58px; + min-height: 56px; align-items: center; justify-content: space-between; gap: 24px; - padding: 10px 16px; + padding: 8px 16px; border-bottom: 1px solid var(--pem-line); - background: #121719; + background: var(--pem-surface); } .pem-brand, .pem-shell-status, .pem-status-cluster, .pem-row-signals { display: flex; align-items: center; } -.pem-brand { gap: 10px; } +.pem-brand { gap: 8px; } .pem-brand strong { display: block; font-size: 13px; letter-spacing: .02em; } .pem-brand small, .pem-shell-status small { color: var(--pem-muted); } .pem-mark { @@ -251,31 +281,41 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- width: 30px; height: 30px; place-items: center; - border: 1px solid #8be5d8; + border: 1px solid var(--pem-accent); border-radius: 5px; color: var(--pem-accent); - font: 700 14px/1 var(--pem-devtools-font-mono, ui-monospace, monospace); + font: 700 13px/1 var(--pem-font-mono); +} +.pem-shell-status { flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.pem-shell-status > span, .pem-shell-status > button { + display: inline-flex; + min-height: 32px; + align-items: center; } -.pem-shell-status { flex-wrap: wrap; justify-content: flex-end; gap: 7px; } -.pem-shell-status > span, .pem-status-cluster > span, .pem-live-status, .pem-registered-status { +.pem-shell-status > button { cursor: pointer; } +.pem-shell-status > button:disabled { cursor: default; } +.pem-shell-status > button:not(:disabled):hover { border-color: var(--pem-muted); background: var(--pem-shell); } +.pem-shell-status > span, .pem-shell-status > button, +.pem-status-cluster > span, .pem-live-status, .pem-registered-status { border: 1px solid var(--pem-line); border-radius: 999px; - padding: 3px 8px; - background: #171d20; - color: #b9c4c8; + padding: 2px 8px; + background: var(--pem-elevated); + color: var(--pem-code); font-size: 11px; white-space: nowrap; } -[data-tone="attention"] { color: var(--pem-attention) !important; } +[data-tone="dirty"] { color: var(--pem-mod) !important; } +[data-tone="error"] { color: var(--pem-del) !important; } .pem-workspace-tabs { display: flex; - min-height: 39px; + min-height: 40px; align-items: end; gap: 4px; padding: 0 12px; border-bottom: 1px solid var(--pem-line); - background: #111618; + background: var(--pem-surface); } .pem-workspace-tabs button, .pem-value-tabs button, .pem-filter-row button, .pem-pause { border: 0; @@ -286,11 +326,11 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- display: inline-flex; min-height: 38px; align-items: center; - gap: 5px; + gap: 4px; padding: 0 12px; border-bottom: 2px solid transparent; color: var(--pem-muted); - font-weight: 650; + font-weight: 600; } .pem-workspace-tabs button:hover, .pem-value-tabs button:hover, @@ -303,19 +343,46 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .pem-time-travel-controls button:hover, .pem-pulse-toggle:hover, .pem-pulse-segments button:hover { - border-color: #5f756f; - background: #202a2d; - color: #f5f2e9; + border-color: var(--pem-muted); + background: var(--pem-elevated); + color: var(--pem-text); } .pem-workspace-tabs small { - color: #829096; - font: 700 10px/1 var(--pem-devtools-font-mono, ui-monospace, monospace); + color: var(--pem-muted); + font: 700 11px/1 var(--pem-font-mono); + font-variant-numeric: tabular-nums; } .pem-workspace-tabs button[aria-selected="true"] { border-color: var(--pem-accent); - color: #f5f2e9; + color: var(--pem-text); } +.pem-rewound-bar { + display: flex; + min-height: 40px; + align-items: center; + gap: 8px; + padding: 4px 16px; + border-bottom: 1px solid var(--pem-mod); + background: var(--pem-surface); + color: var(--pem-mod); + font-size: 12px; + font-weight: 600; +} +.pem-rewound-bar button { + min-height: 32px; + border: 1px solid var(--pem-mod); + border-radius: 999px; + padding: 4px 12px; + background: var(--pem-shell); + color: var(--pem-mod); + font-size: 11px; + font-weight: 600; + cursor: pointer; +} +.pem-rewound-bar button:hover { background: var(--pem-elevated); color: var(--pem-text); } +.pem-rewound-bar button:disabled { cursor: default; opacity: .6; } + .pem-command-feedback { display: flex; min-height: 0; @@ -324,20 +391,21 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- gap: 12px; overflow: hidden; border-bottom: 0 solid var(--pem-line); - padding: 0 14px; - background: #151b1e; + padding: 0 12px; + background: var(--pem-surface); color: var(--pem-muted); font-size: 11px; } .pem-command-feedback[data-state="success"], .pem-command-feedback[data-state="error"] { - min-height: 34px; + min-height: 36px; border-bottom-width: 1px; } -.pem-command-feedback[data-state="success"] { color: var(--pem-accent); } -.pem-command-feedback[data-state="error"] { color: #ffc68b; background: #271e18; } +.pem-command-feedback[data-state="success"] { color: var(--pem-add); } +.pem-command-feedback[data-state="error"] { color: var(--pem-del); } .pem-command-feedback button { + min-height: 32px; border: 0; - padding: 3px 7px; + padding: 4px 8px; background: transparent; color: inherit; cursor: pointer; @@ -346,7 +414,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .pem-shell-main, .pem-workspace, .pem-navigator, .pem-entity-detail, .pem-view-detail, .pem-activity-detail { min-width: 0; min-height: 0; } .pem-shell-main { overflow: hidden; } -.pem-workspace { height: 100%; overflow: auto; } +.pem-workspace { height: 100%; overflow: auto; scrollbar-color: var(--pem-line) var(--pem-shell); } .pem-workspace-heading, .pem-detail-header, .pem-card-heading, .pem-navigator-heading, .pem-activity-heading { display: flex; @@ -361,8 +429,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .pem-eyebrow { margin-bottom: 4px; color: var(--pem-accent); - font-size: 10px; - font-weight: 750; + font-size: 11px; + font-weight: 600; letter-spacing: .12em; text-transform: uppercase; } @@ -372,42 +440,43 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- align-items: center; flex-wrap: wrap; justify-content: flex-end; - gap: 7px; + gap: 8px; } .pem-store-select { display: flex; align-items: center; gap: 6px; color: var(--pem-muted); - font-size: 10px; + font-size: 11px; } .pem-store-select select { max-width: 240px; + min-height: 32px; border: 1px solid var(--pem-line); border-radius: 4px; - padding: 5px 7px; - background: #0d1113; - color: #e8e6df; + padding: 4px 8px; + background: var(--pem-shell); + color: var(--pem-text); } .pem-policy-status { border: 1px solid var(--pem-line); border-radius: 4px; padding: 4px 6px; color: var(--pem-muted); - font: 9px var(--pem-devtools-font-mono, ui-monospace, monospace); + font: 11px var(--pem-font-mono); } .pem-primary-action, .pem-secondary-action, .pem-detail-actions button, .pem-time-travel-controls button { - min-height: 29px; - border: 1px solid #3c494f; + min-height: 32px; + border: 1px solid var(--pem-line); border-radius: 4px; - padding: 5px 9px; - background: #171e21; - color: #d9dfdc; + padding: 4px 8px; + background: var(--pem-surface); + color: var(--pem-text); cursor: pointer; - font-size: 11px; + font-size: 12px; } -.pem-primary-action { border-color: #478e82; background: #1c3b36; color: #a7eee2; } +.pem-primary-action { border-color: var(--pem-accent); background: var(--pem-shell); color: var(--pem-accent); } .pem-primary-action:disabled, .pem-secondary-action:disabled, .pem-detail-actions button:disabled, .pem-time-travel-controls button:disabled { cursor: not-allowed; @@ -418,67 +487,71 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .pem-metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; + gap: 12px; margin-top: 16px; } .pem-metric, .pem-card, .pem-detail-section { border: 1px solid var(--pem-line); border-radius: 6px; - background: var(--pem-panel); + background: var(--pem-elevated); } -.pem-metric { min-height: 82px; padding: 13px; } +.pem-metric { min-height: 82px; padding: 12px; } +button.pem-metric { width: 100%; text-align: left; cursor: pointer; } +button.pem-metric:hover { border-color: var(--pem-muted); background: var(--pem-shell); } .pem-metric span { display: block; margin-bottom: 6px; color: var(--pem-muted); font-size: 11px; } -.pem-metric strong { font-size: 24px; line-height: 1; } +.pem-metric strong { font-size: 24px; line-height: 1; font-variant-numeric: tabular-nums; } .pem-overview-grid { display: grid; grid-template-columns: minmax(240px, .7fr) minmax(420px, 1.3fr); gap: 12px; margin-top: 12px; } -.pem-card, .pem-detail-section { padding: 14px; } -.pem-card-heading { margin-bottom: 10px; } +.pem-card, .pem-detail-section { padding: 12px; } +.pem-card-heading { margin-bottom: 8px; } .pem-card-heading h3 { font-size: 12px; } .pem-card-heading > span { color: var(--pem-muted); - font: 11px var(--pem-devtools-font-mono, ui-monospace, monospace); + font: 11px var(--pem-font-mono); } .pem-readout-list { display: grid; gap: 8px; } .pem-readout-list div { display: flex; justify-content: space-between; gap: 12px; - border-bottom: 1px solid #252e32; - padding-bottom: 7px; + border-bottom: 1px solid var(--pem-line); + padding-bottom: 6px; } .pem-readout-list div:last-child { border: 0; padding-bottom: 0; } .pem-readout-list dt, .pem-readout-list dd, .pem-empty { color: var(--pem-muted); } -.pem-trace-list { display: grid; gap: 5px; } +.pem-trace-list { display: grid; gap: 4px; } .pem-trace-list li { - border-top: 1px solid #252e32; - padding: 8px 0 3px; + border-top: 1px solid var(--pem-line); + padding: 8px 0 2px; } .pem-trace-list button { display: grid; grid-template-columns: 78px minmax(0, 1fr) auto; width: 100%; + min-height: 32px; align-items: baseline; - gap: 10px; + gap: 8px; border: 0; padding: 0; - background: var(--pem-panel); + background: var(--pem-elevated); color: inherit; cursor: pointer; text-align: left; } .pem-trace-type { color: var(--pem-accent); - font: 10px var(--pem-devtools-font-mono, ui-monospace, monospace); + font: 11px var(--pem-font-mono); text-transform: uppercase; } .pem-trace-copy { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pem-trace-list time, .pem-compact-list time { color: var(--pem-muted); - font: 10px var(--pem-devtools-font-mono, ui-monospace, monospace); + font: 11px var(--pem-font-mono); + font-variant-numeric: tabular-nums; } .pem-entity-workspace, .pem-view-workspace, .pem-activity-workspace { @@ -490,52 +563,54 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- grid-template-columns: 286px minmax(0, 1fr) 240px; } .pem-entity-workspace[data-navigator-collapsed="true"] { - grid-template-columns: 38px minmax(0, 1fr) 240px; + grid-template-columns: 40px minmax(0, 1fr) 240px; } .pem-entity-workspace[data-causal-rail-collapsed="true"] { - grid-template-columns: 286px minmax(0, 1fr) 38px; + grid-template-columns: 286px minmax(0, 1fr) 40px; } .pem-entity-workspace[data-navigator-collapsed="true"][data-causal-rail-collapsed="true"] { - grid-template-columns: 38px minmax(0, 1fr) 38px; + grid-template-columns: 40px minmax(0, 1fr) 40px; } .pem-navigator { display: flex; flex-direction: column; - gap: 10px; + gap: 8px; overflow: hidden; - padding: 14px 10px 10px; + padding: 12px 8px 8px; border-right: 1px solid var(--pem-line); - background: #121719; + background: var(--pem-surface); } .pem-navigator[data-collapsed="true"] { padding: 8px 4px; } .pem-rail-collapse, .pem-rail-restore { display: inline-grid; - min-width: 28px; - min-height: 28px; + min-width: 32px; + min-height: 32px; place-items: center; border: 1px solid var(--pem-line); border-radius: 4px; - background: #151b1e; + background: var(--pem-elevated); color: var(--pem-muted); cursor: pointer; } -.pem-rail-restore { width: 28px; margin: 0 auto; } +.pem-rail-restore { width: 32px; margin: 0 auto; } .pem-coverage-note { border-left: 2px solid var(--pem-line); - padding: 3px 7px; + padding: 2px 8px; color: var(--pem-muted); - font-size: 10px; + font-size: 11px; } .pem-search input, .pem-select-label select { width: 100%; + min-height: 32px; border: 1px solid var(--pem-line); border-radius: 5px; - padding: 8px 9px; - background: #0d1113; - color: #e8e6df; + padding: 6px 8px; + background: var(--pem-shell); + color: var(--pem-text); } -.pem-filter-row { display: flex; gap: 5px; } +.pem-filter-row { display: flex; gap: 4px; } .pem-filter-row button, .pem-pause { + min-height: 32px; border: 1px solid var(--pem-line); border-radius: 999px; padding: 4px 8px; @@ -544,11 +619,11 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- text-transform: capitalize; } .pem-filter-row button[aria-pressed="true"], .pem-pause[aria-pressed="true"] { - border-color: #4a887f; - background: #17302e; + border-color: var(--pem-accent); + background: var(--pem-shell); color: var(--pem-accent); } -.pem-scroll-list { min-height: 0; flex: 1; overflow: auto; scrollbar-color: #445158 transparent; } +.pem-scroll-list { min-height: 0; flex: 1; overflow: auto; scrollbar-color: var(--pem-line) var(--pem-surface); } .pem-virtual-space, .pem-virtual-row { position: relative; width: 100%; } .pem-virtual-row { position: absolute; inset: 0 0 auto; } .pem-entity-row, .pem-view-row, .pem-event-row, .pem-membership-row, @@ -556,7 +631,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- width: 100%; border: 0; border-radius: 4px; - background: #0f1315; + background: var(--pem-surface); text-align: left; cursor: pointer; } @@ -566,67 +641,70 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- align-items: center; justify-content: space-between; gap: 8px; - padding: 5px 7px; + padding: 4px 8px; } .pem-entity-row:hover, .pem-view-row:hover, .pem-event-row:hover, -.pem-membership-row:hover, .pem-compact-list button:hover { background: #20282c; } +.pem-membership-row:hover, .pem-compact-list button:hover { background: var(--pem-elevated); } .pem-entity-row[data-selected="true"], .pem-view-row[data-selected="true"], .pem-event-row[data-selected="true"] { - background: #20322f; + background: var(--pem-elevated); box-shadow: inset 2px 0 var(--pem-accent); } .pem-entity-row[data-causal="true"], .pem-view-row[data-causal="true"], .pem-membership-row[data-causal="true"], .pem-compact-list li[data-causal="true"] { box-shadow: inset 2px 0 var(--pem-accent); - background: #1a2c28; + background: var(--pem-surface); } .pem-entity-copy, .pem-event-copy { display: grid; min-width: 0; } .pem-entity-copy strong, .pem-event-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 11px; + font-size: 12px; } .pem-entity-copy code, .pem-event-copy small, .pem-view-row code { overflow: hidden; color: var(--pem-muted); - font-size: 10px; + font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } .pem-row-signals, .pem-status-cluster { flex-wrap: wrap; justify-content: flex-end; - gap: 5px; - color: var(--pem-attention); - font-size: 10px; + gap: 4px; + color: var(--pem-muted); + font-size: 11px; } -.pem-entity-detail, .pem-view-detail, .pem-activity-detail { overflow: auto; padding: 18px; } +.pem-entity-detail, .pem-view-detail, .pem-activity-detail { overflow: auto; padding: 16px; scrollbar-color: var(--pem-line) var(--pem-shell); } .pem-entity-detail article > section, .pem-view-detail article > section, .pem-activity-detail article > section { content-visibility: auto; contain-intrinsic-size: 120px; } -.pem-entity-confirmation { margin: -5px 0 12px; font-size: 10px; } -.pem-detail-header { margin-bottom: 14px; } +.pem-entity-confirmation { margin: -4px 0 12px; font-size: 11px; } +.pem-detail-header { margin-bottom: 12px; } +.pem-dirty-summary { margin-top: 4px; font-size: 12px; } .pem-detail-tools { display: grid; justify-items: end; gap: 8px; } .pem-detail-header h2 code { color: var(--pem-muted); font-size: .75em; font-weight: 500; } .pem-error, .pem-expired { display: grid; - gap: 3px; + gap: 2px; margin-bottom: 12px; - border: 1px solid #7f5233; + border: 1px solid var(--pem-del); border-radius: 5px; - padding: 9px 11px; - background: #2b2019; - color: #ffd4a0; + padding: 8px 12px; + background: var(--pem-surface); + color: var(--pem-text); } +.pem-error strong, .pem-expired strong { color: var(--pem-del); } .pem-value-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--pem-line); } .pem-value-tabs button { - padding: 7px 10px; + min-height: 32px; + padding: 6px 8px; border-bottom: 2px solid transparent; color: var(--pem-muted); text-transform: capitalize; } -.pem-value-tabs button[aria-selected="true"] { border-color: var(--pem-accent); color: #f5f2e9; } +.pem-value-tabs button[aria-selected="true"] { border-color: var(--pem-accent); color: var(--pem-text); } .pem-value-panel { min-height: 160px; max-height: 300px; @@ -634,15 +712,17 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- border: 1px solid var(--pem-line); border-top: 0; border-radius: 0 0 5px 5px; - background: #0e1214; + background: var(--pem-shell); + scrollbar-color: var(--pem-line) var(--pem-shell); } .pem-value { overflow: auto; margin: 0; padding: 12px; - color: #d7e4e1; + color: var(--pem-code); font-size: 11px; line-height: 1.55; + font-variant-numeric: tabular-nums; white-space: pre-wrap; word-break: break-word; } @@ -650,66 +730,68 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- margin-top: 12px; border: 1px solid var(--pem-line); border-radius: 6px; - padding: 13px; - background: #13191b; + padding: 12px; + background: var(--pem-surface); } -.pem-preview-editor { display: grid; gap: 5px; color: var(--pem-muted); font-size: 10px; } +.pem-preview-editor { display: grid; gap: 4px; color: var(--pem-muted); font-size: 11px; } .pem-preview-editor textarea { min-height: 84px; resize: vertical; border: 1px solid var(--pem-line); border-radius: 4px; - padding: 9px; - background: #0d1113; - color: #d7e4e1; - font: 11px/1.5 var(--pem-devtools-font-mono, ui-monospace, monospace); -} -.pem-inline-error { margin-top: 7px; color: #ffc68b; font-size: 11px; } -.pem-preview-diff { margin-top: 10px; border: 1px solid #293337; border-radius: 4px; overflow: hidden; } -.pem-preview-diff > p { padding: 7px 9px; background: #171e21; color: var(--pem-muted); font-size: 10px; } -.pem-preview-actions { justify-content: flex-start; margin-top: 10px; } -.pem-preview-receipt { margin-top: 8px; color: var(--pem-muted); font-size: 10px; } + padding: 8px; + background: var(--pem-shell); + color: var(--pem-code); + font: 11px/1.5 var(--pem-font-mono); +} +.pem-inline-error { margin-top: 8px; color: var(--pem-del); font-size: 11px; } +.pem-preview-diff { margin-top: 8px; border: 1px solid var(--pem-line); border-radius: 4px; overflow: hidden; } +.pem-preview-diff > p { padding: 6px 8px; background: var(--pem-elevated); color: var(--pem-muted); font-size: 11px; } +.pem-preview-actions { justify-content: flex-start; margin-top: 8px; } +.pem-preview-receipt { margin-top: 8px; color: var(--pem-muted); font-size: 11px; } .pem-diff-table { display: grid; } .pem-diff-row { display: grid; grid-template-columns: minmax(90px, .65fr) repeat(2, minmax(120px, 1fr)); - border-top: 1px solid #252e32; + border-top: 1px solid var(--pem-line); } .pem-diff-row:first-child { border-top: 0; } -.pem-diff-row > span { +.pem-diff-row > * { min-width: 0; - padding: 7px 9px; + padding: 6px 8px; overflow-wrap: anywhere; - font: 10px/1.4 var(--pem-devtools-font-mono, ui-monospace, monospace); + font: 11px/1.4 var(--pem-font-mono); + font-variant-numeric: tabular-nums; } -.pem-diff-head { color: var(--pem-muted); background: #161c1f; font-weight: 700; } -.pem-diff-row[data-kind="changed"] > span:first-child, -.pem-diff-row[data-kind="added"] > span:first-child, -.pem-diff-row[data-kind="removed"] > span:first-child { color: var(--pem-attention); } +.pem-diff-head { color: var(--pem-muted); background: var(--pem-elevated); font-weight: 700; } +.pem-diff-row[data-kind="changed"] > :first-child { color: var(--pem-mod); } +.pem-diff-row[data-kind="added"] > :first-child { color: var(--pem-add); } +.pem-diff-row[data-kind="removed"] > :first-child { color: var(--pem-del); } .pem-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; + gap: 12px; margin-top: 12px; } .pem-causal-rail { min-width: 0; overflow: auto; border-left: 1px solid var(--pem-line); - padding: 14px 10px; - background: #121719; + padding: 12px 8px; + background: var(--pem-surface); + scrollbar-color: var(--pem-line) var(--pem-surface); } .pem-causal-rail[data-collapsed="true"] { overflow: hidden; padding: 8px 4px; } .pem-causal-heading { display: flex; align-items: start; justify-content: space-between; gap: 8px; } .pem-causal-heading h2 { font-size: 15px; } -.pem-causal-path { display: grid; gap: 0; margin-top: 14px; } +.pem-causal-path { display: grid; gap: 0; margin-top: 12px; } .pem-causal-path li { position: relative; display: grid; - gap: 5px; + gap: 4px; border-left: 1px solid var(--pem-line); - padding: 0 0 14px 12px; + padding: 0 0 12px 12px; } .pem-causal-path li::before { position: absolute; @@ -719,135 +801,134 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- height: 7px; border: 1px solid var(--pem-accent); border-radius: 50%; - background: #121719; + background: var(--pem-surface); content: ""; } -.pem-causal-path li > span { color: var(--pem-accent); font-size: 9px; font-weight: 800; text-transform: uppercase; } +.pem-causal-path li > span { color: var(--pem-accent); font-size: 11px; font-weight: 700; text-transform: uppercase; } .pem-causal-path button, .pem-causal-path code { display: grid; width: 100%; border: 0; - padding: 3px 0; + padding: 2px 0; background: transparent; - color: #dce4df; + color: var(--pem-code); text-align: left; overflow-wrap: anywhere; - font-size: 10px; + font-size: 11px; } -.pem-causal-path button { cursor: pointer; } -.pem-causal-path small { color: var(--pem-muted); font-size: 9px; } -.pem-compact-list { display: grid; gap: 3px; } -.pem-compact-list button, .pem-membership-row { display: grid; gap: 3px; padding: 6px; } +.pem-causal-path button { min-height: 32px; cursor: pointer; } +.pem-causal-path small { color: var(--pem-muted); font-size: 11px; } +.pem-compact-list { display: grid; gap: 2px; } +.pem-compact-list button, .pem-membership-row { display: grid; gap: 2px; padding: 6px; } +.pem-compact-list button { min-height: 32px; } .pem-compact-list code, .pem-compact-list small, .pem-membership-row code { color: var(--pem-muted); - font-size: 9px; + font-size: 11px; } .pem-view-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-bottom: 12px; } .pem-readout { min-height: 70px; } .pem-list-health { margin-bottom: 12px; } +.pem-membership .pem-scroll-list { max-height: min(420px, 50vh); } .pem-membership-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; } -.pem-membership-row { border: 1px solid #293337; background: #121719; } -.pem-membership-position { color: var(--pem-accent); font: 9px var(--pem-devtools-font-mono, ui-monospace, monospace); } +.pem-membership-list:has(> .pem-virtual-space) { display: block; } +.pem-membership-row { border: 1px solid var(--pem-line); background: var(--pem-surface); } +.pem-membership-position { color: var(--pem-accent); font: 11px var(--pem-font-mono); font-variant-numeric: tabular-nums; } .pem-last-change { margin-bottom: 12px; } .pem-last-change-readout { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 12px; } -.pem-last-change-readout code { grid-column: 1 / -1; color: var(--pem-muted); font-size: 9px; overflow-wrap: anywhere; } +.pem-last-change-readout code { grid-column: 1 / -1; color: var(--pem-muted); font-size: 11px; overflow-wrap: anywhere; } .pem-activity-heading { align-items: start; } -.pem-select-label { display: grid; gap: 5px; color: var(--pem-muted); font-size: 10px; } +.pem-select-label { display: grid; gap: 4px; color: var(--pem-muted); font-size: 11px; } .pem-event-row { min-height: 50px; justify-content: start; } -.pem-correlation { color: var(--pem-muted); font-size: 9px; } +.pem-correlation { color: var(--pem-muted); font-size: 11px; } .pem-event-sequence { flex: 0 0 42px; color: var(--pem-accent); - font: 10px var(--pem-devtools-font-mono, ui-monospace, monospace); + font: 11px var(--pem-font-mono); + font-variant-numeric: tabular-nums; } .pem-time-travel { - border: 1px solid #293337; + border: 1px solid var(--pem-line); border-radius: 5px; - padding: 9px; - background: #101517; + padding: 8px; + background: var(--pem-surface); } -.pem-time-travel .pem-card-heading { margin-bottom: 7px; } -.pem-time-travel-controls { display: grid; gap: 7px; } -.pem-time-travel-controls p { color: var(--pem-muted); font-size: 10px; } +.pem-time-travel .pem-card-heading { margin-bottom: 8px; } +.pem-time-travel-controls { display: grid; gap: 8px; } +.pem-time-travel-controls p { color: var(--pem-muted); font-size: 11px; } .pem-event-readouts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 12px; } -.pem-event-readouts div { border: 1px solid #293337; border-radius: 4px; padding: 8px; } -.pem-change-list { display: grid; gap: 5px; } +.pem-event-readouts div { border: 1px solid var(--pem-line); border-radius: 4px; padding: 8px; } +.pem-change-list { display: grid; gap: 4px; } .pem-change-list li { display: grid; grid-template-columns: 70px minmax(120px, .8fr) minmax(0, 1fr); - gap: 9px; - border-top: 1px solid #252e32; - padding: 7px 0; + gap: 8px; + border-top: 1px solid var(--pem-line); + padding: 6px 0; } .pem-retention-warning { - margin-bottom: 10px; - border-left: 2px solid var(--pem-attention); + margin-bottom: 8px; + border-left: 2px solid var(--pem-mod); padding: 6px 8px; - background: #281a17; - color: #f2b19d; - font-size: 10px; -} -.pem-impact-readouts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 10px; } -.pem-impact-identities { display: flex; flex-wrap: wrap; gap: 5px; margin: 7px 0; } -.pem-impact-identities strong { color: var(--pem-muted); font-size: 10px; } -.pem-impact-identities code { border: 1px solid var(--pem-line); border-radius: 3px; padding: 2px 5px; font-size: 9px; } + background: var(--pem-surface); + color: var(--pem-text); + font-size: 11px; +} +.pem-impact-readouts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 8px; } +.pem-impact-identities { display: flex; flex-wrap: wrap; gap: 4px; margin: 8px 0; } +.pem-impact-identities strong { color: var(--pem-muted); font-size: 11px; } +.pem-impact-identities code { border: 1px solid var(--pem-line); border-radius: 3px; padding: 2px 4px; font-size: 11px; } .pem-graph-pulse { display: grid; grid-template-columns: auto minmax(120px, 1fr) minmax(190px, .45fr); min-height: 54px; align-items: center; - gap: 10px; + gap: 8px; border-top: 1px solid var(--pem-line); - padding: 7px 10px; - background: #0f1315; + padding: 8px 12px; + background: var(--pem-shell); } -.pem-graph-pulse[data-collapsed="true"] { grid-template-columns: auto; min-height: 34px; } +.pem-graph-pulse[data-collapsed="true"] { grid-template-columns: auto; min-height: 36px; } .pem-pulse-toggle { - min-height: 30px; + min-height: 32px; border: 1px solid var(--pem-line); border-radius: 4px; padding: 4px 8px; - background: #171d20; + background: var(--pem-elevated); color: var(--pem-accent); cursor: pointer; - font-weight: 750; + font-weight: 600; } -.pem-pulse-segments { display: flex; min-width: 0; align-items: stretch; gap: 2px; overflow-x: auto; } -.pem-pulse-segments li { flex: 1 0 20px; max-width: 48px; } +.pem-pulse-segments { display: flex; min-width: 0; align-items: stretch; gap: 2px; overflow-x: auto; scrollbar-color: var(--pem-line) var(--pem-shell); } +.pem-pulse-segments li { flex: 1 0 32px; max-width: 48px; } .pem-pulse-segments button { display: grid; width: 100%; min-height: 32px; place-items: center; border: 0; - border-bottom: 2px solid #526069; - background: #0f1315; + border-bottom: 2px solid var(--pem-muted); + background: var(--pem-shell); color: var(--pem-muted); cursor: pointer; - animation: pem-pulse-arrival 150ms ease-out both; } .pem-pulse-segments button[data-event-type="mutation"] { border-color: var(--pem-accent); } -.pem-pulse-segments button[data-selected="true"] { background: #1a2c28; color: #eef8f3; } +.pem-pulse-segments button[data-selected="true"] { background: var(--pem-elevated); color: var(--pem-text); } .pem-pulse-segments button > span { width: 5px; height: 5px; border-radius: 50%; background: currentColor; } -.pem-pulse-segments small { font-size: 8px; } -.pem-pulse-empty { color: var(--pem-muted); font-size: 10px; } +.pem-pulse-segments small { font-size: 11px; font-variant-numeric: tabular-nums; } +.pem-pulse-empty { color: var(--pem-muted); font-size: 11px; } .pem-pulse-readout { display: grid; min-width: 0; } .pem-pulse-readout strong, .pem-pulse-readout span, .pem-pulse-readout code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pem-pulse-readout span, .pem-pulse-readout code { color: var(--pem-muted); font-size: 9px; } -@keyframes pem-pulse-arrival { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } -} -.pem-empty { padding: 10px 2px; font-size: 11px; } +.pem-pulse-readout span, .pem-pulse-readout code { color: var(--pem-muted); font-size: 11px; font-variant-numeric: tabular-nums; } +.pem-empty { padding: 8px 2px; font-size: 11px; } .pem-empty-large { display: grid; min-height: 240px; place-items: center; text-align: center; } .pem-sr-only { position: absolute; @@ -861,47 +942,38 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- } .pem-devtools-loading { min-width: 224px; - border: 1px solid var(--pem-devtools-color-border, #344047); - border-radius: var(--pem-devtools-radius-panel, 6px); + border: 1px solid var(--pem-line); + border-radius: var(--pem-radius-panel); padding: 12px 16px; - background: var(--pem-devtools-color-shell, #111416); - color: var(--pem-devtools-color-text, #e8e6df); + background: var(--pem-shell); + color: var(--pem-text); box-shadow: 0 16px 48px rgb(0 0 0 / 36%); font-size: 13px; line-height: 1.4; } -.pem-mobile-back { display: none; } +.pem-mobile-back { display: none; min-height: 32px; } -@media (max-width: 719px) { - .pem-panel-frame, - .pem-panel-frame[data-layout="floating"], - .pem-panel-frame[data-layout="dock-right"], - .pem-panel-frame[data-layout="dock-bottom"] { - inset: 0; - width: 100vw; - height: 100dvh; - transform: none; - border: 0; - border-radius: 0; - padding-top: env(safe-area-inset-top); - padding-right: env(safe-area-inset-right); - padding-bottom: env(safe-area-inset-bottom); - padding-left: env(safe-area-inset-left); +/* Layout breakpoints follow the panel's own width, not the viewport. */ +@container (width < 900px) { + .pem-entity-workspace, + .pem-entity-workspace[data-causal-rail-collapsed="true"] { + grid-template-columns: 286px minmax(0, 1fr); } - .pem-panel-toolbar { min-height: 44px; } - .pem-panel-toolbar button, .pem-workspace-tabs button, .pem-mobile-back, - .pem-primary-action, .pem-secondary-action, .pem-detail-actions button, - .pem-time-travel-controls button, .pem-settings button, .pem-settings select { - min-width: 44px; - min-height: 44px; + .pem-entity-workspace[data-navigator-collapsed="true"], + .pem-entity-workspace[data-navigator-collapsed="true"][data-causal-rail-collapsed="true"] { + grid-template-columns: 40px minmax(0, 1fr); } + .pem-causal-rail { display: none; } +} + +@container (width < 640px) { .pem-inspector { font-size: 12px; } .pem-shell-header { align-items: flex-start; flex-direction: column; gap: 8px; - padding: 10px 12px; + padding: 8px 12px; } .pem-shell-status { justify-content: flex-start; } .pem-workspace-tabs { @@ -918,14 +990,13 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .pem-workspace-heading { align-items: flex-start; flex-direction: column; } .pem-overview-actions { width: 100%; justify-content: flex-start; } .pem-store-select { width: 100%; } - .pem-store-select select { min-height: 44px; max-width: none; flex: 1; } + .pem-store-select select { max-width: none; flex: 1; } .pem-metric-grid, .pem-view-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .pem-overview-grid, .pem-detail-grid { grid-template-columns: 1fr; } .pem-entity-workspace, .pem-view-workspace, .pem-activity-workspace { display: block; overflow: hidden; } - .pem-causal-rail { display: none; } .pem-graph-pulse { grid-template-columns: auto minmax(180px, 1fr); } .pem-pulse-readout { display: none; } .pem-navigator, .pem-entity-detail, .pem-view-detail, .pem-activity-detail { height: 100%; } @@ -939,21 +1010,21 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- display: inline-flex; align-items: center; justify-content: center; - border: 1px solid #3c494f; + border: 1px solid var(--pem-line); border-radius: 4px; - margin-bottom: 10px; - padding: 6px 10px; - background: #171e21; - color: #d9dfdc; + margin-bottom: 8px; + padding: 6px 12px; + background: var(--pem-surface); + color: var(--pem-text); cursor: pointer; } .pem-detail-header { align-items: flex-start; flex-direction: column; } .pem-detail-tools { width: 100%; justify-items: start; } .pem-detail-actions { justify-content: flex-start; } .pem-value-tabs { overflow-x: auto; } - .pem-value-tabs button { flex: 0 0 auto; min-height: 44px; } + .pem-value-tabs button { flex: 0 0 auto; } .pem-value-panel { max-height: 42vh; } - .pem-preview-panel { padding: 10px; } + .pem-preview-panel { padding: 8px; } .pem-preview-editor textarea { min-height: 120px; } .pem-preview-diff { overflow-x: auto; } .pem-diff-table { min-width: 520px; } @@ -961,14 +1032,49 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .pem-event-readouts { grid-template-columns: 1fr; } .pem-change-list li { grid-template-columns: 62px minmax(0, 1fr); } .pem-change-list li small { grid-column: 2; } - .pem-settings { position: fixed; top: calc(52px + env(safe-area-inset-top)); right: 14px; left: 14px; width: auto; } +} + +/* Viewport-bound rules only: the full-screen frame, safe areas and touch targets. */ +@media (max-width: 719px) { + .pem-panel-frame, + .pem-panel-frame[data-layout="floating"], + .pem-panel-frame[data-layout="dock-right"], + .pem-panel-frame[data-layout="dock-bottom"] { + inset: 0; + width: 100vw; + height: 100dvh; + transform: none; + border: 0; + border-radius: 0; + padding-top: env(safe-area-inset-top); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + } + .pem-panel-toolbar { min-height: 44px; } + .pem-panel-toolbar button, .pem-launcher-settings, + .pem-primary-action, .pem-secondary-action, .pem-detail-actions button, + .pem-time-travel-controls button, + .pem-rail-collapse, .pem-rail-restore, + .pem-filter-row button, .pem-pause, + .pem-workspace-tabs button, .pem-value-tabs button, + .pem-shell-status > button, .pem-rewound-bar button, + .pem-command-feedback button, .pem-causal-path button, + .pem-pulse-segments button, .pem-pulse-toggle, + .pem-store-select select, + .pem-settings fieldset button, .pem-settings-hide button, .pem-settings header button, .pem-settings select, + .pem-trace-list button, .pem-compact-list button, .pem-mobile-back { + min-width: 44px; + min-height: 44px; + } + .pem-settings { position: fixed; top: calc(52px + env(safe-area-inset-top)); right: 16px; left: 16px; width: auto; } .pem-settings fieldset, .pem-settings-hide { grid-template-columns: 1fr; flex-direction: column; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; + animation: none !important; + transition: none !important; scroll-behavior: auto !important; } } diff --git a/packages/entity-graph-react/src/devtools/view-model.ts b/packages/entity-graph-react/src/devtools/view-model.ts index 3ec8745f..575c91a4 100644 --- a/packages/entity-graph-react/src/devtools/view-model.ts +++ b/packages/entity-graph-react/src/devtools/view-model.ts @@ -61,6 +61,19 @@ export interface InspectorActivePreview { receipt: GraphDevtoolsPreviewAppliedReceipt; } +export interface InspectorRewoundSnapshot { + cursor: number; + /** Sequence of the publication that produced the snapshot; null for baselines and imports. */ + eventSequence: number | null; +} + +/** Status filters that map one-to-one onto the header chips and Overview metrics. */ +export type EntityFocusStatus = Exclude; + +function entityMatchesStatus(entity: GraphDevtoolsEntityRecord, status: EntityFocusStatus): boolean { + return status === "dirty" ? entity.dirty : entity.entityState.error !== null; +} + function eventTouchesEntity(event: GraphDevtoolsEvent, entity: GraphDevtoolsEntityRecord): boolean { return event.type === "mutation" && ( affectedEntitiesForEvent(event).some((affected) => ( @@ -117,6 +130,8 @@ export interface EntityGraphInspectorViewModel { setSearch(value: string): void; entityFilter: EntityStatusFilter; setEntityFilter(filter: EntityStatusFilter): void; + /** Open Entities filtered by `status` with the first matching entity selected. */ + focusEntityStatus(status: EntityFocusStatus): void; entities: readonly GraphDevtoolsEntityRecord[]; selectedEntity: GraphDevtoolsEntityRecord | null; selectEntity(entity: GraphDevtoolsEntityRecord): void; @@ -145,6 +160,8 @@ export interface EntityGraphInspectorViewModel { setActivityFilter(filter: ActivityTypeFilter): void; paused: boolean; togglePaused(): void; + /** Events received since the feed was paused; 0 while live. */ + pausedNewCount: number; events: readonly GraphDevtoolsEvent[]; selectedEvent: GraphDevtoolsEvent | null; selectedEventExpired: boolean; @@ -158,6 +175,8 @@ export interface EntityGraphInspectorViewModel { rewind(): Promise; returnToLive(): Promise; timeTravelAvailable: boolean; + /** The retained snapshot being viewed while rewound; null when live. */ + rewoundSnapshot: InspectorRewoundSnapshot | null; exportGraph(): Promise; command: InspectorCommandState; clearCommandFeedback(): void; @@ -197,7 +216,7 @@ export function useEntityGraphInspectorViewModel( )); const [selectedViewId, setSelectedViewId] = useState(initialState?.viewId ?? null); const [selectedSequence, setSelectedSequence] = useState(initialState?.eventSequence ?? null); - const [valueTab, setValueTab] = useState(initialState?.valueTab ?? "original"); + const [valueTabChoice, setValueTab] = useState(initialState?.valueTab ?? null); const [activityFilter, setActivityFilter] = useState(initialState?.activityFilter ?? "all"); const [paused, setPaused] = useState(false); const [pausedEvents, setPausedEvents] = useState([]); @@ -205,6 +224,12 @@ export function useEntityGraphInspectorViewModel( const [previewReceipts, setPreviewReceipts] = useState>({}); const [selectedRewindCursor, setSelectedRewindCursor] = useState(null); const [command, setCommand] = useState({ pending: null, notice: null, error: null }); + // A host adapter that names a workspace, filter or entity has already chosen the landing. + const [landed, setLanded] = useState(() => Boolean( + initialState?.workspace || + initialState?.entityFilter || + (initialState?.entityType && initialState.entityId), + )); useEffect(() => { setSelectedRewindCursor(null); @@ -212,6 +237,17 @@ export function useEntityGraphInspectorViewModel( setCommand({ pending: null, notice: null, error: null }); }, [runtime.storeId]); + // Land on the first dirty entity once, when the first snapshot arrives; later choices win. + useEffect(() => { + if (landed || !model) return; + setLanded(true); + const firstDirty = model.entities.find((entity) => entity.dirty); + if (!firstDirty) return; + setWorkspace("entities"); + setEntityFilter("dirty"); + setSelectedEntityKey(inspectorEntityIdentity(firstDirty)); + }, [landed, model]); + const entities = useMemo(() => (model?.entities ?? []).filter((entity) => { if (entityFilter === "dirty" && !entity.dirty) return false; if (entityFilter === "errors" && !entity.entityState.error) return false; @@ -225,6 +261,8 @@ export function useEntityGraphInspectorViewModel( return all.find((entity) => inspectorEntityIdentity(entity) === selectedEntityKey) ?? entities[0] ?? null; }, [entities, model?.entities, selectedEntityKey]); + const valueTab: EntityValueTab = valueTabChoice ?? (selectedEntity?.dirty ? "diff" : "original"); + const views = useMemo(() => model?.views ?? [], [model?.views]); const selectedView = useMemo( () => views.find((view) => view.viewId === selectedViewId) ?? views[0] ?? null, @@ -284,6 +322,11 @@ export function useEntityGraphInspectorViewModel( const liveEvents = useMemo(() => model?.events ?? [], [model?.events]); const visibleEvents = paused ? pausedEvents : liveEvents; + const pausedNewCount = useMemo(() => { + if (!paused) return 0; + const lastSeen = pausedEvents[pausedEvents.length - 1]?.sequence ?? 0; + return liveEvents.filter((event) => event.sequence > lastSeen).length; + }, [liveEvents, paused, pausedEvents]); const events = useMemo( () => [...visibleEvents] .filter((event) => activityFilter === "all" || event.type === activityFilter) @@ -302,12 +345,19 @@ export function useEntityGraphInspectorViewModel( const causalViewIds = useMemo(() => new Set( selectedEvent ? affectedViewIdsForEvent(selectedEvent) : [], ), [selectedEvent]); - const snapshotReferences = model?.snapshotReferences ?? []; + const modelSnapshotReferences = model?.snapshotReferences; + const snapshotReferences = useMemo(() => modelSnapshotReferences ?? [], [modelSnapshotReferences]); const rewindCursor = selectedRewindCursor !== null && snapshotReferences.some((reference) => reference.cursor === selectedRewindCursor) ? selectedRewindCursor : snapshotReferences[snapshotReferences.length - 1]?.cursor ?? null; const timeTravelAvailable = model?.capabilities.features.includes("time-travel") ?? false; + const rewoundSnapshot = useMemo(() => { + const status = model?.snapshot.snapshots; + if (!status || status.mode !== "rewound" || status.cursor === null) return null; + const reference = snapshotReferences.find((candidate) => candidate.cursor === status.cursor); + return { cursor: status.cursor, eventSequence: reference?.eventSequence ?? null }; + }, [model?.snapshot.snapshots, snapshotReferences]); const perform = useCallback(async ( kind: InspectorCommandKind, @@ -337,6 +387,15 @@ export function useEntityGraphInspectorViewModel( setWorkspace("entities"); setNarrowDetailOpen(true); }, [model?.entities]); + const focusEntityStatus = useCallback((status: EntityFocusStatus) => { + setWorkspace("entities"); + setEntityFilter(status); + setNarrowDetailOpen(false); + const entity = model?.entities.find((candidate) => entityMatchesStatus(candidate, status)); + if (!entity) return; + setSelectedEntityKey(inspectorEntityIdentity(entity)); + setPreviewDraft(""); + }, [model?.entities]); const selectView = useCallback((view: GraphDevtoolsViewRecord) => { setSelectedViewId(view.viewId); setWorkspace("views"); @@ -565,6 +624,7 @@ export function useEntityGraphInspectorViewModel( setSearch, entityFilter, setEntityFilter, + focusEntityStatus, entities, selectedEntity, selectEntity, @@ -593,6 +653,7 @@ export function useEntityGraphInspectorViewModel( setActivityFilter, paused, togglePaused, + pausedNewCount, events, selectedEvent, selectedEventExpired, @@ -606,6 +667,7 @@ export function useEntityGraphInspectorViewModel( rewind, returnToLive, timeTravelAvailable, + rewoundSnapshot, exportGraph, command, clearCommandFeedback, diff --git a/packages/entity-graph-react/src/devtools/workspaces/activity.tsx b/packages/entity-graph-react/src/devtools/workspaces/activity.tsx index 9e71e001..90272a8f 100644 --- a/packages/entity-graph-react/src/devtools/workspaces/activity.tsx +++ b/packages/entity-graph-react/src/devtools/workspaces/activity.tsx @@ -4,7 +4,7 @@ import type { GraphDevtoolsSnapshotHistoryStatus, } from "@prometheus-ags/entity-graph-core/devtools"; import { InspectorVirtualList } from "../components/virtual-list"; -import { eventDetail, eventTitle, formatEventTime } from "../event-format"; +import { eventCorrelationLabel, eventDetail, eventTitle, formatEventTime } from "../event-format"; import { affectedEntitiesForEvent, affectedViewIdsForEvent } from "../causality"; import type { ActivityTypeFilter } from "../view-model"; @@ -15,6 +15,8 @@ export interface ActivityWorkspaceProps { filter: ActivityTypeFilter; onFilter(filter: ActivityTypeFilter): void; paused: boolean; + /** Events received since pausing; shown on the Resume control. */ + pausedNewCount: number; onTogglePaused(): void; onSelect(event: GraphDevtoolsEvent): void; snapshots: GraphDevtoolsSnapshotHistoryStatus; @@ -49,7 +51,9 @@ export function ActivityWorkspace(props: ActivityWorkspaceProps) {

Retained history

Activity