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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions packages/runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.3.0-beta.4 (2026-10-27)

- **The devtools pill counts measured checks** (`src/devtools/`, VISUAL.md 9.3). The collapsed pill showed only
interventions and model detections, so a page where GenClass had fixed invisible text, a stuck scroll lock and a
click block on screen still read "0 · 0". The pill now has a third counter (the scan icon) with the page's measured
checks, red when there are any, its title saying how many were fixed on screen, and it pulses when a new one arrives.
With checks and no interventions or detections yet, the panel opens on the Checks tab. Each check card leads with
the kind in words ("Page cannot scroll", "Page stopped taking clicks", "Invisible text", "Ads covering content"),
the kind itself beside it. Checks stay labelled "measured", never model findings. Tests: `devtools-checks.test.ts`.
No model-visible text changed (`situation-v2.3`).

## 0.3.0-beta.3 (2026-10-26)

- **A colour fix stays while its element is out of view** (`src/visual/engine.ts`, VISUAL.md 11.2). The periodic
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@genclass/runtime",
"version": "0.3.0-beta.3",
"version": "0.3.0-beta.4",
"description": "A runtime for web apps: a small local model watches user actions, async work, network, state and errors, and flags, and optionally prevents, stale responses, races, duplicate requests, inconsistent state and failure storms.",
"type": "module",
"license": "Apache-2.0",
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime/src/devtools/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ border:1px solid var(--line2);font-size:12.5px;font-weight:500;color:var(--fg);t
.pc b{font:500 12px var(--mono)}
.pc.on.a{color:var(--go)}
.pc.on.d{color:var(--bug)}
.pc.on.c{color:var(--bug)}
.pd{width:7px;height:7px;background:var(--fg3);margin-left:1px}
.s-ready .pd,.s-ready .dot{background:var(--go-f)}
.s-error .pd,.s-error .dot{background:var(--bug-f)}
Expand All @@ -63,7 +64,8 @@ border:1px solid var(--line2);font-size:12.5px;font-weight:500;color:var(--fg);t
.pill::after{content:"";position:absolute;inset:-1px;pointer-events:none;opacity:0;outline:1px solid var(--ring,transparent);outline-offset:0}
.pill.pa::after{--ring:var(--go);animation:ring 1.5s cubic-bezier(.2,.7,.3,1)}
.pill.pdd::after{--ring:var(--bug);animation:ring 1.5s cubic-bezier(.2,.7,.3,1)}
.pill.pa .pc.a b,.pill.pdd .pc.d b{animation:bump .5s cubic-bezier(.2,.8,.2,1)}
.pill.pck::after{--ring:var(--bug);animation:ring 1.5s cubic-bezier(.2,.7,.3,1)}
.pill.pa .pc.a b,.pill.pdd .pc.d b,.pill.pck .pc.c b{animation:bump .5s cubic-bezier(.2,.8,.2,1)}
@keyframes ring{0%{opacity:.9;outline-offset:0}100%{opacity:0;outline-offset:12px}}
@keyframes bump{40%{transform:scale(1.25)}}
@keyframes blink{50%{opacity:.35}}
Expand Down Expand Up @@ -142,6 +144,8 @@ background:var(--bg2);border-top:1px solid var(--line);border-bottom:1px solid v
.cb{flex:1;min-width:0}
.ch{display:flex;align-items:baseline;gap:8px;min-height:20px}
.ct{flex:1;min-width:0;font-weight:600;letter-spacing:-.01em;line-height:1.35;padding-top:3px}
.chk .cl{font-weight:600;letter-spacing:-.01em;line-height:1.35;padding-top:3px}
.chk .ct{font:400 11px var(--mono);color:var(--fg3)}
.ago{font:400 11px var(--mono);color:var(--fg3);white-space:nowrap}
.rep{align-self:center;height:18px;padding:0 5px;font:500 11px/16px var(--mono);color:var(--bug);background:var(--bug-bg);border:1px solid var(--bug-line)}
.cx{margin:3px 0 0;font-size:12.5px;line-height:1.5;color:var(--fg2);overflow-wrap:anywhere}
Expand Down
43 changes: 34 additions & 9 deletions packages/runtime/src/devtools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,18 @@ const FILTERS: [Group, string][] = [
["other", "Other"],
];
const QLABEL: Record<string, string> = { diagnosis: "Diagnosis", action: "Action" };
// a measured check's kind in words, on its card and in the pill's label (the kind itself stays on the card)
const CHECK_TITLE: Record<string, string> = {
"text-invisible": "Invisible text",
"text-low-contrast": "Hard-to-read text",
"scroll-lock-stuck": "Page cannot scroll",
"pointer-events-leak": "Page stopped taking clicks",
"click-blocked": "Clicks blocked by an invisible layer",
"dialog-covered": "Dialog covered",
"focus-obscured": "Focused control hidden",
"ad-heavy": "Ads covering content",
"page-blank": "Blank page",
};
const qrank = (q: string): number => (q === "diagnosis" ? 0 : q === "action" ? 1 : 2);

const MAX_CARDS = 200;
Expand Down Expand Up @@ -204,6 +216,7 @@ class Devtools {
private readonly pm: HTMLElement;
private readonly pcA: HTMLElement;
private readonly pcD: HTMLElement;
private readonly pcC: HTMLElement;
private readonly nA: HTMLElement;
private readonly nD: HTMLElement;
private readonly pauseBtn: HTMLElement;
Expand Down Expand Up @@ -287,11 +300,12 @@ class Devtools {
h("span", { class: "vs" }),
(this.pcA = h("span", { class: "pc a", title: "Interventions" }, icon("shield"), h("b", { text: "0" }))),
(this.pcD = h("span", { class: "pc d", title: "Detections" }, icon("eye"), h("b", { text: "0" }))),
(this.pcC = h("span", { class: "pc c", title: "Measured checks" }, icon("scan"), h("b", { text: "0" }))),
h("span", { class: "pd" }),
(this.pb = h("span", { class: "pb", hidden: true }, (this.pbi = h("i")))),
);
this.pill.addEventListener("animationend", (e) => {
if ((e as AnimationEvent).animationName === "ring") this.pill.classList.remove("pa", "pdd");
if ((e as AnimationEvent).animationName === "ring") this.pill.classList.remove("pa", "pdd", "pck");
});

// header
Expand Down Expand Up @@ -460,7 +474,7 @@ class Devtools {
on("report", (r) => this.addReport(r));
on("event", (e) => this.addEvent(e));
on("status", () => this.mark(STATUS | MODE)); // setMode() is announced as a status change too
on("check", (f) => this.addCheck(f));
on("check", (f) => this.addCheck(f, true));
on("report", (r) => {
// a fix line (applied, undone, dropped) changes that finding's card
const c = r.kind === "check" && r.check ? this.chks.get(r.check.id) : undefined;
Expand Down Expand Up @@ -501,11 +515,12 @@ class Devtools {
safe(() => (this.rt as { vis?: { start(): void } | null }).vis?.start(), undefined);
}

private addCheck(f: CheckFinding): void {
private addCheck(f: CheckFinding, live = false): void {
if (!f || this.chks.has(f.id)) return;
this.chks.set(f.id, { f, dirty: false });
bound(this.chks, MAX_CARDS, (c) => c.el?.remove());
this.mark(LISTS);
if (live) this.notify("c");
}

// -------------------------------------------------------------------------------------- data intake
Expand Down Expand Up @@ -626,12 +641,12 @@ class Devtools {
this.mark(EVENTS);
}

private notify(kind: "a" | "d"): void {
private notify(kind: "a" | "d" | "c"): void {
if (this.paused) this.unseen++;
const p = this.pill;
p.classList.remove("pa", "pdd");
p.classList.remove("pa", "pdd", "pck");
void p.offsetWidth; // restart the animation
p.classList.add(kind === "a" ? "pa" : "pdd");
p.classList.add(kind === "a" ? "pa" : kind === "d" ? "pdd" : "pck");
}

// ------------------------------------------------------------------------------------------ render
Expand Down Expand Up @@ -684,10 +699,17 @@ class Devtools {
this.nA.classList.toggle("on", na > 0);
this.nD.classList.toggle("on", nd > 0);
this.nD.title = np ? `${nd} likely, ${np} possible` : "";
this.nC.textContent = String(this.chks.size);
this.nC.classList.toggle("on", this.chks.size > 0);
// measured checks count in the pill too: a page GenClass fixed on screen must not read "0 · 0"
const nc = this.chks.size;
let nf = 0;
for (const c of this.chks.values()) if (c.f.fix?.applied) nf++;
(this.pcC.lastElementChild as HTMLElement).textContent = String(nc);
this.pcC.classList.toggle("on", nc > 0);
this.pcC.title = nc ? `${nc} measured ${nc === 1 ? "check" : "checks"}, ${nf} fixed on screen` : "Measured checks";
this.nC.textContent = String(nc);
this.nC.classList.toggle("on", nc > 0);
const s = safe(() => this.rt.status.state, "off");
this.pill.setAttribute("aria-label", `Open GenClass devtools: ${na} interventions, ${nd} detections, model ${s}`);
this.pill.setAttribute("aria-label", `Open GenClass devtools: ${na} interventions, ${nd} detections, ${nc} checks (${nf} fixed), model ${s}`);
this.pz.hidden = !this.paused;
this.pzT.textContent = this.unseen ? `Feed paused · ${this.unseen} new` : "Feed paused";
}
Expand Down Expand Up @@ -814,6 +836,7 @@ class Devtools {
h(
"div",
{ class: "ch" },
h("span", { class: "cl", text: CHECK_TITLE[f.kind] ?? f.kind }),
h("span", { class: "ct", text: f.kind }),
h("span", { class: "badge", text: "measured", title: "A deterministic measurement against a fixed threshold, not a model finding" }),
h("time", { class: "ago", "data-at": String(f.at), text: ago(this.now() - f.at), title: `runtime clock ${clockT(f.at)}` }),
Expand Down Expand Up @@ -1372,6 +1395,8 @@ class Devtools {
this.pill.setAttribute("aria-expanded", "true");
this.bits |= ALL;
this.flush();
// with only measured checks so far, open on them rather than on an empty Interventions tab
if (this.tab === "interventions" && !this.acts.size && !this.dets.size && this.chks.size) this.tab = "checks";
this.setTab(this.tab, user);
this.startTicking();
}
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
// test/telemetry.test.ts ("RUNTIME_VERSION matches package.json"); bump both together: .github/workflows/release.yml
// checks that the v<version> tag equals package.json and that this file holds the same string.

export const RUNTIME_VERSION = "0.3.0-beta.3";
export const RUNTIME_VERSION = "0.3.0-beta.4";
29 changes: 29 additions & 0 deletions packages/runtime/test/devtools-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,33 @@ describe("devtools: Checks tab (VISUAL.md 9.3)", () => {
$('[data-pane="checks"] [data-act="undo"]')!.click();
expect(undo).toHaveBeenCalledTimes(1);
});

it("counts measured checks in the collapsed pill, names them in words and opens on them (VISUAL.md 9.3)", async () => {
const rt = new MockRuntime();
const h = mountDevtools(rt, {});
handles.push(h);
const sr = (h.element as HTMLElement).shadowRoot as ShadowRoot;
const $ = (s: string) => sr.querySelector<HTMLElement>(s);
expect($(".pc.c b")?.textContent).toBe("0");
expect($(".pc.c")?.classList.contains("on")).toBe(false);
const undo = vi.fn(() => true);
rt.emitTo("check", finding({ fix: { id: "f1", applied: true, css: "p.hero__subtitle { color: rgb(115, 115, 115); }", undo } }));
rt.emitTo("check", finding({ id: "c2", kind: "scroll-lock-stuck", suggestion: undefined, message: "[GenClass] Check (measured): the page cannot scroll." }));
await frame();
// a page GenClass fixed on screen does not read "0 · 0": the pill's third counter has the checks, and it pulses
expect($(".pc.a b")?.textContent).toBe("0");
expect($(".pc.d b")?.textContent).toBe("0");
expect($(".pc.c b")?.textContent).toBe("2");
expect($(".pc.c")?.classList.contains("on")).toBe(true);
expect($(".pc.c")?.title).toBe("2 measured checks, 1 fixed on screen");
expect($(".pill")?.getAttribute("aria-label")).toContain("2 checks (1 fixed)");
expect($(".pill")?.classList.contains("pck")).toBe(true);
// with no interventions or detections yet, the panel opens on the checks
$(".pill")!.click();
await frame();
expect($('[data-tab="checks"]')?.getAttribute("aria-selected")).toBe("true");
const cards = [...sr.querySelectorAll<HTMLElement>('[data-pane="checks"] article.card')];
expect(cards.map((c) => c.querySelector(".cl")?.textContent)).toEqual(["Page cannot scroll", "Invisible text"]);
expect(cards.map((c) => c.querySelector(".ct")?.textContent)).toEqual(["scroll-lock-stuck", "text-invisible"]);
});
});
Loading