From df0e7837af88317c3337fbf11312f701a498e242 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 10 May 2026 17:53:43 +0200 Subject: [PATCH] feat(voyage): implement two-opacity pattern (active/inactive/resolved) --- .../voyage/playground/voyage-playground.html | 54 +++++++++++++++++-- .../playground/voyage-playground.test.mjs | 38 +++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html index eefd404..fcad543 100644 --- a/plugins/voyage/playground/voyage-playground.html +++ b/plugins/voyage/playground/voyage-playground.html @@ -367,15 +367,32 @@ cursor: pointer; /* 2-3px accent stripe extending right from the badge into the gutter */ box-shadow: 0.25rem 0 0 0 var(--color-scope-voyage); - transition: transform 150ms ease; + transition: opacity 150ms ease, transform 150ms ease, outline-color 150ms ease; + /* v4.3 Step 21 — two-opacity pattern: default = inactive (40%) */ + opacity: 0.4; } .voyage-anchor-badge:hover { transform: scale(1.1); + opacity: 0.85; } .voyage-anchor-badge:focus-visible { outline: 2px solid var(--color-focus-ring, #4d90fe); outline-offset: 2px; } + /* v4.3 Step 21 — active badge (selected via J/K nav or click): full opacity + + 2px outline-ring for the "border-width: 2px" intent without layout shift. */ + .voyage-anchor-badge[data-active="true"] { + opacity: 1; + outline: 2px solid var(--color-focus-ring, #4d90fe); + outline-offset: 2px; + } + /* v4.3 Step 21 — resolved badge: 30% opacity + strikethrough on the + numeric label. Status-vocabulary is annotation-specific (data-resolved + on the draft), distinct from dashboard fleet-tile data-status. */ + .voyage-anchor-badge[data-resolved="true"] { + opacity: 0.3; + text-decoration: line-through; + } /* Yellow-tint highlight on the anchored span when annotation is active. Google Docs pattern; rgba value taken from research/04 Dim 4. */ .voyage-anchor-active { @@ -606,12 +623,28 @@ display: flex; align-items: center; gap: var(--space-2); + /* v4.3 Step 21 — two-opacity pattern: default = inactive (40%). + Active sets full opacity + yellow tint (rule below); resolved + renders at 30% with strikethrough. */ + opacity: 0.4; + transition: opacity 150ms ease, background 150ms ease; } .voyage-annotation-list__items li:hover { background: var(--color-bg-soft); + opacity: 0.85; } .voyage-annotation-list__items li[data-active="true"] { background: rgba(255, 235, 59, 0.18); + opacity: 1; + } + /* v4.3 Step 21 — resolved list-item mirrors badge: 30% opacity + + strikethrough on the label. data-resolved is set by renderAnnotationList + from draft.resolved. */ + .voyage-annotation-list__items li[data-resolved="true"] { + opacity: 0.3; + } + .voyage-annotation-list__items li[data-resolved="true"] .voyage-jumplist-label { + text-decoration: line-through; } .voyage-annotation-list__items .voyage-jumplist-num { flex: 0 0 auto; @@ -2097,6 +2130,9 @@ playground first-run shows a complete round-trip-able artifact. badge.type = 'button'; badge.className = 'voyage-anchor-badge'; badge.setAttribute('data-anchor-id', d.id); + // v4.3 Step 21 — two-opacity pattern: data-resolved drives 30% + + // strikethrough state. data-active is toggled by setActiveAnchor. + if (d.resolved) badge.setAttribute('data-resolved', 'true'); badge.setAttribute('aria-label', 'Annotering ' + (i + 1) + ': ' + (d.target_anchor || 'page')); badge.textContent = String(i + 1); (function (el, draftId) { @@ -2115,13 +2151,22 @@ playground first-run shows a complete round-trip-able artifact. if (!viewport) return; var prev = viewport.querySelectorAll('.voyage-anchor-active'); for (var i = 0; i < prev.length; i++) prev[i].classList.remove('voyage-anchor-active'); + // v4.3 Step 21 — clear data-active from prior badge AND prior sidebar + // list-item, then set on the new ones. Mirrors two-opacity intent. + var prevBadges = viewport.querySelectorAll('.voyage-anchor-badge[data-active="true"]'); + for (var pb = 0; pb < prevBadges.length; pb++) prevBadges[pb].removeAttribute('data-active'); + var prevListItems = document.querySelectorAll('#voyage-jumplist li[data-active="true"]'); + for (var pl = 0; pl < prevListItems.length; pl++) prevListItems[pl].removeAttribute('data-active'); if (!anchorId) return; var badge = viewport.querySelector('.voyage-anchor-badge[data-anchor-id="' + anchorId + '"]'); if (badge && badge.parentElement) { + badge.setAttribute('data-active', 'true'); badge.parentElement.classList.add('voyage-anchor-active'); badge.parentElement.scrollIntoView({ block: 'center', behavior: 'smooth' }); badge.focus(); } + var listItem = document.querySelector('#voyage-jumplist li[data-anchor-id="' + anchorId + '"]'); + if (listItem) listItem.setAttribute('data-active', 'true'); } // ---- Gesture 3 — page-level note (button injected near viewport) --- @@ -2350,6 +2395,10 @@ playground first-run shows a complete round-trip-able artifact. var origIdx = sorted.indexOf(d); var li = document.createElement('li'); li.setAttribute('data-anchor-id', d.id); + // v4.3 Step 21 — two-opacity pattern mirror: list-item inherits the + // resolved state from the draft so CSS can render strikethrough + + // 30% opacity to match the gutter-badge. + if (d.resolved) li.setAttribute('data-resolved', 'true'); var num = document.createElement('span'); num.className = 'voyage-jumplist-num'; num.textContent = String(origIdx + 1); @@ -2361,9 +2410,6 @@ playground first-run shows a complete round-trip-able artifact. (function (anchorId) { li.addEventListener('click', function () { setActiveAnchor(anchorId); - var items = list.querySelectorAll('li'); - for (var k = 0; k < items.length; k++) items[k].removeAttribute('data-active'); - li.setAttribute('data-active', 'true'); }); })(d.id); list.appendChild(li); diff --git a/plugins/voyage/tests/playground/voyage-playground.test.mjs b/plugins/voyage/tests/playground/voyage-playground.test.mjs index dbb8a78..8454932 100644 --- a/plugins/voyage/tests/playground/voyage-playground.test.mjs +++ b/plugins/voyage/tests/playground/voyage-playground.test.mjs @@ -299,3 +299,41 @@ test('voyage-playground.html keyboard nav announces via aria-live region (v4.3 S // The wireKeyboardNav body contains announce(... ' av ' ...) for nav-position announce assert.match(text, /announce\('Annotering '/, 'aria-live announce on annotation navigation required'); }); + +// v4.3 Step 21 — two-opacity pattern (active 100% / inactive 40% / resolved 30% strikethrough) +test('voyage-playground.html declares two-opacity inactive default for badges (v4.3 Step 21)', () => { + const text = readFileSync(HTML, 'utf-8'); + // Default badge rule must include opacity: 0.4 (inactive) + assert.match(text, /\.voyage-anchor-badge\s*\{[^}]*opacity:\s*0\.4/s, '.voyage-anchor-badge default opacity: 0.4 required'); +}); + +test('voyage-playground.html declares two-opacity active state for badges (v4.3 Step 21)', () => { + const text = readFileSync(HTML, 'utf-8'); + // Active state: data-active="true" must restore opacity to 1 + assert.match(text, /\.voyage-anchor-badge\[data-active="true"\]\s*\{[^}]*opacity:\s*1/s, 'data-active opacity: 1 required'); +}); + +test('voyage-playground.html declares two-opacity resolved state for badges (v4.3 Step 21)', () => { + const text = readFileSync(HTML, 'utf-8'); + // Resolved state: data-resolved="true" must produce opacity 0.3 + line-through + assert.match(text, /\.voyage-anchor-badge\[data-resolved="true"\]\s*\{[^}]*opacity:\s*0\.3/s, 'data-resolved opacity: 0.3 required'); + assert.match(text, /\.voyage-anchor-badge\[data-resolved="true"\]\s*\{[^}]*text-decoration:\s*line-through/s, 'data-resolved line-through required'); +}); + +test('voyage-playground.html declares two-opacity for sidebar list-items (v4.3 Step 21)', () => { + const text = readFileSync(HTML, 'utf-8'); + // List-item default opacity 0.4 + assert.match(text, /\.voyage-annotation-list__items\s+li\s*\{[^}]*opacity:\s*0\.4/s, 'list-item default opacity: 0.4 required'); + // List-item active overrides to 1 + assert.match(text, /\.voyage-annotation-list__items\s+li\[data-active="true"\][^}]*opacity:\s*1/s, 'list-item active opacity: 1 required'); + // List-item resolved opacity 0.3 + assert.match(text, /\.voyage-annotation-list__items\s+li\[data-resolved="true"\][^}]*opacity:\s*0\.3/s, 'list-item resolved opacity: 0.3 required'); +}); + +test('voyage-playground.html setActiveAnchor toggles data-active on badges (v4.3 Step 21)', () => { + const text = readFileSync(HTML, 'utf-8'); + // setActiveAnchor must clear prior data-active and set new one + assert.match(text, /setAttribute\('data-active',\s*'true'\)/, 'data-active set on active badge required'); + // injectAnchorBadges must propagate resolved state to badge data-resolved + assert.match(text, /setAttribute\('data-resolved',\s*'true'\)/, 'data-resolved set on resolved badge required'); +});