Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,58 @@ credentials alone; `solo-recovery-file` catches the real download and
proves the wrong passphrase is one clean keyslot miss),
pair/resume/check green, invariants 9/9.

**The event record: announcements split three ways; a badge on the
identity circle** (2026-08-26; design memo
[#132](https://github.com/polymorph-components/polyvisor/issues/132),
executed same day). The strip's timed announcement was one transient
line multiplexing three jobs, and every patch it has accumulated —
the sticky/priority windows, the pulseContext retreat, the spoken
FIFO, solo.ts's `syncFailureAnnounced` edge-trigger — was contention
between them. Worse, ANNOUNCED-NEVER-SILENT was hollow: a remote
revocation got 12 seconds on a strip nobody may be watching, and
multi-device means consequential events happen while you're away by
construction (the transparency roadmap's fork alarms make this acute:
"detection requires a response path", and a timed line is not one).
The split: **moment cues** stay push (announce/pulse/speak,
record-less — the fresh-colour teach is about NOW); **standing
conditions** become visor-held keyed state (set/clear, lit while
standing, session-live so a stale condition cannot outlive its
poller); **event records** become a persistent, acknowledgeable list.
The mechanical rule that makes wiring one-line-per-host: EVERY
CONSEQUENTIAL ANNOUNCEMENT LEAVES A RECORD — the sink already carries
the flag, so arrival push is unchanged and the badge is the memory,
not the alarm. The badge is a DOT (never a count) on
`#visor-settings`: zero layout shift (strip geometry is a measured
property), framework voice by construction (no words), lit = unseen
records ∪ standing conditions. THE SYMMETRY RULE: the badge sits on
the anchor of whoever the news is about — identity circle for
me/my-system news (built now); the PET ICON for system-authored news
about one surface (update landed, a pending version requests new
grants — specified in #132, dormant until such events exist). THE
AUTHOR RULE: only visor/engine-authored events light it — the subject
may be an app, the author never is; a self-badging primitive is "look
at me!" handed to components. Two more rules keep it meaningful:
entry is gated to the consequential class (ambient telemetry never
enters, or the one alarm that matters drowns in junk mail), and A
NOTIFICATION NEVER GRANTS — an entry may point at a ceremony, the
grant path stays the powerbox. The list itself is "recent events", a
light drawer tenant reached from a visor-owned settings-sheet row via
the erase entry's suspend/resume motion; conditions first, records
newest-first with coarse ages; OPENING MARKS SEEN (per-entry
dismissal waits for entries that carry actions); entries are flat
framework-voice strings under exactly `announce()`'s three-voices
policy — typed slots (petname/plated-foreign) are the recorded growth
path and the prerequisite for the surface scope. Records persist
under a consumer key (`erase()` wipes them); solo's sync watch now
sets/clears a condition and the edge return replaced its hand-rolled
boolean. Gates: e2e 36/36 (`visor-events` new — a seeded stale boot
cache drives a real reconcile-announced event into badge → list →
seen → reload persistence; `store-outage-recovery` extended to pin
the condition lighting the badge, the failure record outliving the
ambient recovery announce, and the condition block clearing;
`strip-geometry` now takes every measurement with the badge LIT),
invariants green, harness and hosts type-check green.

## Parked and candidate non-goals

- **Metadata privacy**: relays, push services, and origins see traffic
Expand Down
8 changes: 8 additions & 0 deletions demo/e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { type SeverableProxy, startTcpProxy } from "./proxy.ts";
import bootAppSurface from "./scenarios/boot-app-surface.ts";
import petnameCeremony from "./scenarios/petname-ceremony.ts";
import settingsIdentity from "./scenarios/settings-identity.ts";
import visorEvents from "./scenarios/visor-events.ts";
import stripGeometry from "./scenarios/strip-geometry.ts";
import credentialFlow from "./scenarios/credential-flow.ts";
import transportRefusal from "./scenarios/transport-refusal.ts";
Expand Down Expand Up @@ -130,6 +131,13 @@ const SCENARIOS: Scenario[] = [
harnessFaults,
petnameCeremony,
settingsIdentity,
// THE EVENT RECORD (#132), beside the other two visor-sheet scenarios:
// it drives the same drawer and the same settings sheet. It proves the
// badge LIGHTS and clears; the strip's geometry under a lit badge is
// `stripGeometry`'s own business — it seeds an unseen record and takes
// every measurement with the dot rendered, so the two are independent
// rather than one relying on the other's leftovers.
visorEvents,
stripGeometry,
// The credential beats come before the refusal beat: one needs the
// store up, the next needs it down, and a scenario that has to bring
Expand Down
104 changes: 101 additions & 3 deletions demo/e2e/scenarios/store-outage-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@
// before any earlier cycle succeeded necessarily carried it. That
// is the honest reading, stated rather than invented as a
// separate assertion.
// 5. THE FAILURE LEFT AN EVENT RECORD AND THE RECOVERY DID NOT (#132).
// After the heal the identity circle's badge is STILL lit: the
// standing sync CONDITION cleared with the recovery, but the
// consequential failure announcement's RECORD is unseen. The event
// list holds that sentence, with no "ongoing" block beside it and
// no trace of the ambient recovery line. This is the one place in
// the suite where the badge's two terms — unseen records, standing
// conditions — can be told apart, which is why the beat lives here
// rather than in scenarios/visor-events.ts.
//
// THE "Sync now" BYPASS BEAT IS DELIBERATELY OMITTED. Driving it here
// would mean opening the storage sheet purely to reach one button while
Expand All @@ -74,7 +83,15 @@

import type { Page } from "npm:[email protected]";
import type { Ctx, Scenario } from "../run.ts";
import { act, assert, assertEquals, recordSurfaceLine, SOLO_KEYS, stripText } from "../util.ts";
import {
act,
assert,
assertEquals,
assertIncludes,
recordSurfaceLine,
SOLO_KEYS,
stripText,
} from "../util.ts";
import { addTodo, appFrame, createAccount, solo, until, WAITS } from "../solo-util.ts";

const BUCKET = "pm-outage-recovery";
Expand Down Expand Up @@ -151,6 +168,18 @@ async function closeStorageSheet(page: Page): Promise<void> {
await page.waitForSelector("#storage-sheet", { state: "detached", timeout: 15_000 });
}

/** Is the dot on the identity circle? The event badge (#132) is a
* text-less child of `#visor-settings`, so its PRESENCE is the whole
* signal — a pure DOM read that opens nothing and waits on nothing,
* which is what lets it sit inside this scenario's timing-sensitive
* beats. Duplicated here rather than imported from another scenario,
* per this suite's scenario-local-helper convention. */
function badgeLit(page: Page): Promise<boolean> {
return page.evaluate(() =>
document.querySelector("#visor-settings .visor-badge") !== null
);
}

/** `syncStatus`'s shape, named locally so the `until` callbacks below
* read as claims rather than `any`-shaped guesses. */
interface SyncStatusLike {
Expand All @@ -175,8 +204,11 @@ const scenario: Scenario = {
},
// Beats 2 and 3 alone budget past 100s of real backoff waiting; the
// suite-wide deadline was never sized for that (Scenario.deadlineMs's
// own doc comment, run.ts).
deadlineMs: 420_000,
// own doc comment, run.ts). The trailing event-record beat adds a
// drawer round trip (two sheet swaps at SWAP_MS plus two selector
// waits) on top, so the budget grows with it rather than the new beat
// silently eating the old margin.
deadlineMs: 480_000,

async run(page: Page, ctx: Ctx) {
assert(ctx.minioDataDir !== null, "the harness did not expose MinIO's data directory");
Expand Down Expand Up @@ -315,6 +347,24 @@ const scenario: Scenario = {
announced.includes("this device has stopped syncing with your storage"),
`the strip should carry the visor's own announcement: ${JSON.stringify(announced)}`,
);

// THE STANDING CONDITION LIT THE BADGE (#132). The failing sync
// is not a moment, it is a STATE, and host/solo.ts's
// `watchSyncFailures` now raises it as a keyed condition on the
// visor — the edge return is what gates the announcement above,
// and the same call lights the dot on the identity circle for as
// long as the fault stands.
//
// A PURE DOM READ, and deliberately nothing more: opening the
// drawer here would put a sheet over a scenario whose next beat
// is a multi-minute backoff wait, and the sheet's own ceremony
// is exactly the kind of interposed real time this scenario's
// timing story warns about. The list is inspected at the very
// END instead, where nothing is waiting on it.
assert(
await badgeLit(page),
"the standing sync condition must light the identity circle's badge",
);
},
);

Expand Down Expand Up @@ -425,6 +475,54 @@ const scenario: Scenario = {
.waitFor({ state: "visible", timeout: WAITS.converge });
},
);

await act(
"THE OUTAGE IS STILL ON THE RECORD: the badge stays lit for the unseen failure, and the condition is gone",
async () => {
// THE TWO HALVES OF THE BADGE COME APART HERE, which is the
// whole reason this beat sits at the END of this scenario rather
// than in visor-events.ts: the CONDITION cleared when sync
// recovered, and the dot is STILL lit — because the failure
// announcement was CONSEQUENTIAL and therefore left a record
// that nobody has looked at yet (#132's mechanical rule). The
// recovery announcement is ambient and left nothing. So "lit =
// unseen records ∪ standing conditions" is being read here with
// the first term true and the second false, which no other
// scenario can produce.
assert(
await badgeLit(page),
"the unseen failure record must keep the badge lit after the condition cleared",
);

await page.evaluate(() => {
(document.getElementById("visor-settings") as HTMLButtonElement | null)?.click();
});
await page.waitForSelector("#visor-settings-events", { timeout: 15_000 });
await page.click("#visor-settings-events");
await page.waitForSelector("#visor-drawer-inner .events-sheet", { timeout: 15_000 });
const text = await page.evaluate(() =>
document.getElementById("visor-drawer-inner")?.textContent ?? ""
);
assertIncludes(
text,
"this device has stopped syncing with your storage",
"the event list must hold the failure the strip announced",
);
// NO STANDING BLOCK: the condition was cleared on recovery, and
// conditions are session-live — nothing may still be claiming
// the sync is down once it demonstrably is not.
assert(
!text.includes("ongoing:"),
`the sync condition cleared on recovery, so nothing stands: ${JSON.stringify(text)}`,
);
// And the recovery sentence left NO record — it is ambient, and
// ambient lines never enter the list (#132's entry gating).
assert(
!text.includes("this device is syncing with your storage again"),
`an ambient recovery line must not enter the record: ${JSON.stringify(text)}`,
);
},
);
},
};

Expand Down
57 changes: 56 additions & 1 deletion demo/e2e/scenarios/strip-geometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
// These are the claims a hand-drive checks by squinting. Here they are
// numbers: cluster widths as a fraction of the bar, a real tap-target
// floor, and zero horizontal overflow on the DOCUMENT.
//
// AND ALL OF IT IS MEASURED WITH THE EVENT BADGE LIT (#132). The dot on
// the identity circle promises zero layout shift, and that promise is
// pure CSS — absolute positioning, out of flow — so this scenario seeds
// an unseen record (see `SEEDED_EVENT`) and takes every number above
// with the dot rendered. A badge that ever started costing layout would
// move these numbers rather than passing unnoticed.

import type { Scenario } from "../run.ts";
import { act, assert, hook, KEYS, waitForSheet } from "../util.ts";
Expand All @@ -31,6 +38,7 @@ interface Metrics {
lines: { h: number; scrollW: number; clientW: number }[];
docOverflow: number;
idLines: { scrollW: number; clientW: number }[];
badge: boolean;
}

function measure(page: Page): Promise<Metrics> {
Expand Down Expand Up @@ -76,18 +84,52 @@ function measure(page: Page): Promise<Metrics> {
idLines: Array.from(document.querySelectorAll("#visor-identity .id-lines .who")).map(
(e) => ({ scrollW: (e as HTMLElement).scrollWidth, clientW: (e as HTMLElement).clientWidth }),
),
// THE EVENT BADGE (#132), read alongside the geometry rather than
// in a beat of its own: every number above is measured WITH the
// dot rendered, and this is what says so.
badge: btn.querySelector(".visor-badge") !== null,
};
});
}

/** The demo page's event-record key (visor/ui/visor.ts's `eventsKey`, as
* host/demo.ts sets it). Mirrored here rather than imported for the same
* reason `KEYS` is — a rename there should fail this scenario loudly
* rather than quietly stop lighting the badge. */
const EVENTS_KEY = "pm-demo-visor-events";

/** ONE UNSEEN RECORD, seeded so the identity circle's badge (#132) is
* LIT for every measurement below.
*
* WHY IT BELONGS IN THIS SCENARIO. The badge's zero-layout-shift claim
* is made in CSS — an absolutely positioned, out-of-flow span — and a
* claim that rests only on a stylesheet is a claim nobody is checking.
* Seeding it here means the strip's whole measured geometry (two lines,
* the 44px tap floor, the 45/45/10 split, zero document overflow, the
* identity lines' ellipsis behaviour at 390) is taken with the dot on
* the button. If the badge ever starts costing layout, THESE numbers
* move, which is the only place in the suite where that would be
* caught.
*
* `seenAt: 0` is what makes the record unseen; the timestamp is a fixed
* point in the past (2024-01-01T00:00:00Z) rather than a computed one,
* so the seed is a literal and the sheet's coarse age never enters this
* scenario's business. */
const SEEDED_EVENT = JSON.stringify({
seenAt: 0,
events: [{ at: 1_704_067_200_000, text: "seeded for geometry" }],
});

/** Seeded state shared by both widths: pathological words in every
* variable slot the strip has. */
* variable slot the strip has, plus the unseen event that lights the
* badge (see `SEEDED_EVENT`). */
const hostileStorage = {
[KEYS.identity]: JSON.stringify({
name: "Ada Lovelace-Byron the Countess",
device: "the study PC under the stairs",
icon: "⚑",
}),
[EVENTS_KEY]: SEEDED_EVENT,
};

const scenario: Scenario = {
Expand Down Expand Up @@ -125,6 +167,19 @@ const scenario: Scenario = {
}px + padding — something wrapped`,
);

// THE BADGE IS ACTUALLY LIT while all of this is being measured
// (see `SEEDED_EVENT`). Asserted rather than assumed: if the seed
// ever stopped taking — a renamed key, a changed record shape, a
// boot that marks seen on its own — every geometry number above
// would quietly go back to measuring the unlit strip, and the
// zero-layout-shift claim would rest on the stylesheet again with
// nothing to notice.
assert(
m.badge,
`${label}: the seeded unseen event did not light the badge — the geometry above is being ` +
`measured on an UNLIT strip (check ${EVENTS_KEY} and the record shape)`,
);

// A real tap target. 44×44 is the floor a thumb needs, and this
// button is the way into the visor's own settings.
assert(
Expand Down
Loading
Loading