diff --git a/DECISIONS.md b/DECISIONS.md
index 77c5c00..d1f55ab 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1,6 +1,6 @@
# Product decisions pending sign-off
-The implementation resolved four previously-undefined product questions. They
+The implementation resolved five previously-undefined product questions. They
are reasonable defaults, but they should be confirmed (or changed) by product
rather than remaining implicit in the code. Each notes where it lives so a
change is a one-line edit.
@@ -38,9 +38,39 @@ flat history still tolerates normal PSI jitter.
## 4. Collection starts at the workspace's saved local time
The first watched page initializes the workspace to **midnight in that user's
-browser timezone**. The Watchlist setting can override both time and IANA
+browser timezone**. The Settings screen can override both time and IANA
timezone. Active pages receive stable offsets after the chosen start so the
workspace does not burst every page or PSI sample simultaneously.
-- Where: `src/lib/collectionSchedule.ts`, the Watchlist settings panel, and the
+- Where: `src/lib/collectionSchedule.ts`, the Settings screen, and the
collector's 15-minute due-page cron.
+
+## 5. Sensitivity is one control with three positions (option 10b)
+
+What a site considers worth reporting is **one setting**, not twelve. The three
+positions are Only big moves / Normal / Everything, and each resolves to a
+complete threshold set. The limits it resolves to are **displayed beneath the
+control, in the strings the digest itself writes**, so the abstraction is never
+opaque: a reader who wants to know why a line said "above the 250 ms you set"
+can see the 250 ms and see which position put it there.
+
+What the numbers are at each position is the part product should confirm. What
+is settled, and should not be reopened without a decision:
+
+- **Twelve per-metric thresholds** were rejected. Every number honest, and
+ nobody could say what any of them would do to tonight's digest.
+- **No thresholds at all** were rejected. The digest's threshold clause is the
+ reason a reader trusts the line, and it needs a setting behind it to be true.
+- **Per-page sensitivity** does not exist anywhere. S3 removed the page-detail
+ calibration panel and S8 gives it no new home; a site has one answer to "what
+ is worth telling you" because the digest that asks it is one message per site.
+- **No position resolves the savings gate to 0.** At 0 there is no limit the
+ reader set, so the digest withholds the clause and there is nothing to show
+ under the control. "Everything" is 1 ms, which is every measurement there is.
+- A site whose thresholds were hand-tuned before this landed is **mapped to the
+ nearest position and told once**, in the digest footer. Discarding somebody's
+ configuration silently is worse than the configuration was.
+
+- Where: `SENSITIVITY_THRESHOLDS` in `src/lib/sensitivity.ts` is the only place
+ the numbers appear; `DEFAULT_PERFORMANCE_THRESHOLDS` reads the Normal position
+ from it. The migration is `normalizeState` in `src/lib/store/normalize.ts`.
diff --git a/collector-worker/dataStore.ts b/collector-worker/dataStore.ts
index 9a073de..75592a1 100644
--- a/collector-worker/dataStore.ts
+++ b/collector-worker/dataStore.ts
@@ -2,7 +2,7 @@ import { buildInitialState, buildSeedCruxEvidence, DEMO_DATA_VERSION } from "../
import { captureAgentReadiness } from "../src/lib/agentScoring";
import { resolveMarkerIndex } from "../src/lib/followups";
import { mediansOf, pageTrend } from "../src/lib/scoring";
-import { effectivePerformanceThresholds } from "../src/lib/performanceThresholds";
+import { normalizePerformanceThresholds } from "../src/lib/performanceThresholds";
import { normalizeState } from "../src/lib/store/normalize";
import { TENANT, type AppState, type ChangeMarker, type Night } from "../src/lib/types";
import {
@@ -217,7 +217,7 @@ export class FdeDataStore {
desktop: mediansOf(night.scores.desktop),
};
page.agent = agent ?? [];
- page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(draft.performanceThresholds, page));
+ page.status = pageTrend(page, "mobile", normalizePerformanceThresholds(draft.performanceThresholds));
page.runState = undefined;
page.lastRunAt = night.iso ?? new Date().toISOString();
page.lastCollectionStatus = "trusted";
diff --git a/src/app/(app)/pages/[id]/page.tsx b/src/app/(app)/pages/[id]/page.tsx
index e67bcc7..9dbe92e 100644
--- a/src/app/(app)/pages/[id]/page.tsx
+++ b/src/app/(app)/pages/[id]/page.tsx
@@ -8,7 +8,7 @@ import { useIssuesView, useStore } from "@/components/store";
import { CATEGORIES } from "@/lib/types";
import type { CategoryKey, CollectionJob, Night, RangeDays, WatchPage } from "@/lib/types";
import { agentReadinessHistoryPoints } from "@/lib/agentHistory";
-import { effectivePerformanceThresholds } from "@/lib/performanceThresholds";
+import { normalizePerformanceThresholds } from "@/lib/performanceThresholds";
import {
historyForStrategy,
nightHasStrategy,
@@ -798,7 +798,7 @@ function ReadingsSection({
...run,
startsDateGroup: run.dateKey !== runMetadata[index - 1]?.dateKey,
}));
- const thresholds = effectivePerformanceThresholds(store.performanceThresholds, page);
+ const thresholds = normalizePerformanceThresholds(store.performanceThresholds);
const readinessHistory = agentReadinessHistoryPoints(
agentRangeHistory,
page.agentIgnores,
@@ -1301,7 +1301,7 @@ export default function PageDetail() {
const collectionBlocked = page.flag === "paused" || (!!page.runState && page.runState !== "failed");
const activeJob = store.jobs?.find((job) => job.runId === page.runId);
const watchedPageHref = /^[a-z][a-z\d+.-]*:\/\//i.test(page.url) ? page.url : `https://${page.url}`;
- const thresholds = effectivePerformanceThresholds(store.performanceThresholds, page);
+ const thresholds = normalizePerformanceThresholds(store.performanceThresholds);
// A development-only comparison of the two trend renderings side by side.
const isStatusPreview = process.env.NODE_ENV === "development" && searchParams.get("statusPreview") === "compare";
const mobileTrend = isStatusPreview ? "regressing" : pageRangeTrend(page, "mobile", rangeDays, thresholds);
diff --git a/src/app/(app)/pages/pages-content.tsx b/src/app/(app)/pages/pages-content.tsx
index 4877201..33cfb12 100644
--- a/src/app/(app)/pages/pages-content.tsx
+++ b/src/app/(app)/pages/pages-content.tsx
@@ -28,7 +28,7 @@ import type { SegmentRole } from "@/components/segmented-control";
import { CATEGORIES } from "@/lib/types";
import type { AgentIgnoreSettings, Night, WebflowRemediationLevel } from "@/lib/types";
import { agentReadinessForNight, summarizeAgentChecks } from "@/lib/agentScoring";
-import { effectivePerformanceThresholds, normalizePerformanceThresholds } from "@/lib/performanceThresholds";
+import { normalizePerformanceThresholds } from "@/lib/performanceThresholds";
import { historyForRange, pageAgentSnapshotForRange, pageRangeLatestNightForStrategy, pageRangeTrend } from "@/lib/scoring";
import { flagChip, savingsValue } from "@/lib/ui";
import { DESTINATION_LABEL, DESTINATION_PATH, QUEUE_LABEL } from "@/lib/vocabulary";
@@ -294,7 +294,7 @@ function DashboardContent({
const nativeElementRollups = siteNativeElementRollups(activePages);
const rows = pages.map((p, watchlistOrder) => {
- const pageThresholds = effectivePerformanceThresholds(thresholds, p);
+ const pageThresholds = normalizePerformanceThresholds(thresholds);
const mobileTrend = pageRangeTrend(p, "mobile", rangeDays, pageThresholds);
const desktopTrend = pageRangeTrend(p, "desktop", rangeDays, pageThresholds);
const visitorEvidence = evidenceForPage(visitorExperience, p.id, strategy);
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx
index 4087207..c542e2b 100644
--- a/src/app/(app)/settings/page.tsx
+++ b/src/app/(app)/settings/page.tsx
@@ -1,17 +1,575 @@
"use client";
-import { SettingsPageContent } from "../watchlist/page";
+import { useEffect, useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+
+import { AppearanceControl } from "@/components/appearance";
+import { ExclusionReasonPicker } from "@/components/exclusion-reason-picker";
+import { PageHeader } from "@/components/page-header";
import { ProjectMembers } from "@/components/ProjectMembers";
+import { SegmentedControl } from "@/components/segmented-control";
import { useStore } from "@/components/store";
-import { useRouter } from "next/navigation";
-import { useEffect } from "react";
+import { WebflowConnection } from "@/components/webflow-connection";
+import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS } 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";
+import {
+ formatDigestRecipients,
+ digestRecipientIsValid,
+ parseDigestRecipients,
+} from "@/lib/digestRecipients";
+import { digestSiteOf } from "@/lib/digest";
+import { issueCasesFrom } from "@/lib/issue-cases";
+import { remediationKey } from "@/lib/issue-case";
+import { normalizePerformanceThresholds } from "@/lib/performanceThresholds";
+import { SENSITIVITIES, normalizeSensitivity, type Sensitivity } from "@/lib/sensitivity";
+import {
+ SENSITIVITY_LABEL,
+ SETTINGS_APPEARANCE_HELP,
+ SETTINGS_APPEARANCE_LABEL,
+ SETTINGS_DIGEST_HELP,
+ SETTINGS_DIGEST_LABEL,
+ SETTINGS_DIGEST_RECIPIENTS_EMPTY,
+ SETTINGS_DIGEST_RECIPIENTS_HELP,
+ SETTINGS_DIGEST_RECIPIENTS_INVALID,
+ SETTINGS_DIGEST_RECIPIENTS_LABEL,
+ SETTINGS_EXCLUDED_EMPTY,
+ SETTINGS_EXCLUDED_HELP,
+ SETTINGS_EXCLUDED_LABEL,
+ SETTINGS_SENSITIVITY_HELP,
+ SETTINGS_SENSITIVITY_LABEL,
+ SETTINGS_SENSITIVITY_LIMIT_LABEL,
+ SETTINGS_SYSTEMS_HELP,
+ SETTINGS_SYSTEMS_LABEL,
+ settingsSubtitle,
+} from "@/lib/settings-copy";
+import { excludedFromResults, type ExcludedRow } from "@/lib/settings-exclusions";
+import { alertWebhookUrlIsValid } from "@/lib/webhook";
+import {
+ DESTINATION_LABEL,
+ applicabilityActionLabel,
+ type ExclusionReason,
+} from "@/lib/vocabulary";
+
+/**
+ * Settings: one page, five groups, no tabs.
+ *
+ * The groups are in the order a reader needs them, and the order is an
+ * argument. What is worth telling you comes first because it is the only
+ * setting that changes what the product says. The digest is second because it
+ * is how it says it. What is set aside is third because it is the answer to
+ * "why am I not seeing X". Connected systems is fourth because it is
+ * infrastructure. Appearance is last because it is the only one that is not
+ * about the site at all.
+ *
+ * No tabs, deliberately. Five groups fit on one scroll, and a tab is a place to
+ * hide a setting somebody will later swear does not exist — which is exactly how
+ * the twelve thresholds this chunk deleted survived as long as they did.
+ *
+ * Three things are conspicuously absent and must stay absent:
+ *
+ * - Any per-metric threshold. One control, three positions, and the limits it
+ * resolves to are printed beneath it in the digest's own words. Rebuilding
+ * the twelve fields somewhere tidier is the same product with a nicer
+ * drawer.
+ * - Any per-page sensitivity. S3 removed the page-detail calibration panel;
+ * this screen does not adopt it.
+ * - Any weighting, ranking or trust order over the connected systems. The
+ * evidence ledger exists so that two systems disagreeing is visible rather
+ * than averaged away, and a control that ordered them would be an average
+ * with extra steps.
+ */
+
+/* ── Group chrome ───────────────────────────────────────────────────────── */
+
+function SettingsGroup({
+ id,
+ label,
+ help,
+ action,
+ children,
+}: {
+ id: string;
+ label: string;
+ help: string;
+ action?: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
{label}
+
{help}
+
+ {action ?
{action}
: null}
+
+ {children}
+
+ );
+}
+
+/* ── 1. What is worth telling you ───────────────────────────────────────── */
+
+const SENSITIVITY_OPTIONS = SENSITIVITIES.map((value) => ({ value, label: SENSITIVITY_LABEL[value] }));
+
+/**
+ * The control, and the limits it resolves to, together.
+ *
+ * The second half is not decoration. A three-position control over twelve
+ * numbers is only honest if the reader can see what a position means, and the
+ * one thing they can check it against is the digest — so the limit printed here
+ * is the string `digestLimit` gives the digest, not a second formatting of the
+ * same milliseconds. `settings-sensitivity.test.ts` asserts the two are the
+ * same characters; if somebody changes the unit in one place, the test fails
+ * rather than the screen quietly lying.
+ */
+function SensitivityGroup({
+ value,
+ onChange,
+ limit,
+ disabled,
+}: {
+ value: Sensitivity;
+ onChange: (next: Sensitivity) => void;
+ limit: string | null;
+ disabled: boolean;
+}) {
+ return (
+
+
+
+ );
+}
+
+/* ── 3. Excluded from results ───────────────────────────────────────────── */
+
+/**
+ * One row, whatever kind of thing it is.
+ *
+ * The reading stays and is struck through rather than removed, which is the
+ * same treatment the case's pages table gives an excluded page and for the same
+ * reason: struck through says "not counted", and an empty cell would say "never
+ * measured", which is a lie about a thing that was measured.
+ */
+function ExcludedRowView({ row, onInclude }: { row: ExcludedRow; onInclude?: () => void }) {
+ return (
+
+ {row.reading}
+ {/*
+ The control is offered only where the change can be KEPT — the same rule
+ `CasePages` states for the same concept. A button that took a reader's
+ decision, showed it and lost it on reload is the trust failure this
+ product exists to fix, and it is worse than no button. The row is still
+ here, with its reading and its reason, so nothing is hidden meanwhile.
+ */}
+ {onInclude ? (
+
+ ) : null}
+
+ );
+}
+
+function ExcludedGroup({ disabled }: { disabled: boolean }) {
+ const store = useStore();
+ const { pages, recs, agentIgnoreDefaults, caseDecisions } = store;
+ // The cases are derived, so the excluded PAGES in this list come from the
+ // same derivation the case detail draws its own pages table from — decisions
+ // and all (F5). One list covering pages and checks means reading both, not
+ // describing both.
+ const rows = useMemo(() => {
+ const state = { pages, recs, agentIgnoreDefaults, caseDecisions };
+ return excludedFromResults(state, issueCasesFrom(state));
+ }, [pages, recs, agentIgnoreDefaults, caseDecisions]);
+ const [choosing, setChoosing] = useState(null);
+
+ const defaults = normalizeAgentIgnoreSettings(agentIgnoreDefaults);
+ /**
+ * What can still be set aside.
+ *
+ * The Exclude half lives here rather than in a grid of every check, because a
+ * screen that lists twenty checks with a toggle each IS the per-metric panel
+ * this chunk deleted, wearing a different noun. This asks for one thing and
+ * one reason, which is what applicability requires.
+ */
+ 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 })),
+ ...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}`,
+ scope: "check" as const,
+ value: agentCheckKey(check),
+ })),
+ ];
+ const [target, setTarget] = useState("");
+
+ /**
+ * What Include does for this row.
+ *
+ * Three kinds of record, three writers, one word on the button. Each row
+ * knows which record it is, so nothing here guesses — and the control is only
+ * offered where the change can be KEPT, which since F5 is all three: the
+ * decision log persists a case-page exclusion, so the button is real rather
+ * than withheld.
+ */
+ const includeFor = (row: ExcludedRow): (() => void) | undefined => {
+ const to = row.include;
+ if (disabled) return undefined;
+ if (to.target === "native-element") {
+ return () => store.setNativeElementApplicability(to.pageId, to.findingId, null);
+ }
+ if (to.target === "agent-check") {
+ return () => store.setDefaultAgentIgnore(to.scope, to.value, false);
+ }
+ // The key is derived here, from the case, by its single producer. A row
+ // carrying a precomputed one would be a second key in circulation.
+ return () => store.recordCaseDecision({
+ decision: "include",
+ remediationKey: remediationKey(to.issue),
+ pageId: to.pageId,
+ });
+ };
+
+ /**
+ * Excluding IS choosing the reason.
+ *
+ * There is no separate confirm step, and the reason is not a follow-up
+ * prompt: applicability requires one, and a prompt that appears afterwards is
+ * a prompt nobody completes. The chosen reason is stored against the record,
+ * so the row it produces reports what this reader decided rather than what
+ * the old unlabelled toggle used to mean.
+ */
+ const exclude = (reason: ExclusionReason) => {
+ const chosen = excludable.find((item) => item.key === target);
+ setChoosing(null);
+ setTarget("");
+ if (!chosen) return;
+ store.setDefaultAgentIgnore(chosen.scope, chosen.value, true, reason);
+ };
+
+ return (
+
+ {rows.length === 0 ? (
+
+ )}
+
+ );
+}
+
+/* ── 4. Connected systems ───────────────────────────────────────────────── */
+
+/**
+ * Connect, disconnect, credentials. Nothing else.
+ *
+ * No weighting, no ranking and no trust order, and that is the registry's
+ * ruling rather than a layout preference: the evidence ledger keeps one entry
+ * per system precisely so a disagreement is visible instead of averaged away. A
+ * control that ordered these would be a blend with a nicer name, and the group
+ * says so in its own help line.
+ */
+function ConnectedSystemsGroup({ disabled }: { disabled: boolean }) {
+ const {
+ pathFor,
+ alertWebhookUrl,
+ updateAlertWebhookUrl,
+ externalAgentAuditEnabled,
+ setExternalAgentAuditEnabled,
+ } = useStore();
+ const stored = alertWebhookUrl ?? "";
+ const [webhookDraft, setWebhookDraft] = useState(stored);
+ const [syncedFrom, setSyncedFrom] = useState(stored);
+ if (stored !== syncedFrom) {
+ setSyncedFrom(stored);
+ setWebhookDraft(stored);
+ }
+ const webhook = webhookDraft.trim();
+ const webhookValid = !webhook || alertWebhookUrlIsValid(webhook);
+ const webhookDirty = webhook !== (alertWebhookUrl ?? "");
+
+ return (
+
+
+
+
+
+
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.
+
+ Where the digest is delivered, with the recipients this site named. Treat the URL as a credential;
+ it is used for nothing else.
+
+
+
+
+
+ {webhookValid
+ ? webhook
+ ? "HTTPS only."
+ : "Leave this blank and the digest is built but not delivered."
+ : "Enter an HTTPS URL with no embedded username or password."}
+
+
+
+
+
+ );
+}
+
+/* ── 5. Appearance ──────────────────────────────────────────────────────── */
+
+/**
+ * Canonical here.
+ *
+ * The sidebar footer keeps its copy of this control as a shortcut, and it may
+ * collapse below 480px. That is correct rather than a bug to patch: a shortcut
+ * that disappears when the sidebar has no room is fine precisely because this
+ * screen exists, and this screen is reachable at 320px. If the only appearance
+ * control were the sidebar's, the collapse would be a defect.
+ */
+function AppearanceGroup() {
+ const { appearance, setAppearance } = useStore();
+ return (
+ }
+ >
+ {null}
+
+ );
+}
+
+/* ── The page ───────────────────────────────────────────────────────────── */
export default function SettingsPage() {
- const { canManageProject, pathFor } = useStore();
+ const store = useStore();
+ const { canManageProject, pathFor, sensitivity, performanceThresholds, setSensitivity } = store;
const router = useRouter();
useEffect(() => {
if (!canManageProject) router.replace(pathFor("/dashboard"));
}, [canManageProject, pathFor, router]);
+
+ const site = digestSiteOf(store);
+ const position = normalizeSensitivity(sensitivity);
+ const limit = digestLimit(normalizePerformanceThresholds(performanceThresholds));
+
if (!canManageProject) return null;
- return <>
>;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
}
diff --git a/src/app/(app)/watchlist/page.tsx b/src/app/(app)/watchlist/page.tsx
index a7d058e..58e0afc 100644
--- a/src/app/(app)/watchlist/page.tsx
+++ b/src/app/(app)/watchlist/page.tsx
@@ -2,29 +2,34 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
-import { DotsSixVerticalIcon, Info } from "@phosphor-icons/react";
+import { DotsSixVerticalIcon } from "@phosphor-icons/react";
import { useStore } from "@/components/store";
-import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS } from "@/lib/agentChecks";
-import { agentCheckKey, isAgentCheckIgnored, isAgentGroupIgnored, normalizeAgentIgnoreSettings } from "@/lib/agentScoring";
-import { DEFAULT_PERFORMANCE_THRESHOLDS, normalizePerformanceThresholds, PERFORMANCE_THRESHOLD_LIMITS } from "@/lib/performanceThresholds";
-import type { DevicePolicy, PerformanceThresholds } from "@/lib/types";
-import { normalizeCollectionSchedule } from "@/lib/collectionSchedule";
import { naturalDate } from "@/lib/ui";
-import { applicabilityActionLabel, DESTINATION_LABEL } from "@/lib/vocabulary";
+import { DESTINATION_LABEL } from "@/lib/vocabulary";
import { SegToggle } from "@/components/bits";
-import { Magnitude, MAGNITUDE_WEIGHT } from "@/components/magnitude";
-import { ChevronDownIcon, PlusIcon, TrashIcon } from "@/components/icons";
+import { Magnitude } from "@/components/magnitude";
+import { PlusIcon, TrashIcon } from "@/components/icons";
import { flagCapacityError, MAX_ACTIVE_PAGES, MAX_PRIORITY_PAGES, watchCapacity } from "@/lib/watchCapacity";
import { movePageWithinFlag, reorderPageWithinFlag, sortWatchlistPages } from "@/lib/watchlistOrder";
import { failedRunLabel } from "@/lib/collectionStatus";
-import { WebflowConnection } from "@/components/webflow-connection";
-import { alertWebhookUrlIsValid } from "@/lib/webhook";
import { PageHeader } from "@/components/page-header";
+/**
+ * The watchlist, and only the watchlist.
+ *
+ * It carried the settings screen as a second mode until S8 — one component, one
+ * `mode` prop, and two pages that shared a header and nothing else. Settings is
+ * its own route now, so what is left here is the page list, the flags, and the
+ * reordering.
+ *
+ * Nothing on this screen sets a threshold, and nothing on it carries a severity.
+ * Both had gone by the time S8 looked; what this chunk removed was the tolerance
+ * panel below, which was the last threshold UI anywhere in the app outside
+ * /settings.
+ */
+
const GRID = "32px minmax(228px,2.4fr) 230px 1fr 120px";
-type NumericToleranceKey = keyof typeof PERFORMANCE_THRESHOLD_LIMITS;
type WatchlistDropTarget = { pageId: string; position: "before" | "after" };
-const NUMERIC_TOLERANCE_KEYS = Object.keys(PERFORMANCE_THRESHOLD_LIMITS) as NumericToleranceKey[];
function EditablePageTitle({
pageId,
@@ -126,311 +131,24 @@ function EditablePageTitle({
);
}
-function SettingTooltip({ id, label, help }: { id: string; label: string; help: string }) {
- return (
-
-
-
- {help}
-
-
- );
-}
-
-function SettingHeader({
- id,
- label,
- help,
- resetDisabled,
- onReset,
-}: {
- id: string;
- label: string;
- help: string;
- resetDisabled: boolean;
- onReset: () => void;
-}) {
- return (
-
- Send one JSON digest after each day's scheduled collection cohort settles. It includes a stable digest ID, date, title, summary, text, and a machine-readable list of every page that needs attention.
-
-
-
-
-
-
- {alertWebhookValid
- ? normalizedAlertWebhookDraft ? "HTTPS only. Treat this URL as a secret; it is used only for outbound alert delivery." : "Leave blank to disable webhook alerts."
- : "Enter a valid HTTPS URL without embedded username or password credentials."}
-
-
-
-
-
-
Default chart device
-
Choose which device is primary when the app opens. Both device Change labels remain visible.
-
-
-
-
-
-
-
-
-
Visitor experience data
-
- Show or hide Chrome visitor measurements throughout the app. Collection continues weekly while this is hidden.
-
- Adds an independent, origin-level agent-readiness audit from Ora, the scanner behind Is Agentic. It runs
- only when you ask for it, and it never changes your Page Watch checks, performance scores, or page status.
-
-
- Enabling this sends the production origin of each watched page to Ora. Ora scans
- are public:{" "}
- the result is stored in Ora's directory, can appear in its leaderboard and research
- statistics, and is readable by anyone. Webflow staging domains are never sent.
-
- {thresholdsValid
- ? thresholdsDirty ? "Unsaved tolerance changes." : "All monitoring tolerances are saved."
- : "One or more values are outside the supported range."}
-
-
-
-
-
-
-
-
-
-
-
Default agent checks to exclude
-
- Excluded checks are left out of agent-readiness scores on every page. Individual pages can override these defaults.
-
);
@@ -1108,9 +378,5 @@ export default function WatchlistPage() {
if (!canManageProject) router.replace(pathFor("/dashboard"));
}, [canManageProject, pathFor, router]);
if (!canManageProject) return null;
- return ;
-}
-
-export function SettingsPageContent() {
- return ;
+ return ;
}
diff --git a/src/app/api/pages/[id]/performance-thresholds/route.ts b/src/app/api/pages/[id]/performance-thresholds/route.ts
deleted file mode 100644
index c0334d2..0000000
--- a/src/app/api/pages/[id]/performance-thresholds/route.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { NextResponse } from "next/server";
-import { setPagePerformanceThresholdOverrides } from "@/lib/mutations";
-import { performanceThresholdOverridesAreValid } from "@/lib/performanceThresholds";
-import { projectStore } from "@/lib/projects";
-
-export const runtime = "nodejs";
-export const dynamic = "force-dynamic";
-
-export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
- const body = await req.json().catch(() => null);
- if (!performanceThresholdOverridesAreValid(body)) {
- return NextResponse.json({ error: "One or more page overrides are outside the supported range" }, { status: 400 });
- }
- try {
- const { id } = await params;
- return NextResponse.json({ state: await setPagePerformanceThresholdOverrides(id, body, await projectStore(req)) });
- } catch (error) {
- return NextResponse.json({ error: String(error) }, { status: 400 });
- }
-}
diff --git a/src/app/api/settings/agent-ignores/route.ts b/src/app/api/settings/agent-ignores/route.ts
index a3dce53..332815e 100644
--- a/src/app/api/settings/agent-ignores/route.ts
+++ b/src/app/api/settings/agent-ignores/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { setDefaultAgentIgnore } from "@/lib/mutations";
import type { AgentIgnoreScope } from "@/lib/types";
+import { EXCLUSION_REASONS, type ExclusionReason } from "@/lib/vocabulary";
import { projectStore } from "@/lib/projects";
export const runtime = "nodejs";
@@ -10,6 +11,8 @@ interface Body {
scope?: AgentIgnoreScope;
value?: string;
ignored?: boolean;
+ /** Required to exclude, since S8; ignored on an include, which needs none. */
+ reason?: unknown;
}
export async function POST(req: Request) {
@@ -23,8 +26,19 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "ignored must be a boolean" }, { status: 400 });
}
+ const reason = body.reason;
+ if (reason !== undefined && !(EXCLUSION_REASONS as readonly string[]).includes(reason as string)) {
+ return NextResponse.json({ error: "reason must be one of the decided exclusion reasons" }, { status: 400 });
+ }
+
try {
- const state = await setDefaultAgentIgnore(body.scope, value, body.ignored, await projectStore(req));
+ const state = await setDefaultAgentIgnore(
+ body.scope,
+ value,
+ body.ignored,
+ await projectStore(req),
+ reason as ExclusionReason | undefined,
+ );
return NextResponse.json({ state });
} catch (error) {
const message = String(error);
diff --git a/src/app/api/settings/digest/route.ts b/src/app/api/settings/digest/route.ts
new file mode 100644
index 0000000..6219d95
--- /dev/null
+++ b/src/app/api/settings/digest/route.ts
@@ -0,0 +1,49 @@
+import { NextResponse } from "next/server";
+import { setDigestSettings } from "@/lib/mutations";
+import { isDigestCadence } from "@/lib/digestCadence";
+import { digestRecipientIsValid, MAX_DIGEST_RECIPIENTS } from "@/lib/digestRecipients";
+import { projectStore } from "@/lib/projects";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * Cadence and recipients, together, because they are the whole of the digest
+ * setting. Daily or weekly, and who it goes to; there is no third field and no
+ * per-page variant of either.
+ *
+ * A bad address is rejected rather than dropped. `normalizeDigestRecipients`
+ * drops one when it reads stored state, which is right there and wrong here: a
+ * reader who typed an address and got a success response would believe somebody
+ * was on the list.
+ */
+export async function POST(req: Request) {
+ const body = (await req.json().catch(() => ({}))) as { cadence?: unknown; recipients?: unknown };
+ if (!isDigestCadence(body.cadence)) {
+ return NextResponse.json({ error: "Choose a daily or weekly digest" }, { status: 400 });
+ }
+ const recipients = body.recipients;
+ if (!Array.isArray(recipients) || recipients.some((entry) => typeof entry !== "string")) {
+ return NextResponse.json({ error: "Recipients must be a list of email addresses" }, { status: 400 });
+ }
+ if (recipients.length > MAX_DIGEST_RECIPIENTS) {
+ return NextResponse.json(
+ { error: `A digest goes to at most ${MAX_DIGEST_RECIPIENTS} addresses` },
+ { status: 400 },
+ );
+ }
+ const invalid = (recipients as string[]).find((entry) => !digestRecipientIsValid(entry));
+ if (invalid !== undefined) {
+ return NextResponse.json({ error: `"${invalid}" is not an email address` }, { status: 400 });
+ }
+
+ try {
+ const state = await setDigestSettings(
+ { cadence: body.cadence, recipients: recipients as string[] },
+ await projectStore(req),
+ );
+ return NextResponse.json({ state });
+ } catch (error) {
+ return NextResponse.json({ error: String(error) }, { status: 500 });
+ }
+}
diff --git a/src/app/api/settings/performance-thresholds/route.ts b/src/app/api/settings/performance-thresholds/route.ts
deleted file mode 100644
index 0a86cf4..0000000
--- a/src/app/api/settings/performance-thresholds/route.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { NextResponse } from "next/server";
-import { setPerformanceThresholds } from "@/lib/mutations";
-import { performanceThresholdsAreValid } from "@/lib/performanceThresholds";
-import type { PerformanceThresholds } from "@/lib/types";
-import { projectStore } from "@/lib/projects";
-
-export const runtime = "nodejs";
-export const dynamic = "force-dynamic";
-
-export async function POST(req: Request) {
- const body = (await req.json().catch(() => ({}))) as Partial;
- if (!performanceThresholdsAreValid(body)) {
- return NextResponse.json(
- { error: "One or more monitoring tolerances are missing or outside the supported range" },
- { status: 400 },
- );
- }
-
- try {
- const state = await setPerformanceThresholds(body, await projectStore(req));
- return NextResponse.json({ state });
- } catch (error) {
- return NextResponse.json({ error: String(error) }, { status: 500 });
- }
-}
diff --git a/src/app/api/settings/sensitivity/route.ts b/src/app/api/settings/sensitivity/route.ts
new file mode 100644
index 0000000..ab98c67
--- /dev/null
+++ b/src/app/api/settings/sensitivity/route.ts
@@ -0,0 +1,28 @@
+import { NextResponse } from "next/server";
+import { setSensitivity } from "@/lib/mutations";
+import { isSensitivity } from "@/lib/sensitivity";
+import { projectStore } from "@/lib/projects";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/**
+ * The one route that changes what this site considers worth reporting.
+ *
+ * It takes a position, never a threshold set. That is the API surface of option
+ * 10b: a client that could post twelve numbers is a client that could rebuild
+ * the panel this chunk deleted, on a screen nobody reviewed.
+ */
+export async function POST(req: Request) {
+ const body = (await req.json().catch(() => ({}))) as { sensitivity?: unknown };
+ if (!isSensitivity(body.sensitivity)) {
+ return NextResponse.json({ error: "Choose one of the three sensitivity positions" }, { status: 400 });
+ }
+
+ try {
+ const state = await setSensitivity(body.sensitivity, await projectStore(req));
+ return NextResponse.json({ state });
+ } catch (error) {
+ return NextResponse.json({ error: String(error) }, { status: 500 });
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index ff3036d..bd10a6a 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -647,63 +647,6 @@ button:disabled {
cursor: pointer;
}
-.tolerance-number-input {
- appearance: textfield;
- -moz-appearance: textfield;
-}
-
-.tolerance-number-input::-webkit-inner-spin-button,
-.tolerance-number-input::-webkit-outer-spin-button {
- appearance: none;
- -webkit-appearance: none;
- margin: 0;
-}
-
-.tolerance-stepper-button {
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: 0;
- padding: 0;
- border: 0;
- background: var(--surface-raised);
- color: var(--text-muted);
- cursor: pointer;
-}
-
-.tolerance-stepper-button:first-child {
- border-bottom: 1px solid var(--border-strong);
-}
-
-.tolerance-stepper-button:hover,
-.tolerance-stepper-button:active {
- background: var(--surface-raised);
- filter: none !important;
- transform: none !important;
-}
-
-.tolerance-stepper-button:focus-visible {
- position: relative;
- z-index: 1;
- outline-offset: -2px;
-}
-
-.watchlist-setting-card {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- gap: 18px;
- min-width: 0;
- padding: 15px 17px;
- border: 1px solid var(--border-hairline);
- border-radius: 11px;
- background: var(--surface-raised);
-}
-
-.watchlist-setting-card--wide {
- grid-column: 1 / -1;
-}
-
.watchlist-page-row {
position: relative;
transition:
@@ -904,100 +847,292 @@ button:disabled {
border: 0 !important;
}
-.setting-tooltip {
- position: relative;
- display: inline-flex;
+/* ── Settings ─────────────────────────────────────────────────────────────
+
+ One page, five groups, no tabs. Every rule here is a single-column stack
+ that reflows rather than a grid that needs a breakpoint to survive, because
+ the screen has one hard requirement: the appearance control must be
+ reachable at 320px. The sidebar's copy of that control collapses on narrow
+ viewports, and that is correct only because this one does not.
+*/
+
+.settings-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 0 40px 48px;
+}
+
+.settings-group {
+ min-width: 0;
+ padding: 20px;
+ border: 1px solid var(--border-hairline);
+ border-radius: 14px;
+ background: var(--surface-card);
+}
+
+.settings-group__head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ min-width: 0;
+}
+
+.settings-group__label {
+ margin: 0;
+ color: var(--text-body);
+ font-size: 13.5px;
+ font-weight: 600;
+}
+
+.settings-group__help {
+ margin: 4px 0 0;
+ max-width: 68ch;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.settings-group__action {
flex: none;
}
-.setting-tooltip-trigger {
- display: inline-flex;
+.settings-sensitivity {
+ display: flex;
+ flex-wrap: wrap;
align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- padding: 0;
- border: 0;
- border-radius: 50%;
- background: transparent;
+ gap: 14px 20px;
+ margin-top: 14px;
+}
+
+/* The resolved limits, beneath the control that resolves them. */
+.settings-limits {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 18px;
+ margin: 0;
+ min-width: 0;
+}
+
+.settings-limits__row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 7px;
+ min-width: 0;
+}
+
+.settings-limits__label {
color: var(--text-muted);
- cursor: help;
+ font-size: 12px;
}
-.setting-tooltip-trigger:hover,
-.setting-tooltip-trigger:focus-visible {
- background: var(--surface-raised);
- color: var(--text-body);
- filter: none !important;
- transform: none !important;
+.settings-limits__value {
+ margin: 0;
+ color: var(--magnitude-value);
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
}
-.setting-tooltip-content {
- position: absolute;
- z-index: 80;
- bottom: calc(100% + 8px);
- left: 50%;
- width: 248px;
- padding: 9px 11px;
+.settings-field {
+ display: grid;
+ gap: 7px;
+ margin-top: 16px;
+ min-width: 0;
+}
+
+.settings-field__label {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.settings-field__input {
+ width: 100%;
+ padding: 9px 10px;
border: 1px solid var(--border-strong);
- border-radius: 8px;
- background: var(--surface-raised);
- box-shadow: var(--shadow-popover);
+ border-radius: 7px;
+ background: var(--surface-input);
color: var(--text-body);
+ font: inherit;
+ font-size: 13px;
+ line-height: 1.5;
+ resize: vertical;
+}
+
+.settings-field__input[aria-invalid="true"] {
+ border-color: var(--status-danger-border);
+}
+
+.settings-field__foot {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-top: 8px;
+}
+
+.settings-field__help {
+ margin: 0;
+ min-width: 0;
+ color: var(--text-muted);
font-size: 12px;
- font-weight: 450;
- line-height: 1.45;
- opacity: 0;
- pointer-events: none;
- transform: translate(-50%, 4px);
- transition: opacity 120ms ease, transform 120ms ease;
+ line-height: 1.5;
}
-.setting-tooltip:hover .setting-tooltip-content,
-.setting-tooltip:focus-within .setting-tooltip-content {
- opacity: 1;
- transform: translate(-50%, 0);
+.settings-field__help.is-invalid {
+ color: var(--status-danger-text);
}
-.setting-reset-button,
-.setting-reset-all-button {
+.settings-save {
+ flex: none;
+ padding: 9px 13px;
border: 0;
- border-radius: 6px;
- background: transparent;
- color: var(--text-muted);
+ border-radius: 7px;
+ background: var(--action-primary-bg);
+ color: var(--action-primary-text);
font-size: 12px;
- font-weight: 550;
+ font-weight: 600;
cursor: pointer;
}
-.setting-reset-button {
- margin-left: auto;
- padding: 4px 6px;
+.settings-save:disabled {
+ cursor: default;
+ opacity: 0.55;
}
-.setting-reset-all-button {
- flex: none;
- padding: 6px 9px;
- border: 1px solid var(--border-strong);
+.settings-empty {
+ margin: 14px 0 0;
+ color: var(--text-muted);
+ font-size: 12.5px;
+ line-height: 1.5;
+}
+
+.settings-system {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ margin-top: 16px;
+ padding: 15px 17px;
+ border: 1px solid var(--border-hairline);
+ border-radius: 11px;
background: var(--surface-raised);
}
-.setting-reset-button:hover,
-.setting-reset-all-button:hover {
+.settings-system--stacked {
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.settings-system__name {
+ margin: 0;
color: var(--text-body);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.settings-system__note {
+ margin: 4px 0 0;
+ max-width: 68ch;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+/* ── Excluded from results ─────────────────────────────────────────────── */
+
+.excluded-list {
+ margin: 14px 0 0;
+ padding: 0;
+ list-style: none;
+ border: 1px solid var(--border-hairline);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.excluded-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 14px;
+ padding: 11px 14px;
background: var(--surface-raised);
- filter: none !important;
- transform: none !important;
}
-.metric-cutoff-grid {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px;
- padding-top: 14px;
+.excluded-row + .excluded-row {
border-top: 1px solid var(--border-hairline);
}
+.excluded-row__body {
+ display: flex;
+ flex: 1 1 220px;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 4px 10px;
+ min-width: 0;
+}
+
+.excluded-row__title {
+ color: var(--text-muted);
+ font-size: 13px;
+ /* The row keeps its name struck through rather than losing it: struck
+ through reads "not counted", and removing it would read "never existed". */
+ text-decoration: line-through;
+}
+
+.excluded-row__scope {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+
+.excluded-row__reason {
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 550;
+}
+
+.excluded-row__reading {
+ flex: none;
+ color: var(--text-muted);
+ font-size: 12.5px;
+ font-variant-numeric: tabular-nums;
+ text-decoration: line-through;
+}
+
+/* No reading is not a small reading, so it is never struck through as one. */
+.excluded-row__reading.is-unmeasured {
+ text-decoration: none;
+ font-style: italic;
+}
+
+.excluded-row__include {
+ flex: none;
+ padding: 4px 9px;
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ background: var(--surface-card);
+ color: var(--text-body);
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.excluded-add {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin-top: 12px;
+}
+
+.excluded-add .settings-field__input {
+ width: auto;
+ max-width: 100%;
+ flex: 1 1 240px;
+}
+
.dashboard-culprit-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -1024,20 +1159,6 @@ button:disabled {
min-width: 0;
}
-.metric-cutoff-control {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 7px;
- color: var(--text-muted);
- font-size: 12px;
- font-weight: 550;
-}
-
-.metric-cutoff-control .tolerance-stepper {
- width: 84px !important;
-}
-
.watched-page-link {
text-decoration: none;
text-underline-offset: 3px;
@@ -2346,39 +2467,12 @@ textarea:focus-visible,
}
- .watchlist-tolerance-grid {
- grid-template-columns: 1fr !important;
- }
-
.webflow-connection-summary,
.webflow-connection-detail-grid,
.webflow-connection-form {
grid-template-columns: 1fr !important;
}
- .watchlist-tolerance-actions {
- align-items: flex-start !important;
- flex-direction: column;
- }
-
- .watchlist-setting-card--wide {
- grid-column: auto;
- }
-
- .metric-cutoff-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .setting-tooltip-content {
- left: 0;
- transform: translate(0, 4px);
- }
-
- .setting-tooltip:hover .setting-tooltip-content,
- .setting-tooltip:focus-within .setting-tooltip-content {
- transform: translate(0, 0);
- }
-
.app-shell {
display: block !important;
}
@@ -2446,6 +2540,27 @@ textarea:focus-visible,
padding: 0 18px 36px !important;
}
+ .settings-page {
+ padding: 0 18px 36px;
+ }
+
+ /*
+ The group head stacks rather than squeezing. Below this width a heading and
+ a segmented control side by side leave the control about 90px, which is not
+ a control — and the appearance group is exactly that shape, so this is the
+ rule that keeps it usable down to 320px.
+ */
+ .settings-group__head {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12px;
+ }
+
+ .settings-system {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
.guide-toolbar {
position: static;
}
diff --git a/src/components/store.tsx b/src/components/store.tsx
index 996190f..98bb024 100644
--- a/src/components/store.tsx
+++ b/src/components/store.tsx
@@ -2,13 +2,16 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { DEFAULT_RANGE_DAYS } from "@/lib/types";
-import type { AgentIgnoreOverrideMode, AgentIgnoreScope, AppState, CategoryKey, CollectionSchedule, Flag, PagePerformanceThresholdOverrides, PerformanceThresholds, RangeDays, ScoreByCategory, Strategy } from "@/lib/types";
+import type { AgentIgnoreOverrideMode, AgentIgnoreScope, AppState, CategoryKey, CollectionSchedule, Flag, RangeDays, ScoreByCategory, Strategy } from "@/lib/types";
import type { CruxPageEvidence } from "@/lib/crux";
import type { ExternalAgentOriginAudit } from "@/lib/agentAudit";
import { updateAgentIgnoreOverride, updateAgentIgnoreSettings } from "@/lib/agentScoring";
import { collectionRequestMessage, collectionSettlementMessage, hasActiveCollections, startCollectionPolling, type CollectionRequestResult } from "@/lib/collectionPolling";
-import { effectivePerformanceThresholds, normalizePerformanceThresholdOverrides, normalizePerformanceThresholds } from "@/lib/performanceThresholds";
+import { normalizePerformanceThresholds } from "@/lib/performanceThresholds";
+import { thresholdsFor, type Sensitivity } from "@/lib/sensitivity";
+import type { DigestCadence } from "@/lib/digestCadence";
+import { normalizeDigestRecipients } from "@/lib/digestRecipients";
import {
byWorstMeasured,
casesInQueue,
@@ -129,7 +132,8 @@ interface StoreValue extends AppState {
reorderPages: (pageIds: string[]) => void;
renamePage: (id: string, title: string) => void;
setAgentIgnore: (id: string, scope: AgentIgnoreScope, value: string, mode: AgentIgnoreOverrideMode) => void;
- setDefaultAgentIgnore: (scope: AgentIgnoreScope, value: string, ignored: boolean) => void;
+ /** Applicability on a check or a category, for the whole site. A reason to exclude; none to include. */
+ setDefaultAgentIgnore: (scope: AgentIgnoreScope, value: string, ignored: boolean, reason?: ExclusionReason) => void;
/** Applicability on one native-element finding. `null` includes it again. */
setNativeElementApplicability: (id: string, findingId: string, reason: ExclusionReason | null) => void;
/**
@@ -137,8 +141,15 @@ interface StoreValue extends AppState {
* reversing a decision is another entry saying so.
*/
recordCaseDecision: (decision: CaseDecisionRequest) => void;
- updatePerformanceThresholds: (thresholds: PerformanceThresholds) => void;
- updatePagePerformanceThresholds: (id: string, overrides: PagePerformanceThresholdOverrides) => void;
+ /**
+ * The one control over what this site considers worth reporting.
+ *
+ * There is no per-page variant and no per-metric variant. S3 deleted the page
+ * calibration panel; S8 deleted the twelve fields, and neither has a new home
+ * here or anywhere else.
+ */
+ setSensitivity: (sensitivity: Sensitivity) => void;
+ updateDigestSettings: (cadence: DigestCadence, recipients: readonly string[]) => void;
updateCollectionSchedule: (schedule: CollectionSchedule) => void;
updateAlertWebhookUrl: (url: string) => void;
setVisitorExperienceVisible: (visible: boolean) => void;
@@ -640,16 +651,21 @@ export function StoreProvider({
);
const setDefaultAgentIgnore = useCallback(
- (scope: AgentIgnoreScope, value: string, ignored: boolean) => {
+ (scope: AgentIgnoreScope, value: string, ignored: boolean, reason?: ExclusionReason) => {
const cur = dataRef.current;
mutate(
{
...cur,
- agentIgnoreDefaults: updateAgentIgnoreSettings(cur.agentIgnoreDefaults, scope, value, ignored),
+ agentIgnoreDefaults: updateAgentIgnoreSettings(cur.agentIgnoreDefaults, scope, value, ignored, reason),
},
- { url: "/api/settings/agent-ignores", body: { scope, value, ignored } },
+ { url: "/api/settings/agent-ignores", body: { scope, value, ignored, reason } },
{
- success: `${scope === "group" ? "Category" : "Check"} ${ignored ? "ignored" : "restored"} by default`,
+ // The registry's words for the move, not this file's. Same two
+ // sentences the native-element control reports, because it is the
+ // same concept on a different object.
+ success: ignored
+ ? reason ? `${APPLICABILITY_LABEL.excluded} — ${reason}` : APPLICABILITY_LABEL.excluded
+ : `${APPLICABILITY_LABEL.included} again`,
failure: `Couldn't update the default ${scope} — try again`,
},
);
@@ -729,44 +745,44 @@ export function StoreProvider({
[mutate, user.email],
);
- const updatePerformanceThresholds = useCallback(
- (thresholds: PerformanceThresholds) => {
+ const setSensitivity = useCallback(
+ (sensitivity: Sensitivity) => {
const cur = dataRef.current;
- const next = normalizePerformanceThresholds(thresholds);
+ // Optimistically resolved here exactly as the server resolves it, so the
+ // limits shown under the control never briefly disagree with the position
+ // above it. `thresholdsFor` is the single owner of that resolution.
+ const thresholds = thresholdsFor(sensitivity);
mutate(
{
...cur,
- performanceThresholds: next,
+ sensitivity,
+ performanceThresholds: thresholds,
+ sensitivityNotice: undefined,
watcherNote: undefined,
+ pages: cur.pages.map((page) => ({
+ ...page,
+ status: pageTrend(page, "mobile", thresholds),
+ })),
},
- { url: "/api/settings/performance-thresholds", body: next },
+ { url: "/api/settings/sensitivity", body: { sensitivity } },
{
- success: "Performance tolerances updated",
- failure: "Couldn't update the performance tolerances — try again",
+ success: "Sensitivity updated — it applies from the next nightly run",
+ failure: "Couldn't update the sensitivity — try again",
},
);
},
[mutate],
);
- const updatePagePerformanceThresholds = useCallback(
- (id: string, overrides: PagePerformanceThresholdOverrides) => {
+ const updateDigestSettings = useCallback(
+ (cadence: DigestCadence, recipients: readonly string[]) => {
const cur = dataRef.current;
- const normalized = normalizePerformanceThresholdOverrides(overrides);
mutate(
+ { ...cur, digestCadence: cadence, digestRecipients: normalizeDigestRecipients([...recipients]) },
+ { url: "/api/settings/digest", body: { cadence, recipients } },
{
- ...cur,
- watcherNote: undefined,
- pages: cur.pages.map((page) => page.id === id ? {
- ...page,
- performanceThresholdOverrides: normalized,
- status: pageTrend(page, "mobile", effectivePerformanceThresholds(cur.performanceThresholds, normalized)),
- } : page),
- },
- { url: `/api/pages/${id}/performance-thresholds`, body: normalized },
- {
- success: Object.keys(normalized).length ? "Page calibration saved" : "Page calibration reset to team defaults",
- failure: "Couldn't update the page calibration — try again",
+ success: "Digest updated",
+ failure: "Couldn't update the digest — check the addresses and try again",
},
);
},
@@ -1305,8 +1321,8 @@ export function StoreProvider({
setDefaultAgentIgnore,
setNativeElementApplicability,
recordCaseDecision,
- updatePerformanceThresholds,
- updatePagePerformanceThresholds,
+ setSensitivity,
+ updateDigestSettings,
updateCollectionSchedule,
setExternalAgentAuditEnabled,
refreshExternalAgentAudit,
diff --git a/src/lib/__tests__/atomic-store.test.ts b/src/lib/__tests__/atomic-store.test.ts
index 5914d49..aa5a959 100644
--- a/src/lib/__tests__/atomic-store.test.ts
+++ b/src/lib/__tests__/atomic-store.test.ts
@@ -3,8 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { createFsStore, type DataStore } from "../store/fsStore";
-import { addPage, advanceTask, pendingPage, setAgentIgnore, setAlertWebhookUrl, setDefaultAgentIgnore, setNativeElementApplicability, setPageFlag, setPageOrder, setPagePerformanceThresholdOverrides, setPageTitle, setPerformanceThresholds } from "../mutations";
-import { DEFAULT_PERFORMANCE_THRESHOLDS } from "../performanceThresholds";
+import { addPage, advanceTask, pendingPage, setAgentIgnore, setAlertWebhookUrl, setDefaultAgentIgnore, setDigestSettings, setNativeElementApplicability, setPageFlag, setPageOrder, setPageTitle, setSensitivity } from "../mutations";
+import { SENSITIVITY_THRESHOLDS } from "../sensitivity";
import { agentCheckKey } from "../agentScoring";
import { captureBaseline, insertRecommendations, runNightly, runPage } from "../collector";
import type { AppState, CategoryScore, NightScores, Rec } from "../types";
@@ -306,39 +306,38 @@ describe("atomic tenant updates", () => {
expect(afterSettingChange.pages[0].history[0].agentReadiness?.ignoredCheckKeys).toEqual([failingKey]);
});
- it("persists team-wide performance tolerances", async () => {
+ /**
+ * The position is the setting; the limits are its resolution. Storing one
+ * without the other would leave a screen naming a position over numbers that
+ * disagreed with it, which is the opacity the one-control design exists to
+ * prevent.
+ */
+ it("persists a sensitivity position and the limits it resolves to", async () => {
const dataStore = await storeWithState();
- const state = await setPerformanceThresholds(
- {
- ...DEFAULT_PERFORMANCE_THRESHOLDS,
- lowPerformance: 72,
- regression: 5,
- confirmationRuns: 2,
- devicePolicy: "both",
- },
+ const state = await setSensitivity("low", dataStore);
+
+ expect(state.sensitivity).toBe("low");
+ expect(state.performanceThresholds).toEqual(SENSITIVITY_THRESHOLDS.low);
+ });
+
+ it("persists the digest cadence and its recipients together", async () => {
+ const dataStore = await storeWithState();
+
+ const state = await setDigestSettings(
+ { cadence: "weekly", recipients: ["Performance@Example.com", "performance@example.com"] },
dataStore,
);
- expect(state.performanceThresholds).toEqual({
- ...DEFAULT_PERFORMANCE_THRESHOLDS,
- lowPerformance: 72,
- regression: 5,
- confirmationRuns: 2,
- devicePolicy: "both",
- });
+ expect(state.digestCadence).toBe("weekly");
+ // De-duplicated case-insensitively: two spellings of one address are one
+ // recipient, and sending twice would be the product not reading its own list.
+ expect(state.digestRecipients).toEqual(["performance@example.com"]);
});
- it("persists page calibration and applies its evidence gates to new recommendations", async () => {
+ it("applies the resolved evidence gates to new recommendations", async () => {
const dataStore = await storeWithState();
- await setPagePerformanceThresholdOverrides("page", {
- regression: 14,
- confirmationRuns: 3,
- devicePolicy: "both",
- minimumFindingRuns: 3,
- minimumSavingsMs: 250,
- minimumSavingsKilobytes: 50,
- }, dataStore);
+ await setSensitivity("low", dataStore);
// IDs are deliberately unmapped/synthetic (not real Lighthouse audit IDs):
// this test exercises evidence-threshold gating, decoupled from webflow
@@ -351,10 +350,28 @@ describe("atomic tenant updates", () => {
{ id: "single-run", title: "Single-run finding", savingsMs: 900, observedRuns: 1 },
], new Date("2026-08-03T12:00:00.000Z"), { summarize: false });
- const state = await dataStore.getState();
- expect(state.pages[0].performanceThresholdOverrides).toMatchObject({ regression: 14, confirmationRuns: 3, devicePolicy: "both" });
- expect(state.recs.map((item) => item.id)).toEqual(expect.arrayContaining(["rec", "repeatable", "structural"]));
- expect(state.recs.map((item) => item.id)).not.toEqual(expect.arrayContaining(["weak", "single-run"]));
+ const low = await dataStore.getState();
+ expect(low.performanceThresholds).toEqual(SENSITIVITY_THRESHOLDS.low);
+ // At "Only big moves" the limit is a second and a finding must repeat: a
+ // 400 ms saving and a single-run 900 ms saving are both below what this
+ // reader asked to hear about. The structural finding has no measured saving
+ // at all, and rule 18 keeps it out of a gate about the size of one.
+ expect(low.recs.map((item) => item.id).sort()).toEqual(["rec", "structural"]);
+
+ // The same four findings, at the other end of the same control. This is the
+ // assertion that makes the gate traceable to the position rather than to a
+ // number nobody can find: nothing about the findings changed.
+ await setSensitivity("high", dataStore);
+ await insertRecommendations(dataStore, "page", [
+ { id: "weak", title: "Weak finding", savingsMs: 100, savingsBytes: 10_000, observedRuns: 3 },
+ { id: "repeatable", title: "Repeatable finding", savingsMs: 400, observedRuns: 3 },
+ { id: "structural", title: "Structural finding", savingsMs: 0, observedRuns: 3 },
+ { id: "single-run", title: "Single-run finding", savingsMs: 900, observedRuns: 1 },
+ ], new Date("2026-08-04T12:00:00.000Z"), { summarize: false });
+
+ const high = await dataStore.getState();
+ expect(high.recs.map((item) => item.id).sort())
+ .toEqual(["rec", "repeatable", "single-run", "structural", "weak"]);
});
it("keeps an unmapped audit ID's recommendation across repeated collection cycles instead of pruning it", async () => {
@@ -391,11 +408,9 @@ describe("atomic tenant updates", () => {
collectFn: async () => collection(80),
now: () => new Date("2026-08-01T12:00:00.000Z"),
});
- await setPagePerformanceThresholdOverrides("page", {
- regression: 8,
- confirmationRuns: 1,
- newPageGraceRuns: 0,
- }, dataStore);
+ // "Everything" is the position that reports a drop this size on the first
+ // run after a baseline — five points, one confirming run, one run of grace.
+ await setSensitivity("high", dataStore);
await runPage("page", {
dataStore,
@@ -421,7 +436,7 @@ describe("atomic tenant updates", () => {
const payload = alertFn.mock.calls[0][1];
expect(payload).toMatchObject({
event: "page_watch.daily_digest",
- version: 2,
+ version: 3,
id: "nightly:2026-08-02",
date: "2026-08-02",
site: "example.com",
diff --git a/src/lib/__tests__/digest-arrival.test.ts b/src/lib/__tests__/digest-arrival.test.ts
index cc3fed7..742bcca 100644
--- a/src/lib/__tests__/digest-arrival.test.ts
+++ b/src/lib/__tests__/digest-arrival.test.ts
@@ -41,8 +41,6 @@ import { pendingPage } from "../mutations";
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
const appDir = path.resolve(moduleDir, "../../app/(app)");
const AT = "2026-08-25T06:00:00.000Z";
-/** F4 records who fired a transition, not merely that a person did. */
-const PERSON: Caller = { kind: "person", userId: "rae@webflow.com" };
const DATE = "2026-08-25";
const APP = "https://watch.example.com/page-watch";
diff --git a/src/lib/__tests__/digest-cadence.test.ts b/src/lib/__tests__/digest-cadence.test.ts
index b907dd7..7815ef0 100644
--- a/src/lib/__tests__/digest-cadence.test.ts
+++ b/src/lib/__tests__/digest-cadence.test.ts
@@ -11,14 +11,14 @@ import {
} from "../digestCadence";
/**
- * The cadence the footer states, and the setting it is not.
+ * The cadence the footer states, and the shape of the setting behind it.
*
- * S7 states the cadence; S8 makes it changeable. The tests that matter here are
- * therefore about what has NOT been built: no writable field, no route, no
- * control — because a persisted setting nothing writes to is what rule 15 calls
- * not a slot at all. And when S8 does land it, it must land as one switch: no
- * per-page, per-metric or per-severity variant, each of which would let a reader
- * silence the line that mattered and keep a subject claiming nothing needed them.
+ * S7 stated the cadence and deliberately did not store it: a persisted field
+ * nothing writes to is what rule 15 calls not a slot at all. S8 built the
+ * writer, so the assertion flips — what is checked now is that the setting
+ * landed as ONE switch. No per-page, per-metric or per-severity variant, each
+ * of which would let a reader silence the line that mattered while the subject
+ * went on claiming nothing needed them.
*/
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -44,19 +44,23 @@ describe("the digest cadence", () => {
expect(isDigestCadence("hourly")).toBe(false);
});
- it("is stated, not stored — the setting is S8's", () => {
+ it("is stored, and by exactly one route", () => {
/**
- * Rule 15: an evidence slot with no producer is not a slot, and an empty one
- * reads to the user as a reading that found nothing. So there is no
- * `AppState.digestCadence`, no mutation and no route until something writes
- * to them. `digestFor` passes the default, and S8 changes that one line.
+ * The slot has a producer now, which is what rule 15 was waiting for. One
+ * route writes it, and it writes the recipients in the same call because
+ * they are the same setting: how often, and to whom.
+ *
+ * The route is named `digest` rather than `digest-cadence`, and that is the
+ * assertion worth keeping: a route per field is how one setting becomes
+ * three.
*/
- expect(code("../types.ts")).not.toContain("digestCadence");
- expect(code("../mutations.ts")).not.toContain("DigestCadence");
+ expect(code("../types.ts")).toContain("digestCadence");
+ expect(code("../mutations.ts")).toContain("DigestCadence");
const routes = readdirSync(path.resolve(moduleDir, "../../app/api/settings"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
- expect(routes).not.toContain("digest-cadence");
+ expect(routes).toContain("digest");
+ expect(routes.filter((name) => name.startsWith("digest-"))).toEqual([]);
});
it("is not tuned per page, per metric or per severity anywhere", () => {
diff --git a/src/lib/__tests__/digest.test.ts b/src/lib/__tests__/digest.test.ts
index 25b3e62..3da9d61 100644
--- a/src/lib/__tests__/digest.test.ts
+++ b/src/lib/__tests__/digest.test.ts
@@ -34,8 +34,6 @@ import { pendingPage } from "../mutations";
*/
const AT = "2026-08-25T06:00:00.000Z";
-/** F4 records who fired a transition, not merely that a person did. */
-const PERSON: Caller = { kind: "person", userId: "rae@webflow.com" };
const DATE = "2026-08-25";
const APP = "https://watch.example.com/page-watch";
const SCHEDULE = { localTime: "00:00", timeZone: "America/Chicago", overridden: true };
@@ -290,8 +288,14 @@ describe("a reading nobody took", () => {
});
it("withholds it again when there is no limit the reader set", () => {
- // At 0 the gate is off, so there is nothing to attribute to anyone.
- const digest = digestOf({ cases: [cameBackCase()] });
+ // At 0 the gate is off, so there is nothing to attribute to anyone. No
+ // sensitivity position resolves to 0 — that is precisely why they do not,
+ // since a position with no limit has nothing to show under the control —
+ // but the digest must still be honest about a stored set that has one.
+ const digest = digestOf({
+ cases: [cameBackCase()],
+ thresholds: normalizePerformanceThresholds({ minimumSavingsMs: 0 }),
+ });
expect(linesIn(digest, "came_back")[0].text).toBe("The Unused JavaScript on Home is back.");
});
diff --git a/src/lib/__tests__/performance-thresholds.test.ts b/src/lib/__tests__/performance-thresholds.test.ts
index aea9a15..e37a89b 100644
--- a/src/lib/__tests__/performance-thresholds.test.ts
+++ b/src/lib/__tests__/performance-thresholds.test.ts
@@ -1,12 +1,11 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_PERFORMANCE_THRESHOLDS,
- effectivePerformanceThresholds,
normalizePerformanceThresholds,
- performanceThresholdOverridesAreValid,
performanceThresholdsAreValid,
recommendationMeetsEvidenceThresholds,
} from "../performanceThresholds";
+import { DEFAULT_SENSITIVITY, SENSITIVITY_THRESHOLDS } from "../sensitivity";
describe("performance thresholds", () => {
it("normalizes missing and out-of-range persisted values", () => {
@@ -52,14 +51,13 @@ describe("performance thresholds", () => {
expect(performanceThresholdsAreValid({ lowPerformance: 70, regression: 5 })).toBe(false);
});
- it("layers sparse page calibration over normalized team defaults", () => {
- expect(effectivePerformanceThresholds(
- { ...DEFAULT_PERFORMANCE_THRESHOLDS, regression: 8, confirmationRuns: 2 },
- { regression: 14, devicePolicy: "both" },
- )).toMatchObject({ regression: 14, confirmationRuns: 2, devicePolicy: "both" });
- expect(performanceThresholdOverridesAreValid({ regression: 14, minimumFindingRuns: 3 })).toBe(true);
- expect(performanceThresholdOverridesAreValid({ regression: 0 })).toBe(false);
- expect(performanceThresholdOverridesAreValid({ mystery: 2 })).toBe(false);
+ /**
+ * The default set and the default sensitivity position are one fact. This is
+ * the assertion rule 20 asks for when a value has two readers: it fails the
+ * moment somebody edits the default here instead of moving the position.
+ */
+ it("defaults to the limits the Normal position resolves to", () => {
+ expect(DEFAULT_PERFORMANCE_THRESHOLDS).toBe(SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY]);
});
it("gates only quantified recommendations and preserves structural findings", () => {
diff --git a/src/lib/__tests__/sensitivity.test.ts b/src/lib/__tests__/sensitivity.test.ts
new file mode 100644
index 0000000..d361e0b
--- /dev/null
+++ b/src/lib/__tests__/sensitivity.test.ts
@@ -0,0 +1,333 @@
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+import { recordCheckpointReading } from "../checkpoint-evaluation";
+import { buildDigest } from "../digest";
+import { digestLimit } from "../digest-copy";
+import { markFixed, type IssueCase } from "../issue-case";
+import { pendingPage } from "../mutations";
+import type { Caller } from "../caller";
+import {
+ DEFAULT_PERFORMANCE_THRESHOLDS,
+ PERFORMANCE_THRESHOLD_LIMITS,
+ normalizePerformanceThresholds,
+ performanceThresholdsAreValid,
+} from "../performanceThresholds";
+import {
+ DEFAULT_SENSITIVITY,
+ SENSITIVITIES,
+ SENSITIVITY_THRESHOLDS,
+ exactSensitivity,
+ nearestSensitivity,
+ normalizeSensitivity,
+} from "../sensitivity";
+import { SENSITIVITY_LABEL, SETTINGS_SENSITIVITY_LIMIT_LABEL, settingsMigrated } from "../settings-copy";
+import { normalizeState } from "../store/normalize";
+import type { AppState, PerformanceThresholds, WatchPage } from "../types";
+
+/**
+ * One control, and the promise it makes.
+ *
+ * Option 10b is only honest if two things hold, and everything here is one of
+ * them:
+ *
+ * - The limits a position resolves to are the limits the digest uses. Not
+ * equivalent numbers — the same string, from the same function, so a
+ * reworded unit cannot reach one reader and not the other.
+ * - A configuration somebody made by hand is mapped rather than discarded,
+ * and its owner is told once.
+ *
+ * Registry rule 21: these assert against the other half of the decision rather
+ * than against literals. `expect(limit).toBe("250 ms")` would prove that two
+ * copies of one string agree, never that either is right.
+ */
+
+const moduleDir = path.dirname(fileURLToPath(import.meta.url));
+
+const NUMERIC_KEYS = Object.keys(PERFORMANCE_THRESHOLD_LIMITS) as Array;
+
+describe("the sensitivity positions", () => {
+ it("has exactly the three the brief locked, and Normal is the default", () => {
+ expect(SENSITIVITIES).toEqual(["low", "normal", "high"]);
+ expect(Object.keys(SENSITIVITY_LABEL).sort()).toEqual([...SENSITIVITIES].sort());
+ expect(DEFAULT_SENSITIVITY).toBe("normal");
+ expect(normalizeSensitivity(undefined)).toBe(DEFAULT_SENSITIVITY);
+ expect(normalizeSensitivity("paranoid")).toBe(DEFAULT_SENSITIVITY);
+ });
+
+ it("resolves every position to a complete, in-range, already-normal threshold set", () => {
+ for (const position of SENSITIVITIES) {
+ const thresholds = SENSITIVITY_THRESHOLDS[position];
+ expect(performanceThresholdsAreValid(thresholds), position).toBe(true);
+ // Normalising must be a no-op. A position whose numbers get clamped on
+ // the way in would mean the screen names one thing and the run uses
+ // another.
+ expect(normalizePerformanceThresholds(thresholds), position).toEqual(thresholds);
+ expect(exactSensitivity(thresholds)).toBe(position);
+ }
+ });
+
+ it("moves every limit in one direction as the control moves", () => {
+ /**
+ * A reader who moves the control towards "Everything" must never find that
+ * some hidden number moved the other way. Asserted on the fields where
+ * "more sensitive" has an unambiguous direction; `lowPerformance`,
+ * `accessibility`, `bestPractices`, `seo`, `regressionFloor` and
+ * `agentReadiness` are cutoffs, so more sensitive is higher, and the rest
+ * are gates, so more sensitive is lower.
+ */
+ const higherIsMoreSensitive = new Set([
+ "lowPerformance",
+ "accessibility",
+ "bestPractices",
+ "seo",
+ "regressionFloor",
+ "agentReadiness",
+ ]);
+ for (const key of NUMERIC_KEYS) {
+ const [low, normal, high] = SENSITIVITIES.map((position) => SENSITIVITY_THRESHOLDS[position][key]);
+ const ordered = higherIsMoreSensitive.has(key)
+ ? low <= normal && normal <= high
+ : low >= normal && normal >= high;
+ expect(ordered, `${key} does not move in one direction: ${low} / ${normal} / ${high}`).toBe(true);
+ }
+ });
+
+ it("never resolves the savings gate to zero", () => {
+ // At 0 the gate is off, `digestLimit` returns null, and there is nothing to
+ // show under the control — a position that resolves to nothing cannot be
+ // displayed, which is the whole reason "Everything" is 1 ms rather than 0.
+ for (const position of SENSITIVITIES) {
+ expect(digestLimit(SENSITIVITY_THRESHOLDS[position]), position).not.toBeNull();
+ }
+ // And the three are distinguishable, so moving the control visibly moves
+ // the limit rather than resolving two positions to one string.
+ const shown = SENSITIVITIES.map((position) => digestLimit(SENSITIVITY_THRESHOLDS[position]));
+ expect(new Set(shown).size).toBe(SENSITIVITIES.length);
+ });
+
+ it("is the same fact as the default threshold set", () => {
+ expect(DEFAULT_PERFORMANCE_THRESHOLDS).toBe(SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY]);
+ });
+});
+
+/* ── The limit the screen shows is the limit the digest wrote ───────────── */
+
+const AT = "2026-08-04T09:00:00.000Z";
+/**
+ * Whoever marked the fix, named.
+ *
+ * F4 split the registry's `actor` — which classes MAY fire a transition — from
+ * the record of who did, so a bare class no longer satisfies the guard. Nothing
+ * here renders them; these limits are about cases, not about who moved them.
+ */
+const PERSON: Caller = { kind: "person", userId: "rae@webflow.com" };
+
+function pageOf(): WatchPage {
+ return { ...pendingPage("home", "Home", "https://www.example.com/", "watching"), lastRunAt: AT };
+}
+
+/**
+ * A case the evaluator brought back, not one posed as already back.
+ *
+ * Built through `markFixed` and `recordCheckpointReading` for the same reason
+ * `digest.test.ts` does: a hand-written checkpoint array can describe a shape
+ * the lifecycle cannot produce, and a test that asserts against one proves
+ * nothing about the digest a real run would write.
+ */
+function cameBackCase(): IssueCase {
+ const posed: IssueCase = {
+ id: "PW-1",
+ cause: "c",
+ state: "in_progress",
+ title: "Unused JavaScript",
+ diagnosis: "The homepage ships a bundle nothing on it uses.",
+ detectedAt: AT,
+ confirmedRuns: 2,
+ scope: "pages",
+ pageIds: ["home"],
+ strategies: ["mobile"],
+ impactMs: 1_800,
+ effort: "hours",
+ confidence: "confirmed",
+ remediation: { steps: ["Remove it."], actionability: "direct" },
+ successCriteria: "Gone.",
+ checkpoints: [],
+ evidence: [],
+ history: [],
+ };
+ return recordCheckpointReading(
+ markFixed(posed, { by: PERSON, at: AT }),
+ { interval: "7d", outcome: "disagreed", at: AT },
+ ).issue;
+}
+
+describe("the limits shown under the control", () => {
+ it("are the strings the digest writes, character for character", () => {
+ /**
+ * The VERIFY line this chunk exists to satisfy. `digestLimit` has two
+ * readers — the digest's threshold clause and the settings screen — and
+ * this asserts they are reading the same thing rather than two copies that
+ * happen to agree today (rule 20).
+ *
+ * The clause is read out of a real built digest rather than out of
+ * `digest-copy`, so a change to how the sentence is assembled fails here
+ * too, not only a change to how the number is formatted.
+ */
+ for (const position of SENSITIVITIES) {
+ const thresholds = SENSITIVITY_THRESHOLDS[position];
+ const shown = digestLimit(thresholds);
+ expect(shown, position).not.toBeNull();
+
+ const digest = buildDigest({
+ site: "example.com",
+ date: "2026-08-04",
+ cadence: "daily",
+ pages: [pageOf()],
+ thresholds,
+ appUrl: "https://watch.example.com",
+ cases: [cameBackCase()],
+ });
+
+ const line = digest.sections.find((section) => section.kind === "came_back")?.lines[0];
+ expect(line, position).toBeDefined();
+ expect(line!.text, position).toContain(`above the ${shown} you set`);
+ }
+ });
+
+ it("is labelled by the screen and valued by the digest", () => {
+ // The split is deliberate: S8 owns the noun, S7 owns the number. The label
+ // must not contain the value, or it would be a second copy of it.
+ expect(SETTINGS_SENSITIVITY_LIMIT_LABEL).not.toMatch(/\d/);
+ });
+});
+
+/* ── Migration ──────────────────────────────────────────────────────────── */
+
+function stateWith(thresholds?: Partial): AppState {
+ return {
+ pages: [],
+ recs: [],
+ ...(thresholds ? { performanceThresholds: thresholds as PerformanceThresholds } : {}),
+ };
+}
+
+describe("a site that tuned the twelve thresholds by hand", () => {
+ it("maps to the nearest position rather than losing the configuration", () => {
+ // Deliberately close to "Only big moves" without matching it: a bigger
+ // savings gate, more confirming runs, both devices.
+ const handTuned: Partial = {
+ ...SENSITIVITY_THRESHOLDS.low,
+ regression: 22,
+ minimumSavingsMs: 900,
+ };
+ expect(exactSensitivity(handTuned)).toBeNull();
+ expect(nearestSensitivity(handTuned)).toBe("low");
+
+ const state = normalizeState(stateWith(handTuned));
+ expect(state.sensitivity).toBe("low");
+ // The limits are the position's, not the hand-tuned ones. That is the cost
+ // of the abstraction and it is why the reader is told.
+ expect(state.performanceThresholds).toEqual(SENSITIVITY_THRESHOLDS.low);
+ });
+
+ it("is told once, in the digest footer, in the position's own words", () => {
+ const state = normalizeState(stateWith({ ...SENSITIVITY_THRESHOLDS.high, minimumSavingsMs: 3 }));
+ expect(state.sensitivityNotice).toBe(SENSITIVITY_LABEL.high);
+
+ const digest = buildDigest({
+ site: "example.com",
+ date: "2026-08-04",
+ cadence: "daily",
+ cases: [],
+ pages: [pageOf()],
+ thresholds: normalizePerformanceThresholds(state.performanceThresholds),
+ appUrl: "https://watch.example.com",
+ notice: settingsMigrated(state.sensitivityNotice!),
+ });
+ expect(digest.footer.text).toContain(settingsMigrated(SENSITIVITY_LABEL.high));
+ // It names the position, never the numbers it replaced: those are what the
+ // reader no longer has a control for, and repeating them would only
+ // describe something they cannot get back.
+ expect(digest.footer.text).not.toContain("minimumSavingsMs");
+ });
+
+ it("says nothing to a site that never tuned anything", () => {
+ expect(normalizeState(stateWith()).sensitivityNotice).toBeUndefined();
+ expect(normalizeState(stateWith(SENSITIVITY_THRESHOLDS.normal)).sensitivityNotice).toBeUndefined();
+ // Nor to a site that already has a position: the notice is for the
+ // migration, and a stored position means the migration already happened.
+ const settled: AppState = { ...stateWith(SENSITIVITY_THRESHOLDS.low), sensitivity: "low" };
+ expect(normalizeState(settled).sensitivityNotice).toBeUndefined();
+ });
+
+ it("rewrites the limits from the position on every read", () => {
+ // The position is the setting; the limits are its resolution. A stored set
+ // that disagrees loses, or there would be two settings and only one of them
+ // visible.
+ const drifted: AppState = {
+ ...stateWith({ ...SENSITIVITY_THRESHOLDS.normal, regression: 3 }),
+ sensitivity: "normal",
+ };
+ expect(normalizeState(drifted).performanceThresholds).toEqual(SENSITIVITY_THRESHOLDS.normal);
+ });
+});
+
+/* ── No threshold control outside /settings ─────────────────────────────── */
+
+function sourceFiles(dir: string): string[] {
+ return readdirSync(dir).flatMap((entry) => {
+ const full = path.join(dir, entry);
+ if (statSync(full).isDirectory()) return entry === "__tests__" ? [] : sourceFiles(full);
+ return /\.tsx?$/.test(entry) ? [full] : [];
+ });
+}
+
+describe("where a threshold may be edited", () => {
+ /**
+ * One screen, and the check is structural rather than a promise in a comment.
+ *
+ * The twelve fields were deleted, not relocated, and the page-detail
+ * calibration panel S3 removed is given no new home. What this asserts is
+ * that nothing outside `/settings` and the sensitivity route can WRITE a
+ * threshold: reading `performanceThresholds` is what half the app does, and
+ * banning that would be banning the feature.
+ */
+ const writers = [
+ "updatePerformanceThresholds",
+ "updatePagePerformanceThresholds",
+ "setPerformanceThresholds",
+ "setPagePerformanceThresholdOverrides",
+ "performanceThresholdOverrides",
+ ];
+
+ /**
+ * The one place a retired name may still appear, and only to erase it.
+ *
+ * `normalizeState` deletes any stored `performanceThresholdOverrides` when it
+ * reads state, because a value nothing can change and nothing should read is
+ * worse left in the record than removed from it. Naming the exception here
+ * rather than loosening the match keeps the check honest: a second file with
+ * the same string still fails.
+ */
+ const ERASER = "lib/store/normalize.ts";
+
+ it("is nowhere, for every retired writer", () => {
+ const src = path.resolve(moduleDir, "../..");
+ const offenders = sourceFiles(src)
+ .filter((file) => writers.some((writer) => readFileSync(file, "utf8").includes(writer)))
+ .map((file) => path.relative(src, file).split(path.sep).join("/"))
+ .filter((file) => file !== ERASER);
+ expect(offenders, "a retired threshold writer survives").toEqual([]);
+ });
+
+ it("leaves exactly one route that changes what is worth reporting", () => {
+ const settingsRoutes = readdirSync(path.resolve(moduleDir, "../../app/api/settings"), { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name);
+ expect(settingsRoutes).toContain("sensitivity");
+ expect(settingsRoutes).not.toContain("performance-thresholds");
+ });
+});
diff --git a/src/lib/__tests__/settings-copy.test.ts b/src/lib/__tests__/settings-copy.test.ts
new file mode 100644
index 0000000..e104fb9
--- /dev/null
+++ b/src/lib/__tests__/settings-copy.test.ts
@@ -0,0 +1,98 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+import {
+ MAX_DIGEST_RECIPIENTS,
+ digestRecipientIsValid,
+ formatDigestRecipients,
+ normalizeDigestRecipients,
+ parseDigestRecipients,
+} from "../digestRecipients";
+import { SENSITIVITY_LABEL, settingsMigrated, settingsSubtitle } from "../settings-copy";
+import { APPLICABILITY_ACTION_LABEL, DESTINATION_LABEL } from "../vocabulary";
+
+/**
+ * The locked copy, verbatim, and the strings this module refuses to restate.
+ *
+ * The verbatim half is straightforward: the brief locked these words and a
+ * paraphrase is a defect. The interesting half is the refusals — four of the
+ * brief's strings are NOT in `settings-copy.ts`, because something else already
+ * owns them, and a second copy would agree today and drift the first time
+ * either was reworded (rule 20). This asserts the ownership rather than the
+ * value, so the check survives a rewording of the thing it points at.
+ */
+
+const moduleDir = path.dirname(fileURLToPath(import.meta.url));
+/**
+ * Comments stripped first. The module explains at length why it does not carry
+ * the four strings below, and a check that tripped over its own justification
+ * would only teach the next editor to delete the paragraph.
+ */
+const copySource = readFileSync(path.resolve(moduleDir, "../settings-copy.ts"), "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/^\s*\/\/.*$/gm, "");
+
+describe("the locked settings copy", () => {
+ it("says exactly what the brief locked", () => {
+ expect(settingsSubtitle("brandstudio.com"))
+ .toBe("For brandstudio.com. Changes apply from the next nightly run.");
+ expect(SENSITIVITY_LABEL.low).toBe("Only big moves");
+ expect(SENSITIVITY_LABEL.normal).toBe("Normal");
+ expect(SENSITIVITY_LABEL.high).toBe("Everything");
+ expect(settingsMigrated(SENSITIVITY_LABEL.normal))
+ .toBe("Your per-metric thresholds became the Normal setting. Change it in Settings.");
+ });
+
+ it("does not restate the title, which the registry names", () => {
+ // "Settings" is `DESTINATION_LABEL.settings`. A screen does not name itself.
+ expect(DESTINATION_LABEL.settings).toBe("Settings");
+ expect(copySource).not.toMatch(/SETTINGS_TITLE|=\s*"Settings"/);
+ });
+
+ it("does not restate Include, which the applicability concept names", () => {
+ expect(APPLICABILITY_ACTION_LABEL.include).toBe("Include");
+ expect(copySource).not.toMatch(/"Include"/);
+ });
+
+ it("does not restate the limits, which the digest names", () => {
+ // The value under the control comes from `digestLimit`. If a unit or a
+ // number appeared in this module it would be a second spelling of a string
+ // the digest already writes.
+ expect(copySource).not.toMatch(/\bms"|\bms'|\b\d+\s?ms\b/);
+ });
+});
+
+describe("who the digest goes to", () => {
+ it("rejects the shapes that are certainly not addresses", () => {
+ expect(digestRecipientIsValid("performance@example.com")).toBe(true);
+ expect(digestRecipientIsValid(" performance@example.co.uk ")).toBe(true);
+ expect(digestRecipientIsValid("performance")).toBe(false);
+ expect(digestRecipientIsValid("performance@example")).toBe(false);
+ expect(digestRecipientIsValid("two people@example.com")).toBe(false);
+ expect(digestRecipientIsValid("")).toBe(false);
+ });
+
+ it("reads a textarea as one address per line", () => {
+ expect(parseDigestRecipients("a@example.com\n\n b@example.com \n"))
+ .toEqual(["a@example.com", "b@example.com"]);
+ expect(formatDigestRecipients(["a@example.com", "b@example.com"]))
+ .toBe("a@example.com\nb@example.com");
+ });
+
+ it("stores one recipient per person, however they were typed", () => {
+ // Two spellings of one address are one recipient. Sending twice would be
+ // the product failing to read its own list.
+ expect(normalizeDigestRecipients(["A@Example.com", "a@example.com", "b@example.com"]))
+ .toEqual(["a@example.com", "b@example.com"]);
+ expect(normalizeDigestRecipients(["not an address"])).toEqual([]);
+ expect(normalizeDigestRecipients(undefined)).toEqual([]);
+ expect(normalizeDigestRecipients("a@example.com")).toEqual([]);
+ });
+
+ it("caps the list rather than letting one site fan out without limit", () => {
+ const many = Array.from({ length: MAX_DIGEST_RECIPIENTS + 5 }, (_, index) => `p${index}@example.com`);
+ expect(normalizeDigestRecipients(many)).toHaveLength(MAX_DIGEST_RECIPIENTS);
+ });
+});
diff --git a/src/lib/__tests__/settings-exclusions.test.ts b/src/lib/__tests__/settings-exclusions.test.ts
new file mode 100644
index 0000000..96d41d2
--- /dev/null
+++ b/src/lib/__tests__/settings-exclusions.test.ts
@@ -0,0 +1,263 @@
+import { describe, expect, it } from "vitest";
+
+import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS } from "../agentChecks";
+import { agentCheckKey, updateAgentIgnoreSettings } from "../agentScoring";
+import { NOT_MEASURED } from "../impact-format";
+import { excludePage, type IssueCase } from "../issue-case";
+import { pendingPage } from "../mutations";
+import { detectNativeWebflowElements } from "../nativeElements";
+import { excludedFromResults } from "../settings-exclusions";
+import type { AppState, Night, WatchPage } from "../types";
+import {
+ AGENT_RESULT_LABEL,
+ APPLICABILITY_ACTION_LABEL,
+ UNLABELLED_EXCLUSION_REASON,
+ applicabilityActionLabel,
+} from "../vocabulary";
+
+/**
+ * The one list, and the promise every row in it makes.
+ *
+ * "Excluding is not deleting" is the registry's sentence and this is where it
+ * is enforced: a row that lost its reading would say the thing was never
+ * measured, and a row that lost its reason would be the agent tab's original
+ * failure — evidence hidden without saying why — rebuilt on a settings screen.
+ *
+ * Rule 18 has a second job here. A row with no reading says so in words rather
+ * than showing 0, because an absent measurement is not a small one, and a
+ * settings screen is the easiest place in a product to let a blank cell read as
+ * a zero.
+ */
+
+const AT = "2026-08-04T06:00:00.000Z";
+const FINDING_ID = "webflow-background-video";
+
+/**
+ * A real detection rather than a posed one.
+ *
+ * The finding is produced by `detectNativeWebflowElements` from markup, so the
+ * title and the count under test are the ones a scan actually writes. A
+ * hand-built finding would assert this module against a fixture's spelling of a
+ * reading instead of against the reading (rule 21).
+ */
+const SCANNED = detectNativeWebflowElements(
+ `
+
+
+
+ `,
+);
+const BACKGROUND_VIDEO = SCANNED.find((finding) => finding.id === FINDING_ID)!;
+
+const SCORES = { m: 90, lo: 89, hi: 91 };
+
+function nightWith(): Night {
+ const night: Night = {
+ i: 0,
+ date: "Aug 4",
+ iso: AT,
+ scores: {
+ mobile: { perf: SCORES, a11y: SCORES, bp: SCORES, seo: SCORES },
+ desktop: { perf: SCORES, a11y: SCORES, bp: SCORES, seo: SCORES },
+ },
+ nativeElements: { status: "available", findings: SCANNED },
+ };
+ return night;
+}
+
+function pageWith(overrides: Partial = {}): WatchPage {
+ return { ...pendingPage("home", "Home", "https://example.com/", "watching"), ...overrides };
+}
+
+const FIRST_CHECK = ALL_AGENT_CHECKS[0]!;
+const FIRST_GROUP = AGENT_CHECK_GROUPS[0]!;
+
+describe("everything this site has set aside", () => {
+ it("keeps an excluded finding's last reading and its reason", () => {
+ const state: AppState = {
+ pages: [pageWith({
+ history: [nightWith()],
+ nativeElementControls: {
+ [FINDING_ID]: { excluded: { reason: "Intentional" }, updatedAt: AT },
+ },
+ })],
+ recs: [],
+ };
+
+ const [row] = excludedFromResults(state);
+ expect(row.kind).toBe("check");
+ expect(row.title).toBe(BACKGROUND_VIDEO.title);
+ // Scoped to the page it was excluded on, so "why am I not seeing this" has
+ // an answer that names a place as well as a reason.
+ expect(row.scope).toBe("Home");
+ expect(row.reason).toBe("Intentional");
+ // The count the scan recorded, not a number this test chose.
+ expect(BACKGROUND_VIDEO.count).toBe(3);
+ expect(row.reading).toBe(`${BACKGROUND_VIDEO.count} instances`);
+ expect(row.measured).toBe(true);
+ expect(row.include).toEqual({ target: "native-element", pageId: "home", findingId: FINDING_ID });
+ });
+
+ it("says a row has no reading rather than showing it as none", () => {
+ // Rule 18. Excluded before any scan ever saw it: there is no count, and a
+ // blank cell or a 0 would both read as "we looked and found nothing".
+ const state: AppState = {
+ pages: [pageWith({
+ nativeElementControls: {
+ [FINDING_ID]: { excluded: { reason: "Accepted risk" }, updatedAt: AT },
+ },
+ })],
+ recs: [],
+ };
+
+ const [row] = excludedFromResults(state);
+ expect(row.reading).toBe(NOT_MEASURED);
+ expect(row.measured).toBe(false);
+ });
+
+ it("reports the reason this reader chose, when they were asked for one", () => {
+ // S8's Excluded list asks; the toggle it replaced did not. A record written
+ // by the new control carries its own reason rather than the migrated one.
+ const state: AppState = {
+ pages: [pageWith()],
+ recs: [],
+ agentIgnoreDefaults: updateAgentIgnoreSettings(
+ { checks: [], groups: [] },
+ "group",
+ FIRST_GROUP.name,
+ true,
+ "Accepted risk",
+ ),
+ };
+
+ const [row] = excludedFromResults(state);
+ expect(row.reason).toBe("Accepted risk");
+
+ // And Include drops it: a reason for something that is counted again is not
+ // a reason, and keeping it would let a later exclusion silently reinstate a
+ // decision nobody made the second time.
+ const included = updateAgentIgnoreSettings(state.agentIgnoreDefaults, "group", FIRST_GROUP.name, false);
+ expect(included.reasons).toBeUndefined();
+ });
+
+ it("reports a check's worst reading across the site, never a tally", () => {
+ /**
+ * Rule 19: the figure standing for several pages is the worst reading one
+ * of them produced. A check that failed on one page reads Failed here even
+ * where it passed on another, so the row can be reconciled with the pages
+ * beneath it rather than being an average nobody can click through to.
+ */
+ const state: AppState = {
+ pages: [
+ pageWith({ id: "home", agent: [{ ...FIRST_CHECK, pass: true }] }),
+ pageWith({ id: "pricing", title: "Pricing", agent: [{ ...FIRST_CHECK, pass: false }] }),
+ ],
+ recs: [],
+ agentIgnoreDefaults: { checks: [agentCheckKey(FIRST_CHECK)], groups: [] },
+ };
+
+ const [row] = excludedFromResults(state);
+ expect(row.title).toBe(FIRST_CHECK.name);
+ expect(row.reading).toBe(AGENT_RESULT_LABEL.failed);
+ // The toggle that set this never asked for a reason, so it carries the one
+ // that restates its own definition rather than none at all.
+ expect(row.reason).toBe(UNLABELLED_EXCLUSION_REASON);
+ });
+
+ it("does not list a check twice when its whole category is excluded", () => {
+ // Two rows for one exclusion would make Include ambiguous: including the
+ // check would leave the category's exclusion in place and look broken.
+ const inGroup = ALL_AGENT_CHECKS.find((check) => check.group === FIRST_GROUP.name)!;
+ const state: AppState = {
+ pages: [pageWith()],
+ recs: [],
+ agentIgnoreDefaults: { checks: [agentCheckKey(inGroup)], groups: [FIRST_GROUP.name] },
+ };
+
+ const rows = excludedFromResults(state);
+ expect(rows).toHaveLength(1);
+ expect(rows[0].include).toEqual({ target: "agent-check", scope: "group", value: FIRST_GROUP.name });
+ });
+
+ it("covers pages as well as checks, in one list", () => {
+ const issue = {
+ id: "PW-1",
+ cause: "c",
+ state: "new",
+ title: "Unused JavaScript",
+ diagnosis: "The homepage ships a bundle nothing on it uses.",
+ detectedAt: AT,
+ confirmedRuns: 2,
+ scope: "pages",
+ pageIds: ["home", "pricing"],
+ strategies: ["mobile"],
+ impactMs: 1_800,
+ effort: "hours",
+ confidence: "confirmed",
+ remediation: { steps: ["Remove it."], actionability: "direct" },
+ successCriteria: "Gone.",
+ checkpoints: [],
+ evidence: [],
+ history: [],
+ } as unknown as IssueCase;
+ const excluded = excludePage(issue, "pricing", "Not applicable to this site", {
+ // Who excluded it, named. The registry's `actor` is a permission set; this
+ // is the record of who did (F4).
+ by: { kind: "person", userId: "rae@webflow.com" },
+ at: AT,
+ page: "Pricing",
+ });
+
+ const state: AppState = {
+ pages: [pageWith(), pageWith({ id: "pricing", title: "Pricing" })],
+ recs: [],
+ };
+
+ const rows = excludedFromResults(state, [excluded]);
+ const page = rows.find((row) => row.kind === "page")!;
+ // The row carries the case, not a key. The decision log is keyed on the
+ // remediation and `remediationKey` is its single producer, so a row that
+ // carried a precomputed key would put a second one in circulation — the
+ // detachment F5's guard exists to catch.
+ expect(page.include).toEqual({ target: "case-page", issue: excluded, pageId: "pricing" });
+ expect(page.title).toBe("Pricing");
+ expect(page.scope).toBe("Unused JavaScript");
+ expect(page.reason).toBe("Not applicable to this site");
+ // The reading survives the exclusion. Struck through on screen says "not
+ // counted"; removing it would say "never measured".
+ expect(page.reading).toBe("1.8 s");
+ expect(page.measured).toBe(true);
+ });
+
+ it("puts pages before checks and never orders either by importance", () => {
+ /**
+ * An exclusion has no rank — the reader already decided each of these does
+ * not apply. Sorting by cost or severity would be the product arguing with
+ * a decision it was told about, so the order is kind then name.
+ */
+ const state: AppState = {
+ pages: [pageWith({
+ history: [nightWith()],
+ nativeElementControls: {
+ [FINDING_ID]: { excluded: { reason: "Intentional" }, updatedAt: AT },
+ },
+ })],
+ recs: [],
+ agentIgnoreDefaults: { checks: [], groups: [FIRST_GROUP.name] },
+ };
+
+ const rows = excludedFromResults(state);
+ expect(rows.every((row) => row.kind === "check")).toBe(true);
+ expect(rows.map((row) => row.title)).toEqual([...rows.map((row) => row.title)].sort((a, b) => a.localeCompare(b)));
+ });
+
+ it("offers the registry's word for putting something back", () => {
+ // Rule 20: the button reads "Include" because the registry says so, not
+ // because this screen or the case detail each decided to spell it that way.
+ expect(applicabilityActionLabel("excluded")).toBe(APPLICABILITY_ACTION_LABEL.include);
+ });
+
+ it("is empty when nothing has been set aside", () => {
+ expect(excludedFromResults({ pages: [pageWith()], recs: [] })).toEqual([]);
+ });
+});
diff --git a/src/lib/__tests__/settings-reachability.test.ts b/src/lib/__tests__/settings-reachability.test.ts
new file mode 100644
index 0000000..f0e9dfa
--- /dev/null
+++ b/src/lib/__tests__/settings-reachability.test.ts
@@ -0,0 +1,107 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+
+import { DESTINATION_PATH } from "../vocabulary";
+
+/**
+ * The appearance control must be reachable at 320px.
+ *
+ * This is the assertion that makes the sidebar's collapse correct rather than
+ * broken. The sidebar footer keeps a copy of the control as a shortcut and
+ * hides it on a narrow viewport; that is fine — and only fine — because
+ * `/settings` is canonical and survives to 320px. If this test ever fails, the
+ * sidebar's collapse becomes a real defect the same day.
+ *
+ * Checked structurally rather than by rendering, because what would break it is
+ * a CSS rule or a layout container, not a component's return value. The three
+ * ways it could break are the three things asserted: the route stops being
+ * reachable, the group stops fitting, or something hides it outright.
+ */
+
+const moduleDir = path.dirname(fileURLToPath(import.meta.url));
+const read = (file: string) => readFileSync(path.resolve(moduleDir, file), "utf8");
+
+const css = read("../../app/globals.css");
+const sidebar = read("../../components/Sidebar.tsx");
+const settings = read("../../app/(app)/settings/page.tsx");
+
+/** Every `@media (max-width: N)` block in the stylesheet, with its width. */
+function narrowBlocks(): Array<{ width: number; body: string }> {
+ const blocks: Array<{ width: number; body: string }> = [];
+ const opener = /@media\s*\(max-width:\s*(\d+)px\)\s*\{/g;
+ let match: RegExpExecArray | null;
+ while ((match = opener.exec(css)) !== null) {
+ let depth = 1;
+ let index = match.index + match[0].length;
+ const start = index;
+ while (index < css.length && depth > 0) {
+ if (css[index] === "{") depth += 1;
+ else if (css[index] === "}") depth -= 1;
+ index += 1;
+ }
+ blocks.push({ width: Number(match[1]), body: css.slice(start, index - 1) });
+ }
+ return blocks;
+}
+
+describe("the appearance control at 320px", () => {
+ it("is on a destination the collapsed sidebar still links to", () => {
+ // Below 760px the sidebar becomes a row of icons: the labels are hidden but
+ // every link survives, so Settings is one tap away at any width.
+ expect(sidebar).toContain(`destination: "settings"`);
+ expect(DESTINATION_PATH.settings).toBe("/settings");
+ expect(settings).toContain("AppearanceControl");
+ });
+
+ it("is a shortcut in the sidebar, and the shortcut is the copy that may collapse", () => {
+ /**
+ * The order matters: `AppearanceControl` appears in the sidebar AFTER the
+ * `sidebar-admin` container opens, which is the block globals.css hides on
+ * a narrow viewport. That is what makes the collapse a shortcut
+ * disappearing rather than the only control disappearing.
+ */
+ const container = sidebar.indexOf(`className="sidebar-admin"`);
+ const control = sidebar.indexOf(" /\.sidebar-admin[^{]*\{[^}]*display:\s*none/.test(body))).toBe(true);
+ });
+
+ it("is never hidden, at any width", () => {
+ // Nothing in a narrow-viewport block may hide a settings container. A
+ // `display: none` here would silently remove the canonical control and
+ // leave only the shortcut that is already hidden.
+ for (const { width, body } of narrowBlocks()) {
+ const hidden = /\.settings-[a-z-]*(?:__[a-z-]+)?[^{]*\{[^}]*display:\s*none/.exec(body);
+ expect(hidden?.[0], `a settings container is hidden at ${width}px`).toBeUndefined();
+ }
+ });
+
+ it("stacks its group rather than squeezing the control into a corner", () => {
+ /**
+ * The appearance group is a heading beside a three-segment control. Side by
+ * side at 320px the control gets roughly 90px, which is not a control — so
+ * the head stacks. Asserted because it is the rule that does the work, and
+ * because deleting it would leave a screen that technically renders and
+ * cannot be used.
+ */
+ const narrow = narrowBlocks().find(({ body }) => body.includes(".settings-group__head"));
+ expect(narrow, "no narrow-viewport rule for the settings group head").toBeDefined();
+ expect(narrow!.body).toMatch(/\.settings-group__head\s*\{[^}]*flex-direction:\s*column/);
+ // And the page's own padding comes down with it, so a 320px viewport is not
+ // spending a quarter of its width on gutters.
+ expect(narrow!.body).toMatch(/\.settings-page\s*\{[^}]*padding:/);
+ });
+
+ it("lays the page out as a stack with nothing that cannot narrow", () => {
+ // No fixed width, no min-width, no multi-column grid: the failure mode this
+ // rules out is a horizontal scrollbar rather than a hidden control.
+ const page = /\.settings-page\s*\{([^}]*)\}/.exec(css)?.[1] ?? "";
+ expect(page).toContain("flex-direction: column");
+ expect(page).not.toMatch(/min-width|grid-template-columns/);
+ const group = /\.settings-group\s*\{([^}]*)\}/.exec(css)?.[1] ?? "";
+ expect(group).toContain("min-width: 0");
+ });
+});
diff --git a/src/lib/__tests__/watcher.test.ts b/src/lib/__tests__/watcher.test.ts
index 6c77ed9..b851ebd 100644
--- a/src/lib/__tests__/watcher.test.ts
+++ b/src/lib/__tests__/watcher.test.ts
@@ -10,6 +10,16 @@ const cat = (m: number): CategoryScore => ({ m, lo: m - 1, hi: m + 1 });
const ns = (s: ScoreByCategory): NightScores => ({ perf: cat(s.perf), a11y: cat(s.a11y), bp: cat(s.bp), seo: cat(s.seo) });
const strat = (s: ScoreByCategory): StrategyScores => ({ mobile: ns(s), desktop: ns(s) });
+/**
+ * Both devices must report before a page may carry a verdict.
+ *
+ * A team setting now, not a page one. These cases used to reach it through
+ * `performanceThresholdOverrides`, which S8 deleted along with the panel that
+ * edited it — the behaviour under test is unchanged, only where the policy is
+ * set.
+ */
+const BOTH_DEVICES = { devicePolicy: "both" } as const;
+
function page(id: string, baseline: ScoreByCategory, current: ScoreByCategory): WatchPage {
return {
id,
@@ -77,33 +87,32 @@ describe("buildWatcher — a category nobody measured", () => {
scores: strat(scores),
availableStrategies: ["desktop"] as Strategy[],
})),
- performanceThresholdOverrides: { devicePolicy: "both" },
};
}
it("does not claim stability for a device that never reported", () => {
- const w = buildWatcher([desktopOnly("home")], [], "mobile");
+ const w = buildWatcher([desktopOnly("home")], [], "mobile", 30, undefined, BOTH_DEVICES);
expect(w.winning, "claimed stability over a device with no readings").toBeNull();
});
it("still claims stability for the device that did report", () => {
// The withholding must be about the missing reading, not about the page.
- const w = buildWatcher([desktopOnly("home")], [], "desktop");
+ const w = buildWatcher([desktopOnly("home")], [], "desktop", 30, undefined, BOTH_DEVICES);
expect(w.winning).toBe("Accessibility and SEO are stable across the board.");
});
it("withholds the claim for the whole board when one page is unmeasured", () => {
// "Across the board" means every page on the board. A measured page that
// held does not cover for an unmeasured neighbour.
- const measured = { ...page("pricing", good, good), performanceThresholdOverrides: { devicePolicy: "both" as const } };
- const w = buildWatcher([measured, desktopOnly("home")], [], "mobile");
+ const measured = page("pricing", good, good);
+ const w = buildWatcher([measured, desktopOnly("home")], [], "mobile", 30, undefined, BOTH_DEVICES);
expect(w.winning).toBeNull();
});
it("still reports a real drop rather than going quiet", () => {
// Withholding the good news must not also swallow the bad news.
- const dropped = { ...page("pricing", good, { ...good, a11y: 40 }), performanceThresholdOverrides: { devicePolicy: "both" as const } };
- const w = buildWatcher([dropped, desktopOnly("home")], [], "mobile");
+ const dropped = page("pricing", good, { ...good, a11y: 40 });
+ const w = buildWatcher([dropped, desktopOnly("home")], [], "mobile", 30, undefined, BOTH_DEVICES);
expect(w.changed.some((bullet) => bullet.text.includes("Accessibility"))).toBe(true);
});
});
@@ -234,14 +243,13 @@ describe("buildWatcher — the page it falls back to", () => {
scores: strat({ ...good, perf }),
availableStrategies: ["desktop"] as Strategy[],
})),
- performanceThresholdOverrides: { devicePolicy: "both" as const },
});
it("recommends nothing when no page produced a reading on this device", () => {
// Withholding, not failing: the summary is still built, it simply does not
// name a page no run has read. The old comparator scored this page 100 and
// recommended something about it.
- const w = buildWatcher([desktopOnly("home", 40)], [rec("home")], "mobile", 3);
+ const w = buildWatcher([desktopOnly("home", 40)], [rec("home")], "mobile", 3, undefined, BOTH_DEVICES);
expect(w.topRec).toBeNull();
});
@@ -258,6 +266,8 @@ describe("buildWatcher — the page it falls back to", () => {
[rec("perfect"), rec("unknown")],
"mobile",
3,
+ undefined,
+ BOTH_DEVICES,
);
expect(w.topRec?.pageId).toBe("perfect");
});
diff --git a/src/lib/__tests__/webhook.test.ts b/src/lib/__tests__/webhook.test.ts
index a8b0cc7..a7f519e 100644
--- a/src/lib/__tests__/webhook.test.ts
+++ b/src/lib/__tests__/webhook.test.ts
@@ -18,8 +18,6 @@ afterEach(() => {
});
const AT = "2026-08-04T06:00:00.000Z";
-/** F4 records who fired a transition, not merely that a person did. */
-const PERSON: Caller = { kind: "person", userId: "rae@webflow.com" };
/**
* Whoever marked the fix. The payload is the digest message and the digest is
@@ -86,15 +84,20 @@ describe("alert webhook", () => {
at: AT,
}).issue,
]);
- const payload = buildDailyDigestWebhookPayload(digest, "nightly:2026-08-04");
+ const payload = buildDailyDigestWebhookPayload(digest, "nightly:2026-08-04", ["ops@example.com"]);
expect(payload).toMatchObject({
event: "page_watch.daily_digest",
- version: 2,
+ // 3 since S8: the payload carries the cadence and the recipients the site
+ // named, because Page Watch has no mail transport and the endpoint on the
+ // other end is what turns a message into deliveries.
+ version: 3,
id: "nightly:2026-08-04",
date: "2026-08-04",
site: "example.com",
subject: digest.subject,
+ cadence: digest.cadence,
+ recipients: ["ops@example.com"],
});
expect(payload.text).toBe(renderDigestMessage(digest).text);
expect(payload.sections.map((section) => section.kind)).toEqual(
@@ -109,6 +112,10 @@ describe("alert webhook", () => {
const payload = buildDailyDigestWebhookPayload(digestOf(), "nightly:2026-08-04");
expect(payload.subject).toBe("example.com · nothing needs you");
expect(payload.sections).toEqual([]);
+ // Nobody named is an empty list, never an absent field. An absent field
+ // would read to the receiver as "unknown", and unknown is where a message
+ // gets sent to a default nobody chose.
+ expect(payload.recipients).toEqual([]);
});
it("does not attempt delivery without a configured URL", async () => {
diff --git a/src/lib/agentScoring.ts b/src/lib/agentScoring.ts
index ba8c409..a8767ad 100644
--- a/src/lib/agentScoring.ts
+++ b/src/lib/agentScoring.ts
@@ -22,13 +22,35 @@ export function agentCheckKey(check: Pick): string
return `${check.group}${CHECK_KEY_SEPARATOR}${check.name}`;
}
+/**
+ * How a reason is keyed to the thing it explains.
+ *
+ * Scoped, because a category and a check may share a name and their exclusions
+ * are different decisions. One function so the writer and the reader cannot
+ * disagree about the shape of the key (rule 20).
+ */
+export function agentExclusionKey(scope: AgentIgnoreScope, value: string): string {
+ return `${scope}${CHECK_KEY_SEPARATOR}${value}`;
+}
+
export function normalizeAgentIgnoreSettings(settings?: AgentIgnoreSettings): AgentIgnoreSettings {
const checks = Array.isArray(settings?.checks) ? settings.checks : [];
const groups = Array.isArray(settings?.groups) ? settings.groups : [];
- return {
+ const normalized: AgentIgnoreSettings = {
checks: [...new Set(checks.filter((value) => typeof value === "string" && value.length > 0))].sort(),
groups: [...new Set(groups.filter((value) => typeof value === "string" && value.length > 0))].sort(),
};
+ // A reason for something that is not excluded is not a reason. Keeping one
+ // would let an Include followed by an Exclude silently reinstate a reason
+ // nobody chose the second time.
+ const excluded = new Set([
+ ...normalized.groups.map((value) => agentExclusionKey("group", value)),
+ ...normalized.checks.map((value) => agentExclusionKey("check", value)),
+ ]);
+ const reasons = Object.entries(settings?.reasons ?? {})
+ .filter(([key, reason]) => excluded.has(key) && typeof reason === "string" && reason.length > 0);
+ if (reasons.length > 0) normalized.reasons = Object.fromEntries(reasons.sort());
+ return normalized;
}
export function updateAgentIgnoreSettings(
@@ -36,13 +58,22 @@ export function updateAgentIgnoreSettings(
scope: AgentIgnoreScope,
value: string,
ignored: boolean,
+ /**
+ * Why it does not apply. Required by the registry to exclude, and meaningless
+ * to include — `normalizeAgentIgnoreSettings` drops the record either way
+ * when the thing is counted again.
+ */
+ reason?: string,
): AgentIgnoreSettings {
const normalized = normalizeAgentIgnoreSettings(settings);
const key = scope === "group" ? "groups" : "checks";
const values = new Set(normalized[key]);
if (ignored) values.add(value);
else values.delete(value);
- return { ...normalized, [key]: [...values].sort() };
+ const reasons = { ...normalized.reasons };
+ if (ignored && reason) reasons[agentExclusionKey(scope, value)] = reason;
+ else delete reasons[agentExclusionKey(scope, value)];
+ return normalizeAgentIgnoreSettings({ ...normalized, [key]: [...values].sort(), reasons });
}
export function agentIgnoreOverrideMode(
diff --git a/src/lib/cohortAnomaly.ts b/src/lib/cohortAnomaly.ts
index 3d1a350..da925cb 100644
--- a/src/lib/cohortAnomaly.ts
+++ b/src/lib/cohortAnomaly.ts
@@ -1,4 +1,4 @@
-import { effectivePerformanceThresholds, normalizePerformanceThresholds } from "./performanceThresholds";
+import { normalizePerformanceThresholds } from "./performanceThresholds";
import { mediansOf, nightHasStrategy, pageTrend } from "./scoring";
import type {
AppState,
@@ -165,7 +165,7 @@ export function evaluateCohortAnomaly(
mobile: mediansOf(previous.scores.mobile),
desktop: mediansOf(previous.scores.desktop),
};
- page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(thresholds, page));
+ page.status = pageTrend(page, "mobile", normalizePerformanceThresholds(thresholds));
}
const existing = state.measurementIncident;
const confirmationAttempts =
diff --git a/src/lib/collector.ts b/src/lib/collector.ts
index 9bb68fe..ea83d7e 100644
--- a/src/lib/collector.ts
+++ b/src/lib/collector.ts
@@ -11,7 +11,7 @@ import { generateText } from "./anthropic";
import { buildWatcher } from "./watcher";
import { getEnv } from "./env";
import { mediansOf, pageRangeComparison, pageRangeTrend } from "./scoring";
-import { effectivePerformanceThresholds, normalizePerformanceThresholds, recommendationMeetsEvidenceThresholds } from "./performanceThresholds";
+import { normalizePerformanceThresholds, recommendationMeetsEvidenceThresholds } from "./performanceThresholds";
import { parseMarkerDate, shortDate } from "./ui";
import { CATEGORIES, STRATEGIES } from "./types";
import type {
@@ -183,7 +183,7 @@ export async function insertRecommendations(
const snapshot = await dataStore.getState();
const page = snapshot.pages.find((item) => item.id === pageId);
if (!page) return snapshot;
- const thresholds = effectivePerformanceThresholds(snapshot.performanceThresholds, page);
+ const thresholds = normalizePerformanceThresholds(snapshot.performanceThresholds);
const added = shortDate(now);
const actionable = opportunities.filter((opportunity) =>
recommendationMeetsEvidenceThresholds(opportunity, thresholds)
@@ -270,7 +270,7 @@ export async function generateWatcherNote(dataStore: DataStore, now: Date): Prom
const w = buildWatcher(state.pages, state.recs, "desktop", 30, state.agentIgnoreDefaults, thresholds, visitorEvidence);
const regressionDetail = state.pages.flatMap((p) => {
if (!isPageActivelyMonitored(p)) return [];
- if (pageRangeTrend(p, "desktop", 30, effectivePerformanceThresholds(thresholds, p)) !== "regressing" || !p.baseline) return [];
+ if (pageRangeTrend(p, "desktop", 30, normalizePerformanceThresholds(thresholds)) !== "regressing" || !p.baseline) return [];
const drop = Math.abs(pageRangeComparison(p, "desktop", "perf", 30)?.delta ?? 0);
const marker = p.markers.length ? p.markers[p.markers.length - 1].text : null;
return [`${p.title}: dropped ${drop} performance points${marker ? ` after "${marker}"` : ""}`];
diff --git a/src/lib/dailyDigest.ts b/src/lib/dailyDigest.ts
index f2c49da..d08e311 100644
--- a/src/lib/dailyDigest.ts
+++ b/src/lib/dailyDigest.ts
@@ -1,8 +1,10 @@
import { collectionInstant, collectionLocalDateTime, collectionOffsets, normalizeCollectionSchedule } from "./collectionSchedule";
import { buildDigest, digestSiteOf, type Digest } from "./digest";
-import { DEFAULT_DIGEST_CADENCE } from "./digestCadence";
+import { normalizeDigestCadence } from "./digestCadence";
+import { normalizeDigestRecipients } from "./digestRecipients";
import { issueCasesFrom } from "./issue-cases";
import { normalizePerformanceThresholds } from "./performanceThresholds";
+import { settingsMigrated } from "./settings-copy";
import type { AppState, DailyAlertDigest } from "./types";
import { isPageActivelyMonitored } from "./watchCapacity";
import { buildDailyDigestWebhookPayload, postWebhook } from "./webhook";
@@ -30,14 +32,19 @@ export function digestFor(state: AppState, date: string, appUrl: string): Digest
return buildDigest({
site: digestSiteOf(state),
date,
- // The cadence the digest is actually sent on. S8 makes it a setting and
- // passes the stored value here; until it does, a persisted field would be a
- // slot with nothing writing to it, which rule 15 says is not a slot.
- cadence: DEFAULT_DIGEST_CADENCE,
+ // The cadence the site chose, which S8 made writable. The footer states it
+ // because a reader who knows one arrives after every run knows that no
+ // digest means no run.
+ cadence: normalizeDigestCadence(state.digestCadence),
cases: issueCasesFrom(state),
pages: state.pages,
thresholds: normalizePerformanceThresholds(state.performanceThresholds),
...(state.collectionSchedule ? { schedule: state.collectionSchedule } : {}),
+ // One sentence, owed once, to a site whose hand-tuned thresholds were
+ // mapped onto a position. `finishDailyDigest` clears it when the message
+ // that carried it completes, so "once" survives a retry: a send that failed
+ // has not told anybody anything.
+ notice: state.sensitivityNotice ? settingsMigrated(state.sensitivityNotice) : null,
appUrl,
});
}
@@ -149,6 +156,9 @@ async function finishDailyDigest(
if (delivery?.sent) digest.sentAt = now.toISOString();
delete digest.lastError;
delete digest.retryAfterISO;
+ // Told once. Cleared only on a message that actually completed, so a
+ // failed send leaves the sentence owed rather than spent.
+ delete state.sensitivityNotice;
}
});
}
@@ -198,6 +208,7 @@ export async function processDailyDigests(
buildDailyDigestWebhookPayload(
digestFor(snapshot, claimed.date, options.appUrl ?? ""),
claimed.cohortId,
+ normalizeDigestRecipients(snapshot.digestRecipients),
),
);
await finishDailyDigest(dataStore, claimed, delivery, now);
diff --git a/src/lib/digest-copy.ts b/src/lib/digest-copy.ts
index bb95307..febcbd4 100644
--- a/src/lib/digest-copy.ts
+++ b/src/lib/digest-copy.ts
@@ -1,4 +1,6 @@
import { DIGEST_CADENCE_LABEL, type DigestCadence } from "./digestCadence";
+import { formatImpact } from "./impact-format";
+import type { PerformanceThresholds } from "./types";
import { formatDate } from "./watch-copy";
/**
@@ -73,6 +75,26 @@ export interface DigestThreshold {
limit: string;
}
+/**
+ * The limit a digest line names, written exactly as the line writes it.
+ *
+ * Exported because Settings shows the same string under the sensitivity
+ * control, and rule 20 is explicit about what happens otherwise: two spellings
+ * of one limit agree until somebody changes the unit on one of them, and then
+ * the screen that promises "this is what your digest will say" says something
+ * else. So the screen reads the limit from here rather than formatting the
+ * milliseconds again, and `settings-sensitivity.test.ts` asserts that what the
+ * screen shows is character-for-character what a built digest wrote.
+ *
+ * Null when the gate is off. There is then no limit the reader set, so there is
+ * nothing to attribute to them and nothing to display — which is why no
+ * sensitivity position resolves to 0.
+ */
+export function digestLimit(thresholds: PerformanceThresholds): string | null {
+ if (thresholds.minimumSavingsMs <= 0) return null;
+ return formatImpact(thresholds.minimumSavingsMs).text;
+}
+
/**
* A fix that did not hold.
*
@@ -169,9 +191,21 @@ export function digestFooter(
time: string,
cadence: DigestCadence,
site: string,
+ /**
+ * A sentence owed to this reader once, appended rather than sent alone.
+ *
+ * S8 maps a site's hand-tuned thresholds onto a sensitivity position instead
+ * of discarding them, and a migration nobody is told about is the same
+ * silent loss as discarding it. The footer is where it goes because the
+ * footer is already the sentence about how this message is configured, and
+ * because a message that exists only to announce a settings change is a
+ * message nobody asked for.
+ */
+ notice?: string | null,
): string {
const pages = `${pagesMeasured} ${pagesMeasured === 1 ? "page" : "pages"} measured at ${time}.`;
- return `${pages} ${DIGEST_CADENCE_LABEL[cadence]} digest for ${site} — ${DIGEST_FOOTER_CHANGE}.`;
+ const footer = `${pages} ${DIGEST_CADENCE_LABEL[cadence]} digest for ${site} — ${DIGEST_FOOTER_CHANGE}.`;
+ return notice ? `${footer} ${notice}` : footer;
}
/* ── Arrival ────────────────────────────────────────────────────────────── */
diff --git a/src/lib/digest.ts b/src/lib/digest.ts
index c710dba..07128c6 100644
--- a/src/lib/digest.ts
+++ b/src/lib/digest.ts
@@ -3,6 +3,7 @@ import { normalizeCollectionSchedule } from "./collectionSchedule";
import {
DIGEST_SECTION_HEADING,
digestFooter,
+ digestLimit,
digestLineBack,
digestLineDecide,
digestLineHeld,
@@ -113,6 +114,14 @@ export interface DigestInput {
site: string;
date: string;
cadence: DigestCadence;
+ /**
+ * One sentence the footer owes this reader, or nothing.
+ *
+ * The only thing in the message that is not about what the run found. It is
+ * here rather than composed in the template because the template decides
+ * nothing — see `digest-email.ts`.
+ */
+ notice?: string | null;
cases: readonly IssueCase[];
pages: readonly WatchPage[];
thresholds: PerformanceThresholds;
@@ -245,8 +254,9 @@ function diagnosisOf(issue: IssueCase): string {
*/
function thresholdOf(issue: IssueCase, thresholds: PerformanceThresholds) {
const reading = formatImpact(issue.impactMs);
- if (!reading.measured || thresholds.minimumSavingsMs <= 0) return null;
- return { reading: reading.text, limit: formatImpact(thresholds.minimumSavingsMs).text };
+ const limit = digestLimit(thresholds);
+ if (!reading.measured || limit === null) return null;
+ return { reading: reading.text, limit };
}
export interface DigestLineContext {
@@ -417,6 +427,7 @@ export function buildDigest(input: DigestInput): Digest {
`${schedule.localTime} ${schedule.timeZone}`,
cadence,
input.site,
+ input.notice,
),
href: absoluteUrl(input.appUrl, DESTINATION_PATH.settings),
},
diff --git a/src/lib/digestRecipients.ts b/src/lib/digestRecipients.ts
new file mode 100644
index 0000000..a0043b4
--- /dev/null
+++ b/src/lib/digestRecipients.ts
@@ -0,0 +1,64 @@
+/**
+ * Who the digest is for.
+ *
+ * One message per site, so one list of addresses per site. There is no per-page
+ * recipient, no per-section recipient and no per-severity recipient, and that
+ * is the same decision as everything else in S8: the digest is one message, so
+ * every setting about it is one setting.
+ *
+ * The addresses are carried to the delivery endpoint in the webhook payload
+ * rather than posted to a mail server here. Page Watch has no mail transport,
+ * and a recipients field that no producer read would be a slot that is not a
+ * slot (rule 15) — so it goes where the message goes, and the system on the
+ * other end knows who it is for.
+ */
+
+/**
+ * Deliberately not RFC 5322.
+ *
+ * A validator strict enough to be correct rejects addresses that work, and one
+ * loose enough to accept everything that works catches nothing. This rejects
+ * the shapes that are certainly wrong — no @, nothing before it, no dot after
+ * it, whitespace inside — and lets the delivery endpoint be the authority on
+ * the rest, which it is anyway.
+ */
+const SHAPE = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
+
+export const MAX_DIGEST_RECIPIENTS = 20;
+
+export function digestRecipientIsValid(value: string): boolean {
+ return SHAPE.test(value.trim());
+}
+
+/**
+ * The stored list: trimmed, de-duplicated, capped, and free of anything that is
+ * not an address.
+ *
+ * Silently dropping a malformed entry is right here and wrong in the form. The
+ * form tells the reader which line is not an address and refuses to save; this
+ * runs on stored state that has already been through that gate, where the only
+ * way a bad value arrives is a hand-edited record, and where keeping it would
+ * mean the digest names a recipient it can never reach.
+ */
+export function normalizeDigestRecipients(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ const seen = new Set();
+ for (const entry of value) {
+ if (typeof entry !== "string") continue;
+ const trimmed = entry.trim();
+ if (!digestRecipientIsValid(trimmed)) continue;
+ seen.add(trimmed.toLowerCase());
+ if (seen.size >= MAX_DIGEST_RECIPIENTS) break;
+ }
+ return [...seen];
+}
+
+/** What a textarea of one address per line means. Blank lines are not addresses. */
+export function parseDigestRecipients(text: string): string[] {
+ return text.split("\n").map((line) => line.trim()).filter(Boolean);
+}
+
+/** The same list, back in the shape the textarea shows. */
+export function formatDigestRecipients(recipients: readonly string[]): string {
+ return recipients.join("\n");
+}
diff --git a/src/lib/mutations.ts b/src/lib/mutations.ts
index 1fd0051..3eac8d1 100644
--- a/src/lib/mutations.ts
+++ b/src/lib/mutations.ts
@@ -1,13 +1,16 @@
import { randomUUID } from "node:crypto";
import { isKnownAgentIgnoreTarget } from "./agentChecks";
import { updateAgentIgnoreOverride, updateAgentIgnoreSettings } from "./agentScoring";
-import { effectivePerformanceThresholds, normalizePerformanceThresholdOverrides, normalizePerformanceThresholds, performanceThresholdOverridesAreValid, performanceThresholdsAreValid } from "./performanceThresholds";
+import { normalizePerformanceThresholds } from "./performanceThresholds";
+import { isSensitivity, thresholdsFor, type Sensitivity } from "./sensitivity";
+import { isDigestCadence, type DigestCadence } from "./digestCadence";
+import { normalizeDigestRecipients } from "./digestRecipients";
import { collectionScheduleIsValid, ensureCollectionOffsets } from "./collectionSchedule";
import { pageTrend } from "./scoring";
import { getStore } from "./store";
import type { DataStore } from "./store";
import { shortDate } from "./ui";
-import type { AgentIgnoreOverrideMode, AgentIgnoreScope, AppState, CollectionSchedule, Flag, PagePerformanceThresholdOverrides, PerformanceThresholds, RecStatus, ScoreByCategory, TaskStatus, WatchPage } from "./types";
+import type { AgentIgnoreOverrideMode, AgentIgnoreScope, AppState, CollectionSchedule, Flag, RecStatus, ScoreByCategory, TaskStatus, WatchPage } from "./types";
import { defaultNewPageFlag, flagCapacityError } from "./watchCapacity";
import { applyWatchlistPageOrder, changePageFlagOrder, sortWatchlistPages } from "./watchlistOrder";
import { removeTaskMarker } from "./taskMarkers";
@@ -92,17 +95,30 @@ export function setAgentIgnore(
}, dataStore);
}
+/**
+ * Set a check or a category aside for this site, or count it again.
+ *
+ * The reason is required to exclude, because applicability requires one — the
+ * control that used to write this never asked, and S8's Excluded list does. An
+ * unlabelled exclusion is still accepted so an older client is not broken by a
+ * 500; it reads as `UNLABELLED_EXCLUSION_REASON` on the way out, which is what
+ * that record has always meant.
+ */
export function setDefaultAgentIgnore(
scope: AgentIgnoreScope,
value: string,
ignored: boolean,
dataStore: DataStore = getStore(),
+ reason?: ExclusionReason,
): Promise {
return withState((state) => {
if (!isKnownAgentIgnoreTarget(scope, value)) {
throw new Error(`setDefaultAgentIgnore: ${scope} does not exist`);
}
- state.agentIgnoreDefaults = updateAgentIgnoreSettings(state.agentIgnoreDefaults, scope, value, ignored);
+ if (reason !== undefined && !(EXCLUSION_REASONS as readonly string[]).includes(reason)) {
+ throw new Error(`setDefaultAgentIgnore: "${reason}" is not an exclusion reason`);
+ }
+ state.agentIgnoreDefaults = updateAgentIgnoreSettings(state.agentIgnoreDefaults, scope, value, ignored, reason);
}, dataStore);
}
@@ -186,36 +202,54 @@ export async function recordCaseDecision(
}, dataStore);
}
-export function setPerformanceThresholds(
- thresholds: PerformanceThresholds,
+/**
+ * Move the one sensitivity control, and resolve the limits behind it.
+ *
+ * Both halves in one mutation, because they are one fact. The position is what
+ * the reader chose; the limits are what it means; storing the first without
+ * rewriting the second would leave a screen saying "Normal" over yesterday's
+ * numbers, which is precisely the opacity option 10b was chosen to avoid.
+ *
+ * A malformed position fails loudly rather than falling back to Normal. Rule 18
+ * draws that line: an absent value is withheld, but a value that should have
+ * been one of three and is not is a shape that should have been impossible, and
+ * quietly resetting a reader's sensitivity to the default is a worse outcome
+ * than a 400.
+ */
+export function setSensitivity(
+ sensitivity: Sensitivity,
dataStore: DataStore = getStore(),
): Promise {
- if (!performanceThresholdsAreValid(thresholds)) {
- throw new Error("setPerformanceThresholds: values are outside the supported range");
+ if (!isSensitivity(sensitivity)) {
+ throw new Error(`setSensitivity: "${sensitivity}" is not a sensitivity position`);
}
return withState((state) => {
- state.performanceThresholds = normalizePerformanceThresholds(thresholds);
+ state.sensitivity = sensitivity;
+ state.performanceThresholds = thresholdsFor(sensitivity);
+ // A reader who has just set this by hand has been told everything the
+ // migration notice would have said, so it is no longer owed.
+ delete state.sensitivityNotice;
for (const page of state.pages) {
- page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(state.performanceThresholds, page));
+ page.status = pageTrend(page, "mobile", normalizePerformanceThresholds(state.performanceThresholds));
}
delete state.watcherNote;
}, dataStore);
}
-export function setPagePerformanceThresholdOverrides(
- id: string,
- overrides: PagePerformanceThresholdOverrides,
+/**
+ * How often the digest arrives and who it goes to. One site, one answer to
+ * each — there is no other granularity, by decision.
+ */
+export function setDigestSettings(
+ settings: { cadence: DigestCadence; recipients: readonly string[] },
dataStore: DataStore = getStore(),
): Promise {
- if (!performanceThresholdOverridesAreValid(overrides)) {
- throw new Error("setPagePerformanceThresholdOverrides: values are outside the supported range");
+ if (!isDigestCadence(settings.cadence)) {
+ throw new Error(`setDigestSettings: "${settings.cadence}" is not a digest cadence`);
}
return withState((state) => {
- const page = state.pages.find((item) => item.id === id);
- if (!page) throw new Error(`setPagePerformanceThresholdOverrides: page ${id} not found`);
- page.performanceThresholdOverrides = normalizePerformanceThresholdOverrides(overrides);
- page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(state.performanceThresholds, page));
- delete state.watcherNote;
+ state.digestCadence = settings.cadence;
+ state.digestRecipients = normalizeDigestRecipients([...settings.recipients]);
}, dataStore);
}
diff --git a/src/lib/nativeElements.ts b/src/lib/nativeElements.ts
index 86543d0..cd217d2 100644
--- a/src/lib/nativeElements.ts
+++ b/src/lib/nativeElements.ts
@@ -10,7 +10,7 @@ import type {
WebflowRemediationLevel,
} from "./types";
import type { PerformanceIssueCapture, PerformanceIssueStatus } from "./performanceIssues";
-import { EXCLUSION_REASONS, type Applicability, type ExclusionReason } from "./vocabulary";
+import { EXCLUSION_REASONS, UNLABELLED_EXCLUSION_REASON, type Applicability, type ExclusionReason } from "./vocabulary";
import { classifyWebflowPerformance, culpritGroupLabel } from "./webflowPerformance";
interface DetectionDefinition {
@@ -137,15 +137,12 @@ interface RetiredNativeElementControl {
/**
* The reason a retired `suppressed` record carries forward.
*
- * Not a reason invented on the reader's behalf: it is the definition of the
- * state the old button put the finding into. `APPLICABILITY_MEANS.excluded` is
- * "Deliberately not counted, because it does not apply to this site", and the
- * retired control offered exactly that one meaning, unlabelled, with nowhere to
- * record anything narrower. Migrating it to the reason that restates the state
- * keeps the exclusion the reader asked for; dropping the record instead would
- * quietly put the finding back in the count.
+ * The argument for this particular reason lives on `UNLABELLED_EXCLUSION_REASON`
+ * in `vocabulary.ts`, which the agent-check defaults read too. It was stated
+ * here first and moved in S8 rather than copied — two spellings of one
+ * migration rule is the drift rule 20 names.
*/
-const RETIRED_SUPPRESSED_REASON: ExclusionReason = "Not applicable to this site";
+const RETIRED_SUPPRESSED_REASON: ExclusionReason = UNLABELLED_EXCLUSION_REASON;
/**
* The gate between a stored string and the registry's reason list.
diff --git a/src/lib/pageChanges.ts b/src/lib/pageChanges.ts
index 2b0d651..ba5aca3 100644
--- a/src/lib/pageChanges.ts
+++ b/src/lib/pageChanges.ts
@@ -1,6 +1,6 @@
import type { IssueCase } from "./issue-case";
import { queueOf } from "./issue-case";
-import { effectivePerformanceThresholds, normalizePerformanceThresholds } from "./performanceThresholds";
+import { normalizePerformanceThresholds } from "./performanceThresholds";
import type { ScoreBand } from "./scoring";
import {
historyForStrategy,
@@ -421,7 +421,7 @@ export function buildPageChanges({
const monitored = pages.filter(isPageActivelyMonitored);
const rows: PageChangeRow[] = monitored.map((page) => {
- const thresholds = effectivePerformanceThresholds(teamThresholds, page);
+ const thresholds = normalizePerformanceThresholds(teamThresholds);
const readings = readingsFor(page, thresholds, reference);
const arrivedAt = pageArrivedAt(page);
const arrivedMs = parsedISO(arrivedAt);
diff --git a/src/lib/performanceThresholds.ts b/src/lib/performanceThresholds.ts
index b454d00..d3f94f8 100644
--- a/src/lib/performanceThresholds.ts
+++ b/src/lib/performanceThresholds.ts
@@ -1,21 +1,20 @@
-import type { PagePerformanceThresholdOverrides, PerformanceThresholds, WatchPage } from "./types";
+import { DEFAULT_SENSITIVITY, SENSITIVITY_THRESHOLDS } from "./sensitivity";
+import type { PerformanceThresholds } from "./types";
-export const DEFAULT_PERFORMANCE_THRESHOLDS: PerformanceThresholds = {
- lowPerformance: 60,
- regression: 15,
- improvement: 5,
- confirmationRuns: 1,
- devicePolicy: "either",
- accessibility: 90,
- bestPractices: 90,
- seo: 90,
- regressionFloor: 95,
- agentReadiness: 100,
- newPageGraceRuns: 2,
- minimumFindingRuns: 1,
- minimumSavingsMs: 0,
- minimumSavingsKilobytes: 0,
-};
+/**
+ * The limits a site runs on when it has not said otherwise.
+ *
+ * These are the Normal position's limits, read from `sensitivity.ts` rather
+ * than restated here. A default threshold set and a default sensitivity
+ * position are one fact; two literals would agree today and drift the first
+ * time either was tuned (rule 20).
+ *
+ * Nothing writes a partial set any more. The twelve per-metric fields had a
+ * control until S8 deleted it, and this module is now a normaliser for stored
+ * state rather than a set of independently editable knobs — which is why the
+ * per-page override machinery went with the panel that edited it.
+ */
+export const DEFAULT_PERFORMANCE_THRESHOLDS: PerformanceThresholds = SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY];
export const PERFORMANCE_THRESHOLD_LIMITS = {
lowPerformance: { min: 1, max: 100 },
@@ -72,38 +71,6 @@ export function normalizePerformanceThresholds(settings?: Partial) {
- const value = settings[key];
- const limits = PERFORMANCE_THRESHOLD_LIMITS[key];
- if (typeof value === "number" && Number.isFinite(value)) {
- normalized[key] = Math.max(limits.min, Math.min(limits.max, Math.round(value)));
- }
- }
- if (settings.devicePolicy === "either" || settings.devicePolicy === "both" || settings.devicePolicy === "preferred") {
- normalized.devicePolicy = settings.devicePolicy;
- }
- return normalized;
-}
-
-export function effectivePerformanceThresholds(
- teamSettings?: Partial,
- pageOrOverrides?: Pick | PagePerformanceThresholdOverrides,
-): PerformanceThresholds {
- const overrides: PagePerformanceThresholdOverrides | undefined = pageOrOverrides
- && Object.prototype.hasOwnProperty.call(pageOrOverrides, "performanceThresholdOverrides")
- ? (pageOrOverrides as Pick).performanceThresholdOverrides
- : pageOrOverrides as PagePerformanceThresholdOverrides | undefined;
- return normalizePerformanceThresholds({
- ...normalizePerformanceThresholds(teamSettings),
- ...normalizePerformanceThresholdOverrides(overrides),
- });
-}
-
function fieldIsValid(
settings: Partial,
key: K,
@@ -120,23 +87,6 @@ export function performanceThresholdsAreValid(settings: Partial;
- const supported = new Set([...Object.keys(PERFORMANCE_THRESHOLD_LIMITS), "devicePolicy"]);
- if (Object.keys(values).some((key) => !supported.has(key))) return false;
- for (const key of Object.keys(PERFORMANCE_THRESHOLD_LIMITS) as Array) {
- if (!(key in values)) continue;
- const value = values[key];
- const limits = PERFORMANCE_THRESHOLD_LIMITS[key];
- if (!Number.isInteger(value) || (value as number) < limits.min || (value as number) > limits.max) return false;
- }
- return !("devicePolicy" in values)
- || values.devicePolicy === "either"
- || values.devicePolicy === "both"
- || values.devicePolicy === "preferred";
-}
-
export function recommendationMeetsEvidenceThresholds(
finding: { savingsMs: number; savingsBytes?: number; observedRuns?: number; category?: string },
thresholds: PerformanceThresholds,
diff --git a/src/lib/seed.ts b/src/lib/seed.ts
index 30e4382..d7eacef 100644
--- a/src/lib/seed.ts
+++ b/src/lib/seed.ts
@@ -22,7 +22,8 @@ import type {
CruxSnapshot,
} from "./crux";
import type { WebflowConnectionStatus } from "./webflowTypes";
-import { DEFAULT_PERFORMANCE_THRESHOLDS } from "./performanceThresholds";
+import { DEFAULT_SENSITIVITY, thresholdsFor } from "./sensitivity";
+import { DEFAULT_DIGEST_CADENCE } from "./digestCadence";
import { AGENT_CHECK_GROUPS } from "./agentChecks";
import { agentCheckKey, captureAgentReadiness } from "./agentScoring";
import { nativeElementScan, unavailableNativeElementScan } from "./nativeElements";
@@ -447,9 +448,6 @@ export function buildSeedState(now = new Date()): AppState {
updatedAt: isoAt(anchor, -1),
},
};
- page("pricing").performanceThresholdOverrides = {
- regression: 10, confirmationRuns: 2, devicePolicy: "both", minimumFindingRuns: 2, minimumSavingsMs: 250,
- };
page("pricing").markers = [
marker("pricing-hero", page("pricing").history, N - 15, "Published new pricing hero video"),
@@ -564,13 +562,15 @@ export function buildSeedState(now = new Date()): AppState {
recs,
visitorExperienceVisible: true,
agentIgnoreDefaults: { checks: [], groups: [GLOBAL_IGNORED_GROUP] },
- performanceThresholds: {
- ...DEFAULT_PERFORMANCE_THRESHOLDS,
- confirmationRuns: 2,
- minimumFindingRuns: 2,
- minimumSavingsMs: 200,
- minimumSavingsKilobytes: 100,
- },
+ // A position, and the limits it resolves to. The fixture used to carry a
+ // hand-tuned threshold set, which would now make every demo project a
+ // migrated one and put the migration notice in every demo digest — true,
+ // but a fixture should show the ordinary case and let the tests exercise
+ // the migration.
+ sensitivity: DEFAULT_SENSITIVITY,
+ performanceThresholds: thresholdsFor(DEFAULT_SENSITIVITY),
+ digestCadence: DEFAULT_DIGEST_CADENCE,
+ digestRecipients: ["performance@brandstudio.example"],
collectionSchedule: { timeZone: "America/Chicago", localTime: "02:30", overridden: true },
measurementIncident: {
id: "demo-psi-provider-incident",
@@ -728,7 +728,8 @@ export function buildSeedWebflowConnectionStatus(now = new Date()): WebflowConne
export function buildEmptySeedState(): AppState {
return {
pages: [], recs: [], visitorExperienceVisible: false, agentIgnoreDefaults: { checks: [], groups: [] },
- performanceThresholds: { ...DEFAULT_PERFORMANCE_THRESHOLDS }, jobs: [], followUps: [],
+ sensitivity: DEFAULT_SENSITIVITY,
+ performanceThresholds: thresholdsFor(DEFAULT_SENSITIVITY), jobs: [], followUps: [],
};
}
diff --git a/src/lib/sensitivity.ts b/src/lib/sensitivity.ts
new file mode 100644
index 0000000..0f364d5
--- /dev/null
+++ b/src/lib/sensitivity.ts
@@ -0,0 +1,231 @@
+import type { DevicePolicy, PerformanceThresholds } from "./types";
+
+/**
+ * One control, three positions, and the limits each one resolves to.
+ *
+ * The decision this file records is option 10b, and the two rejected
+ * alternatives are worth naming because both look reasonable from a distance:
+ *
+ * - Twelve per-metric thresholds. Every number honest, and nobody could say
+ * what any of them would do to tonight's digest. A control whose effect can
+ * only be discovered by waiting a night is not a control.
+ * - No thresholds at all. Nothing to get wrong, and no answer to "why am I
+ * being told this" other than "the product decided". The digest's threshold
+ * clause — "above the 250 ms you set" — is the whole reason a reader trusts
+ * the line, and it needs a setting behind it to be true.
+ *
+ * So: one control, and the limits it resolves to are shown beneath it in the
+ * words the digest will use. The abstraction is never opaque, which is the only
+ * thing that makes an abstraction over twelve numbers honest rather than
+ * convenient.
+ *
+ * Nothing here is per-page. S3 removed the page-detail calibration panel and
+ * this chunk gives it no new home: a site has one answer to "what is worth
+ * telling you", because the digest that asks the question is one message per
+ * site. A per-page override would mean a digest whose limits differ line by
+ * line, and the clause would have to name which limit it meant.
+ *
+ * This module imports nothing but the shape it fills in, so
+ * `performanceThresholds.ts` can read the Normal position as its default
+ * without the two importing each other.
+ */
+
+export const SENSITIVITIES = ["low", "normal", "high"] as const;
+export type Sensitivity = (typeof SENSITIVITIES)[number];
+
+/**
+ * Normal, until a site says otherwise.
+ *
+ * It is also `DEFAULT_PERFORMANCE_THRESHOLDS`. That is not a coincidence to be
+ * tidied away later: a default threshold set and a default sensitivity position
+ * are one fact, and two statements of it would drift the first time somebody
+ * tuned one (rule 20).
+ */
+export const DEFAULT_SENSITIVITY: Sensitivity = "normal";
+
+/**
+ * The middle position, stated in full. The other two are stated as their
+ * difference from it, so what sensitivity actually varies is readable here
+ * rather than reconstructable by diffing three literals.
+ */
+/**
+ * Frozen, so a caller that edits a resolved set fails loudly instead of
+ * silently retuning every project in the process. `thresholdsFor` hands out
+ * copies for exactly that reason; this is the mechanism behind the promise
+ * rather than a comment asking the next editor to keep it (rule 20).
+ */
+const NORMAL: PerformanceThresholds = Object.freeze({
+ lowPerformance: 60,
+ regression: 15,
+ improvement: 5,
+ confirmationRuns: 1,
+ devicePolicy: "either" satisfies DevicePolicy,
+ accessibility: 90,
+ bestPractices: 90,
+ seo: 90,
+ regressionFloor: 95,
+ agentReadiness: 100,
+ newPageGraceRuns: 2,
+ minimumFindingRuns: 1,
+ // Deliberately not 0. At 0 the savings gate is off, and a gate that is off is
+ // a limit the reader did not set — so `thresholdOf` withholds the digest's
+ // threshold clause entirely and there is nothing to display under the
+ // control. "Everything" is expressed as a 1 ms limit rather than as no limit
+ // for the same reason: a position that resolves to nothing cannot be shown.
+ minimumSavingsMs: 250,
+ minimumSavingsKilobytes: 25,
+});
+
+/**
+ * The resolved limits at each position.
+ *
+ * Every field moves in one direction as the control moves, and that is the
+ * invariant worth stating: a reader who moves the control towards "Everything"
+ * must never find that some hidden number moved the other way. The parity test
+ * asserts it on the fields where "more sensitive" has an unambiguous direction.
+ */
+export const SENSITIVITY_THRESHOLDS: Record = {
+ low: Object.freeze({
+ ...NORMAL,
+ lowPerformance: 50,
+ regression: 25,
+ improvement: 10,
+ confirmationRuns: 2,
+ // Both devices must agree before a page changes status. The strictest of
+ // the three device policies, which is what "only big moves" means when the
+ // two devices disagree.
+ devicePolicy: "both",
+ accessibility: 80,
+ bestPractices: 80,
+ seo: 80,
+ regressionFloor: 90,
+ agentReadiness: 90,
+ newPageGraceRuns: 3,
+ minimumFindingRuns: 2,
+ minimumSavingsMs: 1000,
+ minimumSavingsKilobytes: 100,
+ }),
+ normal: NORMAL,
+ high: Object.freeze({
+ ...NORMAL,
+ lowPerformance: 75,
+ regression: 5,
+ improvement: 1,
+ confirmationRuns: 1,
+ accessibility: 95,
+ bestPractices: 95,
+ seo: 95,
+ regressionFloor: 100,
+ agentReadiness: 100,
+ newPageGraceRuns: 1,
+ minimumFindingRuns: 1,
+ // One millisecond, not zero. A saving smaller than a millisecond is not a
+ // reading `formatImpact` can write, so this is every measurement there is —
+ // and unlike 0 it is a limit the digest can name.
+ minimumSavingsMs: 1,
+ minimumSavingsKilobytes: 1,
+ }),
+};
+
+export function isSensitivity(value: unknown): value is Sensitivity {
+ return typeof value === "string" && (SENSITIVITIES as readonly string[]).includes(value);
+}
+
+/** An unset or unrecognised position reads as the default rather than as nothing. */
+export function normalizeSensitivity(value: unknown): Sensitivity {
+ return isSensitivity(value) ? value : DEFAULT_SENSITIVITY;
+}
+
+/**
+ * The limits a position resolves to. The one place anything reads them.
+ *
+ * A copy, because the result goes into persisted state: a caller that edited
+ * the returned object would be editing the position itself, for every project
+ * in the process.
+ */
+export function thresholdsFor(sensitivity: Sensitivity): PerformanceThresholds {
+ return { ...SENSITIVITY_THRESHOLDS[sensitivity] };
+}
+
+/* ── Migration ──────────────────────────────────────────────────────────── */
+
+type NumericKey = Exclude;
+
+const NUMERIC_KEYS = (Object.keys(NORMAL) as Array)
+ .filter((key): key is NumericKey => key !== "devicePolicy");
+
+/**
+ * How far apart the three positions put one field.
+ *
+ * The distance metric below divides by this rather than by the field's allowed
+ * range, and the difference matters. `regressionFloor` is legal from 1 to 100
+ * but sensitivity only ever moves it between 90 and 100, so a stored 95 is
+ * dead centre of what this control varies and nowhere near the middle of what
+ * the field permits. Measuring against the range would let a field sensitivity
+ * barely touches outvote one it swings across.
+ */
+function spanOf(key: NumericKey): number {
+ const values = SENSITIVITIES.map((position) => SENSITIVITY_THRESHOLDS[position][key]);
+ return Math.max(...values) - Math.min(...values);
+}
+
+/**
+ * The position a hand-tuned threshold set is closest to.
+ *
+ * Registry rule 18's cousin: a configuration somebody spent time on is a
+ * reading, and discarding it because it no longer has a control is exactly the
+ * silent loss this product exists to stop. So it is mapped rather than dropped,
+ * and `settingsMigrated` says so once, in the digest footer, in the reader's
+ * own vocabulary — the position it became, not the numbers it was.
+ *
+ * Fields the three positions agree on are skipped: they carry no information
+ * about which position was meant, and including them would flatten every real
+ * difference towards nothing. A set that differs from all three only on such a
+ * field is equidistant, and lands on the default.
+ */
+export function nearestSensitivity(thresholds: Partial | undefined): Sensitivity {
+ if (!thresholds) return DEFAULT_SENSITIVITY;
+ const distanceTo = (position: Sensitivity): number => {
+ const resolved = SENSITIVITY_THRESHOLDS[position];
+ let total = 0;
+ for (const key of NUMERIC_KEYS) {
+ const span = spanOf(key);
+ if (span === 0) continue;
+ const value = thresholds[key];
+ if (typeof value !== "number" || !Number.isFinite(value)) continue;
+ total += Math.abs(value - resolved[key]) / span;
+ }
+ if (thresholds.devicePolicy && thresholds.devicePolicy !== resolved.devicePolicy) total += 1;
+ return total;
+ };
+ // The default is considered first and ties are kept, so an equidistant set
+ // lands on Normal rather than on whichever position happens to sort first.
+ let nearest: Sensitivity = DEFAULT_SENSITIVITY;
+ let best = distanceTo(DEFAULT_SENSITIVITY);
+ for (const position of SENSITIVITIES) {
+ if (position === DEFAULT_SENSITIVITY) continue;
+ const distance = distanceTo(position);
+ if (distance < best) {
+ best = distance;
+ nearest = position;
+ }
+ }
+ return nearest;
+}
+
+/**
+ * The position a threshold set already IS, or null when it is hand-tuned.
+ *
+ * This is what decides whether anybody is told anything. A stored set that
+ * matches a position exactly was produced by this control and needs no notice;
+ * one that does not was produced by the twelve fields this chunk deletes, and
+ * its owner is owed the sentence.
+ */
+export function exactSensitivity(thresholds: Partial | undefined): Sensitivity | null {
+ if (!thresholds) return null;
+ return SENSITIVITIES.find((position) => {
+ const resolved = SENSITIVITY_THRESHOLDS[position];
+ return thresholds.devicePolicy === resolved.devicePolicy
+ && NUMERIC_KEYS.every((key) => thresholds[key] === resolved[key]);
+ }) ?? null;
+}
diff --git a/src/lib/settings-copy.ts b/src/lib/settings-copy.ts
new file mode 100644
index 0000000..d78023e
--- /dev/null
+++ b/src/lib/settings-copy.ts
@@ -0,0 +1,98 @@
+import type { Sensitivity } from "./sensitivity";
+
+/**
+ * The words Settings says, in one place.
+ *
+ * Locked copy from the S8 brief. Four strings the brief lists are deliberately
+ * NOT here, because something else already owns them and a second statement
+ * would be a defect waiting (rule 20):
+ *
+ * - `settings.title` — "Settings" is `DESTINATION_LABEL.settings`. The
+ * registry names the destinations; a screen does not get to name itself.
+ * - `settings.excluded.include` — "Include" is the registry's applicability
+ * action, produced by `applicabilityActionLabel`. The same button on the
+ * case detail already reads it from there, and two screens spelling one
+ * action differently is exactly what that concept exists to prevent.
+ * - the limits shown under the sensitivity control. Those are
+ * `digestLimit`'s, from S7. The screen's promise is that it shows what the
+ * digest will say, and it can only keep that promise by asking the digest.
+ * - the appearance options. `APPEARANCE_LABEL` owns Auto, Light and Dark, and
+ * the pre-paint script depends on those exact values.
+ *
+ * What is here is the copy nothing else states.
+ */
+
+/* ── The page ───────────────────────────────────────────────────────────── */
+
+export function settingsSubtitle(site: string): string {
+ return `For ${site}. Changes apply from the next nightly run.`;
+}
+
+/* ── Sensitivity ────────────────────────────────────────────────────────── */
+
+export const SETTINGS_SENSITIVITY_LABEL = "What is worth telling you";
+export const SETTINGS_SENSITIVITY_HELP = "Sets the limits every digest line refers to.";
+
+/** The three positions, in the words the reader chooses between. */
+export const SENSITIVITY_LABEL: Record = {
+ low: "Only big moves",
+ normal: "Normal",
+ high: "Everything",
+};
+
+/**
+ * What the limit beneath the control governs.
+ *
+ * The label is this screen's; the value beside it is the digest's. That split
+ * is the whole point of the row: a reader who wants to know why a line said
+ * "above the 250 ms you set" can see the 250 ms here, spelled the same way,
+ * and see which position put it there.
+ */
+export const SETTINGS_SENSITIVITY_LIMIT_LABEL = "Smallest saving a digest line will mention";
+
+/* ── Digest ─────────────────────────────────────────────────────────────── */
+
+export const SETTINGS_DIGEST_LABEL = "Digest";
+export const SETTINGS_DIGEST_HELP =
+ "One message per run. 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";
+export const SETTINGS_DIGEST_RECIPIENTS_HELP = "One address per line.";
+export const SETTINGS_DIGEST_RECIPIENTS_EMPTY = "Nobody is named yet, so the message is built and not sent.";
+export const SETTINGS_DIGEST_RECIPIENTS_INVALID = "One of these is not an email address.";
+
+/* ── Excluded from results ──────────────────────────────────────────────── */
+
+export const SETTINGS_EXCLUDED_LABEL = "Excluded from results";
+export const SETTINGS_EXCLUDED_HELP =
+ "Pages and checks that do not apply to this site. Each keeps its last reading and its reason.";
+
+/** Nothing set aside is good news, and rule 15 says an empty list must say so. */
+export const SETTINGS_EXCLUDED_EMPTY = "Nothing is set aside. Every page and check counts toward this site's results.";
+
+/* ── Connected systems ──────────────────────────────────────────────────── */
+
+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.";
+
+/* ── Appearance ─────────────────────────────────────────────────────────── */
+
+export const SETTINGS_APPEARANCE_LABEL = "Appearance";
+export const SETTINGS_APPEARANCE_HELP = "Applies to this browser only.";
+
+/* ── Migration ──────────────────────────────────────────────────────────── */
+
+/**
+ * What a site with hand-tuned thresholds is told, once.
+ *
+ * Named by the position rather than by the numbers, because the numbers are
+ * what the reader no longer has a control for and repeating them would only
+ * describe something they cannot get back. Silently discarding somebody's
+ * configuration is worse than the configuration was; silently replacing it is
+ * the same failure with a nicer result.
+ */
+export function settingsMigrated(position: string): string {
+ return `Your per-metric thresholds became the ${position} setting. Change it in Settings.`;
+}
diff --git a/src/lib/settings-exclusions.ts b/src/lib/settings-exclusions.ts
new file mode 100644
index 0000000..df8ea0f
--- /dev/null
+++ b/src/lib/settings-exclusions.ts
@@ -0,0 +1,258 @@
+import { AGENT_CHECK_GROUPS, ALL_AGENT_CHECKS } from "./agentChecks";
+import { agentCheckKey, agentExclusionKey, normalizeAgentIgnoreSettings } from "./agentScoring";
+import { formatImpact, NOT_MEASURED } from "./impact-format";
+import { excludedPageIds, exclusionReasonOf, type IssueCase } from "./issue-case";
+import {
+ nativeElementExclusionReason,
+ nativeElementIssuesForPage,
+} from "./nativeElements";
+import type { AgentCheck, AppState, WatchPage } from "./types";
+import { AGENT_RESULT_LABEL, EXCLUSION_REASONS, UNLABELLED_EXCLUSION_REASON, type ExclusionReason } from "./vocabulary";
+
+/**
+ * Everything this site has set aside, as one list.
+ *
+ * One list, not four, and the reason is the registry's: applicability is one
+ * concept that "applies to agent checks, check groups, and native-element
+ * findings alike", and S3 extended it to the pages a case covers. Four screens
+ * for one concept is how a reader ends up with an exclusion they cannot find —
+ * which is the failure the audit recorded when the agent tab hid evidence
+ * without saying why, and the reason every row here carries its reason and its
+ * last reading rather than just disappearing.
+ *
+ * Excluding is not deleting. A row keeps the last thing that was measured about
+ * it, greyed and struck through on screen, so a reader can see both that it was
+ * set aside and what it looked like when it was counted. Rule 18 applies as it
+ * does everywhere: a row with no reading says "Not measured" rather than 0,
+ * because an absent measurement is not a small one.
+ *
+ * What this module does NOT do is decide anything about sensitivity, ranking or
+ * weight. A row is in or out; there is no third state and no ordering by
+ * importance. The list sorts by kind and then alphabetically, which is the only
+ * order that cannot be read as a judgement.
+ */
+
+export type ExcludedKind = "page" | "check";
+
+/**
+ * What Include has to call.
+ *
+ * A tagged union rather than a callback, so this module stays free of React and
+ * of the store: it says which record is excluded, and the screen knows which
+ * mutation owns that record.
+ */
+export type IncludeTarget =
+ | { target: "native-element"; pageId: string; findingId: string }
+ | { target: "agent-check"; scope: "check" | "group"; value: string }
+ /**
+ * Carries the case, not a key.
+ *
+ * The decision log is keyed on the remediation (F5), and `remediationKey` is
+ * its single producer. Handing a precomputed key along a row would put a
+ * second one in circulation — nothing could then tell a key this module made
+ * from one a caller invented, which is the detachment F5's guard exists to
+ * catch. So the case travels and the call site derives the key, where the
+ * guard can see it.
+ */
+ | { target: "case-page"; issue: IssueCase; pageId: string };
+
+export interface ExcludedRow {
+ /** Stable across renders and unique across the kinds. */
+ id: string;
+ kind: ExcludedKind;
+ /** What is excluded. */
+ title: string;
+ /** Where — the page a check sits on, or the case a page sits in. Null when it is site-wide. */
+ scope: string | null;
+ reason: ExclusionReason;
+ /** Its last reading, in the words the app writes readings in. */
+ reading: string;
+ /** False when there is no reading, so the screen can say so rather than show a number. */
+ measured: boolean;
+ include: IncludeTarget;
+}
+
+/* ── Checks: agent-readiness ────────────────────────────────────────────── */
+
+/**
+ * The worst result this check last produced anywhere on the site.
+ *
+ * Rule 19: a figure standing for several pages is the worst reading one of them
+ * produced, never a sum and never an average — so a check that failed on one
+ * page reads Failed here even if it passed on nine others, and the row is
+ * reconcilable with the pages beneath it.
+ *
+ * A check nothing has measured returns null rather than Passed. An exclusion
+ * may well be why nothing measured it, and reporting that as a pass would be a
+ * reading nobody took.
+ */
+function worstAgentResult(pages: readonly WatchPage[], matches: (check: AgentCheck) => boolean): string | null {
+ let seen = false;
+ let unavailable = false;
+ for (const page of pages) {
+ for (const check of page.agent ?? []) {
+ if (!matches(check)) continue;
+ if (check.unavailable) {
+ unavailable = true;
+ continue;
+ }
+ seen = true;
+ if (!check.pass) return AGENT_RESULT_LABEL.failed;
+ }
+ }
+ if (seen) return AGENT_RESULT_LABEL.passed;
+ return unavailable ? AGENT_RESULT_LABEL.unavailable : null;
+}
+
+/**
+ * The reason recorded against this exclusion, or the one it has always meant.
+ *
+ * A record written before the control asked for a reason carries none, and the
+ * honest reading of that is not "no reason" but the state the toggle put it in
+ * — see `UNLABELLED_EXCLUSION_REASON`. A stored string the registry does not
+ * bless is treated the same way, on `normalizeNativeElementControls`' grounds:
+ * a reason nobody decided is the absence of one.
+ */
+function reasonFor(
+ defaults: ReturnType,
+ scope: "check" | "group",
+ value: string,
+): ExclusionReason {
+ const stored = defaults.reasons?.[agentExclusionKey(scope, value)];
+ return (EXCLUSION_REASONS as readonly string[]).includes(stored ?? "")
+ ? stored as ExclusionReason
+ : UNLABELLED_EXCLUSION_REASON;
+}
+
+function agentRows(state: AppState): ExcludedRow[] {
+ const defaults = normalizeAgentIgnoreSettings(state.agentIgnoreDefaults);
+ const groupRows = defaults.groups.flatMap((name): ExcludedRow[] => {
+ if (!AGENT_CHECK_GROUPS.some((group) => group.name === name)) return [];
+ const reading = worstAgentResult(state.pages, (check) => check.group === name);
+ return [{
+ id: `agent-group:${name}`,
+ kind: "check",
+ title: name,
+ scope: null,
+ reason: reasonFor(defaults, "group", name),
+ reading: reading ?? NOT_MEASURED,
+ measured: reading !== null,
+ include: { target: "agent-check", scope: "group", value: name },
+ }];
+ });
+
+ const checkRows = defaults.checks.flatMap((key): ExcludedRow[] => {
+ const check = ALL_AGENT_CHECKS.find((candidate) => agentCheckKey(candidate) === key);
+ if (!check) return [];
+ // A check inside an excluded group is already covered by the group's row.
+ // Two rows for one exclusion would make Include ambiguous.
+ if (defaults.groups.includes(check.group)) return [];
+ const reading = worstAgentResult(
+ state.pages,
+ (candidate) => candidate.group === check.group && candidate.name === check.name,
+ );
+ return [{
+ id: `agent-check:${key}`,
+ kind: "check",
+ title: check.name,
+ scope: check.group,
+ reason: reasonFor(defaults, "check", key),
+ reading: reading ?? NOT_MEASURED,
+ measured: reading !== null,
+ include: { target: "agent-check", scope: "check", value: key },
+ }];
+ });
+
+ return [...groupRows, ...checkRows];
+}
+
+/* ── Checks: native-element findings ────────────────────────────────────── */
+
+/**
+ * How many of the element this page last had.
+ *
+ * A count is the reading a native-element finding produces — there is no
+ * millisecond saving behind it — so it is written as a count rather than
+ * converted into one. A finding excluded before it was ever seen in a scan has
+ * no count and says so.
+ */
+function nativeRows(pages: readonly WatchPage[]): ExcludedRow[] {
+ return pages.flatMap((page) => {
+ const controls = page.nativeElementControls ?? {};
+ const lifecycles = nativeElementIssuesForPage(page.history);
+ return Object.keys(controls).flatMap((findingId): ExcludedRow[] => {
+ const reason = nativeElementExclusionReason(controls, findingId);
+ if (!reason) return [];
+ const finding = lifecycles.find((candidate) => candidate.id === findingId);
+ return [{
+ id: `native:${page.id}:${findingId}`,
+ kind: "check",
+ title: finding?.title ?? findingId,
+ scope: page.title,
+ reason,
+ reading: finding ? `${finding.count} ${finding.count === 1 ? "instance" : "instances"}` : NOT_MEASURED,
+ measured: Boolean(finding),
+ include: { target: "native-element", pageId: page.id, findingId },
+ }];
+ });
+ });
+}
+
+/* ── Pages ──────────────────────────────────────────────────────────────── */
+
+/**
+ * A page a case does not apply to.
+ *
+ * The reading is the case's worst measured saving, which is the same figure the
+ * case's own pages table shows against the row — not a per-page number this
+ * module invents, because there is no per-page saving to invent one from.
+ *
+ * Real since F5: the exclusion is a decision in the log, applied by
+ * `issueCasesFrom`, so these rows describe something a reader actually did
+ * rather than a shape nothing could produce.
+ */
+function pageRows(cases: readonly IssueCase[], pageTitles: Record): ExcludedRow[] {
+ return cases.flatMap((issue) =>
+ excludedPageIds(issue).flatMap((pageId): ExcludedRow[] => {
+ const reason = exclusionReasonOf(issue, pageId);
+ if (!reason) return [];
+ const impact = formatImpact(issue.impactMs);
+ return [{
+ id: `case-page:${issue.id}:${pageId}`,
+ kind: "page",
+ title: pageTitles[pageId] ?? pageId,
+ scope: issue.title,
+ reason,
+ reading: impact.text,
+ measured: impact.measured,
+ include: { target: "case-page", issue, pageId },
+ }];
+ })
+ );
+}
+
+/* ── The list ───────────────────────────────────────────────────────────── */
+
+/**
+ * Pages first, then checks, each alphabetical.
+ *
+ * Not by severity, not by date, not by how much each one is costing. Any of
+ * those would rank exclusions, and an exclusion has no rank: the reader already
+ * decided each of these does not apply. An order that implied otherwise would
+ * be the product arguing with a decision it was told about.
+ */
+export function excludedFromResults(
+ state: AppState,
+ cases: readonly IssueCase[] = [],
+): ExcludedRow[] {
+ const pageTitles = Object.fromEntries(state.pages.map((page) => [page.id, page.title]));
+ const rows = [
+ ...pageRows(cases, pageTitles),
+ ...agentRows(state),
+ ...nativeRows(state.pages),
+ ];
+ return rows.sort((left, right) => {
+ if (left.kind !== right.kind) return left.kind === "page" ? -1 : 1;
+ return left.title.localeCompare(right.title) || (left.scope ?? "").localeCompare(right.scope ?? "");
+ });
+}
diff --git a/src/lib/store/cfStore.ts b/src/lib/store/cfStore.ts
index a111241..55a845e 100644
--- a/src/lib/store/cfStore.ts
+++ b/src/lib/store/cfStore.ts
@@ -2,7 +2,7 @@ import { getCloudflareContext } from "@opennextjs/cloudflare";
import { TENANT, type AppState, type ChangeMarker, type CollectionJob, type Night } from "../types";
import { buildInitialState, buildSeedCruxEvidence, DEMO_DATA_VERSION } from "../seed";
import { captureAgentReadiness } from "../agentScoring";
-import { effectivePerformanceThresholds } from "../performanceThresholds";
+import { normalizePerformanceThresholds } from "../performanceThresholds";
import { mediansOf, pageTrend } from "../scoring";
import { resolveMarkerIndex } from "../followups";
import type { DataStore } from "./fsStore";
@@ -254,7 +254,7 @@ class CfDataStore implements DataStore {
desktop: mediansOf(night.scores.desktop),
};
page.agent = agent ?? [];
- page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(draft.performanceThresholds, page));
+ page.status = pageTrend(page, "mobile", normalizePerformanceThresholds(draft.performanceThresholds));
page.runState = undefined;
page.lastRunAt = night.iso ?? new Date().toISOString();
page.lastCollectionStatus = "trusted";
diff --git a/src/lib/store/fsStore.ts b/src/lib/store/fsStore.ts
index fb2fbdb..a108b8e 100644
--- a/src/lib/store/fsStore.ts
+++ b/src/lib/store/fsStore.ts
@@ -5,7 +5,7 @@ import type { CruxPageEvidence } from "../crux";
import type { ExternalAgentOriginAudit } from "../agentAudit";
import { buildInitialState, buildSeedCruxEvidence, DEMO_DATA_VERSION } from "../seed";
import { captureAgentReadiness } from "../agentScoring";
-import { effectivePerformanceThresholds } from "../performanceThresholds";
+import { normalizePerformanceThresholds } from "../performanceThresholds";
import { mediansOf, pageTrend } from "../scoring";
import { resolveMarkerIndex } from "../followups";
import { normalizeState } from "./normalize";
@@ -266,7 +266,7 @@ class FsDataStore implements DataStore {
desktop: mediansOf(night.scores.desktop),
};
page.agent = agent ?? [];
- page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(draft.performanceThresholds, page));
+ page.status = pageTrend(page, "mobile", normalizePerformanceThresholds(draft.performanceThresholds));
page.runState = undefined;
page.lastRunAt = night.iso ?? new Date().toISOString();
page.lastCollectionStatus = "trusted";
diff --git a/src/lib/store/normalize.ts b/src/lib/store/normalize.ts
index 3f90224..d05a732 100644
--- a/src/lib/store/normalize.ts
+++ b/src/lib/store/normalize.ts
@@ -1,12 +1,54 @@
import type { AppState } from "../types";
import { captureAgentReadiness, normalizeAgentIgnoreSettings } from "../agentScoring";
-import { effectivePerformanceThresholds, normalizePerformanceThresholdOverrides, normalizePerformanceThresholds } from "../performanceThresholds";
+import { normalizeDigestCadence } from "../digestCadence";
+import { normalizePerformanceThresholds } from "../performanceThresholds";
import { pageTrend } from "../scoring";
+import { exactSensitivity, nearestSensitivity, normalizeSensitivity, thresholdsFor } from "../sensitivity";
+import { SENSITIVITY_LABEL } from "../settings-copy";
import { normalizeWatchCapacity } from "../watchCapacity";
import { sortWatchlistPages } from "../watchlistOrder";
import { reconcileTaskMarkers } from "../taskMarkers";
import { normalizeNativeElementControls } from "../nativeElements";
import { normalizeAlertWebhookUrl } from "../webhook";
+import { normalizeDigestRecipients } from "../digestRecipients";
+
+/**
+ * Bring a stored threshold set onto the one control that now edits it.
+ *
+ * Three cases, and the third is the whole reason this function exists:
+ *
+ * - A stored position. It wins, and the limits are rewritten from it. The
+ * position is the setting; the limits are its resolution, and a resolution
+ * that disagreed with its input would be a second setting nobody could see.
+ * - No position, and limits that already match one exactly. That set was
+ * produced by this control before the field was persisted, or by the
+ * defaults. Nothing was tuned, so nobody is told anything.
+ * - No position, and limits that match none of them. Somebody sat down with
+ * twelve fields and made decisions. Those decisions no longer have a
+ * control, and the two dishonest answers are to drop them (their site
+ * quietly starts reporting different things) or to keep them (a setting
+ * screen that cannot show the state it is in). So they are mapped to the
+ * nearest position and the reader is told once, in the digest footer.
+ *
+ * Idempotent, like everything else here: the notice is written once because the
+ * position it writes is also stored, so the next read takes the first branch.
+ */
+function normalizeSensitivitySettings(state: AppState): void {
+ if (state.sensitivity === undefined) {
+ const stored = state.performanceThresholds;
+ const exact = exactSensitivity(stored);
+ if (exact === null && stored !== undefined) {
+ const nearest = nearestSensitivity(stored);
+ state.sensitivity = nearest;
+ state.sensitivityNotice = SENSITIVITY_LABEL[nearest];
+ } else {
+ state.sensitivity = exact ?? normalizeSensitivity(undefined);
+ }
+ }
+ const sensitivity = normalizeSensitivity(state.sensitivity);
+ state.sensitivity = sensitivity;
+ state.performanceThresholds = thresholdsFor(sensitivity);
+}
/** Apply compatible, idempotent upgrades when reading persisted state. */
export function normalizeState(state: AppState): AppState {
@@ -20,7 +62,9 @@ export function normalizeState(state: AppState): AppState {
// external provider request is permitted for this project.
state.externalAgentAuditEnabled = state.externalAgentAuditEnabled === true;
state.agentIgnoreDefaults = normalizeAgentIgnoreSettings(state.agentIgnoreDefaults);
- state.performanceThresholds = normalizePerformanceThresholds(state.performanceThresholds);
+ normalizeSensitivitySettings(state);
+ state.digestCadence = normalizeDigestCadence(state.digestCadence);
+ state.digestRecipients = normalizeDigestRecipients(state.digestRecipients);
if (normalizeWatchCapacity(state.pages)) delete state.watcherNote;
state.pages = sortWatchlistPages(state.pages);
for (const page of state.pages) {
@@ -32,8 +76,11 @@ export function normalizeState(state: AppState): AppState {
// that legacy value described a transient drop, not improvement.
page.agentIgnores = normalizeAgentIgnoreSettings(page.agentIgnores);
page.agentIgnoreRestores = normalizeAgentIgnoreSettings(page.agentIgnoreRestores);
- page.performanceThresholdOverrides = normalizePerformanceThresholdOverrides(page.performanceThresholdOverrides);
- page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(state.performanceThresholds, page));
+ // Page-specific calibration is gone rather than relocated: S3 removed the
+ // panel that edited it and S8 gives it no new home, so a stored override
+ // would be a value nothing can change and nothing should read.
+ delete (page as { performanceThresholdOverrides?: unknown }).performanceThresholdOverrides;
+ page.status = pageTrend(page, "mobile", normalizePerformanceThresholds(state.performanceThresholds));
page.nativeElementControls = normalizeNativeElementControls(page.nativeElementControls);
for (const night of page.history) {
if (!night.agentReadiness && Array.isArray(night.agent)) {
diff --git a/src/lib/store/remoteStore.ts b/src/lib/store/remoteStore.ts
index 02a97bd..db46b25 100644
--- a/src/lib/store/remoteStore.ts
+++ b/src/lib/store/remoteStore.ts
@@ -1,6 +1,6 @@
import type { AppState, ChangeMarker, Night } from "../types";
import { captureAgentReadiness } from "../agentScoring";
-import { effectivePerformanceThresholds } from "../performanceThresholds";
+import { normalizePerformanceThresholds } from "../performanceThresholds";
import { mediansOf, pageTrend } from "../scoring";
import { resolveMarkerIndex } from "../followups";
import { getEnv } from "../env";
@@ -128,7 +128,7 @@ export class RemoteDataStore implements DataStore {
desktop: mediansOf(night.scores.desktop),
};
page.agent = agent ?? [];
- page.status = pageTrend(page, "mobile", effectivePerformanceThresholds(draft.performanceThresholds, page));
+ page.status = pageTrend(page, "mobile", normalizePerformanceThresholds(draft.performanceThresholds));
page.runState = undefined;
page.lastRunAt = night.iso ?? new Date().toISOString();
page.lastCollectionStatus = "trusted";
diff --git a/src/lib/types.ts b/src/lib/types.ts
index f17249b..274e55f 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -477,6 +477,18 @@ export type AgentIgnoreOverrideMode = "inherit" | "ignore" | "restore";
export interface AgentIgnoreSettings {
checks: string[];
groups: string[];
+ /**
+ * Why each excluded check or category does not apply, keyed by
+ * `agentExclusionKey`.
+ *
+ * Optional because the control that wrote these never asked. A record with no
+ * reason predates the question and reads as `UNLABELLED_EXCLUSION_REASON`,
+ * which is the definition of the state the old toggle put it in rather than a
+ * reason invented on the reader's behalf. Stored as a string for the same
+ * reason `NativeElementControl.excluded.reason` is: this module is the
+ * persisted shape and cannot import the registry.
+ */
+ reasons?: Record;
}
export type DevicePolicy = "either" | "both" | "preferred";
@@ -511,8 +523,6 @@ export interface PerformanceThresholds {
minimumSavingsKilobytes: number;
}
-/** Optional page-specific values layered over the team monitoring defaults. */
-export type PagePerformanceThresholdOverrides = Partial;
/** Legacy per-page event-delivery state retained for persisted-state compatibility. */
export interface PerformanceAlertState {
@@ -570,8 +580,7 @@ export interface WatchPage {
agent: AgentCheck[]; // latest agent-readiness scan (per-check)
agentIgnores?: AgentIgnoreSettings; // page-specific ignores, applied after global defaults
agentIgnoreRestores?: AgentIgnoreSettings; // page-specific restores of globally ignored checks/categories
- /** Sparse page-specific calibration; omitted values inherit team defaults. */
- performanceThresholdOverrides?: PagePerformanceThresholdOverrides;
+
/** Legacy event-alert state; daily digests use AppState.alertDigests. */
performanceAlertState?: PerformanceAlertState;
/** Page-scoped triage controls keyed by stable native-element finding id. */
@@ -875,7 +884,33 @@ export interface AppState {
*/
externalAgentAuditEnabled?: boolean;
agentIgnoreDefaults?: AgentIgnoreSettings;
+ /**
+ * The one sensitivity control's position, as a plain string.
+ *
+ * Narrowed to a decided position by `normalizeSensitivity` on read, the same
+ * way an exclusion reason is narrowed by `normalizeNativeElementControls`:
+ * this module is the persisted shape and cannot import the concept modules
+ * that own the value lists.
+ */
+ sensitivity?: string;
+ /**
+ * The limits `sensitivity` resolves to, kept resolved rather than derived at
+ * every read.
+ *
+ * It is not a second setting. `normalizeState` rewrites it from the position
+ * on every read, so a stored set that disagrees with the position loses —
+ * which is what makes the position, not this, the thing a reader edits.
+ */
performanceThresholds?: PerformanceThresholds;
+ /**
+ * A position that hand-tuned thresholds were mapped to, still owed its one
+ * sentence. Cleared by the digest that carries it.
+ */
+ sensitivityNotice?: string;
+ /** How often the digest is sent. Narrowed by `normalizeDigestCadence`. */
+ digestCadence?: string;
+ /** Who the digest is for. Empty means nobody has been named yet. */
+ digestRecipients?: string[];
collectionSchedule?: CollectionSchedule;
measurementIncident?: MeasurementIncident;
jobs?: CollectionJob[];
diff --git a/src/lib/vocabulary.ts b/src/lib/vocabulary.ts
index edcff57..08508b0 100644
--- a/src/lib/vocabulary.ts
+++ b/src/lib/vocabulary.ts
@@ -369,6 +369,23 @@ export const APPLICABILITY_TRANSITIONS: Record {
- const thresholds = effectivePerformanceThresholds(teamThresholds, page);
+ const thresholds = normalizePerformanceThresholds(teamThresholds);
const byDevice = {
mobile: pageRangeTrend(page, "mobile", rangeDays, thresholds),
desktop: pageRangeTrend(page, "desktop", rangeDays, thresholds),
@@ -255,26 +255,26 @@ export function buildWatcher(
const lowPerformance = activePages.filter((page) => devicesForPolicy(
devices.filter((device) => {
const score = latestScore(page, device, "perf", rangeDays);
- return score !== null && score < effectivePerformanceThresholds(teamThresholds, page).lowPerformance;
+ return score !== null && score < normalizePerformanceThresholds(teamThresholds).lowPerformance;
}),
strategy,
- effectivePerformanceThresholds(teamThresholds, page).devicePolicy,
+ normalizePerformanceThresholds(teamThresholds).devicePolicy,
).length > 0).length;
const agentGaps = activePages.filter((page) => {
const snapshot = pageAgentSnapshotForRange(page, rangeDays);
if (!snapshot) return false;
const summary = summarizeAgentChecks(snapshot.checks, page.agentIgnores, agentIgnoreDefaults, page.agentIgnoreRestores);
- return summary.total > 0 && summary.percent < effectivePerformanceThresholds(teamThresholds, page).agentReadiness;
+ return summary.total > 0 && summary.percent < normalizePerformanceThresholds(teamThresholds).agentReadiness;
}).length;
const qualityIssues = activePages.filter((page) => devicesForPolicy(
devices.filter((device) => (["a11y", "bp", "seo"] as const).some((key) => {
const score = latestScore(page, device, key, rangeDays);
- const thresholds = effectivePerformanceThresholds(teamThresholds, page);
+ const thresholds = normalizePerformanceThresholds(teamThresholds);
const qualityCutoffs = { a11y: thresholds.accessibility, bp: thresholds.bestPractices, seo: thresholds.seo } as const;
return score !== null && score < qualityCutoffs[key];
})),
strategy,
- effectivePerformanceThresholds(teamThresholds, page).devicePolicy,
+ normalizePerformanceThresholds(teamThresholds).devicePolicy,
).length > 0).length;
const fieldByPage = new Map(activePages.map((page) => [page.id, pageFieldPriority(page, strategy, visitorEvidence)]));
const hasExactUrlSignal = (page: WatchPage, key: "corroborated" | "fieldOnly") => {
@@ -317,7 +317,7 @@ export function buildWatcher(
const readings = ranked.map((page) => ({
page,
comparison: pageRangeComparison(page, strategy, key, rangeDays),
- thresholds: effectivePerformanceThresholds(teamThresholds, page),
+ thresholds: normalizePerformanceThresholds(teamThresholds),
}));
const dropped = readings.filter(({ comparison, thresholds }) =>
comparison !== null
diff --git a/src/lib/webhook.ts b/src/lib/webhook.ts
index ff91e8a..9d52a5a 100644
--- a/src/lib/webhook.ts
+++ b/src/lib/webhook.ts
@@ -1,5 +1,6 @@
import type { Digest } from "./digest";
import { renderDigestMessage } from "./digest-email";
+import type { DigestCadence } from "./digestCadence";
export interface WebhookDelivery {
sent: boolean;
@@ -37,10 +38,22 @@ export interface DigestWebhookSection {
export interface DailyDigestWebhookPayload {
event: "page_watch.daily_digest";
- version: 2;
+ version: 3;
id: string;
date: string;
site: string;
+ /**
+ * How often this arrives, so the receiver can say the same thing the footer
+ * says rather than guess.
+ */
+ cadence: DigestCadence;
+ /**
+ * Who the site named. Page Watch has no mail transport, so the endpoint on
+ * the other end is what turns this into deliveries — an empty list means the
+ * message was built and nobody was named, which is a different thing from a
+ * message that failed to send.
+ */
+ recipients: string[];
/** The verdict. The same string the message's subject carries. */
subject: string;
/** The whole message, as text. */
@@ -76,13 +89,16 @@ export function normalizeAlertWebhookUrl(value: string | null | undefined): stri
export function buildDailyDigestWebhookPayload(
digest: Digest,
cohortId: string,
+ recipients: readonly string[] = [],
): DailyDigestWebhookPayload {
return {
event: "page_watch.daily_digest",
- version: 2,
+ version: 3,
id: cohortId,
date: digest.date,
site: digest.site,
+ cadence: digest.cadence,
+ recipients: [...recipients],
subject: digest.subject,
text: renderDigestMessage(digest).text,
sections: digest.sections.map((section) => ({
diff --git a/vocabulary.json b/vocabulary.json
index eb5db68..a450187 100644
--- a/vocabulary.json
+++ b/vocabulary.json
@@ -609,9 +609,8 @@
"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.",
+ "$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.",
"src/lib/guide.ts": "S9 — the glossary retires; its entries define the retired terms on purpose",
- "src/app/(app)/watchlist/page.tsx": "C2 — Ignore/Suppress become the applicability concept below",
"src/components/bits.tsx": "F2 — Verifying/Returned belong to the lifecycles F2 deletes",
"src/components/store.tsx": "S2 — route references to the retired destinations",
"src/components/agent-access.tsx": "S4 — route references"