diff --git a/src/content/highlighter.ts b/src/content/highlighter.ts index a601f76..8a2676b 100644 --- a/src/content/highlighter.ts +++ b/src/content/highlighter.ts @@ -53,9 +53,22 @@ const DEFAULT_OPACITY = 0.85; */ const ACCENT_RGB = '37, 99, 235'; // tailwind blue-600 -/** Element width/opacity tokens per state. Tweak these together. */ +/** + * Element width/opacity tokens per state. Tweak these together. + * + * - `hover` / `selected` / `match` use CSS `outline` for full-perimeter + * prominence — these are *interaction* signals and need to read from + * across the page. + * - `ambient.tick` is the length (CSS px) of each corner-accent arm + * used by the ambient (mode='all' / 'editable') rendering. Short + * enough to feel like a hint on large components, long enough to + * register on small ones. + * - `ambient.alpha` is higher than the previous full-outline value + * (was 0.18) because there are far fewer pixels carrying the signal + * — four short L-shapes total, not a continuous border. + */ const TOKENS = { - ambient: { width: 1, alpha: 0.18, offset: -1 }, + ambient: { tick: 8, alpha: 0.55 }, hover: { width: 2, alpha: 0.7, offset: -2 }, selected: { width: 2, alpha: 1, offset: -2 }, match: { width: 2, alpha: 0.95, offset: -2 }, @@ -82,15 +95,71 @@ function buildStyleSheet(): string { const o = (alpha: number) => `rgba(${ACCENT_RGB}, calc(${alpha} * var(${OPACITY_VAR}, ${DEFAULT_OPACITY})))`; + // Ambient color (used inside the linear-gradient stops below). + const ambient = o(TOKENS.ambient.alpha); + const tick = `${TOKENS.ambient.tick}px`; + return ` - /* ── Ambient outlines: gated by html[data-clay-slip-mode] ──────────── - Mode 'all' → every [data-uri] gets a 1px ghost outline. - Mode 'editable' → only [data-editable] does. - Mode 'selection' or 'off' → no rule matches; nothing painted. */ - html[${MODE_ATTR}="all"] [${HIGHLIGHT_ATTR}], - html[${MODE_ATTR}="editable"] [${HIGHLIGHT_ATTR}][data-editable] { - outline: ${TOKENS.ambient.width}px solid ${o(TOKENS.ambient.alpha)} !important; - outline-offset: ${TOKENS.ambient.offset}px !important; + /* ── Ambient corner ticks (mode='all' + mode='editable') ───────────── + Replaces the previous "1px outline at 18% on every edge" with four + short L-shaped accents at each corner. Trade-offs: + - Way less visual mass: nested components no longer create stacks + of parallel lines at shared edges. + - Reads as "this is a discrete thing" without drawing a box around + the content. + - Hover (2px @ 70%) and selection (2px @ 100%) stay visually + dominant by comparison — exactly what you want during inspection. + + The :not() chain keeps the corner ticks from competing with the + richer hover/selected outlines: while you're inspecting, only the + inspected element's outline lights up. + + Mode gating: 'all' → every component; 'editable' → only + [data-editable]; 'selection'/'off' → no rule matches, nothing + painted at all. */ + html[${MODE_ATTR}="all"] [${HIGHLIGHT_ATTR}]:not([${HOVER_ATTR}]):not([${SELECTED_ATTR}]), + html[${MODE_ATTR}="editable"] [${HIGHLIGHT_ATTR}][data-editable]:not([${HOVER_ATTR}]):not([${SELECTED_ATTR}]) { + /* Establish a positioning context for the ::before pseudo. We omit + !important so we never fight a host's own positioning rule — if + the host already has position: relative/absolute/fixed/sticky, + the pseudo positions against that, which is exactly right. The + only failure mode is: host uses position:static AND has an + absolute-positioned descendant currently positioning against a + farther ancestor (it would reparent to this component). 'all' + is opt-in, so the user can dial back to 'selection' if they + hit that edge case. */ + position: relative; + } + + html[${MODE_ATTR}="all"] [${HIGHLIGHT_ATTR}]:not([${HOVER_ATTR}]):not([${SELECTED_ATTR}])::before, + html[${MODE_ATTR}="editable"] [${HIGHLIGHT_ATTR}][data-editable]:not([${HOVER_ATTR}]):not([${SELECTED_ATTR}])::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + /* One below the selection label badge (2147483646) so the badge + always wins when both might paint. */ + z-index: 2147483645; + /* Eight tiny gradients, one per arm of each corner L. Each arm is + ${tick} long × 1px thick. background-size + background-position + keep them anchored at the four corners regardless of element size. */ + background: + /* top-left horizontal */ + linear-gradient(${ambient}, ${ambient}) 0 0 / ${tick} 1px no-repeat, + /* top-left vertical */ + linear-gradient(${ambient}, ${ambient}) 0 0 / 1px ${tick} no-repeat, + /* top-right horizontal */ + linear-gradient(${ambient}, ${ambient}) 100% 0 / ${tick} 1px no-repeat, + /* top-right vertical */ + linear-gradient(${ambient}, ${ambient}) 100% 0 / 1px ${tick} no-repeat, + /* bottom-left horizontal */ + linear-gradient(${ambient}, ${ambient}) 0 100% / ${tick} 1px no-repeat, + /* bottom-left vertical */ + linear-gradient(${ambient}, ${ambient}) 0 100% / 1px ${tick} no-repeat, + /* bottom-right horizontal */ + linear-gradient(${ambient}, ${ambient}) 100% 100% / ${tick} 1px no-repeat, + /* bottom-right vertical */ + linear-gradient(${ambient}, ${ambient}) 100% 100% / 1px ${tick} no-repeat; } /* Hover and selection always render regardless of mode (otherwise diff --git a/tests/content/highlighter.test.ts b/tests/content/highlighter.test.ts index 8651753..590f513 100644 --- a/tests/content/highlighter.test.ts +++ b/tests/content/highlighter.test.ts @@ -212,6 +212,58 @@ describe('setHighlightOpacity', () => { }); }); +describe('ambient corner-tick stylesheet (mode=all / editable)', () => { + // The actual rendering is CSS-only, but the *contract* between the + // highlighter module and its stylesheet is testable: + // 1. The corner-tick rule must be gated by mode='all' or mode='editable' + // so 'selection' and 'off' produce no ambient paint at all. + // 2. The rule must exclude :hover / :selected so the corner ticks don't + // compete with the richer hover/selected outlines. + // 3. The pseudo-element must be ::before (the selection label badge + // uses ::before too, but we exclude :selected from the corner-tick + // rule so they never collide on the same element). + // If any of these invariants change without intent, the test fails and + // forces a deliberate update. + function getStylesheetText(): string { + installHighlightStyles(); + return document.getElementById('clay-slip-highlight-styles')?.textContent ?? ''; + } + + it('gates the corner-tick rule on mode=all + mode=editable', () => { + const css = getStylesheetText(); + expect(css).toMatch(/html\[data-clay-slip-mode="all"\][^{]*::before/); + expect(css).toMatch(/html\[data-clay-slip-mode="editable"\][^{]*::before/); + // No ambient rule should match selection or off mode. + expect(css).not.toMatch(/html\[data-clay-slip-mode="selection"\][^{]*::before/); + expect(css).not.toMatch(/html\[data-clay-slip-mode="off"\][^{]*::before/); + }); + + it('excludes hovered + selected elements from the corner-tick rule', () => { + const css = getStylesheetText(); + // Each corner-tick selector must carry both :not() exclusions so the + // ambient ticks fade out when the user is actually inspecting an + // element. This is the visual handoff to the hover/selected outlines. + const cornerTickRules = css.match( + /html\[data-clay-slip-mode="(?:all|editable)"\][^{]+::before/g + ); + expect(cornerTickRules?.length).toBeGreaterThan(0); + for (const rule of cornerTickRules ?? []) { + expect(rule).toContain(':not([data-clay-slip-hover])'); + expect(rule).toContain(':not([data-clay-slip-selected])'); + } + }); + + it('uses ::before so it does not collide with the annotation dot (::after)', () => { + const css = getStylesheetText(); + // Annotation dot uses ::after; corner ticks must use ::before. Verifying + // the literal pseudo-element keeps the two independent in `all` mode + // where the same element could be both annotated and ambient. + expect(css).toContain('data-clay-slip-annotated]::after'); + const cornerTickRule = css.match(/html\[data-clay-slip-mode="all"\][^{]+::before/); + expect(cornerTickRule).not.toBeNull(); + }); +}); + describe('mode + editable interaction (CSS gating contract)', () => { // The actual visual gating happens in the stylesheet, but we can at least // verify the *contract* the CSS depends on: