From 3ee4a26ff671dc8b63d804ea578d6c644c8dbb50 Mon Sep 17 00:00:00 2001 From: Gabi Date: Thu, 16 Jul 2026 22:18:47 +0200 Subject: [PATCH] feat: infer a project persona from the hero when there's no audience context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When setup captured no audience/brand for the project, the tailored persona had nowhere to come from and the scan fell back to generic built-ins. Step 6a now has a fallback: if the desktop hero pitches a specific, nameable audience, infer one project persona from it, tagged as an unconfirmed hypothesis. It self-gates — a bare search box, app shell, or generic splash produces nothing, because pixels that aren't pitching an audience can't tell you who it's for. Verified against real heroes: sharp on Linear and Stripe, correctly silent on Airbnb's search box. Explicit audience text always wins; the fallback only fires when there was none. --- CLAUDE.md | 1 + dist/agents/pixelslop.md | 9 ++++++++- tests/vision-personas.test.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 695f27c..7dc24da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,6 +150,7 @@ Agents use `pixelslop-tools` (bin/pixelslop-tools.cjs) for all state operations. The measured /20 is the objective backbone; the perceptual layer is how the page actually *reads to a human*. It never touches the score — it's judgment, grounded in what was seen. - **Personas are vision-first.** Each page-relevant persona is a spawned read-only vision agent (`dist/agents/internal/pixelslop-eval-persona.md`) that opens the screenshots and reacts as that human first — the five-second read, eye-path, trust, bounce — then grounds the reaction in measured evidence. Not all 8 run: the orchestrator picks the ~4 that matter for the page type via `browser analyze-page` → `suggestedPersonas`, plus project-specific personas. They spawn independently (blind to each other), with an inline fallback like the other evaluators. Returns `{ humanName, name, narrative, issues, priority, workedWell, reactedTo }` — `reactedTo` proves it opened a screenshot. This replaced the old inline "match triggers against measured findings" synthesis (a voice narrating the spreadsheet). +- **Project persona sourcing (in priority order).** The tailored persona comes from the best available signal: (1) an explicit audience/brand description in `.pixelslop.md` (setup) — always wins; (2) failing that, a **hero-inference fallback** in Step 6a — if the desktop hero pitches a specific, nameable audience ("The product development system for teams and agents"), infer one persona from it, tagged as an unconfirmed hypothesis; (3) failing that, the built-ins. The fallback self-gates: a bare search box, app shell, or generic splash produces nothing (verified against Linear/Stripe/Airbnb heroes — sharp on the first two, correctly silent on Airbnb's search box). Text about the audience always beats a guess from pixels, so the fallback only fires when there was no text to begin with. - **The Read co-headlines.** The design-director's verdict + the sharpest persona reactions lead the report: a `### The Read` section above the Scores, a `Reads as:` header line, a co-led scan summary (`Measured: X/20` **and** `Reads as: …`), and an HTML card next to the /20 (`{{PERCEPTUAL_READ}}`, from `scan.perceptualRead`). It's prose grounded in what was seen — **never a competing number.** A second "/10" would be judgment masquerading as measurement, the exact failure the measured/judgment split prevents. - **The internal evaluator count is now 8** (6 pillars + design-director + persona). `evaluator.test.js` pins it. diff --git a/dist/agents/pixelslop.md b/dist/agents/pixelslop.md index 2e74b2f..5470000 100644 --- a/dist/agents/pixelslop.md +++ b/dist/agents/pixelslop.md @@ -180,7 +180,7 @@ If `custom` is empty and you have audience/brand, synthesize 1-2 personas follow node bin/pixelslop-tools.cjs personas write --root "$ROOT" --raw --json '' ``` -Use a project-specific `id` slug (e.g. `stressed-bride`, not a built-in id). Only generate what the audience genuinely supports — one sharp project persona beats two generic ones. Skip this step entirely when there's no real audience to work from. +Use a project-specific `id` slug (e.g. `stressed-bride`, not a built-in id). Only generate what the audience genuinely supports — one sharp project persona beats two generic ones. When there's no real audience to work from here, skip — but don't give up on a project persona yet: Step 6a can infer one from the page's own hero once the screenshot exists. ### Step 6: Collect Evidence @@ -210,6 +210,13 @@ node bin/pixelslop-tools.cjs log write --agent orchestrator --level info --messa Don't run all 8 personas on every page — a screen-reader deep-dive on a marketing splash is noise, and a design-critic on a settings form misses the point. Pick the few personas that actually matter for *this* page, then evaluate those deeply. +**First, fill the audience gap from the hero (fallback).** If Step 5b produced no project persona — because `.pixelslop.md` had no audience/brand to work from — the page's own hero can sometimes supply one. `Read` `viewports.desktop.screenshot` and judge one thing: **does this hero pitch to a specific, nameable audience?** A value-prop headline aimed at a clear user ("The product development system for teams and agents") does; a bare search box, an app shell, or a generic splash does not. + +- **If it does** — infer one project persona from what the hero is selling (who it's for, what they came to do, what would make them trust it or leave), and `personas write` it. Put `inferred from the page hero — audience is a hypothesis, not confirmed` in its `description`, and use a slug like `inferred-visitor`. It's real signal on a Linear- or Stripe-shaped page, and its findings get read with that caveat in mind. +- **If it doesn't** — write nothing. A search box tells you nothing about who's searching; the built-ins cover that page fine. Don't invent an audience from pixels that aren't pitching one. + +This only fires when there was no context to begin with — it never overrides a persona you generated from a real audience description, which always beats a guess from a screenshot. + ```bash node bin/pixelslop-tools.cjs browser analyze-page --url "$URL" --raw ``` diff --git a/tests/vision-personas.test.js b/tests/vision-personas.test.js index 631980a..421d37a 100644 --- a/tests/vision-personas.test.js +++ b/tests/vision-personas.test.js @@ -107,3 +107,32 @@ describe('docs teach the vision-first read', () => { assert.ok(/pixelslop-eval-persona/.test(schema), 'schema should name the persona evaluator'); }); }); + +describe('hero-inference fallback (persona from the page when context is missing)', () => { + const orch = readDist('agents/pixelslop.md'); + + it('infers a project persona from the hero only when there was no audience context', () => { + assert.ok(/fill the audience gap from the hero/i.test(orch), 'the fallback should be documented'); + assert.ok(/Step 5b produced no project persona|no project persona/i.test(orch), + 'it must be gated on Step 5b producing nothing'); + assert.ok(/never overrides a persona you generated from a real audience/i.test(orch), + 'explicit context must always win over a screenshot guess'); + }); + + it('gates on whether the hero actually pitches a specific audience', () => { + assert.ok(/pitch to a specific, nameable audience/i.test(orch), + 'the gate is a pitch-forward hero, not just brand-vs-product'); + assert.ok(/search box/i.test(orch) && /write nothing|Don't invent/i.test(orch), + 'a functional hero (search box) must produce no persona'); + }); + + it('marks the inferred persona as a hypothesis, not confirmed fact', () => { + assert.ok(/hypothesis, not confirmed|inferred from the page hero/i.test(orch), + 'the inferred persona must be tagged as an unconfirmed hypothesis'); + }); + + it('reads the desktop hero screenshot to do it', () => { + assert.ok(/viewports\.desktop\.screenshot/.test(orch), + 'the fallback should read the desktop hero screenshot'); + }); +});