diff --git a/playground/voyage-playground.html b/playground/voyage-playground.html
index eefd404..fcad543 100644
--- a/playground/voyage-playground.html
+++ b/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/tests/playground/voyage-playground.test.mjs b/tests/playground/voyage-playground.test.mjs
index dbb8a78..8454932 100644
--- a/tests/playground/voyage-playground.test.mjs
+++ b/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');
+});