From f5d95bda5f5a4a991f96521aeb7d58eeabf0d361 Mon Sep 17 00:00:00 2001 From: Matthew P Munger Date: Wed, 26 Aug 2026 15:32:17 -0500 Subject: [PATCH 1/3] feat(s9): retire the glossary, move its operational lines into Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Guide is deleted rather than rewritten. A term a reader has to look up is a term the copy should have explained where they met it, and most of this product's copy is read outside the app — in digests, tickets and screenshots — where no glossary is reachable at all. - `src/lib/guide.ts` and its test are gone. The file had no importer in `src/` besides its own test, so the glossary was already unreachable; what it carried that still matters has moved. - `/guide` now redirects to `/issues`, alongside the other retired destinations. It used to aim at `/settings#reference`, an anchor that never existed and now never will: the operational content is distributed beside the controls it explains, so there is no single section to land on. - The operational half moves to Settings as short lines beside the controls they explain. What a run does and how often it happens is said in the Digest group's help, where the reader meets the phrase "per run". What each system contributes is a new "Always on" block in Connected systems, keyed by the registry's evidence sources so a system added to the ledger cannot arrive unexplained, and carrying its own cadence — nightly for the lab test and the agent checks, weekly for the visitor figures. One screen-level claim about "how often" would have been false for whichever system it did not describe. - The Ora row keeps its public-scan disclosure, which was the one thing in the glossary that was an obligation rather than a definition. - Registry v10 renames `evidence_source.kitesurf`'s label from the codename "Kitesurf" to "Rendered page". The codename was the only name a reader ever saw for that system, which is precisely the term-as-jargon this chunk forbids. The KEY is unchanged, so no stored reading migrates. - `systemLabel` in `agentIssueCases.ts` now reads the ledger's labels instead of holding its own three strings — one of which, "Page Watch HTTP", was a spelling the registry never named (rule 20). Its test asserts against the registry rather than a literal (rule 21). `banned_global.allowlist` loses its `src/lib/guide.ts` entry with the file. Co-Authored-By: Claude Opus 5 --- src/app/(app)/guide/page.tsx | 19 +- src/app/(app)/settings/page.tsx | 42 +- src/app/globals.css | 41 ++ src/lib/__tests__/agent-issue-cases.test.ts | 10 +- src/lib/__tests__/guide.test.ts | 106 ---- src/lib/agentIssueCases.ts | 23 +- src/lib/guide.ts | 663 -------------------- src/lib/settings-copy.ts | 41 +- src/lib/vocabulary.ts | 5 +- vocabulary.json | 12 +- 10 files changed, 171 insertions(+), 791 deletions(-) delete mode 100644 src/lib/__tests__/guide.test.ts delete mode 100644 src/lib/guide.ts diff --git a/src/app/(app)/guide/page.tsx b/src/app/(app)/guide/page.tsx index f5ad317..70ea729 100644 --- a/src/app/(app)/guide/page.tsx +++ b/src/app/(app)/guide/page.tsx @@ -7,13 +7,18 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * Retired destination. The reference material moves into Settings; this route - * only exists to keep old links from 404ing. + * Retired destination. The glossary is gone: a term a reader has to look up is + * a term the copy should have explained where they met it, and most of this + * product's copy is read outside the app — in digests, tickets and screenshots, + * where no glossary is reachable. * - * Note: the `#reference` section of Settings is not built yet, so this lands on - * Settings without scrolling anywhere. That section arrives with the Settings - * work, not with the app chrome. + * This lands on the issues list, alongside the other retired destinations, + * rather than on the Settings section the glossary's replacement was once + * expected to become. There is no `#reference` section and there will not be + * one: the operational lines this route used to explain now sit beside the + * Settings controls they describe, so there is no single anchor to send an old + * link to, and the issues list is where every other retired route goes. */ -export default function RedirectToReference() { - redirect(withBasePath(normalizeBasePath(getEnv("BASE_URL")), `${DESTINATION_PATH.settings}#reference`)); +export default function RedirectToIssues() { + redirect(withBasePath(normalizeBasePath(getEnv("BASE_URL")), DESTINATION_PATH.issues)); } diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index c542e2b..6a8f696 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -42,12 +42,15 @@ import { SETTINGS_SENSITIVITY_LIMIT_LABEL, SETTINGS_SYSTEMS_HELP, SETTINGS_SYSTEMS_LABEL, + SETTINGS_SYSTEM_CONTRIBUTES, settingsSubtitle, } from "@/lib/settings-copy"; import { excludedFromResults, type ExcludedRow } from "@/lib/settings-exclusions"; import { alertWebhookUrlIsValid } from "@/lib/webhook"; import { DESTINATION_LABEL, + EVIDENCE_SOURCES, + EVIDENCE_SOURCE_LABEL, applicabilityActionLabel, type ExclusionReason, } from "@/lib/vocabulary"; @@ -448,13 +451,44 @@ function ConnectedSystemsGroup({ disabled }: { disabled: boolean }) { syncUrl={pathFor("/api/settings/webflow/sync")} /> + {/* + The systems with nothing to configure, and what each one contributes. + + This is the operational half of the retired glossary, and it sits here + rather than on a reference page for the reason the glossary was retired: + a reader who has to leave the screen to find out what took a reading + will not go, and most of this product's copy is read outside the app + entirely, where no link is reachable. + + Derived from the registry's evidence sources rather than listed by hand, + so a system added to the ledger appears here instead of arriving + unexplained. Ora is the one with a control and has its own row below. + */} +
+
+

Always on

+

+ These need no connecting and cannot be switched off. Each one is a separate voice in the evidence + ledger, so where two of them disagree you see both readings rather than an average. +

+
+
+ {EVIDENCE_SOURCES.filter((source) => source !== "ora").map((source) => ( +
+
{EVIDENCE_SOURCE_LABEL[source]}
+
{SETTINGS_SYSTEM_CONTRIBUTES[source]}
+
+ ))} +
+
+
-

Ora

+

{EVIDENCE_SOURCE_LABEL.ora}

- An independent, origin-level agent-readiness audit. Enabling it sends the production origin of each - watched page to Ora, whose scans are public: the result enters Ora's directory and is readable by - anyone. Webflow staging domains are never sent. + {SETTINGS_SYSTEM_CONTRIBUTES.ora} Switching it on sends the live web address of each watched page to + Ora, whose scans are public: the result enters Ora's directory and anyone can read it. Webflow + staging addresses are never sent.

= {}): AgentCheck { const known = ALL_AGENT_CHECKS.find((item) => item.name === name); @@ -219,8 +220,13 @@ describe("result vocabulary stays distinct", () => { }); const item = caseFor(cases, "agent-discoverability:sitemap"); expect(item.confidence).toBe("conflicting"); - expect(item.conflict).toContain("Ora"); - expect(item.conflict).toContain("Page Watch HTTP"); + // Named from the registry's evidence ledger, not from literals here. Rule + // 21: two copies of a name agreeing proves neither is the decided one, and + // this assertion used to hold a third spelling — "Page Watch HTTP" — that + // the ledger never named. Now a relabel in `vocabulary.json` moves the + // sentence and this check together, and a drift between them fails. + expect(item.conflict).toContain(EVIDENCE_SOURCE_LABEL.ora); + expect(item.conflict).toContain(EVIDENCE_SOURCE_LABEL["agent-readiness"]); // Both readings survive so a user can judge for themselves. expect(item.sources.map((s) => s.result).sort()).toEqual(["failed", "pass"]); }); diff --git a/src/lib/__tests__/guide.test.ts b/src/lib/__tests__/guide.test.ts deleted file mode 100644 index 4bd6721..0000000 --- a/src/lib/__tests__/guide.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { GUIDE_CATEGORIES, GUIDE_ENTRIES } from "../guide"; - -const CATEGORY_IDS = new Set(GUIDE_CATEGORIES.map((category) => category.id)); -const TERMS = new Set(GUIDE_ENTRIES.map((entry) => entry.term)); -const LOOKUPS = new Set([ - ...GUIDE_ENTRIES.map((entry) => entry.term), - ...GUIDE_ENTRIES.flatMap((entry) => entry.aliases ?? []), -]); - -describe("guide integrity", () => { - it("gives every entry a unique id and term", () => { - const ids = GUIDE_ENTRIES.map((entry) => entry.id); - const terms = GUIDE_ENTRIES.map((entry) => entry.term); - expect(new Set(ids).size).toBe(ids.length); - expect(new Set(terms).size).toBe(terms.length); - }); - - it("files every entry under a real category", () => { - for (const entry of GUIDE_ENTRIES) { - expect(CATEGORY_IDS, `${entry.id} has category ${entry.category}`).toContain(entry.category); - } - }); - - it("resolves every cross-reference to a term or alias", () => { - // A dangling "related" link is a dead end for the reader. - for (const entry of GUIDE_ENTRIES) { - for (const related of entry.related ?? []) { - expect(LOOKUPS, `${entry.id} links to unknown term ${related}`).toContain(related); - } - } - }); - - it("does not let an alias shadow a different entry's term", () => { - for (const entry of GUIDE_ENTRIES) { - for (const alias of entry.aliases ?? []) { - if (!TERMS.has(alias)) continue; - expect(alias, `${entry.id} aliases another entry's term`).toBe(entry.term); - } - } - }); - - it("never links an entry to itself", () => { - for (const entry of GUIDE_ENTRIES) { - expect(entry.related ?? []).not.toContain(entry.term); - } - }); - - it("gives every entry usable prose", () => { - for (const entry of GUIDE_ENTRIES) { - expect(entry.shortDefinition.length, `${entry.id} definition`).toBeGreaterThan(15); - expect(entry.appMeaning.length, `${entry.id} meaning`).toBeGreaterThan(30); - // A short definition is a sentence, not a paragraph. - expect(entry.shortDefinition.length).toBeLessThan(160); - } - }); -}); - -describe("agent access is explained inline, not only as jargon", () => { - function entry(id: string) { - const found = GUIDE_ENTRIES.find((item) => item.id === id); - if (!found) throw new Error(`missing guide entry ${id}`); - return found; - } - - it("covers the verdict and every one of its states", () => { - const verdict = entry("agent-access-verdict"); - for (const state of ["Ready", "Needs attention", "Blocked", "Unknown"]) { - expect(verdict.appMeaning).toContain(state); - } - // The verdict must not be described as an average of provider scores. - expect(verdict.appMeaning).toContain("never an average"); - }); - - it("explains that sources are merged rather than averaged", () => { - expect(entry("agent-access-issue").appMeaning).toContain("rather than averaged"); - }); - - it("distinguishes not-applicable, ignored, unavailable, and partial", () => { - const meaning = entry("not-applicable").appMeaning; - for (const term of ["Not applicable", "Ignored", "Unavailable", "Partial"]) { - expect(meaning).toContain(term); - } - }); - - it("discloses that external scans are public and origin-scoped", () => { - const external = entry("external-agent-audit"); - expect(external.appMeaning).toContain("public"); - expect(external.appMeaning).toContain("origin"); - // Staging is explicitly out of scope. - expect(external.appMeaning).toContain("staging"); - // Names the relationship the plan asks us to explain. - expect(external.appMeaning).toContain("Is Agentic"); - }); - - it("says provider silence is not proof a fix failed", () => { - expect(entry("agent-verification").appMeaning).toContain("never treated as proof"); - }); - - it("defines an essential blocker precisely", () => { - const blocker = entry("essential-blocker").appMeaning; - expect(blocker).toContain("essential-tier and currently failing"); - // A partial essential finding is deliberately not a blocker. - expect(blocker).toContain("not counted as a blocker"); - }); -}); diff --git a/src/lib/agentIssueCases.ts b/src/lib/agentIssueCases.ts index 75b9e35..8fb8e2b 100644 --- a/src/lib/agentIssueCases.ts +++ b/src/lib/agentIssueCases.ts @@ -23,6 +23,7 @@ import type { AgentCheck, AgentIgnoreSettings, KitesurfEvidence } from "./types"; import { isAgentCheckIgnored } from "./agentScoring"; +import { EVIDENCE_SOURCE_LABEL, type EvidenceSource } from "./vocabulary"; import type { ExternalAgentAuditSnapshot, ExternalAgentCheckResult, @@ -472,8 +473,28 @@ function caseConfidence( return { confidence: systems.size > 1 ? "corroborated" : "single-source" }; } +/** + * The name a reader sees for the system that took a reading. + * + * Read off the registry rather than spelled out again. This used to hold its + * own three strings — "Page Watch HTTP", the "Kitesurf" codename, and "Ora" — + * which is the rule 20 defect: three names for systems the evidence ledger + * already names, agreeing until the day one of them was reworded. Registry v10 + * renamed one of the three, and this is the mapping that makes the rename + * arrive here instead of leaving a fourth spelling behind. + * + * The keys differ from the ledger's by one: this type's `page-watch` is the + * ledger's `agent-readiness` slot, which the registry notes carries "only Page + * Watch's reading". + */ +const EVIDENCE_SOURCE_OF: Record = { + "page-watch": "agent-readiness", + kitesurf: "kitesurf", + ora: "ora", +}; + export function systemLabel(system: AgentEvidenceSystem): string { - return system === "page-watch" ? "Page Watch HTTP" : system === "kitesurf" ? "Kitesurf" : "Ora"; + return EVIDENCE_SOURCE_LABEL[EVIDENCE_SOURCE_OF[system]]; } export interface AssembleAgentIssueCasesInput { diff --git a/src/lib/guide.ts b/src/lib/guide.ts deleted file mode 100644 index c62bb2c..0000000 --- a/src/lib/guide.ts +++ /dev/null @@ -1,663 +0,0 @@ -export const GUIDE_CATEGORIES = [ - { id: "using-page-watch", label: "Using Page Watch" }, - { id: "monitoring", label: "Monitoring" }, - { id: "page-status", label: "Page status" }, - { id: "scores-evidence", label: "Scores & evidence" }, - { id: "recommendations-tasks", label: "Recommendations & tasks" }, - { id: "access-roles", label: "Access & roles" }, -] as const; - -export type GuideCategoryId = (typeof GUIDE_CATEGORIES)[number]["id"]; - -export interface GuideEntry { - id: string; - term: string; - shortDefinition: string; - category: GuideCategoryId; - appMeaning: string; - action?: string; - related?: string[]; - aliases?: string[]; -} - -export const GUIDE_ENTRIES: GuideEntry[] = [ - { - id: "dashboard", - term: "Dashboard", - shortDefinition: "The at-a-glance view of the pages that need your attention.", - category: "using-page-watch", - appMeaning: "The Dashboard combines current Performance trends, visitor experience, agent-readiness gaps, and the most important recommendations across the project.", - action: "Start here to find regressions, low-performing pages, and high-priority fixes.", - related: ["Pages", "Page status", "Visitor experience"], - }, - { - id: "pages", - term: "Pages", - shortDefinition: "A sortable view of every page in the current project.", - category: "using-page-watch", - appMeaning: "The Pages area shows each watched URL and its latest mobile, desktop, Performance, accessibility, Best Practices, SEO, and agent-readiness signals.", - action: "Open a page to investigate its history, evidence, recommendations, and change markers.", - related: ["Dashboard", "Watchlist", "Page detail"], - }, - { - id: "page-detail", - term: "Page detail", - shortDefinition: "The full monitoring record for one page.", - category: "using-page-watch", - appMeaning: "A page detail view brings together score history, device trends, visitor evidence, diagnostics, recommendations, agent readiness, and recorded changes.", - action: "Use it when you need to understand why a page changed or what to do next.", - related: ["Pages", "Change marker", "Recommendation"], - }, - { - id: "inbox", - term: "Inbox", - shortDefinition: "New recommendations waiting for a decision.", - category: "using-page-watch", - appMeaning: "Page Watch places actionable findings in the Inbox so they can be reviewed before becoming committed work.", - action: "Review each recommendation and turn it into a task or ignore it when it is not relevant.", - related: ["Recommendation", "Tasks", "Ignored"], - }, - { - id: "tasks", - term: "Tasks", - shortDefinition: "Recommendations your team has committed to addressing.", - category: "using-page-watch", - appMeaning: "Tasks retain the recommendation evidence while adding a simple To do, In progress, and Done workflow.", - action: "Move work through the task statuses. Marking a task Done records a change marker and enables follow-up comparisons.", - related: ["Recommendation", "Change marker", "Done"], - }, - { - id: "watchlist", - term: "Watchlist", - shortDefinition: "The set of pages Page Watch is configured to monitor.", - category: "using-page-watch", - appMeaning: "Project administrators use the Watchlist to add, reorder, pause, prioritize, and configure page-specific monitoring thresholds.", - action: "Keep the list focused on pages whose performance and readiness matter to the project.", - related: ["Watching", "Priority", "Paused"], - }, - { - id: "baseline", - term: "Baseline", - shortDefinition: "The intentional starting point used to judge later measurements.", - category: "monitoring", - appMeaning: "A baseline stores the initial trusted mobile and desktop scores for a page. Ordinary runs do not silently create or replace it.", - action: "Capture a baseline after adding a page and when the page is in a representative state.", - related: ["Pending", "On-demand run", "Performance trend"], - }, - { - id: "on-demand-run", - term: "On-demand run", - shortDefinition: "A measurement you start manually instead of waiting for the schedule.", - category: "monitoring", - appMeaning: "It collects the same core Lighthouse, agent-readiness, and diagnostic evidence as scheduled monitoring without replacing the baseline.", - action: "Run a page after a meaningful change when you want evidence sooner than the next nightly run.", - related: ["Nightly run", "Collection", "Baseline"], - }, - { - id: "nightly-run", - term: "Nightly run", - shortDefinition: "The project’s automatically scheduled daily collection.", - category: "monitoring", - appMeaning: "Active pages receive a stable offset inside the project’s configured local-time collection window. Results update history, statuses, and recommendations.", - action: "Check the schedule in the sidebar or Settings; use an on-demand run when you cannot wait.", - related: ["On-demand run", "Collection", "Watching"], - }, - { - id: "collection", - term: "Collection", - shortDefinition: "The process of gathering and saving a page’s measurement evidence.", - category: "monitoring", - appMeaning: "A collection coordinates repeated mobile and desktop tests, removes untrustworthy samples, scans agent readiness, gathers diagnostics, and commits a result only when enough evidence is available.", - action: "Let an active collection finish before starting another run for the same page.", - related: ["Reliable", "Inconclusive", "Lighthouse"], - }, - { - id: "queued", - term: "Queued", - shortDefinition: "The collection has been accepted and is waiting to start.", - category: "monitoring", - appMeaning: "Page Watch has reserved the job, but the collector has not started its measurement work yet.", - action: "No action is normally needed.", - related: ["Dispatching", "Running"], - }, - { - id: "dispatching", - term: "Dispatching", - shortDefinition: "The collection is being handed to the background workflow.", - category: "monitoring", - appMeaning: "This is the brief transition between reserving a collection and running its tests.", - action: "No action is normally needed.", - related: ["Queued", "Running"], - }, - { - id: "running", - term: "Running", - shortDefinition: "The collector is actively measuring the page.", - category: "monitoring", - appMeaning: "Mobile and desktop samples or related evidence are currently being gathered.", - action: "Wait for the run to complete before judging the page’s latest status.", - related: ["Waiting for evidence", "Succeeded"], - }, - { - id: "waiting-for-evidence", - term: "Waiting for evidence", - shortDefinition: "The collection needs another independent measurement before it can finish.", - category: "monitoring", - appMeaning: "The durable workflow is waiting before retrying because one or both devices do not yet have enough trustworthy samples.", - action: "No immediate action is needed; Page Watch retries automatically.", - related: ["Running", "Inconclusive", "Reliable"], - }, - { - id: "succeeded", - term: "Succeeded", - shortDefinition: "The collection completed with enough evidence to save a trusted result.", - category: "monitoring", - appMeaning: "The scores and associated evidence were committed to the page’s history.", - action: "Review any resulting status changes or new recommendations.", - related: ["Collection", "Reliable", "Recommendation"], - }, - { - id: "inconclusive", - term: "Inconclusive", - shortDefinition: "The collection finished without enough trustworthy evidence for a result.", - category: "monitoring", - appMeaning: "Provider warnings, repeated cached measurements, or too few independent samples prevented Page Watch from committing scores.", - action: "Try again later. If it repeats, investigate the page or measurement provider rather than treating it as a regression.", - related: ["Waiting for evidence", "Unusable", "Failed"], - }, - { - id: "failed", - term: "Failed", - shortDefinition: "The collection stopped because it could not complete.", - category: "monitoring", - appMeaning: "A workflow, provider, page, or configuration error prevented successful completion.", - action: "Read the displayed error, confirm the URL is reachable, and retry after correcting any known issue.", - related: ["Inconclusive", "Collection"], - }, - { - id: "priority", - term: "Priority", - shortDefinition: "An actively monitored page marked as especially important.", - category: "monitoring", - appMeaning: "Priority distinguishes critical pages from the rest of the active watchlist without changing what evidence is collected.", - action: "Use it for pages such as key landing, conversion, or high-traffic pages.", - related: ["Watching", "Paused", "Watchlist"], - }, - { - id: "watching", - term: "Watching", - shortDefinition: "A page included in regular scheduled monitoring.", - category: "monitoring", - appMeaning: "Watching is the normal active state for a page on the watchlist.", - action: "Leave a page Watching when it should continue receiving nightly measurements.", - related: ["Priority", "Paused", "Nightly run"], - }, - { - id: "paused", - term: "Paused", - shortDefinition: "A page retained in the project but excluded from active monitoring.", - category: "monitoring", - appMeaning: "Its existing history remains available, but scheduled collection does not run while it is paused.", - action: "Pause pages temporarily when they should not consume monitoring capacity; resume them when they matter again.", - related: ["Watching", "Watchlist"], - }, - { - id: "pending", - term: "Pending", - shortDefinition: "There is not enough post-baseline history to classify the page trend.", - category: "page-status", - appMeaning: "Pending appears before the page has a usable baseline and enough later measurements for the selected device and range.", - action: "Capture a baseline if needed, then allow the required follow-up runs to complete.", - related: ["Baseline", "Stable", "Performance trend"], - }, - { - id: "stable", - term: "Stable", - shortDefinition: "Performance has not moved beyond the configured improvement or regression rules.", - category: "page-status", - appMeaning: "For the selected device and date range, the Performance score change remains within the project’s thresholds and observed run-to-run noise.", - action: "No trend-based action is required, but low absolute scores and recommendations may still need attention.", - related: ["Improving", "Regressing", "Performance trend"], - }, - { - id: "improving", - term: "Improving", - shortDefinition: "Performance increased enough to exceed the configured improvement threshold and observed noise.", - category: "page-status", - appMeaning: "The classification is calculated independently for mobile and desktop over the selected date range.", - action: "Review recent change markers to understand what helped and confirm visitor experience where available.", - related: ["Stable", "Regressing", "Change marker"], - }, - { - id: "regressing", - term: "Regressing", - shortDefinition: "Performance dropped enough to meet the project’s confirmed regression rules.", - category: "page-status", - appMeaning: "Page Watch evaluates the selected device and range using configured drop, floor, confirmation, and noise rules.", - action: "Open the page, compare recent evidence with change markers, and triage actionable recommendations.", - related: ["Stable", "Improving", "Recommendation"], - }, - { - id: "performance-trend", - term: "Performance trend", - shortDefinition: "The direction of a page’s Performance score over the selected date range.", - category: "page-status", - appMeaning: "Mobile and desktop are classified separately as Pending, Stable, Improving, or Regressing.", - action: "Always note the selected device and range when interpreting a trend.", - related: ["Page status", "Mobile", "Desktop"], - aliases: ["Page status"], - }, - { - id: "lighthouse", - term: "Lighthouse", - shortDefinition: "Google’s automated lab test for page quality.", - category: "scores-evidence", - appMeaning: "Page Watch uses PageSpeed Insights to run Lighthouse repeatedly for mobile and desktop, then stores trusted median scores, ranges, warnings, and recurring findings.", - action: "Use Lighthouse to reproduce issues consistently, but compare it with visitor evidence when available.", - related: ["Lab data", "Visitor experience", "Median"], - }, - { - id: "lab-data", - term: "Lab data", - shortDefinition: "Measurements collected under controlled, repeatable test conditions.", - category: "scores-evidence", - appMeaning: "Lighthouse scores and diagnostics are lab data. They are useful for debugging and comparison but do not represent every real visitor.", - action: "Use lab evidence to investigate causes; use visitor experience to understand real-world impact.", - related: ["Lighthouse", "Visitor experience", "Lab and field"], - }, - { - id: "visitor-experience", - term: "Visitor experience", - shortDefinition: "Real-world performance evidence from Chrome users.", - category: "scores-evidence", - appMeaning: "Page Watch collects weekly Chrome UX Report rolling 28-day p75 metrics for phone and desktop, preferring exact-URL data and using origin data only as context when needed.", - action: "Use it to confirm whether a lab finding affects real visitors. It changes more slowly than nightly lab data.", - related: ["CrUX", "Lab data", "P75"], - aliases: ["Field data", "Real-user data"], - }, - { - id: "crux", - term: "CrUX", - shortDefinition: "The Chrome UX Report, Google’s source of aggregated real-user performance data.", - category: "scores-evidence", - appMeaning: "CrUX supplies Page Watch’s visitor-experience metrics and Good, Needs improvement, or Poor ratings.", - action: "Check whether evidence is exact-URL or origin-level before applying it to a specific page.", - related: ["Visitor experience", "P75", "LCP"], - aliases: ["Chrome UX Report"], - }, - { - id: "lab-and-field", - term: "Lab and field", - shortDefinition: "A comparison between controlled Lighthouse tests and real visitor evidence.", - category: "scores-evidence", - appMeaning: "Signals can be Aligned, Corroborated, Divergent, Partial, or Unavailable depending on whether both evidence systems exist and tell the same story.", - action: "Prioritize corroborated problems. Investigate divergent results instead of assuming either source is wrong.", - related: ["Lab data", "Visitor experience"], - }, - { - id: "mobile", - term: "Mobile", - shortDefinition: "The phone-oriented measurement strategy.", - category: "scores-evidence", - appMeaning: "Page Watch keeps mobile scores, history, status, findings, and visitor evidence separate from desktop.", - action: "Use the device control to confirm which strategy you are viewing.", - related: ["Desktop", "Performance trend"], - }, - { - id: "desktop", - term: "Desktop", - shortDefinition: "The desktop-oriented measurement strategy.", - category: "scores-evidence", - appMeaning: "Page Watch keeps desktop scores, history, status, findings, and visitor evidence separate from mobile.", - action: "Review both devices; a problem on one does not guarantee the same result on the other.", - related: ["Mobile", "Performance trend"], - }, - { - id: "median", - term: "Median", - shortDefinition: "The middle result from a group of trusted test runs.", - category: "scores-evidence", - appMeaning: "Page Watch uses medians instead of a single run to reduce the effect of unusually fast or slow measurements.", - action: "Treat the median as the nightly score and use the range to understand variability.", - related: ["Range", "Reliable", "Lighthouse"], - }, - { - id: "range", - term: "Range", - shortDefinition: "The lowest and highest trusted scores observed during a collection.", - category: "scores-evidence", - appMeaning: "The range reveals run-to-run variability that a median alone would hide.", - action: "A wide range suggests noisy evidence; avoid overreacting to a small change inside that noise.", - related: ["Median", "Low confidence"], - }, - { - id: "p75", - term: "P75", - shortDefinition: "The value that 75% of recorded visitor experiences were at or better than.", - category: "scores-evidence", - appMeaning: "CrUX uses the 75th percentile for Page Watch’s visitor metrics, helping represent the slower end of typical experiences without focusing only on extremes.", - action: "Compare p75 values with their Good, Needs improvement, and Poor thresholds.", - related: ["Visitor experience", "CrUX"], - aliases: ["75th percentile"], - }, - { - id: "performance", - term: "Performance", - shortDefinition: "Lighthouse’s overall score for loading speed and runtime responsiveness.", - category: "scores-evidence", - appMeaning: "Page Watch uses the Performance category for page trends and combines it with detailed metrics and diagnostics.", - action: "Use the score to spot movement, then inspect metrics and recommendations to find the cause.", - related: ["LCP", "TBT", "CLS"], - aliases: ["Perf"], - }, - { - id: "accessibility", - term: "Accessibility", - shortDefinition: "Automated checks for common barriers affecting people with disabilities.", - category: "scores-evidence", - appMeaning: "The Lighthouse Accessibility score covers detectable issues such as names, contrast, structure, and semantics. It is not a complete accessibility audit.", - action: "Fix reported issues and supplement automated checks with manual and assistive-technology testing.", - related: ["Best Practices", "SEO"], - aliases: ["A11y"], - }, - { - id: "best-practices", - term: "Best Practices", - shortDefinition: "Lighthouse checks for general web quality, safety, and modern implementation practices.", - category: "scores-evidence", - appMeaning: "This category can identify browser errors, insecure behavior, deprecated APIs, and other implementation concerns.", - action: "Open the page diagnostics to see which audits reduced the score.", - related: ["Accessibility", "SEO"], - aliases: ["BP"], - }, - { - id: "seo", - term: "SEO", - shortDefinition: "Automated checks for basic search-engine discoverability.", - category: "scores-evidence", - appMeaning: "The Lighthouse SEO score checks technical fundamentals such as crawlability and metadata; it does not measure rankings or content quality.", - action: "Resolve failed audits, then use dedicated search tools for broader SEO analysis.", - related: ["Accessibility", "Best Practices"], - aliases: ["Search engine optimization"], - }, - { - id: "lcp", - term: "LCP", - shortDefinition: "How long it takes the page’s main visible content to appear.", - category: "scores-evidence", - appMeaning: "Largest Contentful Paint is available in both Lighthouse lab evidence and CrUX visitor evidence.", - action: "Investigate the main content element, image delivery, server response, and render-blocking resources when LCP is slow.", - related: ["Performance", "TTFB"], - aliases: ["Largest Contentful Paint", "Main content load"], - }, - { - id: "inp", - term: "INP", - shortDefinition: "How quickly the page responds to visitor interactions.", - category: "scores-evidence", - appMeaning: "Interaction to Next Paint is a real-user responsiveness metric supplied by CrUX. Lighthouse uses related lab diagnostics rather than directly reproducing the field metric.", - action: "Investigate long main-thread tasks and expensive event handling when INP is slow.", - related: ["TBT", "Visitor experience"], - aliases: ["Interaction to Next Paint", "Interaction responsiveness"], - }, - { - id: "cls", - term: "CLS", - shortDefinition: "How much visible content unexpectedly moves while the page loads.", - category: "scores-evidence", - appMeaning: "Cumulative Layout Shift is measured in Lighthouse and CrUX; lower values indicate a more visually stable page.", - action: "Reserve space for media and embeds, and avoid inserting content above existing content.", - related: ["Performance", "Visitor experience"], - aliases: ["Cumulative Layout Shift", "Visual stability"], - }, - { - id: "ttfb", - term: "TTFB", - shortDefinition: "How long the browser waits for the first byte from the server.", - category: "scores-evidence", - appMeaning: "Time to First Byte is shown as visitor evidence and provides context for loading metrics such as LCP.", - action: "Investigate server processing, redirects, network latency, and caching when it is slow.", - related: ["LCP", "Visitor experience"], - aliases: ["Time to First Byte", "Server response time"], - }, - { - id: "tbt", - term: "TBT", - shortDefinition: "How much time the main thread is blocked during a Lighthouse test.", - category: "scores-evidence", - appMeaning: "Total Blocking Time is a lab metric that helps diagnose responsiveness problems and is related to, but not the same as, real-user INP.", - action: "Reduce long JavaScript tasks, third-party work, and expensive main-thread processing.", - related: ["INP", "Performance"], - aliases: ["Total Blocking Time"], - }, - { - id: "agent-readiness", - term: "Agent readiness", - shortDefinition: "Checks that show whether a page is understandable and usable by automated agents.", - category: "scores-evidence", - appMeaning: "Page Watch evaluates discoverability, structure, semantics, forms, metadata, and related signals independently from Lighthouse category scores. The Agent-readiness tab leads with a verdict for the page rather than a score, and the individual check results sit beneath it as one of the supporting sources.", - action: "Open the page’s agent-readiness evidence, read the verdict and its primary issue, then work down the issue list.", - related: ["Agent access verdict", "Agent access issue", "External agent audit", "Page detail"], - }, - { - id: "agent-access-verdict", - term: "Agent access verdict", - shortDefinition: "Page Watch’s single conclusion about whether agents can use a page.", - category: "scores-evidence", - appMeaning: "Ready means nothing determined is failing. Needs attention means there are issues but none of them is an essential blocker. Blocked means at least one essential check is failing, so agents cannot use the page properly. Unknown means no source could determine a result yet. The verdict is Page Watch’s own judgement across every source; it is never an average of the underlying scores.", - action: "Treat Blocked as work to schedule now, and Unknown as a signal that evidence is missing rather than that the page is fine.", - related: ["Agent readiness", "Agent access issue", "Essential blocker"], - aliases: ["Ready", "Needs attention", "Blocked"], - }, - { - id: "agent-access-issue", - term: "Agent access issue", - shortDefinition: "One problem, with every source that reported it kept underneath.", - category: "scores-evidence", - appMeaning: "Page Watch’s own HTTP checks, the rendered-page probe, and the external audit often describe the same problem in different words and at different scopes. An issue merges them into one entry showing what happened, why it matters to an agent, what to do, how confident Page Watch is, and which sources agree. When sources disagree, both readings are shown rather than averaged.", - action: "Expand an issue to see its sources and confidence before deciding whether to act on it.", - related: ["Agent access verdict", "External agent audit", "Essential blocker", "Not applicable"], - }, - { - id: "essential-blocker", - term: "Essential blocker", - shortDefinition: "A failing check that agents cannot work around.", - category: "scores-evidence", - appMeaning: "An issue is an essential blocker when it is both essential-tier and currently failing. Essential blockers are added to your Inbox automatically because they stop agents using the site; other issues stay on the Agent-readiness tab until you add them to Tasks yourself. A partial result on an essential check is a real problem but is not counted as a blocker.", - action: "Clear essential blockers before spending time on recommended improvements.", - related: ["Agent access verdict", "Agent access issue", "Partial"], - }, - { - id: "external-agent-audit", - term: "External agent audit", - shortDefinition: "An independent, origin-level agent-readiness audit from Ora.", - category: "scores-evidence", - appMeaning: "Ora is the scanner behind Is Agentic, and Page Watch shows the Is Agentic essentials reading by default with Ora’s own score under advanced evidence. The two use different scales and are never averaged with each other or with Page Watch’s check percentage. An audit covers a whole origin, so every watched page on that origin shares one reading, unlike Page Watch checks and the rendered-page probe, which are page-level. Ora scans are public: a result is stored in Ora’s directory and can appear in its leaderboard and research statistics, so a project must opt in before anything is sent. Webflow staging domains are never sent.", - action: "Enable it in Watch List settings if you want independent corroboration, then refresh an audit from a page’s Agent-readiness tab.", - related: ["Agent access issue", "Verifying a fix", "Not applicable", "Agent readiness"], - aliases: ["Ora", "Is Agentic", "Essentials score"], - }, - { - id: "agent-verification", - term: "Verifying a fix", - shortDefinition: "Re-running only the provider checks a task recorded.", - category: "recommendations-tasks", - appMeaning: "When you complete an agent-access task, Page Watch asks the external provider to re-run just the checks that task recorded. If they all pass, or become correctly not applicable, the issue is resolved. If any is still failing or partial, the issue is returned and the task moves back into open work. If the provider cannot answer, the issue stays Verifying and can be retried — provider silence is never treated as proof that your fix did not work.", - action: "Complete the task as usual; the re-check runs on its own alongside the existing 2/7/30-day follow-ups.", - related: ["External agent audit", "Agent access issue", "Change marker"], - }, - { - id: "not-applicable", - term: "Not applicable", - shortDefinition: "Evidence that a check does not apply, which is different from ignoring it.", - category: "scores-evidence", - appMeaning: "Not applicable is supplied by a source that determined the check does not apply to this product, for example a payment protocol on a site that sells nothing. Ignored is your own policy decision to stop counting a check. Unavailable means a source could not determine a result at all, and Partial means a check is genuinely half-met. Page Watch keeps all four distinct rather than collapsing them into pass or fail.", - action: "Prefer Not applicable evidence over ignoring a check, so the reason stays visible to everyone.", - related: ["Agent access issue", "Ignored", "Partial"], - aliases: ["Unavailable", "Partial"], - }, - { - id: "reliable", - term: "Reliable", - shortDefinition: "The collection has enough unique, warning-free samples to trust.", - category: "scores-evidence", - appMeaning: "Reliable is the highest Lighthouse collection-quality status.", - action: "Use the median, range, and promoted findings normally.", - related: ["Low confidence", "Unusable", "Median"], - }, - { - id: "low-confidence", - term: "Low confidence", - shortDefinition: "The evidence is usable but has limitations that deserve caution.", - category: "scores-evidence", - appMeaning: "Fewer eligible runs, warnings, or variability can reduce confidence in scores or findings.", - action: "Look at the sample range and repeat the collection before acting on a marginal change.", - related: ["Reliable", "Unusable", "Range"], - }, - { - id: "unusable", - term: "Unusable", - shortDefinition: "The evidence does not meet the minimum standard for a trusted result.", - category: "scores-evidence", - appMeaning: "Page Watch excludes unusable evidence from normal status decisions.", - action: "Retry later and investigate repeated provider warnings or page failures.", - related: ["Inconclusive", "Low confidence"], - }, - { - id: "recommendation", - term: "Recommendation", - shortDefinition: "A potential improvement supported by recurring measurement evidence.", - category: "recommendations-tasks", - appMeaning: "Recommendations can come from Lighthouse, detected Webflow-native elements, or exact-URL visitor problems that are not reproduced in the lab.", - action: "Review the evidence, likely impact, actionability, and effort before choosing Task or Ignore.", - related: ["Inbox", "Tasks", "Ignored"], - }, - { - id: "ignored", - term: "Ignored", - shortDefinition: "A recommendation intentionally removed from the active workflow.", - category: "recommendations-tasks", - appMeaning: "Ignoring records that the finding was reviewed but should not become a task.", - action: "Use it for irrelevant, accepted, duplicate, or non-actionable findings—not simply to hide uncertain evidence.", - related: ["Recommendation", "Inbox"], - }, - { - id: "to-do", - term: "To do", - shortDefinition: "A task that has been accepted but not started.", - category: "recommendations-tasks", - appMeaning: "This is the initial status when an Inbox recommendation becomes a task.", - action: "Start it when someone begins the work.", - related: ["In progress", "Done", "Tasks"], - }, - { - id: "in-progress", - term: "In progress", - shortDefinition: "A task that is currently being worked on.", - category: "recommendations-tasks", - appMeaning: "The recommendation remains linked to its original page and evidence while work is underway.", - action: "Mark it Done after the relevant change is actually made, or move it back to To do if work stops.", - related: ["To do", "Done", "Tasks"], - }, - { - id: "done", - term: "Done", - shortDefinition: "A task whose associated change has been completed.", - category: "recommendations-tasks", - appMeaning: "Completing a task adds a dated change marker to the page and allows scheduled 2-, 7-, and 30-day follow-up comparisons.", - action: "Only mark work Done after the change is live so later measurements line up with the marker.", - related: ["Change marker", "In progress", "Follow-up"], - }, - { - id: "change-marker", - term: "Change marker", - shortDefinition: "A dated note showing when a meaningful page change happened.", - category: "recommendations-tasks", - appMeaning: "Markers appear alongside page history so score movement can be compared with releases, fixes, experiments, or completed tasks.", - action: "Use a clear description and the date the change became effective.", - related: ["Done", "Performance trend", "Follow-up"], - }, - { - id: "follow-up", - term: "Follow-up", - shortDefinition: "A later comparison that checks the result of a recorded change.", - category: "recommendations-tasks", - appMeaning: "Page Watch can report 2-, 7-, and 30-day comparisons after a completed task’s change marker.", - action: "Use follow-ups to validate impact rather than assuming completion produced an improvement.", - related: ["Change marker", "Done"], - }, - { - id: "active-field-issue", - term: "Active field issue", - shortDefinition: "A real-visitor problem that is currently outside the Good range.", - category: "recommendations-tasks", - appMeaning: "This status is used for exact-URL CrUX evidence that Lighthouse does not currently reproduce.", - action: "Treat it as real visitor impact, while investigating why the lab and field evidence differ.", - related: ["Visitor experience", "Verifying recovery", "Corroborated"], - }, - { - id: "verifying-recovery", - term: "Verifying recovery", - shortDefinition: "One good visitor-data window has appeared, but another is required to confirm resolution.", - category: "recommendations-tasks", - appMeaning: "Page Watch waits for two distinct good CrUX windows because each window covers a rolling period.", - action: "Continue monitoring; do not close the issue based on the first good window alone.", - related: ["Active field issue", "Field issue resolved"], - }, - { - id: "field-issue-resolved", - term: "Field issue resolved", - shortDefinition: "Two distinct visitor-data windows confirm that the issue is back in the Good range.", - category: "recommendations-tasks", - appMeaning: "The field-only recommendation remains documented even though its current lifecycle is resolved.", - action: "Review the associated changes and continue normal monitoring.", - related: ["Verifying recovery", "Field issue returned"], - }, - { - id: "corroborated", - term: "Corroborated", - shortDefinition: "A visitor problem is now also reproduced or explained by lab evidence.", - category: "recommendations-tasks", - appMeaning: "Independent evidence systems now support the same performance concern.", - action: "Prioritize the issue and use the Lighthouse diagnostics to investigate likely causes.", - related: ["Active field issue", "Lab and field"], - }, - { - id: "field-issue-returned", - term: "Field issue returned", - shortDefinition: "A visitor problem reappeared after it had cleared or become corroborated.", - category: "recommendations-tasks", - appMeaning: "The regression is retained as part of the recommendation’s field-evidence lifecycle.", - action: "Review changes since resolution and reopen related work when appropriate.", - related: ["Field issue resolved", "Active field issue"], - }, - { - id: "project-viewer", - term: "Project viewer", - shortDefinition: "A user who can inspect a project without managing it.", - category: "access-roles", - appMeaning: "Viewers can access the project’s monitoring information but do not receive project-administration controls.", - action: "Ask a project administrator when watchlist, settings, or membership changes are needed.", - related: ["Project administrator", "App administrator"], - }, - { - id: "project-administrator", - term: "Project administrator", - shortDefinition: "A user who can view and manage one project.", - category: "access-roles", - appMeaning: "Project administrators receive management controls for their assigned project, including its watchlist, settings, and members.", - action: "Use administrative access carefully because monitoring configuration affects the whole project.", - related: ["Project viewer", "App administrator"], - aliases: ["Project admin"], - }, - { - id: "app-administrator", - term: "App administrator", - shortDefinition: "A user who can administer Page Watch across projects.", - category: "access-roles", - appMeaning: "App administrators can access project-management and app-administration capabilities without needing a membership record for every project.", - action: "Use this role only for people responsible for the application as a whole.", - related: ["Project administrator", "Project viewer"], - aliases: ["App admin"], - }, -]; diff --git a/src/lib/settings-copy.ts b/src/lib/settings-copy.ts index d78023e..6dc1797 100644 --- a/src/lib/settings-copy.ts +++ b/src/lib/settings-copy.ts @@ -1,4 +1,5 @@ import type { Sensitivity } from "./sensitivity"; +import type { EvidenceSource } from "./vocabulary"; /** * The words Settings says, in one place. @@ -53,8 +54,17 @@ export const SETTINGS_SENSITIVITY_LIMIT_LABEL = "Smallest saving a digest line w /* ── Digest ─────────────────────────────────────────────────────────────── */ export const SETTINGS_DIGEST_LABEL = "Digest"; +/** + * What "per run" means, said where the reader meets the phrase. + * + * The glossary used to define "nightly run" on a page of its own, which is the + * wrong shape for it twice over: a reader who has to leave this screen to learn + * what a run is will not, and most of this product's copy is read outside the + * app entirely. So the run is explained in the clause that uses it — what it + * does, and how often — rather than defined somewhere a link could reach. + */ export const SETTINGS_DIGEST_HELP = - "One message per run. Sent even when nothing changed, so silence means the run failed."; + "One message per run — overnight, every watched page measured again. Sent even when nothing changed, so silence means the run failed."; /** Who it goes to. One field, one address per line — there is no other granularity. */ export const SETTINGS_DIGEST_RECIPIENTS_LABEL = "Recipients"; @@ -77,6 +87,35 @@ export const SETTINGS_SYSTEMS_LABEL = "Connected systems"; export const SETTINGS_SYSTEMS_HELP = "Each one speaks for itself in the evidence ledger. Readings are never combined."; +/** + * What each system contributes, and how often it measures. + * + * This is the operational half of the retired glossary, moved to the group it + * describes. Keyed by `EvidenceSource` rather than written as prose, for two + * reasons: the ledger's entries are the registry's, so a system added there + * arrives here as a missing key rather than as a row nobody explained; and the + * cadence differs per system, so one screen-level claim about "how often" would + * be false for whichever system it did not describe. Nightly is true of the + * lab test and the agent checks; the visitor figures move once a week. + * + * Every line puts the plain meaning first and the industry term after it, in + * parentheses — the one pattern, applied here as everywhere. + */ +export const SETTINGS_SYSTEM_CONTRIBUTES: Record = { + lighthouse: + "Scores the page on a simulated phone and desktop every night, keeping the middle result of several runs so one slow load cannot move the number (Lighthouse, run through PageSpeed Insights).", + crux: + "Reports what real Chrome visitors met, updated weekly over a rolling 28 days — so it moves more slowly than the nightly score, and disagreeing with it is not a fault in either (the Chrome UX Report).", + "native-elements": + "Reads the published page for the elements Webflow generated, which is how a finding can tell you whether the fix is yours to make.", + "agent-readiness": + "Asks this site, one page a night, the questions an AI agent asks before it reads anything.", + ora: + "Audits the whole site rather than a page, so every watched page on it shares one reading. The only system here you have to switch on.", + kitesurf: + "Opens the page in a real browser and records what actually rendered, which is how a finding is confirmed on a page that needs scripts to run.", +}; + /* ── Appearance ─────────────────────────────────────────────────────────── */ export const SETTINGS_APPEARANCE_LABEL = "Appearance"; diff --git a/src/lib/vocabulary.ts b/src/lib/vocabulary.ts index 08508b0..080b09c 100644 --- a/src/lib/vocabulary.ts +++ b/src/lib/vocabulary.ts @@ -209,7 +209,10 @@ export const EVIDENCE_SOURCE_LABEL: Record = { "native-elements": "Native elements", "agent-readiness": "Agent readiness checks", ora: "Ora", - kitesurf: "Kitesurf", + // Registry v10: the codename was the only name a reader ever saw for this + // system, and a codename explains nothing. The KEY is unchanged — a data key + // is not copy — so no stored reading has to be migrated. + kitesurf: "Rendered page", }; /* ── Actionability — what the customer can do about a case ──────────────── */ diff --git a/vocabulary.json b/vocabulary.json index 39636cf..46f81e9 100644 --- a/vocabulary.json +++ b/vocabulary.json @@ -1,6 +1,6 @@ { - "$comment": "Page Watch vocabulary registry. Source of truth for user-facing status words. Decided in Chunk F1. CHANGELOG: v4 closed three gaps C1a surfaced. v5 closed the gaps the F1/F2/F3/C1 build report surfaced — Resolved had no legal entry, checkpoints had no vocabulary or evaluation rule, and two measuring systems shared one evidence slot. v6 added rules 18 and 19 from S1. v7 added rules 20 and 21 from R1, and extended rule 18 with the withhold-versus-fail distinction after a wrong ruling on F3. v8 added concepts.action.actor_note. v9 is v8's content with tightened prose on rules 18, 20 and 21 and this changelog — renumbered because a registry that changes its content without changing its number is the drift it exists to prevent, and two files numbered 8 already differed.", - "version": 9, + "$comment": "Page Watch vocabulary registry. Source of truth for user-facing status words. Decided in Chunk F1. CHANGELOG: v4 closed three gaps C1a surfaced. v5 closed the gaps the F1/F2/F3/C1 build report surfaced — Resolved had no legal entry, checkpoints had no vocabulary or evaluation rule, and two measuring systems shared one evidence slot. v6 added rules 18 and 19 from S1. v7 added rules 20 and 21 from R1, and extended rule 18 with the withhold-versus-fail distinction after a wrong ruling on F3. v8 added concepts.action.actor_note. v9 is v8's content with tightened prose on rules 18, 20 and 21 and this changelog — renumbered because a registry that changes its content without changing its number is the drift it exists to prevent, and two files numbered 8 already differed. v10 renames evidence_source.kitesurf's label from the codename Kitesurf to Rendered page, from S9: a name only the team could read was the one name that system had on screen, and S9's rule is that no term may appear as jargon alone. The key is unchanged — a data key is not copy.", + "version": 10, "decided": "2026-08-24", "concepts": { "work_state": { @@ -286,7 +286,8 @@ }, { "key": "kitesurf", - "label": "Kitesurf" + "label": "Rendered page", + "means": "The published page opened in a real browser, recorded as it actually rendered. v10 renames the label: the key stays, because a data key is not copy, but the codename was the only name a reader ever saw for this system and a codename explains nothing." } ], "banned_as_label": [ @@ -609,11 +610,10 @@ "Watching outcomes" ], "allowlist": { - "$comment": "Pre-existing violations in files C1a does not own. Each entry names the chunk that clears it. The list may only shrink. webflow-connection.tsx was cleared in C1a and removed in v5 — the rename it covered is done and the entry was dead. pages/[id]/page.tsx was cleared in S3 and removed: the tabs it named are gone and the native-element dispositions are now the applicability and work_state concepts below. watchlist/page.tsx was cleared in S8 and removed: the settings mode that carried the Ignore/Suppress copy moved to /settings and became the applicability concept below, and the watchlist itself never used those words. agent-access.tsx was cleared in S4 and removed: the two route references were the Tasks button, which now names the Fix queue.", - "src/lib/guide.ts": "S9 — the glossary retires; its entries define the retired terms on purpose", + "$comment": "Pre-existing violations in files C1a does not own. Each entry names the chunk that clears it. The list may only shrink. webflow-connection.tsx was cleared in C1a and removed in v5 — the rename it covered is done and the entry was dead. pages/[id]/page.tsx was cleared in S3 and removed: the tabs it named are gone and the native-element dispositions are now the applicability and work_state concepts below. watchlist/page.tsx was cleared in S8 and removed: the settings mode that carried the Ignore/Suppress copy moved to /settings and became the applicability concept below, and the watchlist itself never used those words. agent-access.tsx was cleared in S4 and removed: the two route references were the Tasks button, which now names the Fix queue. guide.ts was cleared in S9 and removed: the glossary is deleted, and the retired-term definitions it carried went with the file rather than moving anywhere.", "src/components/bits.tsx": "F2 — Verifying/Returned belong to the lifecycles F2 deletes", "src/components/store.tsx": "S2 — route references to the retired destinations" } }, - "revised": "2026-08-25" + "revised": "2026-08-26" } From 4d4aefaca68459c17c667de9741a6ad8f604cd4e Mon Sep 17 00:00:00 2001 From: Matthew P Munger Date: Wed, 26 Aug 2026 15:44:58 -0500 Subject: [PATCH 2/3] feat(s9): plain meaning first for the measurement copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pattern, applied to the screens that carry measurements: plain meaning first, industry term after it in parentheses. Internal shorthand is gone. `plain-language.ts` states the pattern and the two groups it applies to, and `appositive()` builds an introduction so the ordering is structural rather than remembered. What changed and why: - Lighthouse audit ids no longer reach a reader anywhere. `culpritEvidence`'s titles were half-translated already ("DOM structure", "Image encoding waste"); they now say what the finding is ("Deeply nested elements", "Images heavier than they need to be"). The keys are untouched — they are how a reading is matched to the report it came from. - The evidence facts said "Main-thread time" and "Blocking time", which name the browser's internals rather than the reader's problem. They now say what the number cost: "Time the browser spent running it", "Time the page could not respond". - The metric chip led with three letters — "TBT · 30%" — and buried the only part a reader can use. `metricDisplay` puts the plain word first and keeps the acronym after it. The spelled-out term is introduced once per screen in the prose beside the chips, never inside them. - The score-card tooltips listed their measurements as bare acronyms. Each is now introduced by what it measures. The tooltip is not the pattern and must not become it — the comment says so — but its own copy still has to be plain. - The lab/field comparison called its two sides "Lab" and "Visitor", words that only mean that to us. It now says "the nightly test" and "real visitors" throughout. The acronyms stay on the metric rows, because the responsiveness row deliberately compares two DIFFERENT measurements and stripping the names would hide that. - The page-detail history table introduces every measurement in its caption, once, so the four-character column headings are a second mention rather than the reader's first. "A11y" is gone; it was a numeronym. - "PSI" is gone from copy. It is our shorthand for a service nobody else calls that, and the device is the part a reader can act on. Three headline/label sets moved into named exported maps — `LAB_FIELD_HEADLINE`, `VISITOR_CONFIDENCE_LABEL`, `CULPRIT_GROUP_LABELS`. Each was a set of literals inside one if/else, asserted by a second set of literals in a test: a rewording had to be made twice and agreed nowhere (rule 20). The tests now assert against the map, so they check which conclusion was reached rather than how it was spelled (rule 21). Co-Authored-By: Claude Opus 5 --- src/app/(app)/pages/[id]/page.tsx | 67 ++++--- src/app/(app)/pages/pages-content.tsx | 4 +- src/components/bits.tsx | 4 +- .../field-only-recommendations.test.ts | 6 +- .../__tests__/lab-field-comparison.test.ts | 12 +- src/lib/__tests__/performance-issues.test.ts | 5 +- src/lib/__tests__/visitor-experience.test.ts | 7 +- src/lib/__tests__/webflow-performance.test.ts | 6 +- src/lib/culpritEvidence.ts | 70 ++++--- src/lib/labFieldComparison.ts | 81 +++++--- src/lib/labMetrics.ts | 2 +- src/lib/plain-language.ts | 113 +++++++++++ src/lib/scoreCardTooltip.ts | 43 ++++- src/lib/visitorExperience.ts | 33 +++- src/lib/webflowPerformance.ts | 175 +++++++++++------- 15 files changed, 454 insertions(+), 174 deletions(-) create mode 100644 src/lib/plain-language.ts diff --git a/src/app/(app)/pages/[id]/page.tsx b/src/app/(app)/pages/[id]/page.tsx index b264237..f3c37f2 100644 --- a/src/app/(app)/pages/[id]/page.tsx +++ b/src/app/(app)/pages/[id]/page.tsx @@ -27,6 +27,7 @@ import { CONFIDENCE_LABEL, DESTINATION_LABEL, DESTINATION_PATH, + EVIDENCE_SOURCE_LABEL, applicabilityActionLabel, type ExclusionReason, } from "@/lib/vocabulary"; @@ -260,22 +261,22 @@ function CollectionState({ page, job }: { page: WatchPage; job?: CollectionJob } const failed = page.runState === "failed"; const retained = job?.completedStrategies ?? []; const retainedTests = [ - ...retained.map((strategy) => `${strategy[0].toUpperCase()}${strategy.slice(1)} PSI`), - ...(job?.cruxCompletedAt ? ["CrUX"] : []), - ...(job?.agentCompletedAt ? ["Agent readiness"] : []), + ...retained.map((strategy) => `${strategy[0].toUpperCase()}${strategy.slice(1)} score`), + ...(job?.cruxCompletedAt ? ["Visitor figures"] : []), + ...(job?.agentCompletedAt ? ["Agent checks"] : []), ]; const retryingTests = [ ...(["mobile", "desktop"] as const) .filter((strategy) => !retained.includes(strategy)) - .map((strategy) => `${strategy[0].toUpperCase()}${strategy.slice(1)} PSI`), - ...(!job?.cruxCompletedAt ? ["CrUX"] : []), - ...(!job?.agentCompletedAt ? ["Agent readiness"] : []), + .map((strategy) => `${strategy[0].toUpperCase()}${strategy.slice(1)} score`), + ...(!job?.cruxCompletedAt ? ["Visitor figures"] : []), + ...(!job?.agentCompletedAt ? ["Agent checks"] : []), ]; const waitDetail = retainedTests.length > 0 ? `${retainedTests.join(" and ")} retained. ` + `Retrying ${retryingTests.join(" and ")}` + `${job?.nextRetryAt ? ` around ${formatSuccessfulRunAt(job.nextRetryAt)}` : " in the next evidence cycle"}.` - : `No independent test has completed yet. Retrying Mobile PSI, Desktop PSI, CrUX, and Agent readiness` + : `No test has finished yet. Retrying the mobile score, the desktop score, the visitor figures, and the agent checks` + `${job?.nextRetryAt ? ` around ${formatSuccessfulRunAt(job.nextRetryAt)}` : " in the next evidence cycle"}.`; const providerDetail = (["mobile", "desktop"] as const).flatMap((strategy) => { const error = job?.strategyErrors?.[strategy]; @@ -283,8 +284,8 @@ function CollectionState({ page, job }: { page: WatchPage; job?: CollectionJob } }).join(" · "); const latestDetail = [ providerDetail, - job?.cruxError ? `CrUX: ${job.cruxError}` : "", - job?.agentError ? `Agent: ${job.agentError}` : "", + job?.cruxError ? `Visitor figures: ${job.cruxError}` : "", + job?.agentError ? `Agent checks: ${job.agentError}` : "", ].filter(Boolean).join(" · "); const title = page.runState === "queued" ? "Collection queued" @@ -890,7 +891,7 @@ function ReadingsSection({ function fallbackReport(d: Night): string { if (!nightHasStrategy(d, strategy)) { return JSON.stringify({ - note: `No ${strategy} PSI measurement completed for this collection. Other independent results are retained.`, + note: `No ${strategy} score was measured this time. The other readings were kept.`, date: d.date, strategy, agentChecksRecorded: d.agent?.length ?? 0, @@ -900,7 +901,7 @@ function ReadingsSection({ } return JSON.stringify( { - note: "No raw PSI payload is stored for this night (seed / imported data). Showing the stored medians and ranges only.", + note: "The full reply from the test was not stored for this night, so only the saved scores and their spread are shown.", date: d.date, strategy, samples: d.samples ?? d.sampleSize ?? null, @@ -1079,10 +1080,17 @@ function ReadingsSection({ className="table-scroll" style={{ background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: 11 }} > + {/* + The caption is where every measurement below it is introduced in + words. The column headings are acronyms because a column is four + characters wide, and that is only honest if the reader has met the + words first — so they are met here, once, and the headings are the + second mention. Nothing below relies on a hover to be understood. + */}
- Every recorded collection · {strategy} primary · Lighthouse median with range below - {showVisitorColumns && " · CrUX p75 with weekly change below"} - {excludedHistory.length > 0 && " · PSI anomaly rows are observed measurements excluded from scoring"} + Every measurement ever taken · {strategy} first · the middle score of each night's runs, with the spread beneath it + {showVisitorColumns && " · then what real visitors met, at the level three quarters of them did better than (p75): when the main content appeared (LCP), how long the page took to answer a tap (INP), how much it jumped about (CLS), and how long the server took to reply (TTFB), each with its change on the week"} + {excludedHistory.length > 0 && " · rows marked as odd readings were measured and then left out of the scoring"} {` · Dates in ${collectionSchedule.timeZone}`}
Night
Marker
+ {/* Four columns, four score names. "A11y" was a numeronym nobody + outside the trade reads; the others are the score names + themselves, abbreviated to fit the column. */}
Perf
-
A11y
-
BP
+
Access
+
Practices
SEO
{showVisitorColumns && VISITOR_METRICS.map((metric) => (
label !== null); const cell = (k: CategoryKey) => { if (!nightHasStrategy(d, strategy)) { return ( -
+
); @@ -1146,7 +1157,7 @@ function ReadingsSection({ const categoryLabel = CATEGORIES.find((category) => category.key === k)?.label ?? k; return (
{score.m}
@@ -1174,8 +1185,8 @@ function ReadingsSection({ const label = VISITOR_METRICS.find((metric) => metric.key === key)?.label ?? key; return (
@@ -1226,10 +1237,10 @@ function ReadingsSection({ {startsDateGroup && timeLabel ? `${timeLabel} · ${runLabel}` : runLabel}
- {completedTests.join(" · ") || "No completed test"} + {completedTests.join(" · ") || "Nothing measured"}
@@ -1238,7 +1249,7 @@ function ReadingsSection({ title="Retained for diagnosis; not used in status, trend, or recommendations" style={{ color: "var(--text-muted)", fontWeight: 600 }} > - ◆ PSI anomaly · excluded + ◆ Odd reading · left out )} {!excludedAnomaly && markers.length === 0 ? ( diff --git a/src/app/(app)/pages/pages-content.tsx b/src/app/(app)/pages/pages-content.tsx index dd000c3..1bdc467 100644 --- a/src/app/(app)/pages/pages-content.tsx +++ b/src/app/(app)/pages/pages-content.tsx @@ -50,7 +50,7 @@ import { normalizeCollectionSchedule } from "@/lib/collectionSchedule"; import { evidenceForPage, visitorExperienceTrend } from "@/lib/visitorExperience"; import { performanceIssueCounts, siteCulpritRollups, sitePerformanceIssues } from "@/lib/performanceIssues"; import type { PerformanceIssueStatus } from "@/lib/performanceIssues"; -import { customerActionabilityFor, recommendationIsCustomerActionable, remediationTone, triageActionLabel, webflowClassificationFor } from "@/lib/webflowPerformance"; +import { customerActionabilityFor, metricDisplay, recommendationIsCustomerActionable, remediationTone, triageActionLabel, webflowClassificationFor } from "@/lib/webflowPerformance"; import { siteNativeElementRollups } from "@/lib/nativeElements"; import { compareLabAndField } from "@/lib/labFieldComparison"; import { fieldPriorityRankForRec, recommendationEvidenceSignal } from "@/lib/fieldPrioritization"; @@ -780,7 +780,7 @@ function DashboardContent({
{rollup.metrics.filter((metric) => metric.metric !== "other").map((metric) => ( - {metric.metric} · + {metricDisplay(metric.metric)} · {/* The unit sits tight against the numeral so it still reads as a percent sign, not a word. */} diff --git a/src/components/bits.tsx b/src/components/bits.tsx index 7782795..673efc7 100644 --- a/src/components/bits.tsx +++ b/src/components/bits.tsx @@ -9,7 +9,7 @@ import { Magnitude } from "@/components/magnitude"; import { TREND_LABEL } from "@/lib/vocabulary"; import type { Tone, Trend, WorkState } from "@/lib/vocabulary"; import type { VisitorExperienceTrend } from "@/lib/visitorExperience"; -import { remediationTone } from "@/lib/webflowPerformance"; +import { metricDisplay, remediationTone } from "@/lib/webflowPerformance"; import type { PerformanceIssueStatus } from "@/lib/performanceIssues"; import type { LabFieldComparisonStatus } from "@/lib/labFieldComparison"; import type { RecommendationEvidenceSignal } from "@/lib/fieldPrioritization"; @@ -154,7 +154,7 @@ export function WebflowClassificationChips({ // The weight is a quantity, so it carries its own emphasis rather than // a hue; the metric name beside it is a label, not a verdict. - {classification.metric} + {metricDisplay(classification.metric)} diff --git a/src/lib/__tests__/field-only-recommendations.test.ts b/src/lib/__tests__/field-only-recommendations.test.ts index 19cee2b..79b30e4 100644 --- a/src/lib/__tests__/field-only-recommendations.test.ts +++ b/src/lib/__tests__/field-only-recommendations.test.ts @@ -5,6 +5,7 @@ import { isFieldRecommendationActionable, reconcileFieldOnlyRecommendationsInState, } from "../fieldOnlyRecommendations"; +import { COMPARABLE_METRICS } from "../labFieldComparison"; import type { CruxPageEvidence, CruxSnapshot } from "../crux"; import type { AppState, Night, Strategy, WatchPage } from "../types"; @@ -95,7 +96,10 @@ describe("field-only recommendations", () => { fieldSignals: { mobile: { metricKey: "lcp", - fieldLabel: "Visitor LCP p75", + // Named by the comparison that produced it, not spelled again here + // (rule 21) — this asserts the signal carried the LCP row's own + // visitor label through, which is the behaviour under test. + fieldLabel: COMPARABLE_METRICS.find((metric) => metric.key === "lcp")!.fieldLabel, fieldFormatted: "4.5 s", fieldRating: "Poor", scope: "url", diff --git a/src/lib/__tests__/lab-field-comparison.test.ts b/src/lib/__tests__/lab-field-comparison.test.ts index d0c79ee..5df9caf 100644 --- a/src/lib/__tests__/lab-field-comparison.test.ts +++ b/src/lib/__tests__/lab-field-comparison.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { compareLabAndField } from "../labFieldComparison"; +import { compareLabAndField, LAB_FIELD_HEADLINE } from "../labFieldComparison"; import type { CruxPageEvidence, CruxSnapshot } from "../crux"; import type { Night } from "../types"; @@ -53,7 +53,7 @@ function evidence(value: CruxSnapshot): CruxPageEvidence { describe("lab and field comparison", () => { it("aligns direct metrics while labeling TBT versus INP as a proxy", () => { const result = compareLabAndField([lab()], "mobile", evidence(snapshot())); - expect(result).toMatchObject({ status: "aligned", headline: "Lab and visitor signals align" }); + expect(result).toMatchObject({ status: "aligned", headline: LAB_FIELD_HEADLINE.agree }); expect(result.metrics.map((metric) => [metric.key, metric.relationship, metric.verdict])).toEqual([ ["lcp", "direct", "aligned-good"], ["responsiveness", "proxy", "aligned-good"], @@ -64,7 +64,7 @@ describe("lab and field comparison", () => { it("identifies visitor problems that Lighthouse does not reproduce", () => { const result = compareLabAndField([lab()], "mobile", evidence(snapshot({ lcpP75Ms: 4_500 }))); - expect(result).toMatchObject({ status: "divergent", headline: "Lab and visitor evidence diverge" }); + expect(result).toMatchObject({ status: "divergent", headline: LAB_FIELD_HEADLINE.disagree }); expect(result.metrics.find((metric) => metric.key === "lcp")).toMatchObject({ verdict: "field-only-risk", lab: { rating: "Good" }, @@ -103,7 +103,9 @@ describe("lab and field comparison", () => { it("treats origin-wide CrUX as context rather than page-level proof", () => { const result = compareLabAndField([lab()], "mobile", evidence(snapshot({ scope: "origin", effectiveUrl: "https://example.com" }))); - expect(result).toMatchObject({ status: "partial", headline: "Origin-level visitor context only", fieldWindow: { scope: "origin" } }); - expect(result.detail).toContain("rather than proving conditions on this page"); + expect(result).toMatchObject({ status: "partial", headline: LAB_FIELD_HEADLINE.whole_site_only, fieldWindow: { scope: "origin" } }); + // The point of the clause: these figures describe the site, and the copy has + // to say they prove nothing about this page rather than leaving it implied. + expect(result.detail).toContain("do not prove anything about this page"); }); }); diff --git a/src/lib/__tests__/performance-issues.test.ts b/src/lib/__tests__/performance-issues.test.ts index 9ab4641..b8dc56c 100644 --- a/src/lib/__tests__/performance-issues.test.ts +++ b/src/lib/__tests__/performance-issues.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { performanceIssueCounts, performanceIssuesForPage, siteCulpritRollups } from "../performanceIssues"; +import { CULPRIT_GROUP_LABELS } from "../webflowPerformance"; import type { AggregatedLighthouseFinding, Night, WatchPage } from "../types"; const score = { m: 80, lo: 78, hi: 82 }; @@ -116,7 +117,9 @@ describe("performance issue lifecycle", () => { expect(rollups).toEqual([ expect.objectContaining({ culprit: "global-javascript", - label: "Global JavaScript", + // From the taxonomy's own map, so this asserts the rollup picked the + // right culprit rather than restating its words (rule 21). + label: CULPRIT_GROUP_LABELS["global-javascript"], issueCount: 2, pageCount: 2, remediationCounts: expect.objectContaining({ blocked: 2 }), diff --git a/src/lib/__tests__/visitor-experience.test.ts b/src/lib/__tests__/visitor-experience.test.ts index 86e02bb..ce7f68c 100644 --- a/src/lib/__tests__/visitor-experience.test.ts +++ b/src/lib/__tests__/visitor-experience.test.ts @@ -8,6 +8,7 @@ import { visitorSnapshotForNight, visitorConfidenceLabel, visitorExperienceTrend, + VISITOR_CONFIDENCE_LABEL, } from "../visitorExperience"; function snapshot(overrides: Partial = {}): CruxSnapshot { @@ -62,8 +63,10 @@ describe("visitor experience presentation", () => { expect(metricRating("inpP75Ms", 350)).toBe("Needs improvement"); expect(formatVisitorMetric("lcpP75Ms", 2_450)).toBe("2.5 s"); expect(formatVisitorMetric("clsP75", 0.081)).toBe("0.08"); - expect(visitorConfidenceLabel("regressing", "stable")).toBe("Lighthouse worsening; visitor experience stable"); - expect(visitorConfidenceLabel("regressing", "worsening")).toBe("Lighthouse and visitor experience worsening"); + // Asserts which conclusion each pair of trends resolves to, from the one + // place those conclusions are written (rule 21). + expect(visitorConfidenceLabel("regressing", "stable")).toBe(VISITOR_CONFIDENCE_LABEL.worse_test_only); + expect(visitorConfidenceLabel("regressing", "worsening")).toBe(VISITOR_CONFIDENCE_LABEL.worse_both); }); it("matches nightly rows to the latest eligible weekly CrUX window", () => { diff --git a/src/lib/__tests__/webflow-performance.test.ts b/src/lib/__tests__/webflow-performance.test.ts index 28c3475..b7e083c 100644 --- a/src/lib/__tests__/webflow-performance.test.ts +++ b/src/lib/__tests__/webflow-performance.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + CULPRIT_GROUP_LABELS, classifyWebflowPerformance, classificationForPage, culpritGroupLabel, @@ -47,7 +48,10 @@ describe("Webflow performance taxonomy", () => { culprit: "image-delivery", remediation: "available", }); - expect(culpritGroupLabel({ id: "r4", title: "Properly size images" })).toBe("Image delivery"); + // Asserts that the label resolves through the culprit this title maps to, + // not that it reads any particular way (rule 21). + expect(culpritGroupLabel({ id: "r4", title: "Properly size images" })) + .toBe(CULPRIT_GROUP_LABELS["image-delivery"]); }); it("uses remediation-aware impact, effort, and triage labels", () => { diff --git a/src/lib/culpritEvidence.ts b/src/lib/culpritEvidence.ts index b65b3c5..ab48ceb 100644 --- a/src/lib/culpritEvidence.ts +++ b/src/lib/culpritEvidence.ts @@ -10,18 +10,32 @@ import { median } from "./scoring"; type UnknownRecord = Record; +/** + * What each finding is called, in words rather than in the upstream key. + * + * The keys are Lighthouse audit ids and stay keys — they are how a reading is + * matched to the report it came from. None of them is a name for the problem, + * which is why none of them reaches a reader: "uses-optimized-images" sends + * somebody who searches for it to Google's documentation instead of to their + * own page. + * + * A title is short because it labels a row, so where an industry term is worth + * keeping the appositive lands in the sentence beside it rather than here. Two + * of these carry the term anyway, because the plain phrase alone would not tell + * a reader which measurement moved. + */ const TITLES: Record = { - "dom-size": "DOM structure", - "unused-css-rules": "Unused CSS", - "unused-javascript": "Unused JavaScript", - "unminified-javascript": "Unminified JavaScript", - "legacy-javascript": "Legacy JavaScript", - "third-party-summary": "Third-party JavaScript", - "render-blocking-resources": "Render-blocking resources", - "render-blocking-insight": "Render-blocking resources", - "uses-responsive-images": "Oversized images", - "uses-optimized-images": "Image encoding waste", - "largest-contentful-paint-element": "Largest Contentful Paint element", + "dom-size": "Deeply nested elements", + "unused-css-rules": "Style rules the page never uses", + "unused-javascript": "Code the page never runs", + "unminified-javascript": "Code shipped larger than it needs to be", + "legacy-javascript": "Code written for browsers nobody uses", + "third-party-summary": "Code from other companies", + "render-blocking-resources": "Files that delay the first text", + "render-blocking-insight": "Files that delay the first text", + "uses-responsive-images": "Images bigger than they are shown", + "uses-optimized-images": "Images heavier than they need to be", + "largest-contentful-paint-element": "The main thing a visitor waits for (largest contentful paint element)", }; /** @@ -95,10 +109,12 @@ function domEvidence(details: UnknownRecord): CulpritEvidenceFact[] { if (value !== undefined) stats.set(item.statistic.toLowerCase(), value); } const matching = (pattern: RegExp) => [...stats.entries()].find(([key]) => pattern.test(key))?.[1]; + // The patterns still match Lighthouse's own statistic names, which say "DOM"; + // the LABELS are what a reader sees, and they say what the number counts. return compactFacts([ - fact("nodes", "DOM nodes", matching(/total dom|dom elements|elements/), "count"), - fact("depth", "Maximum depth", matching(/maximum dom depth|max.*depth/), "count"), - fact("width", "Maximum children", matching(/maximum child|max.*child/), "count"), + fact("nodes", "Elements on the page", matching(/total dom|dom elements|elements/), "count"), + fact("depth", "Deepest nesting", matching(/maximum dom depth|max.*depth/), "count"), + fact("width", "Most children in one element", matching(/maximum child|max.*child/), "count"), ]); } @@ -108,26 +124,26 @@ function bytesEvidence(details: UnknownRecord, items: UnknownRecord[]): CulpritE const totalBytes = items.reduce((sum, item) => sum + (number(item.totalBytes ?? item.transferSize) ?? 0), 0); const wastedPercent = totalBytes > 0 ? wastedBytes / totalBytes * 100 : undefined; return compactFacts([ - fact("wastedBytes", "Potential savings", wastedBytes, "bytes"), - fact("totalBytes", "Inspected bytes", totalBytes || undefined, "bytes"), - fact("wastedPercent", "Unused", wastedPercent, "percent"), + fact("wastedBytes", "Could be saved", wastedBytes, "bytes"), + fact("totalBytes", "Looked at", totalBytes || undefined, "bytes"), + fact("wastedPercent", "Never used", wastedPercent, "percent"), ]); } function thirdPartyEvidence(items: UnknownRecord[]): CulpritEvidenceFact[] { return compactFacts([ - fact("transferBytes", "Transfer size", items.reduce((sum, item) => sum + (number(item.transferSize) ?? 0), 0), "bytes"), - fact("mainThreadMs", "Main-thread time", items.reduce((sum, item) => sum + (number(item.mainThreadTime) ?? 0), 0), "milliseconds"), - fact("blockingMs", "Blocking time", items.reduce((sum, item) => sum + (number(item.blockingTime) ?? 0), 0), "milliseconds"), - fact("sources", "Third-party sources", new Set(items.map((item) => safeHost(item.url)).filter(Boolean)).size, "count"), + fact("transferBytes", "Downloaded", items.reduce((sum, item) => sum + (number(item.transferSize) ?? 0), 0), "bytes"), + fact("mainThreadMs", "Time the browser spent running it", items.reduce((sum, item) => sum + (number(item.mainThreadTime) ?? 0), 0), "milliseconds"), + fact("blockingMs", "Time the page could not respond", items.reduce((sum, item) => sum + (number(item.blockingTime) ?? 0), 0), "milliseconds"), + fact("sources", "Other companies involved", new Set(items.map((item) => safeHost(item.url)).filter(Boolean)).size, "count"), ]).filter((item) => item.value > 0); } function renderBlockingEvidence(details: UnknownRecord, items: UnknownRecord[]): CulpritEvidenceFact[] { return compactFacts([ - fact("resources", "Blocking resources", items.filter((item) => typeof item.url === "string").length, "count"), - fact("delayMs", "Potential delay", number(details.overallSavingsMs) ?? items.reduce((sum, item) => sum + (number(item.wastedMs) ?? 0), 0), "milliseconds"), - fact("transferBytes", "Transfer size", items.reduce((sum, item) => sum + (number(item.totalBytes ?? item.transferSize) ?? 0), 0), "bytes"), + fact("resources", "Files text waits on", items.filter((item) => typeof item.url === "string").length, "count"), + fact("delayMs", "How long text waits", number(details.overallSavingsMs) ?? items.reduce((sum, item) => sum + (number(item.wastedMs) ?? 0), 0), "milliseconds"), + fact("transferBytes", "Downloaded", items.reduce((sum, item) => sum + (number(item.totalBytes ?? item.transferSize) ?? 0), 0), "bytes"), ]).filter((item) => item.value > 0); } @@ -157,7 +173,7 @@ function extractOne(raw: unknown): CulpritEvidence[] { else if (auditId === "unused-css-rules" || auditId === "unused-javascript" || auditId === "unminified-javascript" || auditId === "legacy-javascript" || auditId === "uses-responsive-images" || auditId === "uses-optimized-images") { facts = bytesEvidence(details, items); sources = sourcesFor(items); - const resourceLabel = auditId.startsWith("uses-") ? "Affected images" : auditId.includes("javascript") ? "Affected scripts" : "Affected stylesheets"; + const resourceLabel = auditId.startsWith("uses-") ? "Images affected" : auditId.includes("javascript") ? "Script files affected" : "Style files affected"; facts.unshift({ key: "resources", label: resourceLabel, value: items.filter((item) => typeof item.url === "string").length, unit: "count" }); } else if (auditId === "third-party-summary") { facts = thirdPartyEvidence(items); @@ -177,8 +193,8 @@ function extractOne(raw: unknown): CulpritEvidence[] { height: number(rect?.height), }; facts = compactFacts([ - fact("width", "Rendered width", lcpElement.width, "pixels"), - fact("height", "Rendered height", lcpElement.height, "pixels"), + fact("width", "Width on screen", lcpElement.width, "pixels"), + fact("height", "Height on screen", lcpElement.height, "pixels"), ]); } } diff --git a/src/lib/labFieldComparison.ts b/src/lib/labFieldComparison.ts index dad3326..446da7a 100644 --- a/src/lib/labFieldComparison.ts +++ b/src/lib/labFieldComparison.ts @@ -38,13 +38,48 @@ interface MetricDefinition { fieldLabel: string; } -const COMPARABLE_METRICS: readonly MetricDefinition[] = [ - { key: "lcp", label: "Main content load", relationship: "direct", labKey: "medianLargestContentfulPaint", labLabel: "Lab LCP", fieldKey: "lcpP75Ms", fieldLabel: "Visitor LCP p75" }, - { key: "responsiveness", label: "Responsiveness", relationship: "proxy", labKey: "medianTotalBlockingTime", labLabel: "Lab TBT", fieldKey: "inpP75Ms", fieldLabel: "Visitor INP p75" }, - { key: "cls", label: "Layout stability", relationship: "direct", labKey: "medianCumulativeLayoutShift", labLabel: "Lab CLS", fieldKey: "clsP75", fieldLabel: "Visitor CLS p75" }, - { key: "ttfb", label: "Server response", relationship: "direct", labKey: "medianServerResponseTime", labLabel: "Lab TTFB", fieldKey: "ttfbP75Ms", fieldLabel: "Visitor TTFB p75" }, +/** + * The four things measured both ways, and what each side's reading is called. + * + * `label` is what the row is about and carries no jargon. The two side labels + * name where the reading came from first and which measurement it is second — + * "Nightly test (LCP)", not "Lab LCP". "Lab" and "Visitor" were doing the work + * of saying which side, in a word that only means that to us; "our nightly + * test" and "real visitors" say it in the reader's terms. + * + * The acronyms stay, and they have to: the responsiveness row deliberately + * compares two DIFFERENT measurements — how long the page could not respond + * during our test against how long real visitors waited for it to answer — + * which is what `relationship: "proxy"` records. Strip the names and that + * mismatch becomes invisible, and a reader would read the two numbers as the + * same measurement disagreeing with itself. + */ +export const COMPARABLE_METRICS: readonly MetricDefinition[] = [ + { key: "lcp", label: "Main content load", relationship: "direct", labKey: "medianLargestContentfulPaint", labLabel: "Nightly test (LCP)", fieldKey: "lcpP75Ms", fieldLabel: "Real visitors (LCP)" }, + { key: "responsiveness", label: "Responsiveness", relationship: "proxy", labKey: "medianTotalBlockingTime", labLabel: "Nightly test (TBT)", fieldKey: "inpP75Ms", fieldLabel: "Real visitors (INP)" }, + { key: "cls", label: "Layout stability", relationship: "direct", labKey: "medianCumulativeLayoutShift", labLabel: "Nightly test (CLS)", fieldKey: "clsP75", fieldLabel: "Real visitors (CLS)" }, + { key: "ttfb", label: "Server response", relationship: "direct", labKey: "medianServerResponseTime", labLabel: "Nightly test (TTFB)", fieldKey: "ttfbP75Ms", fieldLabel: "Real visitors (TTFB)" }, ]; +/** + * The five things this comparison can conclude, in words. + * + * Named and exported rather than written inline, for two reasons. One is rule + * 20: they used to sit as five literals inside one if/else and were asserted by + * five more literals in the test, so a rewording had to be made in two places + * and agreed in neither. The other is that all five said "lab" and "field" — + * our words for the two sides, not the reader's. What a reader has is a nightly + * test and some real visitors, and every one of these now says so. + */ +export const LAB_FIELD_HEADLINE = { + nothing_to_compare: "Nothing to compare yet", + disagree: "The nightly test and real visitors disagree", + both_found_it: "The nightly test found what visitors are meeting", + agree: "The nightly test and real visitors agree", + partly_compared: "Only part of this could be compared", + whole_site_only: "These visitor figures are for the whole site", +} as const; + function latestTrustedLabNight(history: Night[], strategy: Strategy): Night | null { return [...history].reverse().find((night) => { if (night.evidenceStatus === "provider-anomaly") return false; @@ -81,13 +116,13 @@ function formattedLabValue(key: MetricDefinition["labKey"], value: number): stri } function verdictFor(lab: SignalRating | null, field: SignalRating | null): Pick { - if (!lab || !field) return { verdict: "unavailable", verdictLabel: "Comparison unavailable", guidance: "Both lab and visitor measurements are required." }; + if (!lab || !field) return { verdict: "unavailable", verdictLabel: "Nothing to compare", guidance: "This needs a reading from both the nightly test and real visitors." }; const labRisk = lab !== "Good"; const fieldRisk = field !== "Good"; - if (!labRisk && !fieldRisk) return { verdict: "aligned-good", verdictLabel: "Aligned · good", guidance: "Controlled tests and visitor evidence are both within the good range." }; - if (labRisk && fieldRisk) return { verdict: "corroborated-issue", verdictLabel: "Issue corroborated", guidance: "The controlled test reproduces a problem also visible to visitors." }; - if (!labRisk && fieldRisk) return { verdict: "field-only-risk", verdictLabel: "Field-only issue", guidance: "Visitors are seeing a problem that the controlled Lighthouse run does not reproduce." }; - return { verdict: "lab-only-risk", verdictLabel: "Lab-only issue", guidance: "The controlled run detects a problem that is not currently visible at the visitor p75." }; + if (!labRisk && !fieldRisk) return { verdict: "aligned-good", verdictLabel: "Both good", guidance: "The nightly test and real visitors are both inside the good range." }; + if (labRisk && fieldRisk) return { verdict: "corroborated-issue", verdictLabel: "Both found it", guidance: "The nightly test reproduces a problem real visitors are meeting too." }; + if (!labRisk && fieldRisk) return { verdict: "field-only-risk", verdictLabel: "Only real visitors see it", guidance: "Real visitors are meeting a problem the nightly test cannot reproduce." }; + return { verdict: "lab-only-risk", verdictLabel: "Only the nightly test sees it", guidance: "The nightly test finds a problem that is not showing up for real visitors yet." }; } /** Pair only defensibly related Lighthouse and CrUX measurements for one device. */ @@ -126,31 +161,31 @@ export function compareLabAndField( let detail: string; if (available.length === 0) { status = "unavailable"; - headline = "Lab–field comparison unavailable"; - detail = "A retained Lighthouse measurement and Chrome visitor evidence are both required."; + headline = LAB_FIELD_HEADLINE.nothing_to_compare; + detail = "This needs both a kept reading from the nightly test and figures from real visitors."; } else if (fieldOnly.length || labOnly.length) { status = "divergent"; - headline = "Lab and visitor evidence diverge"; + headline = LAB_FIELD_HEADLINE.disagree; detail = fieldOnly.length - ? `${fieldOnly.map((metric) => metric.label).join(" and ")} ${fieldOnly.length === 1 ? "is" : "are"} worse for visitors than in the controlled test.` - : `${labOnly.map((metric) => metric.label).join(" and ")} ${labOnly.length === 1 ? "is" : "are"} worse in the controlled test than at the visitor p75.`; + ? `${fieldOnly.map((metric) => metric.label).join(" and ")} ${fieldOnly.length === 1 ? "is" : "are"} worse for real visitors than in the nightly test.` + : `${labOnly.map((metric) => metric.label).join(" and ")} ${labOnly.length === 1 ? "is" : "are"} worse in the nightly test than for real visitors.`; } else if (corroborated.length) { status = "corroborated"; - headline = "Visitor issues reproduced in Lighthouse"; - detail = `${corroborated.map((metric) => metric.label).join(" and ")} ${corroborated.length === 1 ? "is" : "are"} outside the good range in both sources.`; + headline = LAB_FIELD_HEADLINE.both_found_it; + detail = `${corroborated.map((metric) => metric.label).join(" and ")} ${corroborated.length === 1 ? "is" : "are"} outside the good range on both sides.`; } else if (available.length === metrics.length && alignedGood.length === metrics.length) { status = "aligned"; - headline = "Lab and visitor signals align"; - detail = "All comparable measurements are within the good range in both sources."; + headline = LAB_FIELD_HEADLINE.agree; + detail = "Everything measurable both ways is inside the good range on both sides."; } else { status = "partial"; - headline = "Lab and visitor signals partially align"; - detail = `${available.length} of ${metrics.length} comparisons have evidence in both sources.`; + headline = LAB_FIELD_HEADLINE.partly_compared; + detail = `${available.length} of ${metrics.length} measurements have a reading on both sides.`; } if (fieldSnapshot?.scope === "origin" && available.length > 0) { status = "partial"; - headline = "Origin-level visitor context only"; - detail = `${detail} Exact-URL CrUX evidence is unavailable, so the visitor values describe the origin rather than proving conditions on this page.`; + headline = LAB_FIELD_HEADLINE.whole_site_only; + detail = `${detail} Too few people visited this exact page for it to be reported on its own (the Chrome UX Report), so the visitor figures describe the whole site and do not prove anything about this page.`; } return { diff --git a/src/lib/labMetrics.ts b/src/lib/labMetrics.ts index 51075b4..65ce91e 100644 --- a/src/lib/labMetrics.ts +++ b/src/lib/labMetrics.ts @@ -15,7 +15,7 @@ export const LAB_METRICS: ReadonlyArray<{ { key: "medianFirstContentfulPaint", label: "First content", short: "FCP", good: 1_800, poor: 3_000 }, { key: "medianSpeedIndex", label: "Visual progress", short: "Speed Index", good: 3_400, poor: 5_800 }, { key: "medianLargestContentfulPaint", label: "Main content", short: "LCP", good: 2_500, poor: 4_000 }, - { key: "medianTotalBlockingTime", label: "Main-thread blocking", short: "TBT", good: 200, poor: 600 }, + { key: "medianTotalBlockingTime", label: "Unable to respond", short: "TBT", good: 200, poor: 600 }, { key: "medianCumulativeLayoutShift", label: "Layout stability", short: "CLS", good: 0.1, poor: 0.25 }, ]; diff --git a/src/lib/plain-language.ts b/src/lib/plain-language.ts new file mode 100644 index 0000000..1a6b3ad --- /dev/null +++ b/src/lib/plain-language.ts @@ -0,0 +1,113 @@ +/** + * The one pattern for a technical term: plain meaning first, term after it in + * parentheses. + * + * Option 11b, from S9. Tooltips and glossary links were both considered and + * rejected, and the reason is where this copy is read. A digest arrives in + * email, a fix is pasted into a ticket, a case is screenshotted into Slack — + * none of those carry a hover target or a link back into the app. A term whose + * meaning lives in a tooltip is a term without a meaning everywhere the copy + * actually travels, so the meaning goes in the sentence. + * + * Two groups, two treatments: + * + * Group A, internal names and rule ids. Rewritten out of reader-facing copy + * entirely. "uses-optimized-images" is Lighthouse's key for a finding, not a + * name for the problem, and a reader who searches for it finds Google's + * documentation rather than their own page. A rule id survives only in a + * case's metadata chips, where it is labelled as an identifier and exists so + * an engineer can match a row to an upstream report. + * + * Group B, industry-standard measurements and web standards. The term is + * KEPT — renaming "cumulative layout shift" would cut the reader off from + * every other tool and article that measures it — and introduced with its + * plain meaning on first use. + * + * The rule this file can enforce is the ORDERING: meaning first, term second. + * `plain-language.test.ts` asserts that no reader-facing string leads with a + * Group B term and that no Group A term appears in one at all. What it + * deliberately does not enforce is "one appositive per term per screen" — a + * screen is not a thing a lint rule can see, and the second mention being bare + * is a review call, as the brief says. + */ + +/** + * Plain meaning first, term second. Always build an introduction through this + * rather than writing the parentheses by hand, so the ordering is structural + * rather than remembered. + */ +export function appositive(plain: string, term: string): string { + return `${plain} (${term})`; +} + +/** + * Group B — the terms a reader may meet, kept because they are what the rest of + * the industry calls these things. + * + * Written lower-case; the checks are case-insensitive. Longest first, so + * "cumulative layout shift" is recognised before a substring of it could be. + */ +export const INDUSTRY_TERMS: readonly string[] = [ + "largest contentful paint", + "cumulative layout shift", + "interaction to next paint", + "first contentful paint", + "total blocking time", + "time to first byte", + "core web vitals", + "chrome ux report", + "pagespeed insights", + "render-blocking", + "structured data", + "meta description", + "speed index", + "robots.txt", + "sitemap.xml", + "schema.org", + "lighthouse", + "hreflang", + "json-ld", + "llms.txt", + "viewport", + "crux", + "p75", + "lcp", + "inp", + "cls", + "ttfb", + "tbt", + "fcp", +]; + +/** + * Group A — internal names, vendor codenames, and rule ids. None of these may + * appear in reader-facing copy. + * + * `psi` is Page Watch's own shorthand for PageSpeed Insights and means nothing + * to anyone else; the industry term is in Group B and is what the copy says + * when it needs to name the service. `dom` is here rather than in Group B on + * the strength of the decided rewrite — "The page nests elements 34 levels + * deep" drops the acronym rather than parenthesising it, because the plain + * sentence is complete without it and a reader cannot act on the initialism. + */ +export const INTERNAL_TERMS: readonly string[] = [ + "psi", + "dom", + "gptbot", + "kitesurf", + "audit id", + "audit ids", + "form factor", + "diagnostic proxy", +]; + +/** + * Lighthouse audit ids and other kebab-case rule identifiers, recognised by + * shape rather than by list — the upstream catalogue grows without asking us, + * and a finding whose id nobody added to a list is exactly the one that would + * reach a reader unnoticed. + * + * Ordinary hyphenated English matches the same shape, so the test that uses + * this carries the exceptions. This is the shape, not the verdict. + */ +export const RULE_ID_SHAPE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+){2,}$/; diff --git a/src/lib/scoreCardTooltip.ts b/src/lib/scoreCardTooltip.ts index 7ff0299..51c2ef6 100644 --- a/src/lib/scoreCardTooltip.ts +++ b/src/lib/scoreCardTooltip.ts @@ -1,6 +1,13 @@ // Metric tooltip copy for ScoreCard's title hover (see ScoreCard density // handoff §5). Identical at every density, including the XSmall row-cell -// label. Copy is verbatim from the handoff — do not paraphrase it. +// label. +// +// The handoff's copy was carried verbatim until S9, which rewrote it: every +// body listed its measurements as bare acronyms — "LCP, Total Blocking Time, +// CLS" — and a reader who does not already know those four letters learns +// nothing from a sentence containing them. The measurements are unchanged and +// still named; each one now arrives after the plain description of what it +// measures. import { SCORE_BAD, SCORE_GOOD, SCORE_WARN } from "./scoreCard"; @@ -24,25 +31,47 @@ export const SCORE_BANDS_LABEL = [ { token: SCORE_BAD, text: "0–49 poor" }, ] as const; -const UNIT_LINE = "Lighthouse score · 0–100"; +/** + * Out of 100, and where the number came from. + * + * Reordered rather than reworded: it used to read "Lighthouse score · 0–100", + * which leads with the name of the tool and leaves the reader to guess what the + * number is. The scale is the fact; the tool is the attribution. + */ +const UNIT_LINE = "Out of 100 · the nightly test (Lighthouse)"; -/** Keyed by the card title so callers can look this up with `data.title`. */ +/** + * What each score is about. + * + * Every industry term here is introduced by its plain meaning and follows it in + * parentheses. That is the whole pattern, and these four bodies are where the + * spelled-out measurement names are introduced for the score card — the chips + * elsewhere on the screen carry the acronym alone, which is only honest because + * the words appear here. + * + * These are not the pattern's mechanism, though, and must not become it. A + * tooltip is unreachable in a digest, a ticket or a screenshot, which is where + * most of this copy is read; anything a reader genuinely needs in order to act + * belongs in the sentence that asks them to act, not in a hover. + * + * Keyed by the card title so callers can look this up with `data.title`. + */ export const METRIC_TOOLTIP_COPY: Record = { Performance: { unit: UNIT_LINE, - body: "How quickly the page renders and becomes usable. Weighted from five lab metrics — LCP, Total Blocking Time, CLS, First Contentful Paint and Speed Index — collected on a throttled connection, so it moves more night to night than the other three.", + body: "How quickly the page becomes usable. Weighted from five readings taken on a deliberately slow connection: when the main content appears (largest contentful paint), how long the page cannot respond to a tap (total blocking time), how much the content jumps about while it loads (cumulative layout shift), when the first text or image appears (first contentful paint), and how quickly the page fills in overall (speed index). Because the connection is simulated, this moves more from night to night than the other three.", }, Accessibility: { unit: UNIT_LINE, - body: "Automated checks for common accessibility failures: colour contrast, ARIA usage, form labels, names and focus order. Passing every check does not guarantee the page is accessible — it means nothing automated could be caught.", + body: "Automated checks for the barriers that stop people using the page: colour contrast, the labels screen readers announce, form field names, and the order keyboard focus moves in. Passing every check does not mean the page is accessible — it means nothing a machine can catch was caught.", }, "Best Practices": { unit: UNIT_LINE, - body: "General web health: HTTPS, console errors, deprecated APIs, correctly sized images and browser permission prompts. Scored from a flat set of pass/fail audits, so it steps rather than drifts.", + body: "General web health: whether the page is served securely (HTTPS), errors it logs while loading, browser features it uses that are being withdrawn, images sized correctly, and how it asks permission for things like location. Each check passes or fails outright, so this score steps rather than drifts.", }, SEO: { unit: UNIT_LINE, - body: "Whether a crawler can reach and understand the page: title and meta description, crawlable links, valid robots.txt, legible font sizes, valid hreflang. It checks mechanics, not content quality or ranking.", + body: "Whether a search engine can reach the page and work out what it says (search engine optimisation): its title and summary text (meta description), links it can follow, a valid file telling crawlers what they may read (robots.txt), font sizes big enough to read, and correct markings for other languages (hreflang). It checks the mechanics, not how good the content is or where it will rank.", }, }; diff --git a/src/lib/visitorExperience.ts b/src/lib/visitorExperience.ts index 4828412..e5b9d64 100644 --- a/src/lib/visitorExperience.ts +++ b/src/lib/visitorExperience.ts @@ -61,17 +61,36 @@ export function visitorExperienceTrend(item: CruxPageEvidence | null): VisitorEx return "stable"; } +/** + * How the two sides read together, in words. + * + * Named and exported for the same two reasons as the comparison's headlines: + * they were seven literals in one function, asserted by seven more in the test, + * and every one of them said "Lighthouse" and "visitor experience" — the name of + * the tool and a phrase of ours — where the reader has a nightly test and some + * real visitors. What is measured has not changed; what it is called has. + */ +export const VISITOR_CONFIDENCE_LABEL = { + not_enough: "Not enough visitor figures yet", + worse_both: "Worse in the nightly test and for real visitors", + worse_test_only: "Worse in the nightly test; steady for real visitors", + better_test_only: "Better in the nightly test; steady for real visitors", + worse_visitors: "Getting worse for real visitors", + better_visitors: "Getting better for real visitors", + steady: "Steady for real visitors", +} as const; + export function visitorConfidenceLabel( labTrend: PageStatus, visitorTrend: VisitorExperienceTrend, ): string { - if (visitorTrend === "insufficient") return "Visitor experience unavailable"; - if (labTrend === "regressing" && visitorTrend === "worsening") return "Lighthouse and visitor experience worsening"; - if (labTrend === "regressing" && visitorTrend === "stable") return "Lighthouse worsening; visitor experience stable"; - if (labTrend === "improving" && visitorTrend === "stable") return "Lighthouse improving; visitor experience stable"; - if (visitorTrend === "worsening") return "Visitor experience worsening"; - if (visitorTrend === "improving") return "Visitor experience improving"; - return "Visitor experience stable"; + if (visitorTrend === "insufficient") return VISITOR_CONFIDENCE_LABEL.not_enough; + if (labTrend === "regressing" && visitorTrend === "worsening") return VISITOR_CONFIDENCE_LABEL.worse_both; + if (labTrend === "regressing" && visitorTrend === "stable") return VISITOR_CONFIDENCE_LABEL.worse_test_only; + if (labTrend === "improving" && visitorTrend === "stable") return VISITOR_CONFIDENCE_LABEL.better_test_only; + if (visitorTrend === "worsening") return VISITOR_CONFIDENCE_LABEL.worse_visitors; + if (visitorTrend === "improving") return VISITOR_CONFIDENCE_LABEL.better_visitors; + return VISITOR_CONFIDENCE_LABEL.steady; } export function formatVisitorMetric(key: VisitorMetricKey, value: number | null): string { diff --git a/src/lib/webflowPerformance.ts b/src/lib/webflowPerformance.ts index 02b8498..68c3674 100644 --- a/src/lib/webflowPerformance.ts +++ b/src/lib/webflowPerformance.ts @@ -6,6 +6,7 @@ import type { WebflowRemediationLevel, } from "./types"; import type { Tone } from "./vocabulary"; +import { appositive } from "./plain-language"; type CatalogEntry = Pick< WebflowPerformanceClassification, @@ -34,205 +35,245 @@ const METRIC_WEIGHTS: Record = { other: 0, }; -const CULPRIT_GROUP_LABELS: Record = { - "global-javascript": "Global JavaScript", - "main-thread-work": "Main-thread work", - "third-party-code": "Third-party code", - "dom-complexity": "DOM complexity", - "lcp-element": "LCP element", - "global-css": "Global CSS", - "image-delivery": "Image delivery", - "render-blocking": "Render-blocking resources", - "custom-javascript": "Custom JavaScript", - "layout-stability": "Layout stability", - "background-video": "Background Video", - "video-embeds": "Video embeds", - "interactive-media": "Lottie and Spline", - other: "Other Lighthouse findings", +/** + * What each measurement is about, in words, with the standard name after it. + * + * The keys are the industry acronyms and stay so — they are the axis a finding + * is classified on, and they match what every other tool reports. What changes + * is the reading order: a chip used to say "TBT · 30%", which leads with three + * letters a reader cannot act on and buries the only part they can. Now the + * plain word comes first and the acronym follows it, which is the one pattern. + * + * The acronym rather than the spelled-out term, and only here, because a chip + * is a few characters wide. The spelled-out appositive — "total blocking time" + * — is introduced once on each screen that shows these, in the prose beside the + * chips rather than inside them. + */ +export const METRIC_PLAIN: Record = { + TBT: "Responsiveness", + LCP: "Main content", + CLS: "Content jumping", + // Never displayed: `metricDisplay` is only reached for a classified metric, + // and every caller already hides the chip when the metric is `other`. + other: "", +}; + +/** "Responsiveness (TBT)". Plain meaning first, standard name after it. */ +export function metricDisplay(metric: WebflowPerformanceMetric): string { + return metric === "other" ? "" : appositive(METRIC_PLAIN[metric], metric); +} + +/** + * The name each culprit group carries on screen. + * + * Exported so a test can assert that a rollup resolved the right culprit + * without restating the words. Rule 21: an assertion holding its own copy of + * "Site-wide code" proves the two spellings agree, never that the grouping is + * right, and it fails on a rewording that broke nothing. + * + * Short by necessity — these label chips and group headings — so where a + * measurement needs introducing, the appositive goes in the sentence beside the + * chip rather than inside it. + */ +export const CULPRIT_GROUP_LABELS: Record = { + "global-javascript": "Site-wide code", + "main-thread-work": "Work the browser must finish first", + "third-party-code": "Code from other companies", + "dom-complexity": "Deeply nested elements", + "lcp-element": "The main thing visitors wait for", + "global-css": "Site-wide style rules", + "image-delivery": "How images are sent", + "render-blocking": "Files that delay the first text", + "custom-javascript": "Your own code", + "layout-stability": "Content that moves while loading", + "background-video": "Background video", + "video-embeds": "Video players", + "interactive-media": "Animations and 3D scenes", + other: "Everything else the nightly test found", }; const CATALOG: Record = { "bootup-time": { metric: "TBT", culprit: "global-javascript", - culpritLabel: "JavaScript execution", + culpritLabel: "Code running at startup", remediation: "blocked", - guidance: "Reduce or defer JavaScript that runs during startup, prioritizing code you control and scripts that are not required on this page.", + guidance: "Cut back or delay the code that runs as the page starts, beginning with your own and with anything this page does not need.", }, "mainthread-work-breakdown": { metric: "TBT", culprit: "main-thread-work", - culpritLabel: "Main-thread work", + culpritLabel: "Work the browser must finish first", remediation: "blocked", - guidance: "Inspect the longest main-thread tasks and address the scripts, styles, or page structures responsible for them.", + guidance: "Find the longest jobs the browser had to finish before it could respond, and deal with the scripts, styles or page structure behind them.", }, "third-party-summary": { metric: "TBT", culprit: "third-party-code", - culpritLabel: "Third-party code", + culpritLabel: "Code from other companies", remediation: "partial", - guidance: "Remove, defer, or conditionally load nonessential third-party tags and embeds.", + guidance: "Remove the tags and embeds you do not need, or load them later, or only on the pages that use them.", }, "third-party-facades": { metric: "TBT", culprit: "third-party-code", - culpritLabel: "Third-party embeds", + culpritLabel: "Embeds from other companies", remediation: "partial", - guidance: "Replace eager embeds with poster-image facades or load-on-interaction behavior where possible.", + guidance: "Show a still image in place of the embed and load the real thing when somebody clicks it.", }, "dom-size": { metric: "TBT", culprit: "dom-complexity", - culpritLabel: "DOM complexity", + culpritLabel: "Deeply nested elements", remediation: "partial", - guidance: "Reduce unnecessary nesting and page length, or defer below-the-fold sections where the site implementation allows it.", + guidance: "Flatten nesting you do not need and shorten the page, or load the sections below the first screenful later.", }, "largest-contentful-paint-element": { metric: "LCP", culprit: "lcp-element", - culpritLabel: "LCP element", + culpritLabel: "The main thing visitors wait for", remediation: "partial", - guidance: "Identify the largest above-the-fold element and simplify, resize, preload, or replace the asset where appropriate.", + guidance: "Find the biggest thing visible without scrolling, then simplify it, resize it, load it sooner, or replace it.", }, "lcp-discovery-insight": { metric: "LCP", culprit: "lcp-element", - culpritLabel: "LCP discovery", + culpritLabel: "The main image found late", remediation: "partial", - guidance: "Keep the primary hero resource discoverable early and avoid lazy-loading the above-the-fold LCP asset.", + guidance: "Make sure the browser can find the main image straight away, and do not set anything visible without scrolling to load late.", }, "prioritize-lcp-image": { metric: "LCP", culprit: "lcp-element", - culpritLabel: "LCP image priority", + culpritLabel: "The main image loaded last", remediation: "partial", - guidance: "Prioritize the hero image and avoid loading it indirectly through scripts or late-applied styles.", + guidance: "Load the main image first, and do not reach it through a script or a style that arrives late.", }, "lcp-lazy-loaded": { metric: "LCP", culprit: "lcp-element", - culpritLabel: "LCP lazy loading", + culpritLabel: "The main image set to load late", remediation: "partial", - guidance: "Do not lazy-load the above-the-fold image that Lighthouse identifies as the LCP element.", + guidance: "Do not set the main image to load late when it is visible without scrolling.", }, "unused-css-rules": { metric: "LCP", culprit: "global-css", - culpritLabel: "Unused global CSS", + culpritLabel: "Style rules the site never uses", remediation: "blocked", - guidance: "Remove unused classes and stylesheet rules you control, and avoid loading page-specific styles on pages that do not need them.", + guidance: "Delete the classes and style rules nothing uses, and stop sending one page’s styles to pages that do not need them.", }, "uses-responsive-images": { metric: "LCP", culprit: "image-delivery", - culpritLabel: "Image sizing", + culpritLabel: "Images bigger than they are shown", remediation: "available", - guidance: "Resize the source asset and provide responsive image candidates appropriate for the rendered size.", + guidance: "Resize the original, and offer it at several sizes so a browser can take the one it needs.", }, "uses-optimized-images": { metric: "LCP", culprit: "image-delivery", - culpritLabel: "Image optimization", + culpritLabel: "Images heavier than they need to be", remediation: "available", - guidance: "Compress oversized source assets and use an efficient image format for the required quality.", + guidance: "Compress the originals, and save them in a format that holds the quality you need at a smaller size.", }, "modern-image-formats": { metric: "LCP", culprit: "image-delivery", - culpritLabel: "Image format", + culpritLabel: "Images in an older format", remediation: "available", - guidance: "Convert suitable assets to WebP and keep source dimensions close to their rendered size.", + guidance: "Save the images that suit it in a newer format (WebP), and keep the original close to the size it is shown at.", }, "image-delivery-insight": { metric: "LCP", culprit: "image-delivery", - culpritLabel: "Image delivery", + culpritLabel: "How images are sent", remediation: "available", - guidance: "Resize and compress the flagged assets, then use the most efficient format supported by the site.", + guidance: "Resize and compress the images named here, then save them in the smallest format the site supports.", }, "render-blocking-resources": { metric: "LCP", culprit: "render-blocking", culpritLabel: "Render-blocking resources", remediation: "blocked", - guidance: "Inline critical styles where appropriate and defer or split noncritical stylesheets that you control.", + guidance: "Put the styles the first screenful needs directly in the page, and delay or split the rest of your own.", }, "render-blocking-insight": { metric: "LCP", culprit: "render-blocking", culpritLabel: "Render-blocking resources", remediation: "blocked", - guidance: "Inline critical styles where appropriate and defer or split noncritical stylesheets that you control.", + guidance: "Put the styles the first screenful needs directly in the page, and delay or split the rest of your own.", }, "unminified-javascript": { metric: "LCP", culprit: "custom-javascript", - culpritLabel: "Unminified custom JavaScript", + culpritLabel: "Your own code shipped unshrunk", remediation: "partial", - guidance: "Minify custom JavaScript before publishing it and remove development-only code from production bundles.", + guidance: "Shrink your own code before publishing it, and keep the parts you only use while building out of the live site.", }, "legacy-javascript": { metric: "LCP", culprit: "global-javascript", - culpritLabel: "Legacy JavaScript", + culpritLabel: "Code written for browsers nobody uses", remediation: "blocked", - guidance: "Serve modern JavaScript to current browsers and remove unnecessary legacy transforms or polyfills from code you control.", + guidance: "Send current browsers the modern version of your code, and drop the extra code added for browsers nobody uses.", }, "unused-javascript": { metric: "LCP", culprit: "global-javascript", - culpritLabel: "Unused global JavaScript", + culpritLabel: "Code the site never runs", remediation: "blocked", - guidance: "Remove optional scripts, split bundles by page, or conditionally load code only where it is needed.", + guidance: "Drop the optional scripts, split the code up by page, or load each part only where it is used.", }, "unsized-images": { metric: "CLS", culprit: "layout-stability", - culpritLabel: "Missing image dimensions", + culpritLabel: "Images with no space reserved", remediation: "available", - guidance: "Set explicit image dimensions or use a consistent image reset so space is reserved before assets load.", + guidance: "Give every image a width and height so the space it needs is held open before it arrives.", }, "webflow-background-video": { metric: "LCP", culprit: "background-video", - culpritLabel: "Background Video", + culpritLabel: "Background video", remediation: "partial", - guidance: "Use a poster or static hero where possible, or load background video only when it approaches the viewport.", + guidance: "Use a still image instead where you can, or load the background video only as it comes into view.", }, "webflow-video-embed-eager": { metric: "TBT", culprit: "video-embeds", - culpritLabel: "Eager video embeds", + culpritLabel: "Video players loading too early", remediation: "partial", - guidance: "Replace eager YouTube or Vimeo players with poster-image facades that load the player on click or near the viewport.", + guidance: "Show a still image in place of the YouTube or Vimeo player, and load the player when somebody clicks or as it comes into view.", }, "webflow-video-embed-duplicate": { metric: "TBT", culprit: "video-embeds", - culpritLabel: "Repeated video player runtime", + culpritLabel: "The same video player loaded twice", remediation: "partial", - guidance: "Use one shared player bootstrap and replace repeated eager embeds with poster-image facades that load on interaction.", + guidance: "Load one player for the whole page, and show a still image for the rest until somebody clicks.", }, "webflow-lottie-eager": { metric: "TBT", culprit: "interactive-media", - culpritLabel: "Eager Lottie", + culpritLabel: "Animations loading too early", remediation: "partial", - guidance: "Reduce animation payloads and use custom viewport-based loading when the Lottie element is below the fold.", + guidance: "Make the animation files smaller, and load them only as they come into view when they start below the first screenful.", }, "webflow-spline-eager": { metric: "TBT", culprit: "interactive-media", - culpritLabel: "Eager Spline", + culpritLabel: "3D scenes loading too early", remediation: "partial", - guidance: "Use a static fallback where possible or load the Spline scene only when it approaches the viewport.", + guidance: "Show a still image instead where you can, or load the 3D scene only as it comes into view.", }, "webflow-image-unresponsive": { metric: "LCP", culprit: "image-delivery", - culpritLabel: "Unresponsive raster image", + culpritLabel: "One image size sent to every screen", remediation: "available", - guidance: "Resize the source asset and provide responsive image candidates so browsers do not fetch the full original unnecessarily.", + guidance: "Resize the original, and offer several sizes so a browser does not download the full one for no reason.", }, }; @@ -255,9 +296,9 @@ const TITLE_ALIASES: Record = { const UNKNOWN: CatalogEntry = { metric: "other", culprit: "other", - culpritLabel: "Other Lighthouse finding", + culpritLabel: "Something else the nightly test found", remediation: "unknown", - guidance: "Review the Lighthouse evidence and identify a concrete change before assigning this finding as a task.", + guidance: "Read the evidence and settle on one concrete change before anybody takes this on.", }; function normalizedTitle(value: string): string { From 30385b6230b851dfc4db8eed7f03ef81c10e19f6 Mon Sep 17 00:00:00 2001 From: Matthew P Munger Date: Wed, 26 Aug 2026 16:02:00 -0500 Subject: [PATCH 3/3] feat(s9): rewrite the agent, check and demo copy, and assert the ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of the sweep, plus the test the pattern needed. `plain-language.test.ts` asserts the ORDERING rather than the punctuation: no reader-facing string may open with an industry term, no internal shorthand or rule id may appear in one at all. Checking for a parenthesis would have passed "CLS (cumulative layout shift) 0.24", which is the same defect wearing the pattern's clothes. It was proved by injection — three deliberate violations shaped like the brief's own examples, each caught by a different assertion. It does NOT check "one appositive per term per screen". A screen is not something a test can see, and the brief says that half is a review call. Copy: - The 20 agent-readiness checks now have display labels separate from their names. A separate map rather than a rename, because those names are KEYS: an exclusion is stored as `group + name`, the scanner's results are keyed by check name, and the issue families refer to checks by name. Renaming them would have stranded every exclusion a site has recorded — a migration, not a copy change. "DNS for AI Discovery (DNS-AID)" was already written this way and is the shape the other nineteen now follow. - The agent-access cause lines said "origin" and "parse" — our word for a site, and what a program does to a document. S4 locked them; S9 re-decides them, and the test's lock moved with the decision. - Six issue titles opened with an acronym ("MCP resources are missing…", "API errors are not machine-readable"). Each now leads with what the reader loses and names the standard after it. - The demo data's case titles were Lighthouse audit titles carried through verbatim — "Reduce unused JavaScript", "Properly size images" — which the `IssueCase` contract explicitly forbids: "one plain sentence… Never an audit title." The mock provider in `psi.ts` carried four more, and reaches demo mode as real copy. - "PSI" is gone from the last of the copy, including the collection-status lines, the chart's excluded-reading label, and the demo digest note. - `robots.txt` and `sitemap.xml` are deliberately NOT treated as terms needing an appositive. The decided copy is the argument: "Your robots.txt file tells ChatGPT's crawler not to read this site" opens with the filename and needs no gloss, because a file on the reader's own site is something they can go and look at. Verified: lint, typecheck, 1333 tests and build all pass. The new Settings block was rendered against the compiled token layer at 1280px — two columns, no name wrapping, no clipping, no overflow — and its media query collapses to one column below 620px. Co-Authored-By: Claude Opus 5 --- src/app/(app)/pages/[id]/page.tsx | 8 +- src/app/(app)/pages/pages-content.tsx | 14 +- src/app/(app)/settings/page.tsx | 6 +- src/components/agent-access.tsx | 4 +- src/components/bits.tsx | 18 +- src/components/charts.tsx | 4 +- src/components/overlays.tsx | 2 +- src/components/visitor-experience.tsx | 11 +- src/lib/__tests__/agent-access.test.ts | 14 +- src/lib/__tests__/plain-language.test.ts | 285 +++++++++++++++++++++++ src/lib/__tests__/seed.test.ts | 8 +- src/lib/agent-copy.ts | 13 +- src/lib/agentChecks.ts | 54 +++++ src/lib/agentIssueCases.ts | 18 +- src/lib/collectionStatus.ts | 2 +- src/lib/fieldPrioritization.ts | 4 +- src/lib/lighthouseEvidence.ts | 2 +- src/lib/plain-language.ts | 8 +- src/lib/psi.ts | 8 +- src/lib/seed.ts | 44 ++-- src/lib/settings-exclusions.ts | 9 +- src/lib/webflowPerformance.ts | 4 +- 22 files changed, 453 insertions(+), 87 deletions(-) create mode 100644 src/lib/__tests__/plain-language.test.ts diff --git a/src/app/(app)/pages/[id]/page.tsx b/src/app/(app)/pages/[id]/page.tsx index f3c37f2..04d35c5 100644 --- a/src/app/(app)/pages/[id]/page.tsx +++ b/src/app/(app)/pages/[id]/page.tsx @@ -976,10 +976,10 @@ function ReadingsSection({ }} >
- Desktop and Mobile are stacked for comparison. Each median line includes its run-to-run range; reference - lines show that device's original benchmark and, when enough scans exist, the previous {rangeDays}-day - period median. - {excludedHistory.length > 0 && " Shaded bands mark measurements retained for diagnosis but excluded from scores, trends, and recommendations."} + Desktop and Mobile are stacked for comparison. Each line is the middle score of a night's runs, with the + spread between those runs around it; the reference lines show that device's starting point and, once + there are enough nights, the middle score of the previous {rangeDays} days. + {excludedHistory.length > 0 && " Shaded bands are readings kept for diagnosis but left out of scores, trends and recommendations."}
{historyForStrategy(rangeHistory, "desktop").length < 2 && historyForStrategy(rangeHistory, "mobile").length < 2 ? (
diff --git a/src/app/(app)/pages/pages-content.tsx b/src/app/(app)/pages/pages-content.tsx index 1bdc467..587c05a 100644 --- a/src/app/(app)/pages/pages-content.tsx +++ b/src/app/(app)/pages/pages-content.tsx @@ -550,7 +550,7 @@ function DashboardContent({ ) : topRibbonRec && topRibbonClassification ? ( topRibbonRec.source === "crux-field-only" ? ( <> - Investigate {topRibbonRec.pageTitle} — {topRibbonRec.title} is the clearest next step because exact-URL visitor evidence is outside the good range while Lighthouse did not reproduce or explain it. + Investigate {topRibbonRec.pageTitle} — {topRibbonRec.title} is the clearest next step, because real visitors to this exact page are outside the good range and the nightly test did not reproduce or explain it. ) : customerActionabilityFor(topRibbonRec) === "workaround" ? ( <> @@ -603,8 +603,8 @@ function DashboardContent({ {measurementIncident.status === "suspected" ? measurementIncident.confirmationAttempts - ? "PSI measurement anomaly persists" - : "Possible PSI measurement anomaly" + ? "The test environment is still behaving oddly" + : "The test environment may have behaved oddly" : measurementIncident.status === "confirming" ? "Collecting independent confirmation" : measurementIncident.status === "recovered" @@ -621,13 +621,13 @@ function DashboardContent({
{measurementIncident.status === "suspected" ? measurementIncident.confirmationAttempts - ? `Independent confirmation showed the same PSI environment pattern across ${measurementIncident.affectedPages} pages. The suspect measurements remain excluded, no action is recommended, and the next scheduled cohort will continue monitoring.` - : `${measurementIncident.affectedPages} of ${measurementIncident.eligiblePages} pages moved together while the PSI test environment also changed. No action is recommended until the automatic confirmation finishes.` + ? `A second check found the same pattern across ${measurementIncident.affectedPages} pages. The suspect readings stay out of the scoring, there is nothing to do, and the next run keeps watching.` + : `${measurementIncident.affectedPages} of ${measurementIncident.eligiblePages} pages moved together at the moment the test environment changed too. Nothing to do until the automatic second check finishes.` : measurementIncident.status === "confirming" ? "Page Watch is re-testing the affected cohort with independent, staggered samples. Earlier measurements are excluded from regression statuses while confirmation is running." : measurementIncident.status === "recovered" - ? "Follow-up measurements returned to the expected range. The earlier movement was treated as temporary PSI test-environment variability; no action is needed." - : "Independent follow-up measurements confirmed the synchronized slowdown without the earlier PSI environment anomaly. Review the affected pages and shared site dependencies."} + ? "Later measurements came back to the expected range. The earlier movement was treated as the test environment being briefly unsteady, so there is nothing to do." + : "A separate follow-up confirmed the slowdown, and this time the test environment was steady. Look at the affected pages and anything they share."}
)} diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 6a8f696..56b0c49 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -10,7 +10,7 @@ import { ProjectMembers } from "@/components/ProjectMembers"; import { SegmentedControl } from "@/components/segmented-control"; import { useStore } from "@/components/store"; import { WebflowConnection } from "@/components/webflow-connection"; -import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS } from "@/lib/agentChecks"; +import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS, agentCheckLabel, agentGroupLabel } from "@/lib/agentChecks"; import { agentCheckKey, normalizeAgentIgnoreSettings } from "@/lib/agentScoring"; import { digestLimit } from "@/lib/digest-copy"; import { DIGEST_CADENCES, DIGEST_CADENCE_LABEL, normalizeDigestCadence } from "@/lib/digestCadence"; @@ -303,12 +303,12 @@ function ExcludedGroup({ disabled }: { disabled: boolean }) { const excludable = [ ...AGENT_CHECK_GROUPS .filter((group) => !defaults.groups.includes(group.name)) - .map((group) => ({ key: `group:${group.name}`, label: group.name, scope: "group" as const, value: group.name })), + .map((group) => ({ key: `group:${group.name}`, label: agentGroupLabel(group.name), scope: "group" as const, value: group.name })), ...ALL_AGENT_CHECKS .filter((check) => !defaults.groups.includes(check.group) && !defaults.checks.includes(agentCheckKey(check))) .map((check) => ({ key: `check:${agentCheckKey(check)}`, - label: `${check.group} · ${check.name}`, + label: `${agentGroupLabel(check.group)} · ${agentCheckLabel(check.name)}`, scope: "check" as const, value: agentCheckKey(check), })), diff --git a/src/components/agent-access.tsx b/src/components/agent-access.tsx index 0e2b322..cf2b0de 100644 --- a/src/components/agent-access.tsx +++ b/src/components/agent-access.tsx @@ -365,7 +365,7 @@ function SourceRow({ source }: { source: AgentIssueSource }) { {systemLabel(source.system)} {source.label} · {agentStatusLabel(source.result)} - · {source.scope === "origin" ? "origin-wide" : "this page"} + · {source.scope === "origin" ? "the whole site" : "this page"} ); @@ -400,7 +400,7 @@ function IssueRow({
- {issue.scope === "origin" ? "Origin-wide" : "This page"} + {issue.scope === "origin" ? "The whole site" : "This page"} {confidence.label} {/* A tier is a classification of the check, not a verdict on the page: diff --git a/src/components/bits.tsx b/src/components/bits.tsx index 673efc7..24e677c 100644 --- a/src/components/bits.tsx +++ b/src/components/bits.tsx @@ -59,21 +59,21 @@ export function FieldRecommendationStatusBadge({ rec }: { rec: Pick - Now reproduced in lab + Now reproduced by the nightly test ); } const meta = status === "regressed" - ? { state: "reopened" as const, title: "Field issue returned: the visitor-only issue came back after it had cleared or become corroborated." } + ? { state: "reopened" as const, title: "The problem real visitors were meeting came back after it had cleared or been confirmed." } : status === "active" - ? { state: "new" as const, title: "Active field issue: exact-URL visitor evidence is outside the good range and Lighthouse does not reproduce it." } + ? { state: "new" as const, title: "Real visitors to this exact page are outside the good range, and the nightly test cannot reproduce it." } : status === "verifying" - ? { state: "fixed" as const, title: "Verifying recovery: one distinct CrUX window is good; a second is required to confirm resolution." } - : { state: "resolved" as const, title: "Field issue resolved: two distinct CrUX windows are within the good range." }; + ? { state: "fixed" as const, title: "Verifying recovery: one 28-day window of visitor figures is good; a second is needed to confirm it." } + : { state: "resolved" as const, title: "Settled: two separate 28-day windows of visitor figures are inside the good range." }; return ( @@ -111,9 +111,9 @@ export function FieldEvidenceChip({ signal }: { signal: RecommendationEvidenceSi */ const PERFORMANCE_ISSUE_CHIP: Record = { regressed: { state: "reopened", title: "Returned after a confirmed resolution" }, - resolved: { state: "resolved", title: "Absent from two consecutive diagnostic captures" }, - verifying: { state: "fixed", title: "Absent once; one more clean capture is required" }, - active: { state: "new", title: "Present in the latest diagnostic capture" }, + resolved: { state: "resolved", title: "Gone from the last two nightly tests" }, + verifying: { state: "fixed", title: "Gone once; one more clean night confirms it" }, + active: { state: "new", title: "Found in the latest nightly test" }, }; export function PerformanceIssueStatusBadge({ status }: { status: PerformanceIssueStatus }) { diff --git a/src/components/charts.tsx b/src/components/charts.tsx index 3e53e75..9935f33 100644 --- a/src/components/charts.tsx +++ b/src/components/charts.tsx @@ -273,7 +273,7 @@ export function HistoryChart({ // 1.8:1 as 12px type on the band it sits on. style={{ fill: "var(--text-muted)" }} > - PSI anomaly · excluded + Odd reading · left out ); @@ -379,7 +379,7 @@ export function HistoryChart({ }} >
- {hoveredAnomaly ? "PSI anomaly · excluded" : HISTORY_CATEGORY_LABELS[catKey]} + {hoveredAnomaly ? "Odd reading · left out" : HISTORY_CATEGORY_LABELS[catKey]}
{formatHistoryTooltipDate(hoveredNight.date, hoveredNight.iso)}
diff --git a/src/components/overlays.tsx b/src/components/overlays.tsx index 05dd411..3e74628 100644 --- a/src/components/overlays.tsx +++ b/src/components/overlays.tsx @@ -303,7 +303,7 @@ function ReportModal() {
))}
-
Raw PSI payload (object storage)
+
The full reply from the nightly test
diff --git a/src/components/visitor-experience.tsx b/src/components/visitor-experience.tsx
index ae46cc5..7f8b871 100644
--- a/src/components/visitor-experience.tsx
+++ b/src/components/visitor-experience.tsx
@@ -130,7 +130,7 @@ export function VisitorExperiencePanel({
             Visitor experience
           
- Chrome UX Report · previous 28 days · updated weekly + What real visitors met · previous 28 days · updated weekly (the Chrome UX Report)
@@ -165,7 +165,7 @@ export function VisitorExperiencePanel({
{metric.label}
-
{metric.relationship === "direct" ? "Direct metric comparison" : "Diagnostic proxy · TBT and INP differ"}
+
{metric.relationship === "direct" ? "The same measurement on both sides" : "Two different measurements, compared as the nearest match"}
{metric.verdictLabel}
@@ -191,8 +191,11 @@ export function VisitorExperiencePanel({ )}
- Lighthouse values are controlled lab medians from the latest retained run in this range. Visitor values are CrUX p75 measurements over a rolling 28-day window; they are not Lighthouse scores. - {latest?.scope === "origin" ? " Exact URL data was unavailable, so these measurements cover the entire origin." : ""} + The nightly figures are the middle result of a test run on a deliberately slow connection, from the latest + kept run in this range (Lighthouse). The visitor figures are the level three quarters of real visitors did + better than, over a rolling 28 days (the Chrome UX Report). They are not scores, and they are not comparable + to one. + {latest?.scope === "origin" ? " Too few people visited this exact page for it to be reported on its own, so these figures cover the whole site." : ""} {trendState ? " The trend arrow compares the latest weekly snapshot with the one before it and describes the visitor experience itself, so an up arrow means visitors are better off." : ""}
diff --git a/src/lib/__tests__/agent-access.test.ts b/src/lib/__tests__/agent-access.test.ts index af44892..cda9264 100644 --- a/src/lib/__tests__/agent-access.test.ts +++ b/src/lib/__tests__/agent-access.test.ts @@ -524,14 +524,20 @@ describe("the words", () => { .toBe(`${AGENT_ACCESS_SOURCES.length} sources · never averaged`); }); - it("says the S4 brief's locked strings, verbatim", () => { + it("says the locked strings, verbatim", () => { // These are not registry words, so asserting them here is asserting the // decision rather than one copy against another. + // + // Three of them were re-decided in S9 and the lock moved with the decision. + // They said "origin" and "parse" — our word for a site, and what a program + // does to a document — so the sentences named the fault in vocabulary that + // concealed it. What they distinguish is untouched: got in, versus got in + // and could not read. expect(AGENT_TITLE).toBe("Agent access"); - expect(AGENT_CAUSE.reach).toBe("Agents cannot reach the origin."); - expect(AGENT_CAUSE.comprehension).toBe("Agents reach it but cannot parse the content."); + expect(AGENT_CAUSE.reach).toBe("Agents cannot reach this site at all."); + expect(AGENT_CAUSE.comprehension).toBe("Agents reach the site but cannot read what is on it."); expect(AGENT_UNKNOWN.disagree) - .toBe("Two systems disagree about whether agents can reach this origin."); + .toBe("Two systems disagree about whether agents can reach this site."); expect(AGENT_UNKNOWN.no_reading) .toBe("No reading could be taken, so there is nothing to conclude yet."); expect(AGENT_LAST_CHECKED).toBe("Last checked"); diff --git a/src/lib/__tests__/plain-language.test.ts b/src/lib/__tests__/plain-language.test.ts new file mode 100644 index 0000000..1d8c64a --- /dev/null +++ b/src/lib/__tests__/plain-language.test.ts @@ -0,0 +1,285 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { INDUSTRY_TERMS, INTERNAL_TERMS, RULE_ID_SHAPE, appositive } from "../plain-language"; + +import * as agentChecks from "../agentChecks"; +import * as agentCopy from "../agent-copy"; +import * as agentIssueCases from "../agentIssueCases"; +import * as caseCopy from "../case-copy"; +import * as digestCopy from "../digest-copy"; +import * as impactFormat from "../impact-format"; +import * as labFieldComparison from "../labFieldComparison"; +import * as labMetrics from "../labMetrics"; +import * as pagesCopy from "../pages-copy"; +import * as scoreCardTooltip from "../scoreCardTooltip"; +import * as settingsCopy from "../settings-copy"; +import * as visitorExperience from "../visitorExperience"; +import * as vocabulary from "../vocabulary"; +import * as webflowPerformance from "../webflowPerformance"; + +/** + * The one pattern, enforced where it can be: plain meaning first, term second. + * + * This asserts the ORDERING rather than the punctuation. A string may carry + * "(cumulative layout shift 0.24)" or not carry it at all; what it may not do is + * open with the term, because the reader who does not know it then has nothing + * to read before the thing they cannot read. Checking for a parenthesis would + * pass "CLS (cumulative layout shift) 0.24", which is the same defect wearing + * the pattern's clothes. + * + * What it does NOT check is "one appositive per term per screen". A screen is + * not something a test can see — the same string appears on three of them — and + * the brief is explicit that the second-mention rule is a review call. + * + * The corpus is the modules that own copy. That is deliberate rather than + * convenient: the codebase's rule is that a rendered string belongs to a copy + * module or a display-label map (rule 20), so anything this test cannot see is + * a string that should not have been where it is. + */ + +const MODULES: Record = { + agentChecks, + agentCopy, + agentIssueCases, + caseCopy, + digestCopy, + impactFormat, + labFieldComparison, + labMetrics, + pagesCopy, + scoreCardTooltip, + settingsCopy, + visitorExperience, + vocabulary, + webflowPerformance, +}; + +/** Every string reachable from a module's non-function exports, with its path. */ +function stringsIn(value: unknown, trail: string, out: { where: string; text: string }[] = []) { + if (typeof value === "string") { + if (/[A-Za-z]/.test(value)) out.push({ where: trail, text: value }); + return out; + } + if (Array.isArray(value)) { + value.forEach((item, index) => stringsIn(item, `${trail}[${index}]`, out)); + return out; + } + if (value && typeof value === "object") { + for (const [key, item] of Object.entries(value)) stringsIn(item, `${trail}.${key}`, out); + } + return out; +} + +/** + * Keys whose values are identifiers rather than copy: enum members, registry + * keys, route paths, evidence-source keys. They are what the copy is keyed BY. + */ +const IDENTIFIER_KEYS = [ + ".key", ".id", ".psi", ".short", ".value", ".token", ".items[", ".name", + ".oraChecks", ".localChecks", ".labKey", ".fieldKey", ".relationship", + ".verdict", ".half", ".scope", ".remediation", ".metric", ".culprit", + ".actionability", ".source", ".unit", ".tier", ".category", + "vocabulary.WORK_STATES", "vocabulary.QUEUES", "vocabulary.TONES", + "vocabulary.ISSUE_ACTIONS", "vocabulary.CHECKPOINT_RESULTS", + "vocabulary.EVIDENCE_SOURCES", "vocabulary.ACTIONABILITIES", + "vocabulary.TRENDS", "vocabulary.HEALTHS", "vocabulary.CONFIDENCES", + "vocabulary.AGENT_RESULTS", "vocabulary.AGENT_VERDICTS", + "vocabulary.DESTINATIONS", "vocabulary.APPLICABILITIES", + "vocabulary.APPLICABILITY_ACTIONS", "vocabulary.DESTINATION_PATH", + "vocabulary.WORK_STATE_QUEUE", "vocabulary.WORK_STATE_TONE", + "vocabulary.COUNTED_QUEUES", "vocabulary.QUEUE_HOLDS", + "vocabulary.ISSUE_TRANSITIONS", "vocabulary.APPLICABILITY_TRANSITIONS", + "vocabulary.ACTIONABILITY_REQUIRES_REASON", + "agentChecks.AGENT_CHECK_GROUPS", "agentChecks.ALL_AGENT_CHECKS", + "agentIssueCases.AGENT_ISSUE_FAMILIES.", // family KEYS are ids; titles are read below + "labFieldComparison.COMPARABLE_METRICS", + "webflowPerformance.METRIC_PLAIN", + // The catalogue of upstream audit ids, and the two catalogues kept in step + // with it. They ARE identifiers; that is the whole point of the lists. + "webflowPerformance.DOCUMENTED_WEBFLOW_AUDIT_IDS", + "culpritEvidence.CULPRIT_EVIDENCE", + "plainLanguage.", +]; + +/** + * The term half of a pair whose other half is the plain meaning. + * + * `VISITOR_METRICS` carries `label` and `technicalName` precisely so a caller + * can render the meaning and then the term, and `EVIDENCE_SOURCE_LABEL` names + * systems rather than measurements — "Lighthouse" is what that tool is called, + * and no plainer word for it exists. Both are checked by their own tests below + * rather than by the leading-term rule, which would ask a parenthetical to + * introduce itself. + */ +const TERM_HALVES = [ + "visitorExperience.VISITOR_METRICS", // .technicalName; .label is the plain half + "vocabulary.EVIDENCE_SOURCE_LABEL", + "labMetrics.LAB_METRICS", // .short is the acronym; .label is the plain half +]; + +/** + * Prose the registry writes for ITSELF — the evaluation rules and concept + * notes, which describe the vocabulary to an implementer rather than to a + * reader. `CHECKPOINT_EVALUATION` is the registry's own text, verbatim. + */ +const REGISTRY_PROSE = [ + "vocabulary.CHECKPOINT_EVALUATION", + "vocabulary.CHECKPOINT_RESULT_MEANS", +]; + +const isIdentifier = (where: string) => IDENTIFIER_KEYS.some((key) => where.includes(key)); +const isRegistryProse = (where: string) => REGISTRY_PROSE.some((key) => where.startsWith(key)); + +/** The copy corpus: every reader-facing string these modules state. */ +const COPY = Object.entries(MODULES) + .flatMap(([name, module]) => stringsIn(module, name)) + .filter(({ where }) => !isIdentifier(where) && !isRegistryProse(where)) + // Deduplicate: several maps legitimately share a word. + .filter((row, index, all) => all.findIndex((other) => other.text === row.text && other.where === row.where) === index); + +const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** + * Does this string OPEN with the term? An article or a possessive in front is + * still leading with it — "The LCP is 4.1s" tells a reader no more than + * "LCP 4.1s" does. + */ +function leadsWith(text: string, term: string): boolean { + return new RegExp(`^(?:the|a|an|your|its|this|these|no|non-)?\\s*${escape(term)}\\b`, "i").test(text.trim()); +} + +function contains(text: string, term: string): boolean { + return new RegExp(`(^|[^A-Za-z0-9_-])${escape(term)}([^A-Za-z0-9_-]|$)`, "i").test(text); +} + +describe("the copy corpus", () => { + it("is large enough that a passing run means something", () => { + // A guard on the guard. If a refactor moves copy out of these modules, this + // suite would go quietly green over an empty corpus. + expect(COPY.length).toBeGreaterThan(150); + }); +}); + +describe("plain meaning first, term second", () => { + it("never opens a reader-facing string with an industry term", () => { + const offenders = COPY + .filter(({ where }) => !TERM_HALVES.some((prefix) => where.startsWith(prefix))) + .flatMap(({ where, text }) => + INDUSTRY_TERMS.filter((term) => leadsWith(text, term)).map((term) => `${where}: "${text}" leads with "${term}"`)); + expect(offenders).toEqual([]); + }); + + it("puts the plain half of every measurement pair before the term half", () => { + // These two maps exist so a caller renders meaning-then-term. This asserts + // the pair is actually in that order — a `technicalName` with no `label` + // beside it, or a `label` that is itself the acronym, is the defect. + for (const metric of visitorExperience.VISITOR_METRICS) { + expect(metric.label, `${metric.key} has no plain half`).toBeTruthy(); + expect(metric.label).not.toBe(metric.technicalName); + expect(INDUSTRY_TERMS.some((term) => leadsWith(metric.label, term)), `${metric.key}'s label is a term`).toBe(false); + } + for (const metric of labMetrics.LAB_METRICS) { + expect(metric.label, `${metric.key} has no plain half`).toBeTruthy(); + expect(metric.label).not.toBe(metric.short); + expect(INDUSTRY_TERMS.some((term) => leadsWith(metric.label, term)), `${metric.key}'s label is a term`).toBe(false); + } + }); + + it("explains every system it names, where the reader can reach it", () => { + // `EVIDENCE_SOURCE_LABEL` is exempt from the leading-term rule because a + // system's name is the plain thing to call it. The price of that exemption + // is this: each one has a line in Settings saying what it contributes, so + // the name is never the only thing a reader is given. A source added to the + // ledger fails here rather than appearing unexplained. + for (const source of vocabulary.EVIDENCE_SOURCES) { + const line = settingsCopy.SETTINGS_SYSTEM_CONTRIBUTES[source]; + expect(line, `${source} is named but never explained`).toBeTruthy(); + expect(line.length).toBeGreaterThan(40); + } + }); + + it("builds an introduction with the meaning ahead of the term", () => { + // The helper is the mechanism, so its own ordering is asserted directly. + expect(appositive("Content jumps around as this page loads", "cumulative layout shift 0.24")) + .toBe("Content jumps around as this page loads (cumulative layout shift 0.24)"); + const built = appositive("Responsiveness", "TBT"); + expect(built.indexOf("Responsiveness")).toBeLessThan(built.indexOf("TBT")); + }); +}); + +describe("internal names never reach a reader", () => { + it("keeps our own shorthand out of reader-facing copy", () => { + const offenders = COPY.flatMap(({ where, text }) => + INTERNAL_TERMS.filter((term) => contains(text, term)).map((term) => `${where}: "${text}" contains "${term}"`)); + expect(offenders).toEqual([]); + }); + + it("keeps rule ids out of reader-facing copy", () => { + // A Lighthouse audit id is a key, not a name for a problem. A reader who + // searches for "uses-optimized-images" reaches Google's documentation + // rather than their own page. + const offenders = COPY.flatMap(({ where, text }) => + text.split(/[\s,.;:()"']+/) + .filter((word) => RULE_ID_SHAPE.test(word)) + .map((word) => `${where}: "${text}" contains rule id "${word}"`)); + expect(offenders).toEqual([]); + }); +}); + +/* ── The glossary is gone, not moved ────────────────────────────────────── */ + +const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +function sourceFiles(dir: string, found: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) sourceFiles(full, found); + else if (/\.tsx?$/.test(entry)) found.push(full); + } + return found; +} + +const ALL_SOURCE = sourceFiles(SRC).map((file) => ({ + file: path.relative(SRC, file), + text: readFileSync(file, "utf8"), +})); + +describe("the glossary retired with its definitions", () => { + it("has no guide module left to import", () => { + const importers = ALL_SOURCE.filter(({ text }) => /from\s+["'][^"']*\/guide["']|from\s+["']@\/lib\/guide["']/.test(text)); + expect(importers.map(({ file }) => file)).toEqual([]); + }); + + it("did not relocate the retired terms' definitions", () => { + // These four are gone from the product. Their glossary entries were the last + // place they were defined, and a definition of a word nobody uses is worse + // than no definition: it teaches a reader vocabulary the app will never say + // back to them. So they went with the file rather than to a new home. + const retired = ["Verifying", "Acknowledged", "Suppressed", "Action Center"]; + const definitions = ALL_SOURCE.flatMap(({ file, text }) => { + // A definition, not a mention: the retired word followed by prose saying + // what it means. `bits.tsx` still RENDERS "Verifying recovery", which is + // F2's allowlist entry to clear, and is not a definition of the word. + const stripped = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + return retired + .filter((term) => new RegExp(`(shortDefinition|appMeaning|term)\\s*:\\s*"[^"]*${term}`).test(stripped)) + .map((term) => `${file} defines "${term}"`); + }); + expect(definitions).toEqual([]); + }); + + it("sends /guide to the issues list, like every other retired route", () => { + const route = ALL_SOURCE.find(({ file }) => file.endsWith(path.join("guide", "page.tsx"))); + expect(route, "the /guide route must still exist, or old links 404").toBeDefined(); + // Comments stripped: the route explains at length why it no longer aims at + // a Settings anchor, and a check that tripped over its own justification + // would only teach the next editor to delete the paragraph. + const code = route!.text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + // Asserts the destination it resolves, not a literal path (rule 21). + expect(code).toContain("DESTINATION_PATH.issues"); + expect(code).not.toContain("#reference"); + }); +}); diff --git a/src/lib/__tests__/seed.test.ts b/src/lib/__tests__/seed.test.ts index ff6a352..b6408c2 100644 --- a/src/lib/__tests__/seed.test.ts +++ b/src/lib/__tests__/seed.test.ts @@ -60,7 +60,13 @@ describe("scenario-rich demo data", () => { expect(state.collectionSchedule).toMatchObject({ timeZone: "America/Chicago", overridden: true }); expect(state.followUps?.some(({ sent }) => sent)).toBe(true); expect(state.followUps?.some(({ retryAfterISO }) => !!retryAfterISO)).toBe(true); - expect(state.watcherNote?.text).toContain("provider incident"); + // The note must still account for the excluded nights — a demo that shows + // two missing readings and never says why teaches the reader to distrust + // the record. Asserted on the claim rather than on the old phrase + // "provider incident", which named the fault in our words rather than in + // the reader's (S9). + expect(state.watcherNote?.text).toMatch(/left out|excluded/); + expect(state.watcherNote?.text).toMatch(/testing service|provider/); }); it("keeps live mode empty and free of demo-only metadata", () => { diff --git a/src/lib/agent-copy.ts b/src/lib/agent-copy.ts index 9aebe1e..2ad1c5d 100644 --- a/src/lib/agent-copy.ts +++ b/src/lib/agent-copy.ts @@ -30,15 +30,22 @@ export const AGENT_TITLE = "Agent access"; /* ── The subline: which half is at fault ────────────────────────────────── */ +/** + * S9 rewrote both of these. "Origin" is our word for a site, and "parse" is + * what a program does rather than something a reader recognises as a failure; + * between them the two sentences named the fault in vocabulary that hid it. + * The distinction they carry — got in, versus got in and could not read — is + * unchanged, and it is the whole reason there is no second verdict concept. + */ export const AGENT_CAUSE: Record = { - reach: "Agents cannot reach the origin.", - comprehension: "Agents reach it but cannot parse the content.", + reach: "Agents cannot reach this site at all.", + comprehension: "Agents reach the site but cannot read what is on it.", }; /* ── Unknown, and which of its two causes ───────────────────────────────── */ export const AGENT_UNKNOWN: Record = { - disagree: "Two systems disagree about whether agents can reach this origin.", + disagree: "Two systems disagree about whether agents can reach this site.", no_reading: "No reading could be taken, so there is nothing to conclude yet.", }; diff --git a/src/lib/agentChecks.ts b/src/lib/agentChecks.ts index e3baeb8..e2fc08c 100644 --- a/src/lib/agentChecks.ts +++ b/src/lib/agentChecks.ts @@ -18,6 +18,60 @@ export const ALL_AGENT_CHECKS: Pick[] = AGENT_CHEC group.items.map((name) => ({ group: group.name, name })), ); +/** + * What each check is called on screen: plain meaning first, standard name after + * it in parentheses. + * + * A separate map rather than a rename, because the names above are KEYS. An + * exclusion is stored as `${group}${name}`, the scanner's result record is + * keyed by check name, and `AGENT_ISSUE_FAMILIES.localChecks` refers to checks + * by name. Renaming them would silently strand every exclusion a site has + * already recorded — a migration, not a copy change, and not one this chunk + * needs in order to fix the words. + * + * "DNS for AI Discovery (DNS-AID)" was already written this way, and it is the + * shape the rest now follow. Where a name is already the plain thing — + * `robots.txt` is a file the reader owns, a sitemap is a sitemap — it stands + * alone; an appositive on a word that needs none is noise. + * + * The commerce four are acronyms with nothing else to go on, and they are + * exactly the case for this pattern: "x402" tells a reader nothing, and "Pay + * per request (x402)" tells them whether they care. + */ +export const AGENT_CHECK_LABEL: Readonly> = { + "Link headers": "Related-page pointers (Link headers)", + "Markdown negotiation": "A plain-text version on request (Markdown negotiation)", + "Content Signals": "What may be reused, and how (Content Signals)", + "Web Bot Auth": "Agents that can prove who they are (Web Bot Auth)", + "API Catalog": "A directory of your interfaces (API Catalog)", + "OAuth discovery": "Where an agent signs in (OAuth discovery)", + "OAuth Protected Resource": "What signing in gives access to (OAuth Protected Resource)", + "Auth.md": "Sign-in instructions written for agents (Auth.md)", + "MCP Server Card": "A list of tools agents may use (MCP Server Card)", + "A2A Agent Card": "How another agent should talk to yours (A2A Agent Card)", + WebMCP: "Tools offered inside the page itself (WebMCP)", + x402: "Pay per request (x402)", + MPP: "Payments made by machines (MPP)", + UCP: "A shared way to describe products (UCP)", + ACP: "Checkout an agent can complete (ACP)", +}; + +/** The group headings, where the key was trade shorthand rather than a phrase. */ +export const AGENT_GROUP_LABEL: Readonly> = { + "Content Accessibility": "Readable content", + "API / Auth / MCP": "Interfaces, signing in, and agent tools", +}; + +/** The words for one check. Falls through to the key when it needs no help. */ +export function agentCheckLabel(name: string): string { + return AGENT_CHECK_LABEL[name] ?? name; +} + +/** The words for one group. Same fall-through. */ +export function agentGroupLabel(name: string): string { + return AGENT_GROUP_LABEL[name] ?? name; +} + export function isKnownAgentIgnoreTarget(scope: "check" | "group", value: string): boolean { return scope === "group" ? AGENT_CHECK_GROUPS.some((group) => group.name === value) diff --git a/src/lib/agentIssueCases.ts b/src/lib/agentIssueCases.ts index 8fb8e2b..729deb1 100644 --- a/src/lib/agentIssueCases.ts +++ b/src/lib/agentIssueCases.ts @@ -156,7 +156,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { "State an explicit Allow or Disallow for each; silence is read as ambiguity.", "Keep the policy consistent with any Content Signals or bot-management rules already in force.", ], - successCriteria: "robots.txt resolves and states an explicit policy for AI user agents.", + successCriteria: "Your robots.txt file loads and states, for each AI crawler, whether it may read the site.", localChecks: ["robots.txt", "AI bot rules", "Content Signals"], oraChecks: ["robots-ai-policy-quality", "robots-agent-user-policy"], }, @@ -187,7 +187,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { }, "agent-discoverability:link-headers": { half: "reach", - title: "Related resources are not discoverable from HTTP", + title: "Agents must load the whole page to find related documents (no Link headers)", consequence: "Link headers let an agent find alternates and related documents without parsing the page. Without them it has to render first.", scope: "origin", remediation: ["Emit RFC 8288 Link headers for alternates and related agent resources."], @@ -197,7 +197,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { }, "agent-discoverability:dns": { half: "reach", - title: "No DNS-level agent discovery record", + title: "Nothing in the domain records points agents anywhere (no DNS-AID record)", consequence: "DNS-AID lets an agent find the site's agent entry points before making a single HTTP request.", scope: "origin", remediation: ["Publish a DNS for AI Discovery record pointing at the agent resources you expose."], @@ -206,7 +206,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { }, "agent-content:markdown": { half: "comprehension", - title: "Agents cannot get a clean text version of the page", + title: "Agents cannot get a plain-text version of the page", consequence: "Agents parse Markdown far more reliably than rendered HTML. Without negotiation they burn context on markup, or misread the page.", scope: "origin", remediation: [ @@ -219,7 +219,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { }, "agent-content:no-js": { half: "comprehension", - title: "The page has no content without JavaScript", + title: "The page is empty until scripts run", consequence: "Most agents do not execute JavaScript. If the primary content only appears after hydration, they see an empty page.", scope: "origin", remediation: [ @@ -243,7 +243,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { }, "agent-api:openapi": { half: "comprehension", - title: "Agents cannot reliably discover machine-readable API documentation", + title: "Agents cannot find documentation for your interfaces (no API catalogue)", consequence: "Without a published contract an agent has to infer endpoints and parameters from prose, which it will get wrong.", scope: "origin", remediation: [ @@ -256,7 +256,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { }, "agent-api:errors": { half: "comprehension", - title: "API errors are not machine-readable", + title: "When a request fails, agents get a message they cannot read (unstructured API errors)", consequence: "An agent that cannot tell a rate limit from a validation failure retries the wrong thing, or gives up on a recoverable error.", scope: "origin", remediation: [ @@ -319,7 +319,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { }, "agent-mcp:discovery": { half: "reach", - title: "No MCP server is discoverable", + title: "Agents cannot find any tools this site offers them (no MCP server)", consequence: "MCP is how an agent takes action rather than only reading. Without a discoverable server the site is read-only to agents.", scope: "origin", remediation: [ @@ -332,7 +332,7 @@ export const AGENT_ISSUE_FAMILIES: Readonly> = { }, "agent-mcp:resources": { half: "comprehension", - title: "MCP resources are missing or poorly described", + title: "The tools the site offers agents are unnamed or unexplained (MCP resources)", consequence: "An agent chooses tools by their descriptions. Thin or missing resource metadata makes it pick the wrong one.", scope: "origin", remediation: [ diff --git a/src/lib/collectionStatus.ts b/src/lib/collectionStatus.ts index aa487c6..946ded4 100644 --- a/src/lib/collectionStatus.ts +++ b/src/lib/collectionStatus.ts @@ -36,7 +36,7 @@ export function latestSuccessfulRunAt(pages: WatchPage[]): string | null { } export function formatSuccessfulRunAt(iso: string | null): string { - if (!iso) return "No successful PSI run yet"; + if (!iso) return "No successful measurement yet"; return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", diff --git a/src/lib/fieldPrioritization.ts b/src/lib/fieldPrioritization.ts index 6f86246..e0c7bef 100644 --- a/src/lib/fieldPrioritization.ts +++ b/src/lib/fieldPrioritization.ts @@ -127,11 +127,11 @@ export function alertFieldContext(page: WatchPage, strategies: Strategy[], evide const scopeNote = scopes.has("origin") ? " Origin-wide CrUX is contextual, not page-level proof." : ""; if (corroborated.length) return { signature: `corroborated:${corroborated.sort().join("|")}:${[...scopes].sort().join(",")}`, - text: `CrUX corroborates ${corroborated.join(", ")}.${scopeNote}`, + text: `Real visitors confirm ${corroborated.join(", ")}.${scopeNote}`, }; if (fieldOnly.length) return { signature: `field-only:${fieldOnly.sort().join("|")}:${[...scopes].sort().join(",")}`, - text: `CrUX also shows field-only risk for ${fieldOnly.join(", ")}.${scopeNote}`, + text: `Real visitors are also seeing a problem on ${fieldOnly.join(", ")}.${scopeNote}`, }; if (labOnly.length) return { signature: `lab-only:${labOnly.sort().join("|")}:${[...scopes].sort().join(",")}`, diff --git a/src/lib/lighthouseEvidence.ts b/src/lib/lighthouseEvidence.ts index 1b7771b..5239d2f 100644 --- a/src/lib/lighthouseEvidence.ts +++ b/src/lib/lighthouseEvidence.ts @@ -93,7 +93,7 @@ export function lighthouseRuntimeError(value: unknown): string | null { const runtimeError = response?.lighthouseResult?.runtimeError; if (!runtimeError) return null; const detail = [runtimeError.code, runtimeError.message].filter(Boolean).join(": "); - return detail || "Lighthouse runtime error"; + return detail || "The nightly test could not finish (Lighthouse runtime error)"; } /** Validate all four requested Lighthouse category scores. */ diff --git a/src/lib/plain-language.ts b/src/lib/plain-language.ts index 1a6b3ad..4efd839 100644 --- a/src/lib/plain-language.ts +++ b/src/lib/plain-language.ts @@ -46,6 +46,12 @@ export function appositive(plain: string, term: string): string { * * Written lower-case; the checks are case-insensitive. Longest first, so * "cumulative layout shift" is recognised before a substring of it could be. + * + * `robots.txt` and `sitemap.xml` are deliberately NOT here, and the decided + * copy is the reason: "Your robots.txt file tells ChatGPT's crawler not to read + * this site" opens with the filename and needs no gloss, because the filename + * is a thing the reader owns and can go and look at. A term needs introducing + * when the reader cannot act on it; a file on their own site is not that. */ export const INDUSTRY_TERMS: readonly string[] = [ "largest contentful paint", @@ -61,8 +67,6 @@ export const INDUSTRY_TERMS: readonly string[] = [ "structured data", "meta description", "speed index", - "robots.txt", - "sitemap.xml", "schema.org", "lighthouse", "hreflang", diff --git a/src/lib/psi.ts b/src/lib/psi.ts index 85ac7fa..148db93 100644 --- a/src/lib/psi.ts +++ b/src/lib/psi.ts @@ -47,10 +47,10 @@ function mockCollect(url: string, strategy: Strategy, n: number): CollectResult run: index + 1, warnings: [], findings: [ - { id: "unused-javascript", title: "Reduce unused JavaScript", category: "Performance", savingsMs: 1800, savingsBytes: 0, actionable: true }, - { id: "modern-image-formats", title: "Serve images in next-gen formats", category: "Performance", savingsMs: 1200, savingsBytes: 0, actionable: true }, - { id: "render-blocking-resources", title: "Eliminate render-blocking resources", category: "Performance", savingsMs: 600, savingsBytes: 0, actionable: true }, - { id: "dom-size", title: "Avoid an excessive DOM size", category: "Performance", score: 0, scoreDisplayMode: "binary", savingsMs: 0, savingsBytes: 0, actionable: true }, + { id: "unused-javascript", title: "Code the page never runs is costing 1.8 seconds", category: "Performance", savingsMs: 1800, savingsBytes: 0, actionable: true }, + { id: "modern-image-formats", title: "Images could be sent in a lighter format", category: "Performance", savingsMs: 1200, savingsBytes: 0, actionable: true }, + { id: "render-blocking-resources", title: "A stylesheet delays the first text on the page", category: "Performance", savingsMs: 600, savingsBytes: 0, actionable: true }, + { id: "dom-size", title: "The page nests elements too deeply", category: "Performance", score: 0, scoreDisplayMode: "binary", savingsMs: 0, savingsBytes: 0, actionable: true }, ], })); const aggregated = aggregateLighthouseRunEvidence(runEvidence, n); diff --git a/src/lib/seed.ts b/src/lib/seed.ts index d7eacef..26213ab 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -161,7 +161,7 @@ function performanceOffset(seed: Seed, sequence: number): number { } function sampleOpportunity(id: string): LighthouseOpportunity { - const title = id === "unused-javascript" ? "Reduce unused JavaScript" : "Properly size images"; + const title = id === "unused-javascript" ? "Code the page never runs is costing 1.5 seconds" : "Images were bigger than the space they fill"; return { id, title, @@ -463,37 +463,37 @@ export function buildSeedState(now = new Date()): AppState { { key: "pricing:unused-javascript", pageId: "pricing", pageTitle: "Pricing", url: page("pricing").url, id: "unused-javascript", source: "lighthouse", strategies: ["mobile", "desktop"], sourceRunId: page("pricing").history.at(-1)!.runId, - title: "Reduce unused JavaScript", category: "Performance", webflow: classifyWebflowPerformance("unused-javascript"), + title: "Code the page never runs is costing 1.5 seconds", category: "Performance", webflow: classifyWebflowPerformance("unused-javascript"), savings: "1.5 s", estTime: "2 days", status: "task", taskStatus: "in-progress", added: dateKey(isoAt(anchor, -12)), doneDate: null, - aiSummary: "Global JavaScript repeatedly blocks the main thread across trusted mobile and desktop samples.", + aiSummary: "Site-wide code keeps the browser busy, so the page cannot respond to a tap. Measured on both phone and desktop.", }, { key: "designer:uses-responsive-images", pageId: "designer", pageTitle: "Designer", url: page("designer").url, id: "uses-responsive-images", source: "lighthouse", strategies: ["mobile"], sourceRunId: page("designer").history.at(-1)!.runId, - title: "Properly size images", category: "Performance", webflow: classifyWebflowPerformance("uses-responsive-images"), + title: "Images were bigger than the space they fill", category: "Performance", webflow: classifyWebflowPerformance("uses-responsive-images"), savings: "0.6 s", estTime: "3 hours", status: "task", taskStatus: "done", added: dateKey(isoAt(anchor, -20)), doneDate: dateKey(isoAt(anchor, -8)), - aiSummary: "Responsive candidates now match the rendered image size; follow-up scans are improving.", + aiSummary: "The page now sends each image at the size it is shown, and later measurements are improving.", }, { key: "hosting:webflow-spline-eager", pageId: "hosting", pageTitle: "Hosting", url: page("hosting").url, id: "webflow-spline-eager", source: "native-elements", strategies: ["mobile", "desktop"], sourceRunId: page("hosting").history.at(-1)!.runId, - title: "Spline scene loads eagerly", category: "Native elements", webflow: classifyWebflowPerformance("webflow-spline-eager"), + title: "A 3D scene starts loading before anyone scrolls to it", category: "Native elements", webflow: classifyWebflowPerformance("webflow-spline-eager"), savings: "Observed", estTime: "1 day", status: "inbox", taskStatus: "todo", added: dateKey(isoAt(anchor, -3)), doneDate: null, - aiSummary: "The rendered page contains a Spline scene that begins loading before it approaches the viewport.", + aiSummary: "The published page starts loading a 3D scene before it comes into view.", }, { key: "templates:webflow-video-embed-eager", pageId: "templates", pageTitle: "Templates", url: page("templates").url, id: "webflow-video-embed-eager", source: "native-elements", strategies: ["mobile"], sourceRunId: page("templates").history[N - 2].runId, - title: "YouTube or Vimeo players load eagerly", category: "Native elements", webflow: classifyWebflowPerformance("webflow-video-embed-eager"), + title: "Video players load before anyone plays them", category: "Native elements", webflow: classifyWebflowPerformance("webflow-video-embed-eager"), savings: "Observed", estTime: "1 day", status: "ignored", taskStatus: "todo", added: dateKey(isoAt(anchor, -7)), doneDate: null, }, { key: "hosting:crux-field-only-inp", pageId: "hosting", pageTitle: "Hosting", url: page("hosting").url, id: "crux-field-only-inp", source: "crux-field-only", strategies: ["mobile"], - title: "Visitor responsiveness is slower than lab tests", category: "Visitor experience", + title: "Real visitors wait longer for a tap to register than our test does", category: "Visitor experience", webflow: { ...classifyWebflowPerformance("third-party-summary"), source: "crux-field-only" }, - savings: "Field p75 620 ms", estTime: "Needs review", status: "inbox", taskStatus: "todo", added: dateKey(isoAt(anchor, -2)), doneDate: null, - fieldSignals: { mobile: { metricKey: "responsiveness", metricLabel: "Responsiveness", relationship: "proxy", labLabel: "Lab TBT", labFormatted: "120 ms", fieldLabel: "Visitor INP p75", fieldValue: 620, fieldFormatted: "620 ms", fieldRating: "Poor", scope: "url", collectionStart: dateKey(isoAt(anchor, -29)), collectionEnd: dateKey(isoAt(anchor, -2)), detectedAt: isoAt(anchor, -2) } }, + savings: "620 ms for real visitors", estTime: "Needs review", status: "inbox", taskStatus: "todo", added: dateKey(isoAt(anchor, -2)), doneDate: null, + fieldSignals: { mobile: { metricKey: "responsiveness", metricLabel: "Responsiveness", relationship: "proxy", labLabel: "Nightly test (TBT)", labFormatted: "120 ms", fieldLabel: "Real visitors (INP)", fieldValue: 620, fieldFormatted: "620 ms", fieldRating: "Poor", scope: "url", collectionStart: dateKey(isoAt(anchor, -29)), collectionEnd: dateKey(isoAt(anchor, -2)), detectedAt: isoAt(anchor, -2) } }, fieldLifecycle: { mobile: { status: "active", firstDetectedAt: isoAt(anchor, -9), lastDetectedAt: isoAt(anchor, -2), lastEvaluatedCollectionEnd: dateKey(isoAt(anchor, -2)), consecutiveGoodWindows: 0 } }, }, // Two causes, one fix. These exist so the remediation GROUP renders in the @@ -508,25 +508,25 @@ export function buildSeedState(now = new Date()): AppState { { key: "enterprise:render-blocking-resources", pageId: "enterprise", pageTitle: "Enterprise", url: page("enterprise").url, id: "render-blocking-resources", source: "lighthouse", strategies: ["mobile"], sourceRunId: page("enterprise").history.at(-1)!.runId, - title: "Eliminate render-blocking resources", category: "Performance", webflow: classifyWebflowPerformance("render-blocking-resources"), + title: "A stylesheet delays the first text on the page", category: "Performance", webflow: classifyWebflowPerformance("render-blocking-resources"), savings: "0.9 s", estTime: "1 day", status: "inbox", taskStatus: "todo", added: dateKey(isoAt(anchor, -4)), doneDate: null, - aiSummary: "A stylesheet in the site-wide head delays first paint on the Enterprise page.", - agentIssue: sharedHeadFix("enterprise:render-blocking-resources", "Eliminate render-blocking resources", isoAt(anchor, -4)), + aiSummary: "A site-wide stylesheet has to finish loading before any text appears on Enterprise.", + agentIssue: sharedHeadFix("enterprise:render-blocking-resources", "A stylesheet delays the first text on the page", isoAt(anchor, -4)), }, { key: "ai:unused-css-rules", pageId: "ai", pageTitle: "AI", url: page("ai").url, id: "unused-css-rules", source: "lighthouse", strategies: ["mobile"], sourceRunId: page("ai").history.at(-1)!.runId, - title: "Reduce unused CSS", category: "Performance", webflow: classifyWebflowPerformance("unused-css-rules"), + title: "Most of a stylesheet this page loads goes unused", category: "Performance", webflow: classifyWebflowPerformance("unused-css-rules"), savings: "0.4 s", estTime: "1 day", status: "inbox", taskStatus: "todo", added: dateKey(isoAt(anchor, -4)), doneDate: null, aiSummary: "Most of the same site-wide stylesheet goes unused on this page.", - agentIssue: sharedHeadFix("ai:unused-css-rules", "Reduce unused CSS", isoAt(anchor, -4)), + agentIssue: sharedHeadFix("ai:unused-css-rules", "Most of a stylesheet this page loads goes unused", isoAt(anchor, -4)), }, { key: "pricing:crux-field-only-lcp", pageId: "pricing", pageTitle: "Pricing", url: page("pricing").url, id: "crux-field-only-lcp", source: "crux-field-only", strategies: ["mobile"], - title: "Visitor main-content load remains elevated", category: "Visitor experience", + title: "The main content still takes too long for real visitors", category: "Visitor experience", webflow: { ...classifyWebflowPerformance("largest-contentful-paint-element"), source: "crux-field-only" }, - savings: "Field p75 4.8 s", estTime: "Needs review", status: "task", taskStatus: "done", added: dateKey(isoAt(anchor, -22)), doneDate: dateKey(isoAt(anchor, -5)), + savings: "4.8 s for real visitors", estTime: "Needs review", status: "task", taskStatus: "done", added: dateKey(isoAt(anchor, -22)), doneDate: dateKey(isoAt(anchor, -5)), fieldLifecycle: { mobile: { status: "verifying", firstDetectedAt: isoAt(anchor, -22), lastDetectedAt: isoAt(anchor, -9), lastEvaluatedCollectionEnd: dateKey(isoAt(anchor, -2)), consecutiveGoodWindows: 1 } }, }, ]; @@ -592,12 +592,12 @@ export function buildSeedState(now = new Date()): AppState { cruxCompletedAt: isoAt(anchor, 0, 0.2), agentCompletedAt: isoAt(anchor, 0, 0.3), error: "Run exceeded the 30 minute stale limit", }], followUps: [ - { id: "demo-followup-sent", pageId: "designer", markerId: "task:designer:uses-responsive-images", markerText: "Completed: Properly size images", markerDate: dateKey(isoAt(anchor, -8)), interval: "2d", dueISO: isoAt(anchor, -6), sent: true, attempts: 1, lastAttemptISO: isoAt(anchor, -6, 1), lastHttpStatus: 200 }, - { id: "demo-followup-pending", pageId: "designer", markerId: "task:designer:uses-responsive-images", markerText: "Completed: Properly size images", markerDate: dateKey(isoAt(anchor, -8)), interval: "30d", dueISO: isoAt(anchor, 22), sent: false, attempts: 0 }, - { id: "demo-followup-retry", pageId: "pricing", markerId: "task:pricing:crux-field-only-lcp", markerText: "Completed: Visitor main-content load remains elevated", markerDate: dateKey(isoAt(anchor, -5)), interval: "2d", dueISO: isoAt(anchor, -3), sent: false, attempts: 1, lastAttemptISO: isoAt(anchor, -3, 1), lastHttpStatus: 429, lastError: "rate_limited", retryAfterISO: isoAt(anchor, 2) }, + { id: "demo-followup-sent", pageId: "designer", markerId: "task:designer:uses-responsive-images", markerText: "Completed: Images were bigger than the space they fill", markerDate: dateKey(isoAt(anchor, -8)), interval: "2d", dueISO: isoAt(anchor, -6), sent: true, attempts: 1, lastAttemptISO: isoAt(anchor, -6, 1), lastHttpStatus: 200 }, + { id: "demo-followup-pending", pageId: "designer", markerId: "task:designer:uses-responsive-images", markerText: "Completed: Images were bigger than the space they fill", markerDate: dateKey(isoAt(anchor, -8)), interval: "30d", dueISO: isoAt(anchor, 22), sent: false, attempts: 0 }, + { id: "demo-followup-retry", pageId: "pricing", markerId: "task:pricing:crux-field-only-lcp", markerText: "Completed: The main content still takes too long for real visitors", markerDate: dateKey(isoAt(anchor, -5)), interval: "2d", dueISO: isoAt(anchor, -3), sent: false, attempts: 1, lastAttemptISO: isoAt(anchor, -3, 1), lastHttpStatus: 429, lastError: "rate_limited", retryAfterISO: isoAt(anchor, 2) }, ], watcherNote: { - text: "Pricing is the clearest sustained regression, while Designer and Hosting are improving. Two PSI cohorts were excluded as a verified provider incident, and Hosting shows a visitor-only responsiveness risk despite healthy lab blocking time.", + text: "Pricing is the clearest sustained regression, while Designer and Hosting are improving. Two nights of readings were left out as a confirmed problem at the testing service, and real visitors to Hosting are waiting longer for a tap to register even though the nightly test finds nothing wrong.", generatedAt: isoAt(anchor, 0, 0.5), modelVersion: 2, }, }; diff --git a/src/lib/settings-exclusions.ts b/src/lib/settings-exclusions.ts index df8ea0f..579ffeb 100644 --- a/src/lib/settings-exclusions.ts +++ b/src/lib/settings-exclusions.ts @@ -1,4 +1,4 @@ -import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS } from "./agentChecks"; +import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS, agentCheckLabel, agentGroupLabel } from "./agentChecks"; import { agentCheckKey, agentExclusionKey, normalizeAgentIgnoreSettings } from "./agentScoring"; import { formatImpact, NOT_MEASURED } from "./impact-format"; import { excludedPageIds, exclusionReasonOf, type IssueCase } from "./issue-case"; @@ -132,7 +132,8 @@ function agentRows(state: AppState): ExcludedRow[] { return [{ id: `agent-group:${name}`, kind: "check", - title: name, + // The stored value is the key; the row shows the words for it. + title: agentGroupLabel(name), scope: null, reason: reasonFor(defaults, "group", name), reading: reading ?? NOT_MEASURED, @@ -154,8 +155,8 @@ function agentRows(state: AppState): ExcludedRow[] { return [{ id: `agent-check:${key}`, kind: "check", - title: check.name, - scope: check.group, + title: agentCheckLabel(check.name), + scope: agentGroupLabel(check.group), reason: reasonFor(defaults, "check", key), reading: reading ?? NOT_MEASURED, measured: reading !== null, diff --git a/src/lib/webflowPerformance.ts b/src/lib/webflowPerformance.ts index 68c3674..b79c9ab 100644 --- a/src/lib/webflowPerformance.ts +++ b/src/lib/webflowPerformance.ts @@ -194,14 +194,14 @@ const CATALOG: Record = { "render-blocking-resources": { metric: "LCP", culprit: "render-blocking", - culpritLabel: "Render-blocking resources", + culpritLabel: "Files that delay the first text", remediation: "blocked", guidance: "Put the styles the first screenful needs directly in the page, and delay or split the rest of your own.", }, "render-blocking-insight": { metric: "LCP", culprit: "render-blocking", - culpritLabel: "Render-blocking resources", + culpritLabel: "Files that delay the first text", remediation: "blocked", guidance: "Put the styles the first screenful needs directly in the page, and delay or split the rest of your own.", },