viewer-panels: read-only boots panels open-collapsed; kicad-style layer rows

- Read-only sessions boot BOTH panels open as collapsed headers (a stored
  per-browser choice wins); inspector's default anchor moves to the
  top-left, layers keep the top-right stack under the FAB.
- Layer rows follow KiCad's Appearance-pane order: color swatch, eye
  toggle, layer name (name click = set active).
- read-only spec: boot-default assertions (open + collapsed + top-left
  anchor) in the lock test, chevron expansion in the panels test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gJ3M1RpeZLeNUUj8jKC4h
This commit is contained in:
Gergő Törcsvári 2026-08-20 11:49:55 +02:00
commit 3df3468d36
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
4 changed files with 90 additions and 30 deletions

View file

@ -154,6 +154,25 @@ test('viewer boots locked: chrome-less, selection inspect-only, hotkey edits ine
await expect(writer.locator('[data-testid="chrome-toggle"]')).toBeVisible();
await expect(writer.getByTestId('view-only-pill')).toHaveCount(0);
// Viewer-panels boot defaults: both panels come up OPEN as COLLAPSED
// headers (inspector anchored top-LEFT, layers top-right) — then close
// them so the click probes below can't land on a header. The panels test
// re-opens them through the overlay-menu toggles. First close the still-
// open overlay menu (z-50) — it covers the layers header's close button.
await viewer.getByTestId('overlay-menu-fab').click();
await expect(viewer.getByTestId('overlay-menu-panel')).toHaveCount(0);
await expect(viewer.getByTestId('layers-panel')).toBeVisible();
await expect(viewer.getByTestId('inspector-panel')).toBeVisible();
await expect(viewer.getByTestId('layers-panel-list')).toHaveCount(0);
await expect(viewer.getByTestId('inspector-panel-list')).toHaveCount(0);
const inspectorBox = (await viewer.getByTestId('inspector-panel').boundingBox())!;
expect(inspectorBox.x, 'inspector defaults to the top-left').toBeLessThan(100);
expect(inspectorBox.y, 'inspector defaults to the top-left').toBeLessThan(60);
await viewer.getByTestId('layers-panel-close').click();
await viewer.getByTestId('inspector-panel-close').click();
await expect(viewer.getByTestId('layers-panel')).toHaveCount(0);
await expect(viewer.getByTestId('inspector-panel')).toHaveCount(0);
// No presence/comments surfaces for a viewer.
await expect(viewer.locator('[data-testid="presence-roster"]')).toHaveCount(0);
expect(
@ -348,6 +367,9 @@ test('viewer panels: layer selector + selection inspector (viewer-panels)', asyn
// and would swallow the row clicks below.
await viewer.keyboard.press('Escape');
await expect(viewer.getByTestId('overlay-menu-panel')).toHaveCount(0);
// Reopened panels keep the read-only default: collapsed — expand.
await viewer.getByTestId('layers-panel-collapse').click();
await expect(viewer.getByTestId('layers-panel-list')).toBeVisible();
await expect(viewer.locator('[data-testid="layer-row"]').first()).toBeVisible();
const st0 = await layersState();
@ -433,6 +455,10 @@ test('viewer panels: layer selector + selection inspector (viewer-panels)', asyn
// show. The FAB toggle never touches the canvas.
await viewer.getByTestId('overlay-menu-fab').click();
await expect(viewer.getByTestId('overlay-menu-panel')).toHaveCount(0);
// Still collapsed by default — expand (this is a header-button click, it
// never touches the canvas or the selection).
await viewer.getByTestId('inspector-panel-collapse').click();
await expect(viewer.getByTestId('inspector-panel-list')).toBeVisible();
// The already-made selection renders with real property rows (every item
// type yields at least one of these labels).
await expect

View file

@ -78,7 +78,17 @@ export function parseLayersState(json: string): LayersState | null {
}
}
export function LayerPanel({ mod, onClose }: { mod: LayersModule; onClose: () => void }) {
export function LayerPanel({
mod,
defaultCollapsed,
onClose,
}: {
mod: LayersModule;
/** Boot state when no per-browser choice is stored (read-only sessions
* start as a collapsed header viewer-panels). */
defaultCollapsed?: boolean;
onClose: () => void;
}) {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const drag = useDraggablePanel({
storageKey: PANEL_POS_KEY,
@ -87,10 +97,12 @@ export function LayerPanel({ mod, onClose }: { mod: LayersModule; onClose: () =>
});
const [collapsed, setCollapsedState] = React.useState<boolean>(() => {
try {
return localStorage.getItem(PANEL_COLLAPSED_KEY) === "1";
const stored = localStorage.getItem(PANEL_COLLAPSED_KEY);
if (stored !== null) return stored === "1";
} catch {
return false;
/* private mode */
}
return defaultCollapsed === true;
});
const setCollapsed = (v: boolean) => {
setCollapsedState(v);
@ -201,22 +213,12 @@ export function LayerPanel({ mod, onClose }: { mod: LayersModule; onClose: () =>
state.active === l.id ? "bg-sky-500/10 dark:bg-sky-400/10" : ""
}`}
>
{/* Row body sets the ACTIVE layer (the wx Appearance pane's
click semantics); the eye toggles visibility. */}
<button
data-testid="layer-activate"
className="flex min-w-0 flex-1 items-center gap-2 text-left hover:text-sky-600 dark:hover:text-sky-300"
title={`Make ${l.name} the active layer`}
onClick={() => setActive(l.id)}
>
{/* KiCad Appearance-pane row order: color swatch, eye, name.
The name sets the ACTIVE layer; the eye toggles visibility. */}
<span
className="h-3 w-3 shrink-0 rounded-sm ring-1 ring-inset ring-black/20 dark:ring-white/25"
style={l.color ? { backgroundColor: l.color } : undefined}
/>
<span className={`truncate ${state.active === l.id ? "font-semibold" : ""}`}>
{l.name}
</span>
</button>
<button
data-testid="layer-visibility"
aria-pressed={l.visible}
@ -230,6 +232,16 @@ export function LayerPanel({ mod, onClose }: { mod: LayersModule; onClose: () =>
>
{l.visible ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<button
data-testid="layer-activate"
className="min-w-0 flex-1 text-left hover:text-sky-600 dark:hover:text-sky-300"
title={`Make ${l.name} the active layer`}
onClick={() => setActive(l.id)}
>
<span className={`block truncate ${state.active === l.id ? "font-semibold" : ""}`}>
{l.name}
</span>
</button>
</div>
))}
</div>

View file

@ -37,12 +37,16 @@ const MAX_ITEMS = 20;
export function SelectionInspector({
doc,
defaultCollapsed,
onClose,
}: {
/** The bound collab doc (pcbnew: the board room; eeschema: the ACTIVE
* sheet's room). Null when no doc room is bound (?collab=0) the panel
* then shows selection counts only. */
doc: Y.Doc | null;
/** Boot state when no per-browser choice is stored (read-only sessions
* start as a collapsed header viewer-panels). */
defaultCollapsed?: boolean;
onClose: () => void;
}) {
const rootRef = React.useRef<HTMLDivElement | null>(null);
@ -53,10 +57,12 @@ export function SelectionInspector({
});
const [collapsed, setCollapsedState] = React.useState<boolean>(() => {
try {
return localStorage.getItem(PANEL_COLLAPSED_KEY) === "1";
const stored = localStorage.getItem(PANEL_COLLAPSED_KEY);
if (stored !== null) return stored === "1";
} catch {
return false;
/* private mode */
}
return defaultCollapsed === true;
});
const setCollapsed = (v: boolean) => {
setCollapsedState(v);
@ -103,11 +109,12 @@ export function SelectionInspector({
const count = selection.uuids.length;
// Default anchor: below the overlay-menu FAB, clear of the layer panel's
// default (right 12 / top 56, w-64) — stack under it.
// Default anchor: TOP-LEFT (the layer panel owns the top-right stack under
// the overlay-menu FAB). Clear of the transient status chip (left-3 top-3,
// pointer-events-none anyway).
const style: React.CSSProperties = drag.pos
? { left: drag.pos.x, top: drag.pos.y }
: { right: 12, top: 96 };
: { left: 12, top: 12 };
return (
<div

View file

@ -1182,12 +1182,17 @@ export function WasmTool({
// Read-only sessions never bind presence, so the inspector's selection
// store is fed by this minimal local handler (+ the C++ input hooks).
const localSelectionRef = React.useRef<{ destroy(): void } | null>(null);
// Read-only sessions boot with BOTH panels open as collapsed headers
// (viewer-panels): discoverable without a trip through the menu; a stored
// per-browser choice wins over the default.
const [layersOpen, setLayersOpenState] = React.useState<boolean>(() => {
try {
return localStorage.getItem(LAYERS_OPEN_KEY) === "1";
const stored = localStorage.getItem(LAYERS_OPEN_KEY);
if (stored !== null) return stored === "1";
} catch {
return false;
/* private mode */
}
return readOnly === true;
});
const setLayersOpen = React.useCallback((v: boolean) => {
setLayersOpenState(v);
@ -1199,10 +1204,12 @@ export function WasmTool({
}, []);
const [inspectorOpen, setInspectorOpenState] = React.useState<boolean>(() => {
try {
return localStorage.getItem(INSPECTOR_OPEN_KEY) === "1";
const stored = localStorage.getItem(INSPECTOR_OPEN_KEY);
if (stored !== null) return stored === "1";
} catch {
return false;
/* private mode */
}
return readOnly === true;
});
const setInspectorOpen = React.useCallback((v: boolean) => {
setInspectorOpenState(v);
@ -2767,13 +2774,21 @@ export function WasmTool({
inspector for canvas-only sessions the React stand-ins for the wx
Appearance/Properties panes that kicadSetChrome(false) hides. */}
{ready && effectiveChromeHidden && layersOpen && layersMod && (
<LayerPanel mod={layersMod} onClose={() => setLayersOpen(false)} />
<LayerPanel
mod={layersMod}
defaultCollapsed={readOnly}
onClose={() => setLayersOpen(false)}
/>
)}
{ready &&
effectiveChromeHidden &&
inspectorOpen &&
(tool === "pcbnew" || tool === "eeschema") && (
<SelectionInspector doc={panelDoc} onClose={() => setInspectorOpen(false)} />
<SelectionInspector
doc={panelDoc}
defaultCollapsed={readOnly}
onClose={() => setInspectorOpen(false)}
/>
)}
{/* DEV: presence style tuner (VITE_PRESENCE_TUNER=1). */}