Add the shared disclosure, the basis for showing less by default

Native details/summary, so keyboard, screen readers and find-in-page keep
working and closed is not hidden. What someone opens stays open for them,
remembered per section in that browser only; anything needing attention
is forced open regardless, and a closed section still carries the useful
value in its summary. Safe when storage is blocked and on the server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-20 06:39:35 -05:00
parent 8be9b1c154
commit 213d1c05f3
3 changed files with 194 additions and 0 deletions

View File

@ -1177,3 +1177,11 @@
{"id":"evt-e7ypp3ac15hx","type":"node.updated","subject":"gol-6x4ctm2","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"target":5,"direction":"up","unit":"issues done"}},"at":"2026-09-20T11:34:42.718Z","parents":["evt-3cxchwjnx1ta"],"hash":"4a3d176349fb8afa1ecf40ec1bb4f1b887ab72a255bd472f5bcc88945d2b4652"}
{"id":"evt-7s7945vv080q","type":"node.created","subject":"mea-xq3gw0y","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"Interface simplification: 0 of 5 issues done","body":"Starting point on 2026-09-20, when the work was broken down per screen: iss-jz3431t the shared disclosure and its rules (the other four wait on it), iss-t9twsxg the dashboard, iss-g9bgm9g the camera page, iss-aygmw21 the recordings page, iss-3jhw2p5 what a new install sees first. Counted from vrek, not estimated. The user's own judgement that each screen is simpler is what closes each one.","status":"recorded","owner":null,"attrs":{"value":0,"applies_at":"2026-09-20"}},"at":"2026-09-20T11:34:45.390Z","parents":["evt-e7ypp3ac15hx"],"hash":"77beb04fe884e70a54e3821fbb94f0381f916f89f54ed2bf5632b797dd43bf99"}
{"id":"evt-7d0awcm95tv0","type":"edge.added","subject":"mea-xq3gw0y","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-xq3gw0y","to":"gol-6x4ctm2"},"at":"2026-09-20T11:34:45.393Z","parents":["evt-7s7945vv080q"],"hash":"ed3a84305999b6e1467005f1362d91c241daf02447f59b87617ad519c99dd092"}
{"id":"evt-trcm12pcnwxj","type":"node.created","subject":"dec-0kchta3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Simplification: native disclosures that remember per person, editing stays per section, cards hide the address","body":"User's answers on 2026-09-20 to the written proposal, settling what gol-6x4ctm2's issues depend on.\n\nDisclosures: native details/summary, so keyboard, screen readers and find-in-page work without reimplementation. An opened section stays open for that person, remembered per section in browser storage, which never reaches the server or another device. Small screens behave the same, stacked. Attention overrides memory: a camera needing a login or setup, a clock far out of sync, a missing video bridge, all open themselves and say why. A closed section still carries the useful value in its summary, e.g. \"Streams — main 4096×1860 at 20 fps\", \"Time — 1s behind\".\n\nEditing: option A. Edit stays inside each section, with less around it, rather than a page-level edit mode. The forms work and are well tested; rebuilding them buys little.\n\nDashboard cards: the picture, the name and the status only. The address moves behind Details with the device line, stream summary, location, profile table and the web page and login links. Pop out stays visible, as does anything wrong. Scanning is one button with timeout and sweep behind Options, defaults unchanged.\n\nCamera page: one Image section instead of Image plus Advanced image, since the split reflects which protocol answered rather than anything a user cares about; settings that can only be read are marked. Streams, Time &amp; NTP and Network become closed disclosures.\n\nRecordings: grouped by day then camera, newest day open, older days closed with a count; Play and Delete stay per row.\n\nNew installs: an empty dashboard is one line and one button; setup and login put the action first with the explanation behind \"Why does this matter?\"; the unsecured warning keeps its force and wording.\n\nCost accepted: hidden things are harder to find, mitigated by summary lines and by attention cases never hiding; about 60 existing tests assert text that will move inside a disclosure.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T11:37:25.065Z","parents":["evt-7d0awcm95tv0"],"hash":"b52cb1e0aeb9a2b5165fe5820c7799e04d743b6c192be651c5df0259a67a582f"}
{"id":"evt-zq1pkd30ra5t","type":"edge.added","subject":"dec-0kchta3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-0kchta3","to":"gol-6x4ctm2"},"at":"2026-09-20T11:37:25.068Z","parents":["evt-trcm12pcnwxj"],"hash":"72111a94233a77544a02cc535390bbc3c8ad64b3fc2210e9c561e219b90b6a38"}
{"id":"evt-1z76qyw30pwm","type":"node.created","subject":"ver-zyzqx9x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"One disclosure component the whole app can use: native details/summary, remembers per person and per section in this browser, forced open when something needs attention, a summary that carries the useful value while closed, and safe where storage is unavailable or on the server","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T11:39:21.458Z","parents":["evt-zq1pkd30ra5t"],"hash":"647865e57fac71db254f098282109bf219c30c6bbe1b18f981f00dbe90c408ba"}
{"id":"evt-eh8kpnff0278","type":"edge.added","subject":"ver-zyzqx9x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-zyzqx9x","to":"iss-jz3431t"},"at":"2026-09-20T11:39:21.461Z","parents":["evt-1z76qyw30pwm"],"hash":"6477a2e8ba6c6b34d727fec66ed357af8c3706e963ae74db732cd099b7f284da"}
{"id":"evt-kzzwz3e5jc0h","type":"verification.recorded","subject":"ver-zyzqx9x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/disclosure.tsx with src/app/disclosure.test.tsx (8 tests: closed by default but content still reachable, summary beside the label, defaultOpen, remembering open and closed across visits and beating the default, sections kept apart, attention overriding what was remembered, storage throwing, and server rendering without touching storage). The remembered state is read with useSyncExternalStore, so nothing is set from an effect. Full suite 963 tests pass, 99.66% lines; tsc and eslint clean."},"at":"2026-09-20T11:39:21.463Z","parents":["evt-eh8kpnff0278"],"hash":"c4e88c66535c2365a4fd54748eeb6e35899640f14f23e75d90f8b4824e4cacaa"}
{"id":"evt-jyahmdef91ar","type":"node.status_changed","subject":"iss-jz3431t","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T11:39:22.694Z","parents":["evt-kzzwz3e5jc0h"],"hash":"3d87103db08734a822a9083195041bbca6305e086471b992149f1c11adfb8418"}
{"id":"evt-3sec7pkrge2y","type":"node.created","subject":"mea-4c17z3m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"Interface simplification: 1 of 5 issues done","body":"Counted from vrek on 2026-09-20: the shared disclosure component and its rules are done (iss-jz3431t), which unblocks the four screens. Left: the dashboard, the camera page, the recordings page, and what a new install sees first.","status":"recorded","owner":null,"attrs":{"value":1,"applies_at":"2026-09-20"}},"at":"2026-09-20T11:39:23.964Z","parents":["evt-jyahmdef91ar"],"hash":"b5bff23790e8e894ef886795f96687eb1155dbaa70bccbf1b00c4d8a0cd35dab"}
{"id":"evt-k39g60g2v64h","type":"edge.added","subject":"mea-4c17z3m","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-4c17z3m","to":"gol-6x4ctm2"},"at":"2026-09-20T11:39:23.975Z","parents":["evt-3sec7pkrge2y"],"hash":"ebbc6e8b6e4908ffd94cdc58e10ea239ed4c4bdc0c4f8dafef4b017250bc673e"}

View File

@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Disclosure from "./disclosure";
afterEach(cleanup);
beforeEach(() => localStorage.clear());
const section = () => document.querySelector("details")!;
/** Clicking the summary opens it; the toggle event that follows is queued, so wait for it. */
async function toggle() {
fireEvent.click(screen.getByText("Details"));
await new Promise((r) => setTimeout(r, 0));
}
const show = (props: Partial<Parameters<typeof Disclosure>[0]> = {}) =>
render(
<Disclosure id="camera-details" label="Details" {...props}>
<p>the detail</p>
</Disclosure>,
);
describe("Disclosure", () => {
it("is a real details element, closed by default, naming what is inside", () => {
show();
expect(section().open).toBe(false);
expect(screen.getByText("Details")).toBeTruthy();
// Closed is not hidden: find-in-page and screen readers still reach the content.
expect(screen.getByText("the detail")).toBeTruthy();
});
it("shows the useful part beside the label while closed", () => {
show({ summary: "main 4096×1860 at 20 fps" });
expect(screen.getByText("main 4096×1860 at 20 fps")).toBeTruthy();
});
it("can start open", () => {
show({ defaultOpen: true });
expect(section().open).toBe(true);
});
it("remembers what this person opened, and what they closed again", async () => {
show();
await toggle();
expect(section().open).toBe(true);
expect(localStorage.getItem("disclosure:camera-details")).toBe("open");
cleanup();
show();
expect(section().open).toBe(true); // opened again on the next visit
await toggle();
expect(localStorage.getItem("disclosure:camera-details")).toBe("closed");
cleanup();
show({ defaultOpen: true });
expect(section().open).toBe(false); // their choice beats the default
});
it("keeps sections apart", () => {
localStorage.setItem("disclosure:other", "open");
show();
expect(section().open).toBe(false);
});
it("stays open when something needs attention, whatever was remembered", () => {
localStorage.setItem("disclosure:camera-details", "closed");
show({ open: true });
expect(section().open).toBe(true);
});
it("works where browser storage is unavailable", async () => {
const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
show();
await toggle();
expect(section().open).toBe(true);
getItem.mockRestore();
setItem.mockRestore();
});
it("renders on the server without reaching for storage", () => {
const html = renderToStaticMarkup(
<Disclosure id="x" label="Details" summary="1s behind">
<p>the detail</p>
</Disclosure>,
);
expect(html).toContain("<details");
expect(html).not.toContain("open=");
expect(html).toContain("1s behind");
});
});

90
src/app/disclosure.tsx Normal file
View File

@ -0,0 +1,90 @@
"use client";
import { useState, useSyncExternalStore } from "react";
/**
* One collapsible section, used everywhere detail is hidden by default (vrek dec-0kchta3).
*
* Native details/summary, so keyboard, screen readers and find-in-page keep working. What
* a person opens stays open for them, remembered per section in this browser only; it
* never reaches the server or another device. `open` forces it open regardless of that
* memory, for anything needing attention, which must never be hidden.
*/
const KEY_PREFIX = "disclosure:";
/** Browser storage is per viewer and can be unavailable (private windows, blocked data). */
export function remembered(id: string): boolean | null {
try {
const value = localStorage.getItem(KEY_PREFIX + id);
return value === null ? null : value === "open";
} catch {
return null;
}
}
function remember(id: string, open: boolean) {
try {
localStorage.setItem(KEY_PREFIX + id, open ? "open" : "closed");
} catch {
// Nothing to do: the section still works, it just won't be remembered.
}
}
/** Another tab's change is the only outside change worth following. */
function subscribe(onChange: () => void) {
window.addEventListener("storage", onChange);
return () => window.removeEventListener("storage", onChange);
}
export default function Disclosure({
id,
label,
summary,
open,
defaultOpen = false,
children,
className = "",
}: {
/** Stable name for what is inside, e.g. "camera-details"; used to remember the state. */
id: string;
/** What the section is, read out as the control's name. */
label: string;
/** The useful part, shown beside the label while closed, e.g. "1s behind". */
summary?: React.ReactNode;
/** Forces it open (something needs attention); the person's own choice is left alone. */
open?: boolean;
defaultOpen?: boolean;
children: React.ReactNode;
className?: string;
}) {
// On the server, and until the browser has hydrated, the section shows its default.
const saved = useSyncExternalStore(
subscribe,
() => remembered(id),
() => null,
);
const [chosen, setChosen] = useState<boolean | null>(null);
const isOpen = open || (chosen ?? saved ?? defaultOpen);
return (
<details
open={isOpen}
onToggle={(e) => {
const nowOpen = (e.currentTarget as HTMLDetailsElement).open;
if (nowOpen === isOpen) return;
setChosen(nowOpen);
remember(id, nowOpen);
}}
className={className}
>
<summary className="cursor-pointer list-none text-sm marker:content-none [&::-webkit-details-marker]:hidden">
<span className="inline-flex items-center gap-1.5 underline decoration-dotted underline-offset-4">
{label}
</span>
{summary !== undefined && <span className="ml-2 text-zinc-500">{summary}</span>}
</summary>
<div className="mt-2">{children}</div>
</details>
);
}