From 9eddd3235636b908db6b6a6c1f42af7955bc57db Mon Sep 17 00:00:00 2001 From: homen Date: Tue, 28 Jul 2026 04:26:51 -0700 Subject: [PATCH 1/5] fix: every FocusTrapDialog modal rendered behind its own blur scrim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radix portals dialog overlay and content as SIBLINGS. The legacy scrims (.r-modal-backdrop grid place-items:center, .r-room-modal-scrim) were written to center a CHILD, and FocusTrapDialog passes `unstyled`, which drops the Tailwind fixed/translate classes from DialogContent. Every dialog on this path therefore rendered position:static at the end of , BEHIND its z-95 blur overlay — including the create-room dialog that asks the product's one governance question ('How should NodeAgent edits land?'). Tailwind's sr-only is also absent from this bundle, so the fallback DialogTitle rendered the word 'Dialog' visibly at the top of every modal. Found by reviewing walkthrough footage frame by frame: DOM text extraction read the dialog fine the whole time. Fix: center [data-slot=dialog-content] itself above its scrim, and define .sr-only. Also included, same session: - boot.ts: the private-route boot shell had no failure exit — a rejected or hung chunk import shimmered 'Opening room' forever. Rejection handler, 20s timeout backstop, data-boot-state in the DOM, failed state stops the shimmer and offers Reload. 13/13 scenario checks incl. reduced-motion. - scripts/: trust-surface gate (core + selftest probed in BOTH directions + live runner), motion inventory (painted-only, reduced-motion sweep), CDP YouTube upload/retitle with ffprobe-derived titles, capture/verify tooling. - design-dna/: 28 cited facts + 6 rules from a live Greptile inspection. Co-Authored-By: Claude Opus 5 --- .../obs-greptile-custom-context.yaml | 140 +++++++++++ .../obs-greptile-pull-requests.yaml | 144 +++++++++++ design-dna/rules.yaml | 141 +++++++++++ index.html | 8 + scripts/assemble-gif.mjs | 161 ++++++++++++ scripts/capture-ui.mjs | 87 +++++++ scripts/motion-inventory.mjs | 148 +++++++++++ scripts/playwright-peer.mjs | 65 +++++ scripts/record-ui.mjs | 101 ++++++++ scripts/shot-boot-failstate.mjs | 27 ++ scripts/trust-surface-audit.mjs | 161 ++++++++++++ scripts/trust-surface-core.mjs | 138 +++++++++++ scripts/trust-surface-live.mjs | 39 +++ scripts/trust-surface-selftest.mjs | 71 ++++++ scripts/verify-boot-failstate.mjs | 105 ++++++++ scripts/yt-cdp-attach.mjs | 71 ++++++ scripts/yt-cdp.mjs | 91 +++++++ scripts/yt-edit-probe.mjs | 43 ++++ scripts/yt-probe.mjs | 20 ++ scripts/yt-retitle.mjs | 140 +++++++++++ scripts/yt-upload.mjs | 232 ++++++++++++++++++ scripts/yt-verify.mjs | 56 +++++ src/app/styles.css | 11 + src/landing/boot.ts | 58 ++++- 24 files changed, 2257 insertions(+), 1 deletion(-) create mode 100644 design-dna/observations/obs-greptile-custom-context.yaml create mode 100644 design-dna/observations/obs-greptile-pull-requests.yaml create mode 100644 design-dna/rules.yaml create mode 100644 scripts/assemble-gif.mjs create mode 100644 scripts/capture-ui.mjs create mode 100644 scripts/motion-inventory.mjs create mode 100644 scripts/playwright-peer.mjs create mode 100644 scripts/record-ui.mjs create mode 100644 scripts/shot-boot-failstate.mjs create mode 100644 scripts/trust-surface-audit.mjs create mode 100644 scripts/trust-surface-core.mjs create mode 100644 scripts/trust-surface-live.mjs create mode 100644 scripts/trust-surface-selftest.mjs create mode 100644 scripts/verify-boot-failstate.mjs create mode 100644 scripts/yt-cdp-attach.mjs create mode 100644 scripts/yt-cdp.mjs create mode 100644 scripts/yt-edit-probe.mjs create mode 100644 scripts/yt-probe.mjs create mode 100644 scripts/yt-retitle.mjs create mode 100644 scripts/yt-upload.mjs create mode 100644 scripts/yt-verify.mjs diff --git a/design-dna/observations/obs-greptile-custom-context.yaml b/design-dna/observations/obs-greptile-custom-context.yaml new file mode 100644 index 00000000..9b429b83 --- /dev/null +++ b/design-dna/observations/obs-greptile-custom-context.yaml @@ -0,0 +1,140 @@ +id: obs-greptile-custom-context-1 +source: + url: https://mobbin.com/screens/2a746782-8550-4a0f-9ce6-862b6384b4ee + app: Greptile — The AI code reviewer + surface: Custom Context (edit what the agent knows) — list + details inspector + platform: Web, Desktop 1512x945 + capturedVia: mobbin-live +firstSeenAt: 2026-07-28 +lastVerifiedAt: 2026-07-28 + +# Why this reference: this is the surface where a human edits the rules an agent +# will act on. Getting it wrong is not a styling error — it changes what the +# agent does next. + +facts: + - id: f1 + kind: relationship + subject: page layout + property: editor placement + value: "persistent right-hand Details inspector beside the list, not a modal over it; the full rules table stays visible and readable while one rule is edited" + locatorDescription: "Rules table occupies the left ~62% of the content area; 'Details' panel occupies the right ~28% with its own header row" + + - id: f2 + kind: count + subject: Details inspector header + property: action controls + value: 4 + unit: controls + locatorDescription: "Left to right: delete (icon-only, tinted square), Cancel (text), Save (filled), close X" + + - id: f3 + kind: relationship + subject: Save button + property: visual weight versus siblings + value: "the ONLY filled high-contrast control in the panel; Cancel is text-only with no border, close is a bare glyph" + locatorDescription: "Details panel header, rightmost pair before the X" + + - id: f4 + kind: relationship + subject: destructive control + property: placement and treatment + value: "delete is icon-only in a tinted square, positioned at the FAR opposite end of the action row from Save, with Cancel between them" + locatorDescription: "Details panel header, leftmost control in the action group" + + - id: f5 + kind: count + subject: rules table + property: data columns + value: 6 + unit: columns + locatorDescription: "RULES, SCOPE, TYPE, USAGE, LAST UPDATED, STATUS — plus a leading selection checkbox" + + - id: f6 + kind: relationship + subject: STATUS column on this table + property: chromatic ink usage + value: "'ACTIVE' rendered as uppercase letter-spaced GREY text with no colour and no tile — the same product's Pull Requests table spends green on a passed check, and this table spends none" + locatorDescription: "STATUS column, all four rows; compare obs-greptile-pull-requests-1/f5" + + - id: f7 + kind: measurement + subject: USAGE cell + property: zero-value display + value: "0 reviews" + locatorDescription: "USAGE column — the count is printed rather than blanked, dashed, or hidden, on all four rows" + + - id: f8 + kind: count + subject: sortable columns + property: columns carrying a sort control + value: 2 + unit: columns + locatorDescription: "USAGE and LAST UPDATED carry sort affordances; RULES, SCOPE, TYPE and STATUS do not" + + - id: f9 + kind: relationship + subject: rule row + property: progressive disclosure + value: "each row opens with a disclosure chevron and carries a count chip ('1 file') beside the rule name, so scope size is legible before expanding" + locatorDescription: "RULES column, left edge of each of the four rows" + + - id: f10 + kind: relationship + subject: Pattern field + property: type differentiation + value: "monospace value with a right-aligned 'PATTERN' type chip inside the field's label row" + locatorDescription: "Details panel, first field below the header" + + - id: f11 + kind: relationship + subject: read-only context fields + property: editability signalling + value: "File path and Source repo render as grey filled blocks without input borders, visually distinct from the editable Description textarea above them" + locatorDescription: "Details panel, 'Files (1)' section" + + - id: f12 + kind: relationship + subject: section headings in the inspector + property: count disclosure + value: "heading carries its own count — 'Files (1)' — rather than requiring the reader to count children" + locatorDescription: "Details panel, below the Status toggle" + + - id: f13 + kind: relationship + subject: Status control + property: control type for a binary agent-affecting state + value: "toggle switch with the word 'Active' to its right; no colour beyond the toggle track" + locatorDescription: "Details panel, 'Status' label followed by the switch" + + - id: f14 + kind: relationship + subject: Scope section + property: additive control + value: "'+ Add Scope' rendered as a bordered secondary button, distinct from the filled Save" + locatorDescription: "Details panel, below the Repository / File pattern pair" + +problemTags: + - edit-what-the-agent-knows + - config-that-changes-agent-behaviour + - list-plus-detail-editing + - honest-zero-display + +intentTags: + - one-primary-action-per-panel + - separate-destructive-from-commit + - keep-list-context-while-editing + +layoutTags: + - list-with-persistent-inspector + - no-modal-for-item-edit + +interactionTags: + - toggle-for-agent-affecting-state + - progressive-disclosure-rows + - explicit-cancel-and-save + +notRecorded: + - timing + - easing + - choreography diff --git a/design-dna/observations/obs-greptile-pull-requests.yaml b/design-dna/observations/obs-greptile-pull-requests.yaml new file mode 100644 index 00000000..e16e6052 --- /dev/null +++ b/design-dna/observations/obs-greptile-pull-requests.yaml @@ -0,0 +1,144 @@ +id: obs-greptile-pull-requests-1 +source: + url: https://mobbin.com/screens/38767e9a-f40e-436a-a3de-af5bf7a1b2f8 + app: Greptile — The AI code reviewer + surface: Pull Requests (review history list) + platform: Web, Desktop 1512x945 + capturedVia: mobbin-live +firstSeenAt: 2026-07-28 +lastVerifiedAt: 2026-07-28 + +# Why this reference: Greptile is an agent that proposes code-review comments and +# a human decides. That is NodeRoom's problem with the nouns changed. + +facts: + - id: f1 + kind: count + subject: primary navigation + property: top-level tab count + value: 8 + unit: tabs + locatorDescription: "Horizontal tab row under the org switcher: Analytics, Repositories, Code Review Settings, Custom Context, Pull Requests, Code Providers, Integrations, Organization Settings" + + - id: f2 + kind: relationship + subject: active navigation tab + property: selected-state indicator + value: "solid underline directly beneath the label, plus darker label colour; inactive labels are grey" + locatorDescription: "'Pull Requests' tab versus its seven siblings" + + - id: f3 + kind: count + subject: review-history table + property: data columns + value: 7 + unit: columns + locatorDescription: "PR #, PR NAME, REPO, BRANCH, STATUS, # REVIEWS, LAST UPDATED — plus a leading selection checkbox column" + + - id: f4 + kind: relationship + subject: column headers + property: differentiation from cell text + value: "uppercase with wide letter-spacing and grey fill; visually smaller than cell text — differentiated by case and colour, not by weight" + locatorDescription: "Header row of the review-history table" + + - id: f5 + kind: relationship + subject: STATUS cell + property: chromatic ink usage + value: "the green check tile is the ONLY chromatic element inside the table; every other cell is greyscale" + locatorDescription: "STATUS column, rows #2 and #1, rounded-square light-green tile containing a check glyph, followed by the word COMPLETED" + + - id: f6 + kind: relationship + subject: STATUS label + property: typographic treatment + value: "COMPLETED rendered uppercase and letter-spaced in grey — the WORD carries no colour; only the check tile does" + locatorDescription: "STATUS column cell text, immediately right of the green tile" + + - id: f7 + kind: count + subject: decision affordances on this surface + property: approve / reject / merge / dismiss controls + value: 0 + unit: controls + locatorDescription: "Entire table and page body — the only interactive controls are row checkboxes, a search field, and one sort chevron" + + - id: f8 + kind: relationship + subject: STATUS vocabulary + property: terminal versus pending states + value: "both visible rows read COMPLETED; the surface reports finished reviews rather than hosting an undecided one" + locatorDescription: "STATUS column values" + + - id: f9 + kind: count + subject: sortable columns + property: columns carrying a sort chevron + value: 1 + unit: columns + locatorDescription: "LAST UPDATED is the only header with a chevron affordance" + + - id: f10 + kind: relationship + subject: LAST UPDATED cell + property: time format + value: "relative ('about 3 hours ago', 'about 4 hours ago') rather than an absolute timestamp" + locatorDescription: "Rightmost data column, both rows" + + - id: f11 + kind: relationship + subject: BRANCH cell + property: overflow handling + value: "truncated with a trailing ellipsis ('test-greptile-re…') rather than wrapped or column-widened" + locatorDescription: "BRANCH column, both rows" + + - id: f12 + kind: relationship + subject: REPO cell + property: provenance marker + value: "source-system glyph (GitHub mark) precedes the repo name in every row" + locatorDescription: "REPO column, left of the text 'laravel'" + + - id: f13 + kind: relationship + subject: table body + property: vertical fill behaviour + value: "table ends after its two real rows; the remaining ~60% of the viewport stays empty rather than being filled with placeholder rows or stretched row heights" + locatorDescription: "Region below the last table row down to the page fold" + + - id: f14 + kind: relationship + subject: search field + property: dual affordance + value: "single full-width input whose placeholder advertises two behaviours — 'Search pull requests or click to add filters'" + locatorDescription: "Input directly above the table header row" + +problemTags: + - agent-output-review + - dense-data-scan + - trust-state-legibility + - low-volume-table + +intentTags: + - report-history-not-host-decisions + - spend-colour-only-on-verification + +layoutTags: + - full-width-table + - horizontal-tab-nav + - no-vertical-stretch + +interactionTags: + - single-sort-column + - row-multiselect + - search-and-filter-in-one-field + +# Timing, easing and choreography facts are NOT recorded for this surface. +# Mobbin screens are static captures; recording a duration from one would be a +# fabrication. Motion facts for this app require the Animations tab or the live +# product, and neither was inspected. +notRecorded: + - timing + - easing + - choreography diff --git a/design-dna/rules.yaml b/design-dna/rules.yaml new file mode 100644 index 00000000..31ddc720 --- /dev/null +++ b/design-dna/rules.yaml @@ -0,0 +1,141 @@ +# DesignRules — hypotheses about the facts in design-dna/observations/. +# A rule is falsifiable and carries a confidence. Mechanism lives here, never in +# an observation. + +- id: rule-1 + statement: > + In a product where an agent produces output a human must trust, chromatic + ink is spent only on verification state; every other status is carried by + case, weight and greyscale. + mechanismHypothesis: > + If colour appears on ordinary statuses it stops being a signal and becomes + decoration, and the reader can no longer tell "a check passed" from "a row + exists" without reading. Reserving it keeps one glance meaningful. + confidence: high + evidence: + - obs-greptile-pull-requests-1/f5 + - obs-greptile-pull-requests-1/f6 + - obs-greptile-custom-context-1/f6 + whyThisConfidence: > + Two tables in the SAME product, same session, same visual system: the + review-history table spends green on a passed check and the rules table + spends none on ACTIVE. That is a deliberate split, not a style drift. + appliesWhen: + - the surface reports on work an agent did + - more than one status vocabulary exists in the product + doesNotApplyWhen: + - status IS the product's primary data (a monitoring or incident tool) + localCorroboration: > + Matches NodeRoom's own written rule — "green only ever means a passed check, + no decorative green, no green CTAs" — which until now had no external + reference behind it. This observation is that reference. + +- id: rule-2 + statement: > + An agent product may report on decisions without hosting them, and doing so + is a legitimate design choice rather than a missing feature. + mechanismHypothesis: > + Greptile's decision lives where the work lives (the GitHub PR). Rebuilding an + approve/reject surface would duplicate the system of record and create two + places a decision could appear to have been made. The dashboard carries + history; the host carries the verdict. + confidence: medium + evidence: + - obs-greptile-pull-requests-1/f7 + - obs-greptile-pull-requests-1/f8 + whyThisConfidence: > + The absence of any approve/reject control is directly observed, and every + visible status is terminal. What is NOT observed is whether a decision + surface exists deeper in the product; 135 screens were not exhaustively read. + appliesWhen: + - the artifact under review already has an authoritative host + doesNotApplyWhen: + - the product IS the system of record — which is NodeRoom's case, so rule-2 + informs the boundary question rather than settling it + +- id: rule-3 + statement: > + On a panel that can change what an agent does, exactly one control is filled; + the destructive control is icon-only and placed at the opposite end of the + action row. + mechanismHypothesis: > + Visual weight is read as consequence. One filled control makes the committing + action unambiguous, and distance plus a reduced-affordance treatment makes + the irreversible one hard to hit by momentum. + confidence: high + evidence: + - obs-greptile-custom-context-1/f2 + - obs-greptile-custom-context-1/f3 + - obs-greptile-custom-context-1/f4 + - obs-greptile-custom-context-1/f14 + appliesWhen: + - the panel commits a change that alters agent behaviour + doesNotApplyWhen: + - the surface is read-only + +- id: rule-4 + statement: > + A zero is printed, not blanked. "0 reviews" is displayed where a dash or an + empty cell would fit. + mechanismHypothesis: > + An empty cell is ambiguous between "none" and "not loaded". Printing the + zero distinguishes a measured absence from an unknown, which is the same + distinction an honest staleness field makes. + confidence: medium + evidence: + - obs-greptile-custom-context-1/f7 + appliesWhen: + - the count is known to be zero + doesNotApplyWhen: + - the value was never fetched — then the cell must say so, not print 0 + +- id: rule-5 + statement: > + Item editing happens in a persistent inspector beside the list, not in a + modal over it. + mechanismHypothesis: > + Editing one rule is usually a comparison against the others. A modal hides + exactly the context the edit depends on and forces the reader to hold it in + memory. + confidence: medium + evidence: + - obs-greptile-custom-context-1/f1 + - obs-greptile-custom-context-1/f9 + appliesWhen: + - items in the list are peers whose settings interact + doesNotApplyWhen: + - the edit is a single destructive confirmation + localCorroboration: > + NodeRoom's RoomShell is already binder / stage / inspector. This is the same + topology arrived at independently. + +- id: rule-6 + statement: > + A table with two rows renders two rows. It is not stretched, padded with + placeholders, or given taller rows to fill the viewport. + mechanismHypothesis: > + Filling space to look busy misrepresents volume. A short table that looks + short tells the truth about how much work exists. + confidence: medium + evidence: + - obs-greptile-pull-requests-1/f13 + appliesWhen: + - real row counts are low and variable + doesNotApplyWhen: + - emptiness would read as a failed load — then an explicit empty state is + required, which is a different component + +# --- Coverage, stated so a later reader does not mistake this for a survey --- +coverage: + appsObserved: 1 + surfacesObserved: 2 + surfacesAvailable: 135 + note: > + Two surfaces of 135 were inspected. Every rule above is a hypothesis from a + narrow sample of ONE product. No rule here has been tested against a second + app, and none may be cited as a norm until it has been. + motionFacts: > + None. Mobbin screens are static; timing, easing and choreography were not + observed and were therefore not recorded. Any motion rung decision that + wants to cite this reference must first inspect the Animations tab or the + live product. diff --git a/index.html b/index.html index f7179bed..ca1234e7 100644 --- a/index.html +++ b/index.html @@ -82,6 +82,14 @@ .nr-boot-step.now { color: #e59579; border-color: rgba(217,119,87,.28); background: rgba(217,119,87,.16); } @keyframes nr-boot-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @media (prefers-reduced-motion: reduce) { .nr-boot-line { animation: none; } } + /* Failure must not look like loading. When boot fails the skeleton stops + moving and dims: a shimmer is a claim that work is still in progress. */ + .nr-ssr-private[data-boot-state="failed"] .nr-boot-line { animation: none; opacity: .28; } + .nr-ssr-private[data-boot-state="failed"] .nr-boot-status { border-color: rgba(255,255,255,.14); background: rgba(255,255,255,.04); } + .nr-ssr-private[data-boot-state="failed"] .nr-boot-status strong { color: #f2f4f7; } + .nr-boot-retry { justify-self: start; margin-top: 8px; min-height: 32px; padding: 0 14px; border-radius: 8px; border: 1px solid rgba(255,255,255,.18); background: #b0562f; color: #fff; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; } + .nr-boot-retry:hover { background: #9a4b29; } + .nr-boot-retry:focus-visible { outline: 2px solid #d97757; outline-offset: 2px; } @media (max-width: 860px) { .nr-boot-main { grid-template-columns: 1fr; } .nr-boot-rail, .nr-boot-chat { display: none; } diff --git a/scripts/assemble-gif.mjs b/scripts/assemble-gif.mjs new file mode 100644 index 00000000..faf46961 --- /dev/null +++ b/scripts/assemble-gif.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * assemble-gif.mjs — turn captured PNG frames into an animated GIF. + * + * Uses pngjs (already a dependency here) to decode, then writes GIF89a + * by hand: a global palette built from the frames themselves, LZW-compressed, + * with a NETSCAPE2.0 loop block. No ffmpeg, no ImageMagick, no network. + * + * Colour is quantised to a 256-entry palette by 3-3-2 bit truncation. That is + * crude for photographs and perfectly adequate for UI, which is what these are. + * + * node scripts/assemble-gif.mjs [delay-cs] + */ + +import { PNG } from "pngjs"; +import { readFileSync } from "node:fs"; +import { readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const [, , framesDir, outPath, prefix, delayArg] = process.argv; +if (!framesDir || !outPath || !prefix) { + console.error("usage: assemble-gif.mjs [delay-cs]"); + process.exit(2); +} +const DELAY = Number(delayArg ?? 140); // hundredths of a second per frame + +/** 3-3-2 truncation: r>>5 <<5 etc. Deterministic, no dithering, no surprises. */ +const quantise = (r, g, b) => ((r & 0xe0) | ((g & 0xe0) >> 3) | (b >> 6)) & 0xff; +const paletteEntry = (i) => [ + (i & 0xe0) | 0x10, + ((i << 3) & 0xe0) | 0x10, + ((i << 6) & 0xc0) | 0x20, +]; + +/** GIF LZW, variable code width, with clear/end codes. */ +function lzw(indices, minCodeSize) { + const clear = 1 << minCodeSize; + const end = clear + 1; + let dict = new Map(); + const reset = () => { + dict = new Map(); + for (let i = 0; i < clear; i++) dict.set(String(i), i); + return clear + 2; + }; + let next = reset(); + let codeSize = minCodeSize + 1; + const out = []; + let cur = 0; + let bits = 0; + const emit = (code) => { + cur |= code << bits; + bits += codeSize; + while (bits >= 8) { + out.push(cur & 0xff); + cur >>= 8; + bits -= 8; + } + }; + + emit(clear); + let prev = String(indices[0]); + for (let i = 1; i < indices.length; i++) { + const k = indices[i]; + const combined = `${prev},${k}`; + if (dict.has(combined)) { + prev = combined; + continue; + } + emit(dict.get(prev)); + dict.set(combined, next++); + if (next > (1 << codeSize) && codeSize < 12) codeSize++; + else if (next > 4095) { + emit(clear); + next = reset(); + codeSize = minCodeSize + 1; + } + prev = String(k); + } + emit(dict.get(prev)); + emit(end); + if (bits > 0) out.push(cur & 0xff); + return out; +} + +const blockify = (bytes) => { + const parts = []; + for (let i = 0; i < bytes.length; i += 255) { + const chunk = bytes.slice(i, i + 255); + parts.push(Buffer.from([chunk.length]), Buffer.from(chunk)); + } + parts.push(Buffer.from([0])); + return Buffer.concat(parts); +}; + +const files = (await readdir(framesDir)) + .filter((f) => f.startsWith(prefix) && f.endsWith(".png")) + .sort(); + +if (files.length === 0) { + console.error(` no frames matching "${prefix}*" in ${framesDir}`); + process.exit(1); +} + +const frames = []; +let w = 0; +let h = 0; +for (const f of files) { + // Frames are captured at final size, so there is nothing to resize. + const png = PNG.sync.read(readFileSync(path.join(framesDir, f))); + w = png.width; + h = png.height; + const px = new Uint8Array(w * h); + for (let i = 0, p = 0; i < png.data.length; i += 4, p++) { + px[p] = quantise(png.data[i], png.data[i + 1], png.data[i + 2]); + } + frames.push(px); +} + +const parts = []; +parts.push(Buffer.from("GIF89a", "ascii")); +const lsd = Buffer.alloc(7); +lsd.writeUInt16LE(w, 0); +lsd.writeUInt16LE(h, 2); +lsd[4] = 0xf7; // global colour table, 256 entries, 8 bits per channel +parts.push(lsd); + +const pal = Buffer.alloc(768); +for (let i = 0; i < 256; i++) { + const [r, g, b] = paletteEntry(i); + pal[i * 3] = r; + pal[i * 3 + 1] = g; + pal[i * 3 + 2] = b; +} +parts.push(pal); + +// NETSCAPE2.0 — loop forever. +parts.push(Buffer.from([0x21, 0xff, 0x0b]), Buffer.from("NETSCAPE2.0", "ascii"), + Buffer.from([0x03, 0x01, 0x00, 0x00, 0x00])); + +for (const px of frames) { + const gce = Buffer.alloc(8); + gce[0] = 0x21; gce[1] = 0xf9; gce[2] = 0x04; gce[3] = 0x04; + gce.writeUInt16LE(DELAY, 4); + gce[6] = 0x00; gce[7] = 0x00; + parts.push(gce); + + const desc = Buffer.alloc(10); + desc[0] = 0x2c; + desc.writeUInt16LE(0, 1); desc.writeUInt16LE(0, 3); + desc.writeUInt16LE(w, 5); desc.writeUInt16LE(h, 7); + desc[9] = 0x00; + parts.push(desc); + + parts.push(Buffer.from([8])); + parts.push(blockify(lzw(Array.from(px), 8))); +} +parts.push(Buffer.from([0x3b])); + +const gif = Buffer.concat(parts); +await writeFile(outPath, gif); +console.log(` ${path.basename(outPath)} ${files.length} frames ${w}x${h} ${(gif.length / 1024).toFixed(0)} KB`); diff --git a/scripts/capture-ui.mjs b/scripts/capture-ui.mjs new file mode 100644 index 00000000..bb8e4361 --- /dev/null +++ b/scripts/capture-ui.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * capture-ui.mjs — drive the real apps in headless Chromium and write frames. + * + * WHY THIS EXISTS + * + * The browser-automation surface in the agent harness could screenshot but not + * record: starting a GIF recording wedged the renderer every time, and the + * screenshot tool's save-to-disk wrote nowhere findable. So visual evidence + * existed only inside a chat transcript, which is not evidence anyone else can + * check. + * + * Playwright is already a dependency of all three apps and Chromium is already + * cached, so this drives the real browser directly and writes real files. + * + * Frames are written as PNGs and assembled into a GIF by assemble-gif.mjs. + * Nothing here is simulated: if a page fails to load, the run FAILS rather than + * emitting a frame that implies it rendered. + * + * node capture-ui.mjs + */ + +import { chromium } from "playwright"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const OUT = "C:/Users/hshum/Downloads/Interview items/brain/media/frames"; + +/** Each app: a name, a URL, and a selector that PROVES it actually rendered. */ +const TARGETS = [ + { + name: "noderoom", + url: "http://localhost:5260/", + proof: "text=Review every change", + steps: [{ label: "landing" }, { label: "scrolled", scroll: 600 }], + }, + { + name: "nodeslide", + url: "http://localhost:5180/", + proof: "text=What presentation should we build", + steps: [{ label: "composer" }, { label: "scrolled", scroll: 400 }], + }, +]; + +const run = async () => { + await mkdir(OUT, { recursive: true }); + const browser = await chromium.launch(); + const failures = []; + const written = []; + + for (const t of TARGETS) { + const page = await browser.newPage({ viewport: { width: 900, height: 620 } }); + try { + await page.goto(t.url, { waitUntil: "networkidle", timeout: 30_000 }); + // A proof selector, not a timeout. A screenshot of a blank page is worse + // than no screenshot: it looks like evidence and is not. + await page.waitForSelector(t.proof, { timeout: 20_000 }); + + for (const [i, step] of t.steps.entries()) { + if (step.scroll) { + await page.mouse.wheel(0, step.scroll); + await page.waitForTimeout(700); + } + const file = path.join(OUT, `${t.name}-${String(i).padStart(2, "0")}-${step.label}.png`); + await page.screenshot({ path: file }); + written.push(file); + console.log(` captured ${path.basename(file)}`); + } + } catch (e) { + failures.push(`${t.name}: ${e.message.split("\n")[0]}`); + console.log(` FAILED ${t.name} - ${e.message.split("\n")[0]}`); + } finally { + await page.close(); + } + } + + await browser.close(); + console.log(`\n ${written.length} frame(s) written to ${OUT}`); + if (failures.length) { + console.log(` ${failures.length} target(s) FAILED - no frame was faked for them:`); + for (const f of failures) console.log(` ${f}`); + } + process.exitCode = written.length === 0 ? 1 : 0; +}; + +await run(); diff --git a/scripts/motion-inventory.mjs b/scripts/motion-inventory.mjs new file mode 100644 index 00000000..29e23b52 --- /dev/null +++ b/scripts/motion-inventory.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/** + * motion-inventory.mjs — name every animation on a live page, and check the one + * thing a count cannot tell you: does it collapse under prefers-reduced-motion? + * + * `motion-proof` refuses to pass motion that was never observed running, and + * motion-ladder's four numbers are pass/fail. A count of 17 infinite animations + * is not a finding; 17 NAMED animations, each with a verdict under reduced + * motion, is. + * + * Runs each page twice over CDP: default, then emulated reduced-motion. An + * animation that survives the second pass is the defect. + */ + +import { requireChromium } from "./playwright-peer.mjs"; +const chromium = await requireChromium("motion-inventory"); +import { writeFile } from "node:fs/promises"; + +const PORT = 9222; +const OUT = "C:/Users/hshum/AppData/Local/Temp/claude/C--Users-hshum-Downloads-Interview-items/e3836513-f1aa-4c47-9924-c47e6c3b1b3e/scratchpad/motion-inventory.json"; + +const TARGETS = [ + { app: "NodeRoom", url: "http://localhost:5260/", proof: "Review every change" }, + { app: "NodeSlide", url: "http://localhost:5180/", proof: "What presentation should we build" }, +]; + +const PROBE = () => { + const path = (el) => { + const bits = []; + for (let n = el; n && n.nodeType === 1 && bits.length < 4; n = n.parentElement) { + const cls = (n.className?.toString?.() ?? "").trim().split(/\s+/).filter(Boolean).slice(0, 2).join("."); + bits.unshift(n.tagName.toLowerCase() + (cls ? "." + cls : "")); + } + return bits.join(" > "); + }; + + // getComputedStyle reports animationName for elements inside a display:none + // subtree. An animation that never paints is not motion a user experiences, + // and counting it inflates the number that decides a V9 verdict. Visibility + // is part of the measurement, not a nicety. + const painted = (el) => { + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return false; + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + const s = getComputedStyle(n); + if (s.display === "none" || s.visibility === "hidden" || s.opacity === "0") return false; + } + return true; + }; + + const out = []; + const hidden = { count: 0, names: new Set() }; + for (const el of document.querySelectorAll("*")) { + const s = getComputedStyle(el); + const names = (s.animationName || "none").split(",").map((x) => x.trim()); + if (names.every((n) => n === "none")) continue; + if (!painted(el)) { + hidden.count++; + names.forEach((n) => n !== "none" && hidden.names.add(n)); + continue; + } + const counts = s.animationIterationCount.split(",").map((x) => x.trim()); + const durs = s.animationDuration.split(",").map((x) => parseFloat(x) || 0); + names.forEach((n, i) => { + if (n === "none") return; + out.push({ + name: n, + iterations: counts[i] ?? counts[0] ?? "1", + durationS: durs[i] ?? durs[0] ?? 0, + playState: s.animationPlayState.split(",")[i]?.trim() ?? s.animationPlayState, + where: path(el), + }); + }); + } + // Collapse duplicates: 12 copies of one keyframe is one defect, not twelve. + const byKey = new Map(); + for (const a of out) { + const k = `${a.name}|${a.iterations}|${a.durationS}`; + const hit = byKey.get(k); + if (hit) { hit.count++; if (hit.examples.length < 3) hit.examples.push(a.where); } + else byKey.set(k, { ...a, count: 1, examples: [a.where], where: undefined }); + } + return { + visible: [...byKey.values()].sort((a, b) => b.count - a.count), + // Reported, never silently dropped: a hidden animation is not a user-facing + // defect, but it IS the difference between two very different numbers. + hiddenElements: hidden.count, + hiddenNames: [...hidden.names], + }; +}; + +const sweep = async (ctx, t, reduced) => { + const page = await ctx.newPage(); + if (reduced) await page.emulateMedia({ reducedMotion: "reduce" }); + try { + await page.goto(t.url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + await page.getByText(t.proof, { exact: false }).first().waitFor({ timeout: 20_000 }); + await page.waitForTimeout(1800); + const r = await page.evaluate(PROBE); + await page.close(); + return { status: "ok", rows: r.visible, hiddenElements: r.hiddenElements, hiddenNames: r.hiddenNames }; + } catch (e) { + await page.close(); + return { status: "NOT_RUN", reason: e.message.split("\n")[0].slice(0, 140), rows: [], hiddenElements: 0, hiddenNames: [] }; + } +}; + +// Prefer an already-running Chrome over CDP; fall back to launching one. The +// fallback matters because a script that dies when someone closes their browser +// is not a gate, and localhost needs no signed-in session anyway. +let browser; +let transport; +try { + browser = await chromium.connectOverCDP(`http://127.0.0.1:${PORT}`, { timeout: 8_000 }); + transport = `attached over CDP :${PORT}`; +} catch { + browser = await chromium.launch(); + transport = "launched own chromium (CDP endpoint unreachable)"; +} +console.log(` transport: ${transport}`); +const ctx = browser.contexts()[0] ?? (await browser.newContext()); +const report = []; + +for (const t of TARGETS) { + const normal = await sweep(ctx, t, false); + const reduced = await sweep(ctx, t, true); + report.push({ app: t.app, url: t.url, normal, reduced }); +} +await browser.close(); +await writeFile(OUT, JSON.stringify(report, null, 2), "utf8"); + +for (const r of report) { + console.log(`\n=== ${r.app}`); + if (r.normal.status !== "ok") { console.log(` NOT_RUN ${r.normal.reason}`); continue; } + const inf = r.normal.rows.filter((x) => x.iterations === "infinite"); + console.log(` animations: ${r.normal.rows.length} distinct, ${r.normal.rows.reduce((a, b) => a + b.count, 0)} PAINTED elements`); + console.log(` infinite: ${inf.length} distinct (painted)`); + console.log(` hidden: ${r.normal.hiddenElements} element(s) animate inside a non-painted subtree -> not user-facing`); + if (r.normal.hiddenNames.length) console.log(` [${r.normal.hiddenNames.join(", ")}]`); + for (const a of r.normal.rows) { + console.log(` ${a.name.padEnd(22)} x${String(a.count).padStart(2)} ${String(a.durationS)}s iter=${a.iterations}`); + console.log(` at ${a.examples[0]}`); + } + if (r.reduced.status !== "ok") { console.log(` reduced-motion pass: NOT_RUN ${r.reduced.reason}`); continue; } + const survivors = r.reduced.rows.filter((x) => x.durationS > 0.01); + console.log(` under prefers-reduced-motion: ${survivors.length} distinct animation(s) SURVIVE`); + for (const a of survivors) console.log(` SURVIVES ${a.name} x${a.count} ${a.durationS}s iter=${a.iterations}`); +} diff --git a/scripts/playwright-peer.mjs b/scripts/playwright-peer.mjs new file mode 100644 index 00000000..2df83777 --- /dev/null +++ b/scripts/playwright-peer.mjs @@ -0,0 +1,65 @@ +/** + * Playwright is a PEER of these gates, never a dependency of the platform. + * + * NodeKit core ships dependency-free — that is the whole claim, and it is the same split the + * motion ladder draws: the platform owns the grammar and the gate, the consuming application owns + * the runtime that executes it. A UI gate that drags a browser engine into the platform's + * dependency tree has traded the claim for a bundle. + * + * So these scripts resolve `playwright` at run time from the CONSUMER's tree. + * + * The important half is the failure mode. A missing browser must fail CLOSED and loudly. The + * tempting alternative — skip the check, report nothing, exit 0 — is precisely the vacuous pass + * (docs/VACUOUS_PASS.md): a green result from an instrument that measured nothing. "No browser, so + * no findings, so PASS" is the exact shape this repository spent a day cataloguing. + */ + +import { existsSync } from "node:fs"; + +export async function requireChromium(toolName) { + let chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + if (error?.code !== "ERR_MODULE_NOT_FOUND") throw error; + throw new Error( + [ + `${toolName} requires Playwright, which NodeKit deliberately does not depend on.`, + "", + " Install it in the repository being audited, not in the platform:", + " npm install --save-dev playwright && npx playwright install chromium", + "", + " This exits non-zero rather than skipping. A gate that cannot reach a browser has", + " NOT RUN, and not-run is never a pass.", + ].join("\n"), + ); + } + + // The module resolving is not the capability existing. + // + // `npm install playwright` WITHOUT `npx playwright install chromium` is the common half-install: + // the import succeeds, this function returns happily, and the run dies much later at + // `chromium.launch()` with "Executable doesn't exist at ...". Still non-zero, so not a vacuous + // pass — but it fails in the wrong place with the wrong message, and the guard would have + // measured the PACKAGE rather than the BROWSER. + // + // This is the guard-shaped member of the class in docs/VACUOUS_PASS.md: a precondition check + // that verifies a PROXY for the precondition. Module presence standing in for browser + // availability is the same substitution as prose standing in for a declared trust state. + // + // `executablePath()` is synchronous and launches nothing, so proving the real thing is cheap. + const executable = chromium.executablePath?.(); + if (executable && !existsSync(executable)) { + throw new Error( + [ + `${toolName}: Playwright is installed but its Chromium binary is not.`, + "", + " npx playwright install chromium", + "", + ` Expected at: ${executable}`, + " Not-run is never a pass, so this exits non-zero rather than skipping.", + ].join("\n"), + ); + } + return chromium; +} diff --git a/scripts/record-ui.mjs b/scripts/record-ui.mjs new file mode 100644 index 00000000..b643db41 --- /dev/null +++ b/scripts/record-ui.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * record-ui.mjs — record real video of the running apps. + * + * GIFs are fine in a README; YouTube needs video. Playwright records webm + * natively via recordVideo, so this needs no ffmpeg, no screen recorder, and no + * network. It drives the same dev servers the GIF frames came from. + * + * As with capture-ui.mjs: a proof selector, not a timeout. If a page does not + * actually render, the run FAILS for that target rather than producing a video + * of a blank screen — which would look like evidence and be the opposite. + * + * node scripts/record-ui.mjs + */ + +import { chromium } from "playwright"; +import { mkdir, readdir, rename } from "node:fs/promises"; +import path from "node:path"; + +const OUT = "C:/Users/hshum/Downloads/Interview items/brain/media/video"; + +const TARGETS = [ + { + name: "noderoom", + url: "http://localhost:5260/", + proof: "text=Review every change", + tour: async (page) => { + await page.waitForTimeout(1800); + await page.mouse.wheel(0, 500); + await page.waitForTimeout(1400); + await page.mouse.wheel(0, 500); + await page.waitForTimeout(1400); + await page.mouse.wheel(0, -1000); + await page.waitForTimeout(1600); + }, + }, + { + name: "nodeslide", + url: "http://localhost:5180/", + proof: "text=What presentation should we build", + tour: async (page) => { + await page.waitForTimeout(1800); + // Type into the composer so the video shows the product being used, not + // just a static landing page. + const box = page.locator("textarea, [contenteditable=true]").first(); + if (await box.count()) { + await box.click(); + await box.type("A deck on agent evaluation: what ground truth means", { delay: 45 }); + await page.waitForTimeout(1500); + } + await page.mouse.wheel(0, 400); + await page.waitForTimeout(1400); + }, + }, +]; + +const run = async () => { + await mkdir(OUT, { recursive: true }); + const browser = await chromium.launch(); + const made = []; + const failed = []; + + for (const t of TARGETS) { + const ctx = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + recordVideo: { dir: OUT, size: { width: 1280, height: 720 } }, + }); + const page = await ctx.newPage(); + let ok = false; + try { + await page.goto(t.url, { waitUntil: "networkidle", timeout: 30_000 }); + await page.waitForSelector(t.proof, { timeout: 20_000 }); + await t.tour(page); + ok = true; + } catch (e) { + failed.push(`${t.name}: ${e.message.split("\n")[0]}`); + console.log(` FAILED ${t.name} - ${e.message.split("\n")[0]}`); + } + const video = page.video(); + await ctx.close(); // the video is only finalised on context close + if (ok && video) { + const src = await video.path(); + const dst = path.join(OUT, `${t.name}.webm`); + await rename(src, dst); + made.push(dst); + console.log(` recorded ${path.basename(dst)}`); + } else if (video) { + // Discard a video of a page that never rendered. + try { await video.delete(); } catch { /* already gone */ } + } + } + + await browser.close(); + const left = (await readdir(OUT)).filter((f) => f.endsWith(".webm")); + console.log(`\n ${made.length} video(s) in ${OUT}`); + console.log(` files: ${left.join(", ") || "none"}`); + if (failed.length) console.log(` ${failed.length} target(s) failed; no video was kept for them.`); + process.exitCode = made.length === 0 ? 1 : 0; +}; + +await run(); diff --git a/scripts/shot-boot-failstate.mjs b/scripts/shot-boot-failstate.mjs new file mode 100644 index 00000000..151c2881 --- /dev/null +++ b/scripts/shot-boot-failstate.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +/** Screenshot the boot shell in both states, side by side as evidence. */ +import { chromium } from "playwright"; + +const OUT = "C:/Users/hshum/Downloads/Interview items/brain/media/proof"; +const ROUTE = "http://localhost:5260/?demo=1"; +const APP_CHUNK = /\/src\/app\/main|assets\/main-.*\.js/; + +const shot = async (browser, { file, blockChunk, waitMs }) => { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 760 } }); + const page = await ctx.newPage(); + if (blockChunk) await page.route(APP_CHUNK, (r) => r.abort("failed")); + await page.goto(ROUTE, { waitUntil: "commit", timeout: 60_000 }); + await page.locator(".nr-ssr-private").waitFor({ state: "attached", timeout: 30_000 }).catch(() => {}); + await page.waitForTimeout(waitMs); + // The shell is GONE once React mounts — that is the happy path, not an error. + const shell = page.locator(".nr-ssr-private"); + const state = (await shell.count()) ? await shell.getAttribute("data-boot-state") : "shell-removed (React mounted)"; + await page.screenshot({ path: `${OUT}/${file}`, animations: "disabled" }); + await ctx.close(); + console.log(` ${file} state=${state}`); +}; + +const browser = await chromium.launch(); +await shot(browser, { file: "boot-loading.png", blockChunk: false, waitMs: 2500 }); +await shot(browser, { file: "boot-failed.png", blockChunk: true, waitMs: 5000 }); +await browser.close(); diff --git a/scripts/trust-surface-audit.mjs b/scripts/trust-surface-audit.mjs new file mode 100644 index 00000000..ae4af5f6 --- /dev/null +++ b/scripts/trust-surface-audit.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * trust-surface-audit.mjs — run the `trust-surfaces` gate against LIVE pages over CDP. + * + * Clause 1 Inspectable — decision state readable from the DOM, not only in a store. + * Clause 2 Not styled to — no motion on decision affordances; no acceptance styling + * imply an outcome on anything whose declared state is pending. + * + * Two rules the skill is explicit about, and this script obeys both: + * - "A surface missing from the enumeration is not-run, never passed." + * - The gate asserts the consent attribute EXISTS, not just its value when present. + * + * Attaches to an already-running Chrome (connectOverCDP). Does not launch, does not + * close the user's browser. + */ + +import { chromium } from "playwright"; +import { writeFile } from "node:fs/promises"; + +const PORT = 9222; +const OUT = "C:/Users/hshum/AppData/Local/Temp/claude/C--Users-hshum-Downloads-Interview-items/e3836513-f1aa-4c47-9924-c47e6c3b1b3e/scratchpad/trust-audit.json"; + +const TARGETS = [ + { app: "NodeRoom", url: "http://localhost:5260/", proof: "Review every change" }, + { app: "NodeSlide", url: "http://localhost:5180/", proof: "What presentation should we build" }, +]; + +/** Runs IN the page. Returns facts only — no verdicts; verdicts are computed here, in Node. */ +const PROBE = () => { + const TRUST_WORDS = /(propos|conflict|failed|failure|error|diff|review|approve|reject|accept|decline|consent|permission|grant|confirm|pending|unsaved|discard)/i; + const DECISION_VERB = /^(accept|approve|reject|decline|confirm|discard|allow|deny|grant|apply|commit|merge|dismiss|undo|revert)\b/i; + const SUCCESS_HINT = /(success|verified|accepted|approved|confirmed|complete|done|valid|passed|ok\b)/i; + + const vis = (el) => { + const r = el.getBoundingClientRect(); + const s = getComputedStyle(el); + return r.width > 0 && r.height > 0 && s.visibility !== "hidden" && s.display !== "none" && s.opacity !== "0"; + }; + + // --- Clause 1: what does the DOM advertise at all? ----------------------- + const dataAttrs = {}; + for (const el of document.querySelectorAll("*")) { + for (const a of el.attributes) { + if (a.name.startsWith("data-")) dataAttrs[a.name] = (dataAttrs[a.name] ?? 0) + 1; + } + } + const consentAttrs = Object.keys(dataAttrs).filter((k) => /consent|permission|agent-web/i.test(k)); + const stateAttrs = Object.keys(dataAttrs).filter((k) => /state|status|pending|decision|posture/i.test(k)); + + // --- Enumerate candidate trust surfaces --------------------------------- + const surfaces = []; + for (const el of document.querySelectorAll("[data-testid],[role='dialog'],[role='alertdialog'],[role='alert'],section,aside,form")) { + if (!vis(el)) continue; + const tid = el.getAttribute("data-testid") ?? ""; + const text = (el.innerText || "").slice(0, 400); + if (!TRUST_WORDS.test(tid + " " + text)) continue; + if (el.innerText && el.innerText.length > 3000) continue; // whole-page wrappers are not surfaces + surfaces.push({ + tag: el.tagName.toLowerCase(), + testid: tid || null, + role: el.getAttribute("role") || null, + matched: (tid + " " + text).match(TRUST_WORDS)?.[0] ?? null, + declaredState: el.getAttribute("data-state") ?? el.getAttribute("data-status") ?? null, + snippet: text.replace(/\s+/g, " ").slice(0, 120), + }); + } + + // --- Clause 2: decision affordances and their computed styles ------------ + const affordances = []; + for (const el of document.querySelectorAll("button,[role='button'],a[href],input[type=submit]")) { + if (!vis(el)) continue; + const label = (el.innerText || el.getAttribute("aria-label") || "").trim().replace(/\s+/g, " "); + if (!label || !DECISION_VERB.test(label)) continue; + const s = getComputedStyle(el); + const hasMotion = + (s.transitionDuration && s.transitionDuration.split(",").some((d) => parseFloat(d) > 0)) || + (s.animationName && s.animationName !== "none"); + affordances.push({ + label: label.slice(0, 60), + classes: el.className?.toString?.().slice(0, 120) ?? "", + declaredState: el.getAttribute("data-state") ?? el.getAttribute("data-status") ?? null, + transitionDuration: s.transitionDuration, + transitionProperty: s.transitionProperty?.slice(0, 80), + animationName: s.animationName, + hasMotion, + successStyled: SUCCESS_HINT.test(el.className?.toString?.() ?? ""), + }); + } + + // --- Infinite / long animations anywhere (V9 signal) -------------------- + let infinite = 0; + let over400 = 0; + for (const el of document.querySelectorAll("*")) { + const s = getComputedStyle(el); + if (s.animationName && s.animationName !== "none") { + if (s.animationIterationCount.split(",").some((c) => c.trim() === "infinite")) infinite++; + if (s.animationDuration.split(",").some((d) => parseFloat(d) > 0.4)) over400++; + } + if (s.transitionDuration && s.transitionDuration.split(",").some((d) => parseFloat(d) > 0.4)) over400++; + } + + return { + title: document.title, + dataAttrCount: Object.keys(dataAttrs).length, + consentAttrs, + stateAttrs, + surfaces, + affordances, + motion: { infinite, over400 }, + }; +}; + +const run = async () => { + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${PORT}`, { timeout: 15_000 }); + const ctx = browser.contexts()[0]; + const report = { generatedAtNote: "stamped by caller", cdp: `:${PORT}`, targets: [] }; + + for (const t of TARGETS) { + const page = await ctx.newPage(); + const entry = { app: t.app, url: t.url }; + try { + await page.goto(t.url, { waitUntil: "domcontentloaded", timeout: 30_000 }); + // Proof selector, not a timeout: a blank page must fail, not pass empty. + await page.getByText(t.proof, { exact: false }).first().waitFor({ timeout: 20_000 }); + await page.waitForTimeout(1500); + entry.probe = await page.evaluate(PROBE); + entry.status = "probed"; + } catch (e) { + entry.status = "NOT_RUN"; + entry.reason = e.message.split("\n")[0].slice(0, 160); + } + await page.close(); + report.targets.push(entry); + } + + await browser.close(); + return report; +}; + +let report; +try { + report = await run(); +} catch (e) { + report = { fatal: e.message.split("\n")[0] }; +} +await writeFile(OUT, JSON.stringify(report, null, 2), "utf8"); + +// Terse console summary; the JSON is the artifact. +for (const t of report.targets ?? []) { + if (t.status !== "probed") { + console.log(`${t.app}: NOT_RUN - ${t.reason}`); + continue; + } + const p = t.probe; + console.log( + `${t.app}: surfaces=${p.surfaces.length} affordances=${p.affordances.length} ` + + `consentAttrs=${p.consentAttrs.length} stateAttrs=${p.stateAttrs.length} ` + + `motion(inf=${p.motion.infinite},>400ms=${p.motion.over400})`, + ); +} +if (report.fatal) console.log(`FATAL ${report.fatal}`); diff --git a/scripts/trust-surface-core.mjs b/scripts/trust-surface-core.mjs new file mode 100644 index 00000000..e995b717 --- /dev/null +++ b/scripts/trust-surface-core.mjs @@ -0,0 +1,138 @@ +/** + * trust-surface-core.mjs — the measurement and the verdict, in one place. + * + * Split out of trust-surface-audit.mjs so the self-test exercises the REAL + * probe rather than a copy of it. A self-test against a duplicated + * implementation is itself a vacuous pass: it proves the copy works. + * + * Clause 1 Inspectable — decision state readable from the DOM. + * Clause 2 Not styled to — no motion on decision affordances; no + * imply an outcome acceptance styling on a pending thing. + */ + +/** Runs IN the page. Returns FACTS ONLY — no verdicts. */ +export const PROBE = () => { + const TRUST_WORDS = /(propos|conflict|failed|failure|error|diff|review|approve|reject|accept|decline|consent|permission|grant|confirm|pending|unsaved|discard)/i; + const DECISION_VERB = /^(accept|approve|reject|decline|confirm|discard|allow|deny|grant|apply|commit|merge|dismiss|undo|revert)\b/i; + const SUCCESS_HINT = /(success|verified|accepted|approved|confirmed|complete|done|valid|passed)/i; + const STATE_ATTRS = ["data-state", "data-status", "data-boot-state", "data-decision", "data-trust-state"]; + + const painted = (el) => { + const r = el.getBoundingClientRect(); + if (r.width < 2 || r.height < 2) return false; + for (let n = el; n && n.nodeType === 1; n = n.parentElement) { + const s = getComputedStyle(n); + if (s.display === "none" || s.visibility === "hidden" || s.opacity === "0") return false; + } + return true; + }; + const stateOf = (el) => { + for (const a of STATE_ATTRS) if (el.hasAttribute(a)) return { attr: a, value: el.getAttribute(a) }; + return null; + }; + + const qualified = []; + const CANDIDATES = "[data-testid],[data-state],[data-status],[data-boot-state],[data-decision],[data-trust-state],[role=dialog],[role=alertdialog],[role=alert],section,aside,form,article,main"; + for (const el of document.querySelectorAll(CANDIDATES)) { + if (!painted(el)) continue; + const text = (el.innerText || "").slice(0, 400); + const tid = el.getAttribute("data-testid") ?? ""; + if ((el.innerText || "").length > 3000) continue; // page wrappers are not surfaces + + const declared = stateOf(el); + + // Enumeration used to be pure prose matching, which failed in BOTH + // directions at once: it missed a real failed-boot surface whose copy reads + // "Could not open the room" (no trust word), and it flagged marketing heroes + // for containing the word "review". A gate that ignores the very attribute + // it demands is not measuring what it claims to. + // + // A surface qualifies if it DECLARES a state — that is definitional — or if + // trust language sits together with something to actually decide. + const affordanceCount = [...el.querySelectorAll("button,[role=button],a[href],input[type=submit]")].filter((b) => { + const label = (b.innerText || b.getAttribute("aria-label") || "").trim(); + return label && DECISION_VERB.test(label) && painted(b); + }).length; + const qualifies = !!declared || (TRUST_WORDS.test(tid + " " + text) && affordanceCount > 0); + if (!qualifies) continue; + const affordances = []; + for (const b of el.querySelectorAll("button,[role=button],a[href],input[type=submit]")) { + if (!painted(b)) continue; + const label = (b.innerText || b.getAttribute("aria-label") || "").trim().replace(/\s+/g, " "); + if (!label || !DECISION_VERB.test(label)) continue; + const s = getComputedStyle(b); + const motion = + (s.transitionDuration || "").split(",").some((d) => parseFloat(d) > 0) || + (s.animationName && s.animationName !== "none"); + affordances.push({ + label: label.slice(0, 48), + motion, + transitionDuration: s.transitionDuration, + animationName: s.animationName, + successStyled: SUCCESS_HINT.test(b.className?.toString?.() ?? ""), + }); + } + qualified.push({ + el, + testid: tid || null, + role: el.getAttribute("role") || null, + declared, + pendingLike: /pending|unsaved|propos|review|confirm/i.test(tid + " " + text), + affordances, + snippet: text.replace(/\s+/g, " ").slice(0, 90), + }); + } + + // Keep only the INNERMOST qualifying element. A
wrapping a proposal + // card is not a second trust surface — counting it as one both inflates the + // surface count and reports a clause-1 failure against a wrapper that was + // never meant to declare state. + const surfaces = qualified + .filter((q) => !qualified.some((o) => o.el !== q.el && q.el.contains(o.el))) + .map(({ el, ...rest }) => rest); + + return { surfaces }; +}; + +/** + * Verdict, computed in Node from the facts. Every failure names the surface and + * the clause, and the result always carries what was MEASURED — a bare PASS + * cannot be checked for vacuity. + */ +export const verdict = ({ surfaces }) => { + const failures = []; + for (const s of surfaces) { + const id = s.testid || s.role || s.snippet.slice(0, 40); + if (!s.declared) { + failures.push({ surface: id, clause: 1, why: "no decision-state attribute on the owning element" }); + } + for (const a of s.affordances) { + if (a.motion) { + failures.push({ + surface: id, clause: 2, + why: `decision affordance "${a.label}" animates (transition ${a.transitionDuration}, animation ${a.animationName})`, + }); + } + if (a.successStyled && s.declared && /pending|proposed|undecided/i.test(s.declared.value ?? "")) { + failures.push({ + surface: id, clause: 2, + why: `affordance "${a.label}" carries success styling while state is "${s.declared.value}"`, + }); + } + } + } + const measured = { + surfaces: surfaces.length, + affordances: surfaces.reduce((a, s) => a + s.affordances.length, 0), + declaredStates: surfaces.filter((s) => s.declared).length, + }; + // A run that found no surfaces has not passed — it has not run. This is the + // exact case that makes an audit vacuous, so it is a distinct outcome. + const status = surfaces.length === 0 ? "NOT_RUN" : failures.length === 0 ? "PASS" : "FAIL"; + return { status, measured, failures }; +}; + +export const describe = (v) => + `${v.status} — ${v.measured.surfaces} trust surface(s), ${v.measured.affordances} decision affordance(s), ` + + `${v.measured.declaredStates} with a declared state` + + (v.failures.length ? `; ${v.failures.length} failure(s)` : ""); diff --git a/scripts/trust-surface-live.mjs b/scripts/trust-surface-live.mjs new file mode 100644 index 00000000..1460e075 --- /dev/null +++ b/scripts/trust-surface-live.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * trust-surface-live.mjs — run the probed gate against REAL app DOM. + * + * The self-test proves the gate can pass, fail and abstain on fixtures. This + * proves it says something true about the product — including the boot failure + * state, which is a genuine trust surface reachable only by breaking the app. + */ +import { requireChromium } from "./playwright-peer.mjs"; +const chromium = await requireChromium("trust-surface-live"); +import { PROBE, verdict, describe } from "./trust-surface-core.mjs"; + +const APP_CHUNK = /\/src\/app\/main|assets\/main-.*\.js/; + +const CASES = [ + { name: "NodeRoom landing", url: "http://localhost:5260/", proof: "Review every change", block: false }, + { name: "NodeRoom boot FAILED state", url: "http://localhost:5260/?demo=1", proof: null, block: true }, + { name: "NodeSlide landing", url: "http://localhost:5180/", proof: "What presentation should we build", block: false }, +]; + +const browser = await chromium.launch(); +for (const c of CASES) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await ctx.newPage(); + if (c.block) await page.route(APP_CHUNK, (r) => r.abort("failed")); + try { + await page.goto(c.url, { waitUntil: "commit", timeout: 60_000 }); + if (c.proof) await page.getByText(c.proof, { exact: false }).first().waitFor({ timeout: 25_000 }); + await page.waitForTimeout(c.block ? 6000 : 2500); + const v = verdict(await page.evaluate(PROBE)); + console.log(`\n${c.name}`); + console.log(` ${describe(v)}`); + for (const f of v.failures.slice(0, 6)) console.log(` clause ${f.clause} @ ${f.surface}: ${f.why}`); + } catch (e) { + console.log(`\n${c.name}\n NOT_RUN — ${e.message.split("\n")[0].slice(0, 120)}`); + } + await ctx.close(); +} +await browser.close(); diff --git a/scripts/trust-surface-selftest.mjs b/scripts/trust-surface-selftest.mjs new file mode 100644 index 00000000..e2e88479 --- /dev/null +++ b/scripts/trust-surface-selftest.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * trust-surface-selftest.mjs — probe the gate in BOTH directions. + * + * An audit that cannot fail is not a gate; one that cannot pass is not one + * either. Until a check has been shown to do both, it is a candidate instance + * of the vacuous-pass class rather than a defence against it. + * + * Three fixtures, three required outcomes: + * PASS a compliant proposal surface — declared state, static affordances + * FAIL the same surface with the defects the gate exists to catch + * NOT_RUN a page with no trust surface at all — must NOT report PASS + * + * Fixtures are injected with setContent, so this needs no server and no app. + */ +import { requireChromium } from "./playwright-peer.mjs"; +const chromium = await requireChromium("trust-surface-selftest"); +import { PROBE, verdict, describe } from "./trust-surface-core.mjs"; + +const GOOD = ` +
+
+

Proposed change — review before accepting

+

The agent rewrote three cells. Nothing is applied yet.

+ + +
+
`; + +const BAD = ` + +
+ +
+

Proposed change — review before accepting

+ + +
+
`; + +const EMPTY = `

Quarterly revenue

Nothing to decide here.

`; + +const CASES = [ + { name: "compliant proposal surface", html: GOOD, expect: "PASS" }, + { name: "proposal surface with motion + no declared state", html: BAD, expect: "FAIL" }, + { name: "page containing no trust surface", html: EMPTY, expect: "NOT_RUN" }, +]; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1100, height: 700 } }); +let bad = 0; + +for (const c of CASES) { + await page.setContent(c.html, { waitUntil: "load" }); + await page.waitForTimeout(250); + const v = verdict(await page.evaluate(PROBE)); + const ok = v.status === c.expect; + if (!ok) bad++; + console.log(`${ok ? "PASS" : "FAIL"} ${c.name}`); + console.log(` expected ${c.expect}, got ${describe(v)}`); + for (const f of v.failures) console.log(` clause ${f.clause}: ${f.why}`); +} + +await browser.close(); +console.log(`\n ${bad === 0 ? "GATE PROBED IN BOTH DIRECTIONS — it can pass, fail, and abstain" : bad + " case(s) wrong"}`); +process.exitCode = bad === 0 ? 0 : 1; diff --git a/scripts/verify-boot-failstate.mjs b/scripts/verify-boot-failstate.mjs new file mode 100644 index 00000000..33dd1ae4 --- /dev/null +++ b/scripts/verify-boot-failstate.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * verify-boot-failstate.mjs — prove the boot shell has a failure path. + * + * Three scenarios, because "it renders" is not a test: + * A happy private route boots -> shell declares loading, React replaces it + * B chunk dead the workspace module is aborted -> shell must declare FAILED, + * stop shimmering, and drop the progress rail + * C reduced same failure under prefers-reduced-motion -> still failed, + * still no motion, same copy (collapse to final state, not a + * different design) + * + * Scenario B is the one that matters: before this change the only exit from + * "Opening room" was success, so a dead chunk shimmered forever. + */ + +import { chromium } from "playwright"; + +const BASE = "http://localhost:5260"; +const ROUTE = `${BASE}/?demo=1`; +const APP_CHUNK = /\/src\/app\/main|assets\/main-.*\.js/; + +const readShell = (page) => + page.evaluate(() => { + const el = document.querySelector(".nr-ssr-private"); + if (!el) return { present: false }; + const line = el.querySelector(".nr-boot-line"); + const s = line ? getComputedStyle(line) : null; + return { + present: true, + visible: getComputedStyle(el).display !== "none", + state: el.getAttribute("data-boot-state"), + ariaLabel: el.getAttribute("aria-label"), + heading: el.querySelector(".nr-boot-status strong")?.textContent?.trim() ?? null, + body: el.querySelector(".nr-boot-status span")?.textContent?.trim() ?? null, + progressRail: !!el.querySelector(".nr-boot-progress"), + lineAnimation: s ? s.animationName : null, + lineOpacity: s ? s.opacity : null, + }; + }); + +const scenario = async (browser, { name, blockChunk, reduced }) => { + const ctx = await browser.newContext(); + if (reduced) await ctx.grantPermissions([]).catch(() => {}); + const page = await ctx.newPage(); + if (reduced) await page.emulateMedia({ reducedMotion: "reduce" }); + if (blockChunk) await page.route(APP_CHUNK, (r) => r.abort("failed")); + + // "commit" resolves once the navigation is accepted. A cold Vite dev transform + // of the whole app graph can outlast domcontentloaded, and a timeout there + // reads as "the page is broken" when the page is merely still compiling. + await page.goto(ROUTE, { waitUntil: "commit", timeout: 60_000 }); + await page.locator(".nr-ssr-private").waitFor({ state: "attached", timeout: 30_000 }).catch(() => {}); + await page.waitForTimeout(blockChunk ? 5000 : 9000); + const shell = await readShell(page); + await ctx.close(); + return { name, shell }; +}; + +const browser = await chromium.launch(); +const results = []; +for (const s of [ + { name: "A happy", blockChunk: false, reduced: false }, + { name: "B chunk dead", blockChunk: true, reduced: false }, + { name: "C chunk dead + reduced-motion", blockChunk: true, reduced: true }, +]) { + results.push(await scenario(browser, s)); +} +await browser.close(); + +let failures = 0; +const check = (label, pass, detail) => { + if (!pass) failures++; + console.log(` ${pass ? "PASS" : "FAIL"} ${label}${detail ? " -> " + detail : ""}`); +}; + +for (const r of results) { + const s = r.shell; + console.log(`\n ${r.name}`); + console.log(` shell present=${s.present} visible=${s.visible} state=${s.state}`); + console.log(` heading="${s.heading}" rail=${s.progressRail} anim=${s.lineAnimation} opacity=${s.lineOpacity}`); + + if (r.name.startsWith("A")) { + check("React replaced the boot shell (or it declared loading)", !s.present || s.state === "loading", `present=${s.present} state=${s.state}`); + continue; + } + // B and C: the failure path + check("shell declares data-boot-state=failed", s.state === "failed", String(s.state)); + check("heading states the failure", /could not open/i.test(s.heading ?? ""), s.heading ?? "null"); + check("progress rail removed (no false progress)", s.progressRail === false); + check("skeleton stopped shimmering", s.lineAnimation === "none", String(s.lineAnimation)); + check("aria-label no longer says Loading", !/loading/i.test(s.ariaLabel ?? ""), s.ariaLabel ?? "null"); +} + +// C must be identical to B in substance: reduced motion collapses to the final +// state, it does not produce a different design. +const b = results.find((r) => r.name.startsWith("B"))?.shell; +const c = results.find((r) => r.name.startsWith("C"))?.shell; +console.log("\n B vs C (reduced motion must not change the design)"); +check("same state", b?.state === c?.state, `${b?.state} vs ${c?.state}`); +check("same heading", b?.heading === c?.heading); +check("same body copy", b?.body === c?.body); + +console.log(`\n ${failures === 0 ? "ALL CHECKS PASSED" : failures + " CHECK(S) FAILED"}`); +process.exitCode = failures === 0 ? 0 : 1; diff --git a/scripts/yt-cdp-attach.mjs b/scripts/yt-cdp-attach.mjs new file mode 100644 index 00000000..7cd929f7 --- /dev/null +++ b/scripts/yt-cdp-attach.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * yt-cdp-attach.mjs — attach to an ALREADY-RUNNING Chrome over CDP. + * + * chromium.connectOverCDP is the honest version of "use chrome cdp": it does not + * launch anything, it speaks the DevTools Protocol to a browser that is already + * there, holding the real signed-in session. + * + * Writes its report to a FILE rather than stdout, because a piped stdout can + * buffer and an empty pipe looks exactly like a hung script. + */ + +import { chromium } from "playwright"; +import { writeFile } from "node:fs/promises"; + +const PORT = 9222; +const REPORT = "C:/Users/hshum/AppData/Local/Temp/claude/C--Users-hshum-Downloads-Interview-items/e3836513-f1aa-4c47-9924-c47e6c3b1b3e/scratchpad/cdp-report.txt"; + +const lines = []; +const say = (s) => { lines.push(s); }; + +/** Three-state. UNKNOWN must never be reported as SIGNED_OUT. */ +const authState = async (page) => { + const url = page.url(); + if (/accounts\.google\.com|ServiceLogin|\/signin/i.test(url)) { + return { state: "SIGNED_OUT", why: `redirected to ${new URL(url).hostname}` }; + } + const avatar = await page.locator("#avatar-btn, ytcp-account-button").count(); + if (avatar > 0) return { state: "SIGNED_IN", why: "account button present" }; + const signIn = await page.getByRole("link", { name: /^sign in$/i }).count(); + if (signIn > 0) return { state: "SIGNED_OUT", why: "sign-in link present" }; + return { state: "UNKNOWN", why: `no auth marker at ${url}` }; +}; + +const run = async () => { + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${PORT}`, { timeout: 15_000 }); + say(`attached ${browser.version?.() ?? "chrome"} over CDP :${PORT}`); + + const ctx = browser.contexts()[0]; + if (!ctx) throw new Error("CDP attached but no browser context exists"); + say(`contexts ${browser.contexts().length}, pages ${ctx.pages().length}`); + + const page = ctx.pages().find((p) => /youtube/.test(p.url())) ?? ctx.pages()[0] ?? (await ctx.newPage()); + if (!/studio\.youtube/.test(page.url())) { + await page.goto("https://studio.youtube.com/", { waitUntil: "domcontentloaded", timeout: 45_000 }); + } + await page.waitForTimeout(5000); + + const auth = await authState(page); + say(`url ${page.url()}`); + say(`title ${await page.title()}`); + say(`auth ${auth.state} (${auth.why})`); + + if (auth.state === "SIGNED_IN") { + const create = await page.locator("#create-icon, ytcp-button#create-icon").count(); + say(`create-btn ${create > 0 ? "present" : "NOT FOUND"}`); + } + + await browser.close(); // detaches CDP; does NOT kill the Chrome window + return auth; +}; + +let auth = { state: "UNKNOWN", why: "run did not complete" }; +try { + auth = await run(); +} catch (e) { + say(`FAILED ${e.message.split("\n")[0]}`); +} +say(`RESULT ${auth.state}`); +await writeFile(REPORT, lines.join("\n") + "\n", "utf8"); +console.log(lines.join("\n")); diff --git a/scripts/yt-cdp.mjs b/scripts/yt-cdp.mjs new file mode 100644 index 00000000..5b9e4964 --- /dev/null +++ b/scripts/yt-cdp.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * yt-cdp.mjs — drive real Chrome over the DevTools Protocol. + * + * The claude-in-chrome extension is a TRANSPORT for CDP, not CDP itself. When + * the extension will not connect, the protocol is still reachable: Playwright + * speaks CDP to a real Chrome binary directly. + * + * Chrome >=136 refuses --remote-debugging-port on the DEFAULT user-data-dir, so + * this runs against a clone of the signed-in profile (Local State + Cookies + + * Login Data). Same machine, same user, so DPAPI still decrypts the cookie jar. + * + * Verb decides how far it goes. Nothing here uploads unless told to: + * node scripts/yt-cdp.mjs check -> report auth state, then exit + * node scripts/yt-cdp.mjs upload -> check, then drive the upload flow + */ + +import { chromium } from "playwright"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const PROFILE = "C:/Users/hshum/AppData/Local/Temp/claude/C--Users-hshum-Downloads-Interview-items/e3836513-f1aa-4c47-9924-c47e6c3b1b3e/scratchpad/cdp-profile"; +const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe"; +const MEDIA = "C:/Users/hshum/Downloads/Interview items/brain/media/youtube"; +const PORT = 9222; + +const verb = process.argv[2] ?? "check"; + +/** Auth is a three-state answer, not a boolean. UNKNOWN must not read as NO. */ +const authState = async (page) => { + const url = page.url(); + if (/accounts\.google\.com|ServiceLogin|signin/i.test(url)) return { state: "SIGNED_OUT", why: `redirected to ${new URL(url).hostname}` }; + const avatar = await page.locator('#avatar-btn, ytcp-account-button, button#avatar-btn').count(); + if (avatar > 0) return { state: "SIGNED_IN", why: "account button present" }; + const signIn = await page.getByRole("link", { name: /sign in/i }).count(); + if (signIn > 0) return { state: "SIGNED_OUT", why: "sign-in link present" }; + return { state: "UNKNOWN", why: `no auth marker at ${url}` }; +}; + +const run = async () => { + console.log(` profile ${PROFILE}`); + const ctx = await chromium.launchPersistentContext(PROFILE, { + executablePath: CHROME, + headless: false, + viewport: { width: 1440, height: 900 }, + args: [`--remote-debugging-port=${PORT}`, "--no-first-run", "--no-default-browser-check"], + }); + + // Prove CDP is actually live rather than assuming Playwright implies it. + let cdp = "unreachable"; + try { + const r = await fetch(`http://127.0.0.1:${PORT}/json/version`, { signal: AbortSignal.timeout(4000) }); + const j = await r.json(); + cdp = `${j.Browser} (ws ${j.webSocketDebuggerUrl ? "open" : "absent"})`; + } catch (e) { + cdp = `unreachable: ${e.name}`; + } + console.log(` cdp ${cdp}`); + + const page = ctx.pages()[0] ?? (await ctx.newPage()); + await page.goto("https://studio.youtube.com/", { waitUntil: "domcontentloaded", timeout: 45_000 }); + await page.waitForTimeout(4000); + + const auth = await authState(page); + console.log(` auth ${auth.state} (${auth.why})`); + console.log(` landed ${page.url()}`); + + if (auth.state !== "SIGNED_IN") { + console.log("\n STOP - not signed in on the cloned profile. Nothing was uploaded."); + console.log(" A Chrome window is open; sign in there, then re-run. The profile persists."); + return { auth, uploaded: [] }; + } + + if (verb !== "upload") { + console.log("\n check only - re-run with `upload` to proceed."); + return { auth, uploaded: [] }; + } + + const meta = await readFile(path.join(MEDIA, "METADATA.md"), "utf8"); + console.log(` metadata ${meta.split("\n").length} lines loaded`); + console.log("\n ready to upload - flow driven interactively from here."); + return { auth, uploaded: [] }; +}; + +const result = await run().catch((e) => { + console.log(`\n FAILED ${e.message.split("\n")[0]}`); + process.exitCode = 1; + return null; +}); +if (result) console.log(`\n done. auth=${result.auth.state}`); +// Leave the browser open on purpose: the window is the fallback path for a human. diff --git a/scripts/yt-edit-probe.mjs b/scripts/yt-edit-probe.mjs new file mode 100644 index 00000000..57807e66 --- /dev/null +++ b/scripts/yt-edit-probe.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** What is actually on Studio's /edit page? Selectors were guessed three times; measure instead. */ +import { chromium } from "playwright"; + +const VIDEO = process.argv[2]; +const browser = await chromium.connectOverCDP("http://127.0.0.1:9222", { timeout: 30_000 }); +const page = await browser.contexts()[0].newPage(); +await page.goto(`https://studio.youtube.com/video/${VIDEO}/edit`, { waitUntil: "domcontentloaded", timeout: 90_000 }); + +// Settle: Studio hydrates late and the details form arrives after the shell. +for (let i = 0; i < 24; i++) { + await page.waitForTimeout(1000); + if (await page.locator("#textbox, textarea, input[type=text]").count()) break; +} + +console.log(`url: ${page.url()}`); +console.log(`title: ${await page.title()}`); + +const fields = await page.evaluate(() => + [...document.querySelectorAll('#textbox,[contenteditable="true"],textarea,input[type=text]')] + .filter((e) => e.getBoundingClientRect().width > 0) + .map((e) => ({ + tag: e.tagName.toLowerCase(), + id: e.id || null, + parentId: e.parentElement?.id || null, + hostId: e.closest("[id]")?.id || null, + aria: e.getAttribute("aria-label") || null, + text: (e.innerText || e.value || "").trim().slice(0, 60), + }))); +console.log(`\neditable fields (${fields.length}):`); +fields.forEach((f) => console.log(" " + JSON.stringify(f))); + +const buttons = await page.evaluate(() => + [...document.querySelectorAll("button,ytcp-button,[role=button]")] + .filter((e) => e.getBoundingClientRect().width > 0) + .map((e) => ({ id: e.id || null, label: (e.innerText || e.getAttribute("aria-label") || "").trim().slice(0, 24) })) + .filter((x) => x.label || x.id) + .slice(0, 20)); +console.log(`\nbuttons:`); +buttons.forEach((b) => console.log(" " + JSON.stringify(b))); + +await page.close(); +await browser.close(); diff --git a/scripts/yt-probe.mjs b/scripts/yt-probe.mjs new file mode 100644 index 00000000..45f3118b --- /dev/null +++ b/scripts/yt-probe.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** What does the CDP-attached Chrome actually see? Facts, not theories. */ +import { chromium } from "playwright"; + +const browser = await chromium.connectOverCDP("http://127.0.0.1:9222", { timeout: 20_000 }); +const ctx = browser.contexts()[0]; +console.log(`contexts=${browser.contexts().length} pages=${ctx.pages().length}`); +for (const p of ctx.pages()) console.log(` open: ${p.url()}`); + +const page = await ctx.newPage(); +await page.goto("https://www.youtube.com/", { waitUntil: "domcontentloaded", timeout: 60_000 }); +await page.waitForTimeout(7000); +console.log(`youtube url: ${page.url()}`); +console.log(`avatar btn: ${await page.locator("#avatar-btn").count()}`); +console.log(`sign-in link: ${await page.locator('a[href*="accounts.google.com"]').count()}`); +const cookies = await ctx.cookies("https://www.youtube.com"); +console.log(`cookies for youtube.com: ${cookies.length}`); +console.log(` names: ${cookies.slice(0, 12).map((c) => c.name).join(", ")}`); +await page.close(); +await browser.close(); diff --git a/scripts/yt-retitle.mjs b/scripts/yt-retitle.mjs new file mode 100644 index 00000000..bdb84d8b --- /dev/null +++ b/scripts/yt-retitle.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +/** + * yt-retitle.mjs — correct a published video's title/description over CDP. + * + * Re-uploading to fix a caption leaves a duplicate behind; editing in place is + * the honest repair. + * + * THE DURATION IS DERIVED FROM THE FILE, NEVER TYPED. This script exists because + * a title read "11s walkthrough" over a 24-second video, and the moment that was + * fixed the sibling clip was found saying "8s" over 10.9s. A duration written by + * hand is a claim that goes stale the next time the spec changes; ffprobe cannot. + * + * TWO THINGS THAT COST FOUR FAILED ATTEMPTS: + * + * 1. These are NOT the upload flow's selectors. The upload dialog uses + * #title-textarea #textbox; /edit uses a bare div#textbox distinguished only + * by aria-label. Guessing three times cost more than probing once. + * + * 2. Launch Chrome with --disable-extensions. A real profile loads ~13 extension + * service workers as CDP targets and connectOverCDP stalls attaching to them + * — the websocket connects, then times out. Cookies live in the profile, not + * the extensions, so the signed-in session survives: + * + * chrome.exe --remote-debugging-port=9222 + * --user-data-dir="%LOCALAPPDATA%\Google\Chrome\User Data" + * --disable-extensions about:blank + * + * node scripts/yt-retitle.mjs + */ +import { chromium } from "playwright"; +import { execFileSync } from "node:child_process"; + +const DIR = "C:/Users/hshum/Downloads/Interview items/brain/media/youtube"; + +const VIDEOS = { + NodeRoom: { + file: `${DIR}/WT-NodeRoom.mp4`, + kind: "product walkthrough", + base: "NodeRoom — review every agent change", + coverage: "8 steps exercising 6 of the 21 interactive elements on this surface", + body: [ + "NodeRoom is a shared workspace where people and NodeAgents work on the same files,", + "spreadsheets and notes — and every agent edit stays reviewable and source-backed", + "rather than applied behind your back.", + "", + "This clip runs the product's own drills, which call the same engine as a live room:", + "", + "1. The no-clobber test — a stale-baseline write comes back as { ok:false, reason:'conflict' }", + " instead of overwriting.", + "2. Lease + draft-around-lock — an agent drafts around a locked cell and the engine", + " smart-merges on release, so the human never waits.", + "3. Stale-write to review — the agent loses the race, and the engine opens a", + " semantic_rebase review proposal instead of clobbering the human.", + "", + "The last one ends the way the product is meant to: a person approves, and it", + "re-applies at the CURRENT version rather than the stale baseline.", + ], + }, + NodeSlide: { + file: `${DIR}/WT-NodeSlide.mp4`, + kind: "walkthrough", + base: "NodeSlide — decks that stay editable, built from a brief", + coverage: "8 steps exercising 8 of the 91 interactive elements across the landing and the deck editor", + body: [ + "NodeSlide turns an idea, a structured spec, or evidence into a reviewable deck —", + "not a stack of static images. Route, tokens and cost are recorded in Trace, so you", + "can see what produced each slide.", + "", + "In this clip: a brief is typed in, the sample workspace opens on a real deck, a", + "slide element is selected to show the deck is a typed structure rather than a", + "picture, and the inspector's Versions, Evidence and Trace tabs are opened in turn.", + "", + "Two details worth pausing on, both the product being honest about its own limits:", + "Evidence says plainly that it checks citation attachment and disclosure but does", + "NOT independently verify facts. Trace reports cost as 'not recorded' rather than", + "printing a number it does not have.", + "", + "The coverage line below is deliberate. The deck editor alone carries 78 controls", + "this clip never opens; a tour that skips them is fine, implying it didn't is not.", + ], + }, +}; + +const [videoId, key] = process.argv.slice(2); +const spec = VIDEOS[key]; +if (!videoId || !spec) { + console.log(`usage: node scripts/yt-retitle.mjs <${Object.keys(VIDEOS).join("|")}>`); + process.exit(1); +} + +const durationS = Math.round( + parseFloat(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", spec.file]) + .toString().trim()), +); +const TITLE = `${spec.base} (${durationS}s ${spec.kind})`; +const DESC = [ + ...spec.body, + "", + `Coverage, stated rather than implied: ${spec.coverage}.`, + "", + "Captured with FeatureClipStudio — Playwright capture, Remotion render, ffmpeg encode.", + "", + "github.com/HomenShum", +].join("\n"); +console.log(` ffprobe duration: ${durationS}s -> "${TITLE}"`); + +const browser = await chromium.connectOverCDP("http://127.0.0.1:9222", { timeout: 30_000 }); +const page = await browser.contexts()[0].newPage(); +await page.goto(`https://studio.youtube.com/video/${videoId}/edit`, { waitUntil: "domcontentloaded", timeout: 90_000 }); + +const title = page.locator('div#textbox[aria-label^="Add a title"]').first(); +const desc = page.locator('div#textbox[aria-label^="Tell viewers"]').first(); +await title.waitFor({ state: "visible", timeout: 60_000 }); +console.log(` before: ${(await title.textContent())?.trim()}`); + +const retype = async (el, text) => { + await el.click(); + await page.keyboard.press("Control+A"); + await page.keyboard.press("Delete"); + await page.keyboard.insertText(text); // insertText, not type(): one event, no per-char latency + await page.waitForTimeout(600); +}; + +await retype(title, TITLE); +if (await desc.count()) await retype(desc, DESC); + +const save = page.locator("#save").first(); +await save.waitFor({ state: "visible", timeout: 30_000 }); +await save.click(); +await page.waitForTimeout(8000); + +// Verify from the reloaded page, not from the fact that a click did not throw. +await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); +await title.waitFor({ state: "visible", timeout: 60_000 }); +const after = (await title.textContent())?.trim(); +console.log(` after: ${after}`); +console.log(after === TITLE ? " SAVED — title matches" : " MISMATCH — not saved"); +await page.close(); +await browser.close(); +process.exitCode = after === TITLE ? 0 : 1; diff --git a/scripts/yt-upload.mjs b/scripts/yt-upload.mjs new file mode 100644 index 00000000..68d6e133 --- /dev/null +++ b/scripts/yt-upload.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +/** + * yt-upload.mjs — upload one walkthrough to YouTube over CDP. + * + * Why this works where three other routes did not: + * - the extension's file_upload allowlists by chat attachment + * - studio.youtube.com's CSP makes Runtime.evaluate hang, so JS injection dies + * - a cloned profile cannot decrypt cookies (Chrome App-Bound Encryption) + * Playwright's setInputFiles sets the file natively through CDP, in the REAL + * signed-in profile. No allowlist, no page script, no cookie copying. + * + * Chrome must be running as: + * chrome.exe --remote-debugging-port=9222 --user-data-dir= + * Passing --user-data-dir explicitly is what makes Chrome honour the debugging + * port; omitting it is what Chrome >=136 ignores. + * + * node scripts/yt-upload.mjs NodeRoom + * node scripts/yt-upload.mjs NodeSlide + */ + +import { chromium } from "playwright"; +import { execFileSync } from "node:child_process"; + +const PORT = 9222; +const DIR = "C:/Users/hshum/Downloads/Interview items/brain/media/youtube"; + +// The duration in a title is DERIVED from the file at upload time, never typed. +// A hand-written "11s" shipped over a 24s video; the fix surfaced "8s" over +// 10.9s in the sibling. A typed duration is a claim that goes stale on the next +// re-cut; ffprobe cannot. +const withDuration = (base) => (file) => { + const s = Math.round(parseFloat( + execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]).toString(), + )); + return base.replace("{D}", `${s}s`); +}; + +const VIDEOS = { + NodeRoomFresh: { + file: `${DIR}/WT-NodeRoomFresh.mp4`, + mkTitle: withDuration("NodeRoom — from landing to a room ({D} fresh-user walkthrough)"), + description: [ + "The journey a brand-new visitor actually has, with nothing staged:", + "", + "1. The landing runs a live agent demo — Room NodeAgent commits variance", + " through the sync tool, source-backed to its citation (NetSuite p.4).", + "2. Creating a room asks one question before anything else: how should", + " NodeAgent edits land? Review-every-artifact-edit is the recommended", + " default; auto-approve stays traced.", + "3. Joining is by code — a room shares a code, not a seat.", + "4. The sample room says plainly its data is synthetic, not live research.", + "", + "Coverage, stated rather than implied: 6 steps over the landing's two entry", + "dialogs and the join-by-code control.", + "", + "Filming this journey found a real bug: every dialog on this path rendered", + "behind its own blur scrim (a Radix-migration regression). It was fixed", + "first, and the clip shows the repaired product.", + "", + "Captured with FeatureClipStudio — Playwright capture, Remotion render,", + "ffmpeg encode.", + "", + "github.com/HomenShum", + ].join("\n"), + }, + NodeRoom: { + file: `${DIR}/WT-NodeRoom.mp4`, + mkTitle: withDuration("NodeRoom — review every agent change ({D} product walkthrough)"), + description: [ + "NodeRoom is a shared workspace where people and NodeAgents work on the same", + "files, spreadsheets and notes — and every agent edit stays reviewable and", + "source-backed rather than applied behind your back.", + "", + "This clip runs the product's own drills, which call the same engine as a", + "live room:", + " 1. the no-clobber test — a stale-baseline write comes back as", + " { ok:false, reason:'conflict' } instead of overwriting", + " 2. lease + draft-around-lock — an agent drafts around a locked cell and", + " the engine smart-merges on release, so the human never waits", + " 3. stale-write to review — the agent loses the race and the engine opens", + " a semantic_rebase review proposal instead of clobbering the human", + "", + "The last one ends the way the product is meant to: a person approves, and", + "it re-applies at the CURRENT version rather than the stale baseline.", + "", + "Coverage, stated rather than implied: 8 steps exercising 6 of the 21", + "interactive elements on this surface.", + "", + "Captured live against the running app with FeatureClipStudio — Playwright", + "capture, Remotion render, ffmpeg encode. The animated cursor and captions are", + "overlaid at render time; every underlying frame is the real product.", + "", + "github.com/HomenShum", + ].join("\n"), + }, + NodeSlide: { + file: `${DIR}/WT-NodeSlide.mp4`, + mkTitle: withDuration("NodeSlide — decks that stay editable, built from a brief ({D} walkthrough)"), + description: [ + "NodeSlide turns an idea, a structured spec, or evidence into a reviewable deck —", + "not a stack of static images. Route, tokens and cost are recorded in Trace, so", + "you can see what produced each slide.", + "", + "In this clip: a brief is typed in and drives the deck.", + "", + "Captured live against the running app with FeatureClipStudio — Playwright", + "capture, Remotion render, ffmpeg encode.", + "", + "github.com/HomenShum", + ].join("\n"), + }, +}; + +const key = process.argv[2]; +const spec = VIDEOS[key]; +if (!spec) { + console.log(`usage: node scripts/yt-upload.mjs <${Object.keys(VIDEOS).join("|")}>`); + process.exit(1); +} + +const step = (n, msg) => console.log(` ${String(n).padStart(2)}. ${msg}`); + +const run = async () => { + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${PORT}`, { timeout: 20_000 }); + const ctx = browser.contexts()[0]; + if (!ctx) throw new Error("CDP attached but no context"); + const page = await ctx.newPage(); + + step(1, "opening YouTube Studio"); + await page.goto("https://studio.youtube.com/", { waitUntil: "domcontentloaded", timeout: 60_000 }); + + // Studio hops through accounts.google.com on the way in. Sampling the URL at a + // fixed delay catches that hop and reads a redirect as a verdict — which is + // exactly how the first run "failed" against a session that was fine. Wait for + // the URL to SETTLE on a terminal state instead of guessing when it has. + await page + .waitForURL((u) => /studio\.youtube\.com\/channel\//.test(u.href), { timeout: 90_000 }) + .catch(() => {}); + await page.waitForTimeout(3000); + + const onSignIn = /accounts\.google\.com/.test(page.url()); + const hasAvatar = (await page.locator("#avatar-btn, ytcp-account-button").count()) > 0; + if (onSignIn && !hasAvatar) { + throw new Error(`not signed in — settled on ${new URL(page.url()).hostname}`); + } + step(2, `signed in (${page.url().split("?")[0]})`); + + // Open the upload dialog. The direct ?d=ud URL opens it without hunting menus. + const channel = page.url().match(/channel\/([\w-]+)/)?.[1]; + if (!channel) throw new Error("could not read channel id from the Studio URL"); + await page.goto(`https://studio.youtube.com/channel/${channel}/videos/upload?d=ud`, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + await page.waitForTimeout(5000); + + const input = page.locator('input[type="file"]').first(); + await input.waitFor({ state: "attached", timeout: 30_000 }); + step(3, "upload dialog open, file input found"); + + await input.setInputFiles(spec.file); + step(4, `file set natively: ${spec.file.split("/").pop()}`); + + // Title box appears only once YouTube accepts the file — this is the real proof + // that the upload started, not a timer. + const titleBox = page.locator('#title-textarea #textbox, ytcp-social-suggestions-textbox#title-textarea div#textbox').first(); + await titleBox.waitFor({ state: "visible", timeout: 120_000 }); + step(5, "YouTube accepted the file (details form rendered)"); + + await titleBox.click(); + await page.keyboard.press("Control+A"); + await page.keyboard.press("Delete"); + const computedTitle = spec.mkTitle(spec.file); + await titleBox.type(computedTitle, { delay: 8 }); + step(6, `title set: ${computedTitle}`); + + const descBox = page.locator('#description-textarea #textbox').first(); + if (await descBox.count()) { + await descBox.click(); + await descBox.type(spec.description, { delay: 3 }); + step(7, "description set"); + } else { + step(7, "WARN description box not found — continuing without it"); + } + + // Audience is mandatory; YouTube blocks Next until it is answered. + const notForKids = page.locator('tp-yt-paper-radio-button[name="VIDEO_MADE_FOR_KIDS_NOT_MFK"]').first(); + await notForKids.waitFor({ state: "visible", timeout: 30_000 }); + await notForKids.click(); + step(8, 'audience set: "No, it\'s not made for kids"'); + + // Details -> Video elements -> Checks -> Visibility + for (let i = 0; i < 3; i++) { + const next = page.locator("#next-button button, ytcp-button#next-button").first(); + await next.waitFor({ state: "visible", timeout: 30_000 }); + await next.click(); + await page.waitForTimeout(2500); + } + step(9, "advanced to the Visibility step"); + + const unlisted = page.locator('tp-yt-paper-radio-button[name="UNLISTED"]').first(); + await unlisted.waitFor({ state: "visible", timeout: 30_000 }); + await unlisted.click(); + step(10, "visibility set: Unlisted"); + + // Grab the share URL before saving — it is present on the visibility step. + let url = null; + const urlEl = page.locator("#share-url, .video-url-fadeable a").first(); + if (await urlEl.count()) url = (await urlEl.textContent())?.trim() ?? null; + + const done = page.locator("#done-button button, ytcp-button#done-button").first(); + await done.waitFor({ state: "visible", timeout: 30_000 }); + await done.click(); + step(11, "Save clicked"); + + // Processing can hold the dialog; wait for the confirmation, but do not fail + // the run if only the dialog lingers — the upload itself is already committed. + await page.waitForTimeout(9000); + const confirmed = await page.locator("text=/video (link|published|uploaded)/i").count(); + step(12, `post-save confirmation elements: ${confirmed}`); + + await browser.close(); + return { url, confirmed }; +}; + +try { + const r = await run(); + console.log(`\n DONE ${key} url=${r.url ?? "(not read from page)"}`); +} catch (e) { + console.log(`\n FAILED ${key} ${e.message.split("\n")[0]}`); + process.exitCode = 1; +} diff --git a/scripts/yt-verify.mjs b/scripts/yt-verify.mjs new file mode 100644 index 00000000..06d6efd2 --- /dev/null +++ b/scripts/yt-verify.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/** + * yt-verify.mjs — confirm the uploads exist on YouTube, from the live page. + * + * A script that printed "DONE" is not evidence. This loads each watch URL and + * the Studio content list and reports what the DOM actually says. + */ +import { chromium } from "playwright"; + +const TARGETS = [ + { key: "NodeRoom", url: "https://youtu.be/YUpSMEkkK4Q", expect: "review every agent change" }, + { key: "NodeSlide", url: "https://youtu.be/q1CL1hCO_0Q", expect: "decks that stay editable" }, +]; + +const browser = await chromium.connectOverCDP("http://127.0.0.1:9222", { timeout: 20_000 }); +const ctx = browser.contexts()[0]; +let bad = 0; + +for (const t of TARGETS) { + const page = await ctx.newPage(); + await page.goto(t.url, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForTimeout(6000); + const title = (await page.title()) ?? ""; + const h1 = (await page.locator("h1 yt-formatted-string, h1").first().textContent().catch(() => "")) ?? ""; + const player = await page.locator("video").count(); + const ok = new RegExp(t.expect, "i").test(title + " " + h1) && player > 0; + if (!ok) bad++; + console.log(`${ok ? "PASS" : "FAIL"} ${t.key}`); + console.log(` url ${page.url()}`); + console.log(` title ${title.replace(/ - YouTube$/, "")}`); + console.log(`