diff --git a/docs/src/content/docs/install.md b/docs/src/content/docs/install.md index cb28a71..ba27e73 100644 --- a/docs/src/content/docs/install.md +++ b/docs/src/content/docs/install.md @@ -115,6 +115,14 @@ set of reserved keys is also accepted for non-rule build-time toggles: [Debug trace](/agent-browser-shield/debug-trace/) for the retrieval recipes and event schema. +- `placeholderAdaptivePalette` (boolean, default **off**, experimental) — sample + each placeholder's ancestor backgrounds at insert time and pick a light or + dark stripe palette so redactions on dark-themed pages don't flare against the + page chrome. Off by default while the visual heuristic is still being tuned; + the toggle is also surfaced in the Options page under the *Placeholder + display* section so humans can flip it without rebuilding. Enable for + deployments on consistently dark UIs. + The file may be partial; rules not listed keep the committed default. Unknown keys (neither a registered rule id nor a reserved key) and non-boolean values fail the build with a message naming them. diff --git a/extension/build.ts b/extension/build.ts index 59beae0..713563d 100644 --- a/extension/build.ts +++ b/extension/build.ts @@ -106,7 +106,8 @@ async function build(): Promise { Object.keys(overrides.rules).length + (overrides.optionsButton === undefined ? 0 : 1) + (overrides.runOnInactiveTabs === undefined ? 0 : 1) + - (overrides.debugTrace === undefined ? 0 : 1); + (overrides.debugTrace === undefined ? 0 : 1) + + (overrides.placeholderAdaptivePalette === undefined ? 0 : 1); console.log( `Applying ${changed} build-time default override(s) from ${defaultsPath}.`, ); @@ -184,6 +185,12 @@ async function build(): Promise { "process.env.EXTENSION_DEBUG_TRACE_DEFAULT": JSON.stringify( overrides.debugTrace === undefined ? "" : String(overrides.debugTrace), ), + "process.env.EXTENSION_PLACEHOLDER_ADAPTIVE_PALETTE_DEFAULT": + JSON.stringify( + overrides.placeholderAdaptivePalette === undefined + ? "" + : String(overrides.placeholderAdaptivePalette), + ), }, }); diff --git a/extension/data/defaults-overrides.example.json b/extension/data/defaults-overrides.example.json index 9a382e1..28d637d 100644 --- a/extension/data/defaults-overrides.example.json +++ b/extension/data/defaults-overrides.example.json @@ -5,5 +5,6 @@ "optionsButton": true, "runOnInactiveTabs": false, - "debugTrace": false + "debugTrace": false, + "placeholderAdaptivePalette": false } diff --git a/extension/scripts/__tests__/load-default-overrides.test.ts b/extension/scripts/__tests__/load-default-overrides.test.ts index 5bd6bc1..24736de 100644 --- a/extension/scripts/__tests__/load-default-overrides.test.ts +++ b/extension/scripts/__tests__/load-default-overrides.test.ts @@ -139,6 +139,42 @@ describe("loadDefaultOverrides", () => { ).toThrow(/non-boolean values for: debugTrace/); }); + it("extracts the placeholderAdaptivePalette reserved key alongside rules", () => { + const file = writeFile( + "with-adaptive-palette.json", + JSON.stringify({ "pii-redact": true, placeholderAdaptivePalette: true }), + ); + expect( + loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), + ).toEqual({ + rules: { "pii-redact": true }, + placeholderAdaptivePalette: true, + }); + }); + + it("accepts placeholderAdaptivePalette on its own", () => { + const file = writeFile( + "only-adaptive-palette.json", + JSON.stringify({ placeholderAdaptivePalette: false }), + ); + expect( + loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), + ).toEqual({ + rules: {}, + placeholderAdaptivePalette: false, + }); + }); + + it("rejects a non-boolean placeholderAdaptivePalette value", () => { + const file = writeFile( + "bad-adaptive-palette.json", + JSON.stringify({ placeholderAdaptivePalette: "on" }), + ); + expect(() => + loadDefaultOverrides({ path: file, knownRuleIds: KNOWN_IDS }), + ).toThrow(/non-boolean values for: placeholderAdaptivePalette/); + }); + it("throws when the file does not exist", () => { expect(() => loadDefaultOverrides({ diff --git a/extension/scripts/load-default-overrides.ts b/extension/scripts/load-default-overrides.ts index d6cc523..07179cb 100644 --- a/extension/scripts/load-default-overrides.ts +++ b/extension/scripts/load-default-overrides.ts @@ -26,6 +26,7 @@ export interface DefaultOverrides { optionsButton?: boolean; runOnInactiveTabs?: boolean; debugTrace?: boolean; + placeholderAdaptivePalette?: boolean; } // Reserved top-level keys are not rule ids; the loader maps each one to a @@ -35,6 +36,7 @@ const RESERVED_KEYS = new Set([ "optionsButton", "runOnInactiveTabs", "debugTrace", + "placeholderAdaptivePalette", ]); export function loadDefaultOverrides( @@ -95,6 +97,10 @@ export function loadDefaultOverrides( result.debugTrace = value; break; } + case "placeholderAdaptivePalette": { + result.placeholderAdaptivePalette = value; + break; + } } continue; } diff --git a/extension/src/lib/__tests__/placeholder-adaptive-palette.test.ts b/extension/src/lib/__tests__/placeholder-adaptive-palette.test.ts new file mode 100644 index 0000000..ca8bcfd --- /dev/null +++ b/extension/src/lib/__tests__/placeholder-adaptive-palette.test.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Adaptive-palette experimental toggle: classifier walks the ancestor chain +// for a non-transparent background, and `replaceWithBlockPlaceholder` / +// `replaceMatchesInTextNode` stamp `data-abs-placeholder-palette="dark"` +// only when the cache is on AND the sample classifies as dark. + +import { PLACEHOLDER_PALETTE_ATTR } from "../dom-markers"; +import { + PLACEHOLDER_CLASS, + pickPaletteFromAncestor, + replaceMatchesInTextNode, + replaceWithBlockPlaceholder, +} from "../placeholder"; +import { setAdaptivePaletteCache } from "../placeholder-adaptive-palette"; +import type { RuleId } from "../storage"; + +const RULE_ID = "footer-redact" as RuleId; +const PII_RULE_ID = "pii-redact" as RuleId; + +beforeEach(() => { + document.body.innerHTML = ""; + document.body.removeAttribute("style"); + // Default to off so the cache state of one test doesn't leak into the + // next. Each test that needs the toggle on flips it explicitly. + setAdaptivePaletteCache(false); +}); + +describe("pickPaletteFromAncestor", () => { + it("returns 'light' when the nearest non-transparent background is bright", () => { + document.body.innerHTML = `
x
`; + const target = document.querySelector("#target"); + if (!target) { + throw new Error("fixture missing #target"); + } + expect(pickPaletteFromAncestor(target)).toBe("light"); + }); + + it("returns 'dark' when the nearest non-transparent background is dark", () => { + document.body.innerHTML = `
x
`; + const target = document.querySelector("#target"); + if (!target) { + throw new Error("fixture missing #target"); + } + expect(pickPaletteFromAncestor(target)).toBe("dark"); + }); + + it("walks past transparent ancestors to the first opaque background", () => { + document.body.setAttribute("style", "background-color: rgb(13, 17, 23)"); + document.body.innerHTML = `
x
`; + const target = document.querySelector("#target"); + if (!target) { + throw new Error("fixture missing #target"); + } + // The intermediate divs/sections have no background; only does. + expect(pickPaletteFromAncestor(target)).toBe("dark"); + }); + + it("falls back to 'light' when every ancestor is transparent", () => { + document.body.innerHTML = `
x
`; + const target = document.querySelector("#target"); + if (!target) { + throw new Error("fixture missing #target"); + } + expect(pickPaletteFromAncestor(target)).toBe("light"); + }); + + it("returns 'light' when start is null", () => { + expect(pickPaletteFromAncestor(null)).toBe("light"); + }); +}); + +describe("replaceWithBlockPlaceholder palette stamping", () => { + it("does not stamp the palette attribute when the toggle is off", () => { + document.body.setAttribute("style", "background-color: rgb(13, 17, 23)"); + document.body.innerHTML = `
x
`; + const target = document.querySelector("#target"); + if (!target) { + throw new Error("fixture missing #target"); + } + + const placeholder = replaceWithBlockPlaceholder( + target, + RULE_ID, + "[hidden]", + ); + expect(placeholder.hasAttribute(PLACEHOLDER_PALETTE_ATTR)).toBe(false); + }); + + it("stamps palette='dark' when toggle on and ancestor sampled dark", () => { + setAdaptivePaletteCache(true); + document.body.setAttribute("style", "background-color: rgb(13, 17, 23)"); + document.body.innerHTML = `
x
`; + const target = document.querySelector("#target"); + if (!target) { + throw new Error("fixture missing #target"); + } + + const placeholder = replaceWithBlockPlaceholder( + target, + RULE_ID, + "[hidden]", + ); + expect(placeholder.getAttribute(PLACEHOLDER_PALETTE_ATTR)).toBe("dark"); + }); + + it("omits the attribute when toggle on but ancestor sampled light", () => { + setAdaptivePaletteCache(true); + document.body.setAttribute("style", "background-color: rgb(255, 255, 255)"); + document.body.innerHTML = `
x
`; + const target = document.querySelector("#target"); + if (!target) { + throw new Error("fixture missing #target"); + } + + const placeholder = replaceWithBlockPlaceholder( + target, + RULE_ID, + "[hidden]", + ); + expect(placeholder.hasAttribute(PLACEHOLDER_PALETTE_ATTR)).toBe(false); + }); +}); + +describe("inline placeholder palette stamping", () => { + it("stamps palette='dark' on inline placeholders when ancestor is dark", () => { + setAdaptivePaletteCache(true); + document.body.innerHTML = `

hello world

`; + const host = document.querySelector("#host"); + if (!host) { + throw new Error("fixture missing #host"); + } + const textNode = host.firstChild as Text; + + replaceMatchesInTextNode( + textNode, + [{ start: 0, end: 5, label: "[hidden]" }], + PII_RULE_ID, + ); + + const placeholder = host.querySelector(`.${PLACEHOLDER_CLASS}--inline`); + expect(placeholder?.getAttribute(PLACEHOLDER_PALETTE_ATTR)).toBe("dark"); + }); + + it("omits the attribute on inline placeholders when toggle is off", () => { + document.body.innerHTML = `

hello world

`; + const host = document.querySelector("#host"); + if (!host) { + throw new Error("fixture missing #host"); + } + const textNode = host.firstChild as Text; + + replaceMatchesInTextNode( + textNode, + [{ start: 0, end: 5, label: "[hidden]" }], + PII_RULE_ID, + ); + + const placeholder = host.querySelector(`.${PLACEHOLDER_CLASS}--inline`); + expect(placeholder?.hasAttribute(PLACEHOLDER_PALETTE_ATTR)).toBe(false); + }); +}); diff --git a/extension/src/lib/dom-markers.ts b/extension/src/lib/dom-markers.ts index 90db434..71e310e 100644 --- a/extension/src/lib/dom-markers.ts +++ b/extension/src/lib/dom-markers.ts @@ -34,6 +34,13 @@ export const HIDDEN_ATTR = "data-abs-hidden"; // re-render against the right shape. export const PLACEHOLDER_MODE_ATTR = "data-abs-placeholder-mode"; +// Stamped by `placeholder.ts` on each placeholder when the experimental +// adaptive-palette toggle is on and the surrounding background sampled as +// dark. The placeholder stylesheet reads it as `[data-abs-placeholder- +// palette="dark"]` to swap the stripe / chip palette. Absent when the toggle +// is off or the background sampled light. +export const PLACEHOLDER_PALETTE_ATTR = "data-abs-placeholder-palette"; + // Per-rule — set by exactly one rule's `apply` and read by its watcher / // teardown. Add new entries here when a new rule needs an attribute marker. diff --git a/extension/src/lib/placeholder-adaptive-palette.ts b/extension/src/lib/placeholder-adaptive-palette.ts new file mode 100644 index 0000000..bd1af04 --- /dev/null +++ b/extension/src/lib/placeholder-adaptive-palette.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Experimental: when enabled, each placeholder samples the background of its +// ancestor chain at insert time and picks a light- or dark-tuned palette so +// the redaction stripes don't clash with the surrounding page (e.g. a +// dark-mode GitHub page where the default light stripes flare). Default off +// while the visual heuristic is still being tuned. Operators can preflip it +// per deployment via the build-time defaults file. +// +// Placeholder creation has to know the toggle's value synchronously, so this +// module also exposes a module-local cache initialised from storage in +// `rule-engine.start()`. Reading from a cache rather than awaiting storage +// per-placeholder keeps the redaction path off the chrome.storage round +// trip. + +import { createChromeStorageValue } from "./chrome-storage-value"; + +export const PLACEHOLDER_ADAPTIVE_PALETTE_DEFAULT = false; + +// `process.env.EXTENSION_PLACEHOLDER_ADAPTIVE_PALETTE_DEFAULT` is substituted +// by build.ts when the operator passes a defaults file with a +// `placeholderAdaptivePalette` field. Literal `"true"` / `"false"` forces a +// value; empty string falls back to the committed default above. +function resolveDefault(): boolean { + const raw = process.env.EXTENSION_PLACEHOLDER_ADAPTIVE_PALETTE_DEFAULT; + if (raw === "true") { + return true; + } + if (raw === "false") { + return false; + } + return PLACEHOLDER_ADAPTIVE_PALETTE_DEFAULT; +} + +export const placeholderAdaptivePaletteStorage = + createChromeStorageValue({ + key: "agent-browser-shield.placeholder-adaptive-palette", + defaultValue: resolveDefault(), + }); + +let cachedEnabled = resolveDefault(); + +export function isAdaptivePaletteEnabled(): boolean { + return cachedEnabled; +} + +export function setAdaptivePaletteCache(value: boolean): void { + cachedEnabled = value; +} diff --git a/extension/src/lib/placeholder.ts b/extension/src/lib/placeholder.ts index 482632e..1d374aa 100644 --- a/extension/src/lib/placeholder.ts +++ b/extension/src/lib/placeholder.ts @@ -2,8 +2,13 @@ // Licensed under PolyForm Shield 1.0.0 — see LICENSE. import { isDebugTraceEnabled, recordRuleApplication } from "./debug-trace"; -import { REVEALED_ATTR, RULE_ATTR } from "./dom-markers"; +import { + PLACEHOLDER_PALETTE_ATTR, + REVEALED_ATTR, + RULE_ATTR, +} from "./dom-markers"; import { createRuleLogger, log } from "./log"; +import { isAdaptivePaletteEnabled } from "./placeholder-adaptive-palette"; import type { RuleId } from "./storage"; import { traceMutation } from "./trace-mutation"; @@ -18,6 +23,77 @@ export interface InlineMatch { label: string; } +// Adaptive palette: walk the placeholder's ancestor chain, find the first +// non-transparent background color, and classify it as light or dark. Used +// only when the experimental `placeholderAdaptivePalette` toggle is on — +// otherwise placeholders take the committed light palette unconditionally. +// Returned value is stamped as `data-abs-placeholder-palette="dark"` (or +// omitted for light); the placeholder stylesheet swaps CSS variables based +// on the attribute. +export type PlaceholderPalette = "light" | "dark"; + +// rgb(r,g,b), rgb(r,g,b,a), rgba(...) — getComputedStyle in Chrome/Firefox +// normalises every color into one of these shapes. `oklch()` / `color()` +// can leak through in newer engines, but the engines that support them also +// normalise to the legacy `rgb()` form for `background-color` reads. If we +// hit a value we don't understand we return null and the caller keeps +// walking up the ancestor chain. +const RGB_PATTERN = + /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*([\d.]+))?\s*\)$/; + +function parseBackgroundBrightness(color: string): number | null { + const match = RGB_PATTERN.exec(color); + if (!match) { + return null; + } + const alpha = match[4] === undefined ? 1 : Number(match[4]); + // Treat near-transparent backgrounds as "no information" so the walker + // keeps climbing. 0.5 is the threshold the WebAIM contrast tooling uses + // for the same purpose; below that the underlying ancestor's background + // dominates the perceived color. + if (!Number.isFinite(alpha) || alpha < 0.5) { + return null; + } + const r = Number(match[1]); + const g = Number(match[2]); + const b = Number(match[3]); + // Perceived brightness (Rec. 601 weights). Cheap and stable; we only need + // a binary light/dark decision, not WCAG-grade relative luminance. + return (0.299 * r + 0.587 * g + 0.114 * b) / 255; +} + +// Exported for unit tests. Brightness threshold of 0.5 splits "page chrome" +// backgrounds cleanly: light pages typically land 0.95+, dark pages 0.1 or +// below; the midpoint catches mid-gray pages the toggle isn't really aimed +// at and falls back to light, matching the committed default. +export function pickPaletteFromAncestor( + start: Element | null, +): PlaceholderPalette { + let node: Element | null = start; + while (node) { + const bg = globalThis.getComputedStyle(node).backgroundColor; + const brightness = parseBackgroundBrightness(bg); + if (brightness !== null) { + return brightness < 0.5 ? "dark" : "light"; + } + node = node.parentElement; + } + return "light"; +} + +function applyPalette( + placeholder: HTMLElement, + ancestor: Element | null, +): void { + if (!isAdaptivePaletteEnabled()) { + return; + } + const palette = pickPaletteFromAncestor(ancestor); + if (palette === "dark") { + placeholder.setAttribute(PLACEHOLDER_PALETTE_ATTR, "dark"); + } +} + function describeNode(node: Node): Record { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as Element; @@ -144,6 +220,11 @@ export function replaceWithBlockPlaceholder( placeholder.append(createRevealButton(ruleId, label)); + // Sample from the element being replaced — it still has its computed + // ancestor chain at this point — so the palette decision reflects the + // surrounding page chrome, not the placeholder's blank inserted state. + applyPalette(placeholder, element); + placeholder.style.width = `${rect.width}px`; placeholder.style.minHeight = `${rect.height}px`; const display = computed.display; @@ -188,6 +269,7 @@ export function replaceMatchesInTextNode( const text = textNode.nodeValue ?? ""; const fragment = document.createDocumentFragment(); let cursor = 0; + const ancestor = textNode.parentElement; for (const match of matches) { if (match.start < cursor) { @@ -201,6 +283,7 @@ export function replaceMatchesInTextNode( ruleId, match.label, text.slice(match.start, match.end), + ancestor, ), ); cursor = match.end; @@ -217,6 +300,7 @@ function createInlinePlaceholder( ruleId: RuleId, label: string, originalText: string, + ancestor: Element | null, ): HTMLButtonElement { // Inline placeholders are short and have no scrollable area, so the //