feat(voyage): replace pencil-icon with numbered-badge gutter + yellow-tint highlight

Step 18 of v4.3 playground plan. Replaces v4.2 Gesture 2 pencil-icon
hover-reveal with numbered circular badges in the left gutter (one per
anchored paragraph; ordering matches sidebar jumplist). 2-3px accent stripe
extends right from the badge into the gutter. Yellow-tint highlight
(rgba 255, 235, 59, 0.25 — Google Docs pattern) applies to the anchored
paragraph when an annotation is active. Body text never reflowed or
recolored. Gesture 1 (text-select adder) and Gesture 3 (page-level note)
remain for new annotation creation.

CSS uses --color-scope-voyage token for badge background and stripe.
JS adds injectAnchorBadges() + setActiveAnchor() and rewires mountRender.

Trace: SC1 + SC6, research/04 Insight 3 + Recommendation marker-design.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 17:06:59 +02:00
commit 84f41014f9
2 changed files with 120 additions and 45 deletions

View file

@ -331,31 +331,56 @@
} }
.voyage-adder-popup[hidden] { display: none; } .voyage-adder-popup[hidden] { display: none; }
/* Gesture 2 — paragraph-anchored always-visible-icon (hover-reveal) */ /* v4.3 Step 18 — numbered-badge gutter + yellow-tint highlight.
.voyage-viewport p { Replaces v4.2's pencil-icon hover-reveal (Gesture 2). Badge appears
only on already-anchored paragraphs; ordering numbers match sidebar
jumplist. Body-text never reflowed or recolored — only the gutter
element + tinting. Gesture 1 (text-select adder popup) and Gesture 3
(page-level note button) remain in place for new annotation creation. */
.voyage-viewport p,
.voyage-viewport li,
.voyage-viewport h1,
.voyage-viewport h2,
.voyage-viewport h3,
.voyage-viewport h4,
.voyage-viewport h5,
.voyage-viewport h6 {
position: relative; position: relative;
} }
.voyage-pencil-btn { .voyage-anchor-badge {
position: absolute; position: absolute;
left: -32px; left: -2.5rem;
top: 0; top: 0.15rem;
width: 24px; width: 1.5rem;
height: 24px; height: 1.5rem;
padding: 0; padding: 0;
border: none; border: none;
background: transparent; border-radius: 50%;
opacity: 0; background: var(--color-scope-voyage);
color: #fff;
font-size: var(--font-size-xs, 0.75rem);
font-weight: 600;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer; cursor: pointer;
transition: opacity 150ms ease; /* 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;
} }
.voyage-viewport p:hover .voyage-pencil-btn, .voyage-anchor-badge:hover {
.voyage-pencil-btn:focus-visible { transform: scale(1.1);
opacity: 1;
} }
.voyage-pencil-btn svg { .voyage-anchor-badge:focus-visible {
width: 16px; outline: 2px solid var(--color-focus-ring, #4d90fe);
height: 16px; outline-offset: 2px;
fill: var(--color-text-tertiary); }
/* 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 {
background: rgba(255, 235, 59, 0.25);
transition: background 150ms ease;
} }
/* Gesture 3 — page-level note button (placeholder; sidebar is Step 10) */ /* Gesture 3 — page-level note button (placeholder; sidebar is Step 10) */
@ -1946,35 +1971,61 @@ playground first-run shows a complete round-trip-able artifact.
openModal({ target: target, line: line, snippet: snippet, storageKey: key }); openModal({ target: target, line: line, snippet: snippet, storageKey: key });
} }
// ---- Gesture 2 — paragraph-anchored pencil icon (hover-reveal) ----- // ---- v4.3 Step 18 — numbered-badge gutter (replaces pencil-icon) ---
function injectPencilIcons() { // Iterate active drafts (loaded from storage) and inject a numbered
// circular badge into the gutter of each anchored paragraph. Ordering
// matches the export-flow numbering 1, 2, 3 ... so badges align with
// the Step 19 sidebar jumplist. Body text is never recolored; only
// the gutter element + (when active) yellow-tint highlight on the
// annotated paragraph itself.
function injectAnchorBadges() {
var viewport = $('voyage-viewport'); var viewport = $('voyage-viewport');
if (!viewport) return; if (!viewport) return;
var paras = viewport.querySelectorAll('p, li'); var ta = $('voyage-paste-input');
for (var i = 0; i < paras.length; i++) { var fm = quickParseFrontmatter(ta ? ta.value : '');
var p = paras[i]; var key = deriveStorageKey(fm);
if (p.querySelector('.voyage-pencil-btn')) continue; var drafts = loadDrafts(key);
var btn = document.createElement('button'); if (!Array.isArray(drafts) || drafts.length === 0) return;
btn.type = 'button'; var paras = viewport.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, pre');
btn.className = 'voyage-pencil-btn'; // Sort drafts by line ASC for stable 1..N numbering
btn.setAttribute('aria-label', 'Annotér dette avsnittet'); var sorted = drafts.slice().sort(function (a, b) {
btn.innerHTML = return (Number(a.line) || 0) - (Number(b.line) || 0);
'<svg viewBox="0 0 24 24" aria-hidden="true">' + });
'<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1.003 1.003 0 0 0 0-1.42l-2.34-2.34a1.003 1.003 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.82z"/>' + for (var i = 0; i < sorted.length; i++) {
'</svg>'; var d = sorted[i];
(function (el) { var ln = Number(d.line);
btn.addEventListener('click', function () { if (!ln || ln < 1 || ln > paras.length) continue;
var ta = $('voyage-paste-input'); var p = paras[ln - 1];
var fm = quickParseFrontmatter(ta ? ta.value : ''); if (!p) continue;
var key = deriveStorageKey(fm); if (p.querySelector('.voyage-anchor-badge')) continue;
var target = deriveHeadingPath(el); var badge = document.createElement('button');
var line = deriveLineNumber(el); badge.type = 'button';
openModal({ target: target, line: line, snippet: '', storageKey: key }); badge.className = 'voyage-anchor-badge';
badge.setAttribute('data-anchor-id', d.id);
badge.setAttribute('aria-label', 'Annotering ' + (i + 1) + ': ' + (d.target_anchor || 'page'));
badge.textContent = String(i + 1);
(function (el, draftId) {
badge.addEventListener('click', function () {
setActiveAnchor(draftId);
}); });
})(p); })(p, d.id);
// Insert at start so it floats in left margin via CSS p.insertBefore(badge, p.firstChild);
p.style.position = 'relative'; }
p.insertBefore(btn, p.firstChild); }
// Apply yellow-tint highlight to the anchored paragraph for the given
// anchor id; remove the class from any previously-active paragraph.
function setActiveAnchor(anchorId) {
var viewport = $('voyage-viewport');
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');
if (!anchorId) return;
var badge = viewport.querySelector('.voyage-anchor-badge[data-anchor-id="' + anchorId + '"]');
if (badge && badge.parentElement) {
badge.parentElement.classList.add('voyage-anchor-active');
badge.parentElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
badge.focus();
} }
} }
@ -1998,11 +2049,11 @@ playground first-run shows a complete round-trip-able artifact.
return btn; return btn;
} }
// Re-run gesture-2 + gesture-3 wiring after each render. // Re-run anchor-badge (Step 18) + gesture-3 wiring after each render.
var originalMountRender = mountRender; var originalMountRender = mountRender;
mountRender = function (text) { mountRender = function (text) {
originalMountRender(text); originalMountRender(text);
injectPencilIcons(); injectAnchorBadges();
ensurePageNoteButton(); ensurePageNoteButton();
}; };

View file

@ -226,3 +226,27 @@ test('voyage-playground.html declares relocateAnchorsToBlockBoundaries pure func
assert.match(text, /function\s+relocateAnchorsToBlockBoundaries\s*\(\s*text\s*,\s*anchors\s*\)/, assert.match(text, /function\s+relocateAnchorsToBlockBoundaries\s*\(\s*text\s*,\s*anchors\s*\)/,
'relocateAnchorsToBlockBoundaries(text, anchors) pure function required'); 'relocateAnchorsToBlockBoundaries(text, anchors) pure function required');
}); });
test('voyage-playground.html declares .voyage-anchor-badge gutter component (v4.3 Step 18)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /\.voyage-anchor-badge\s*\{/, '.voyage-anchor-badge CSS class required');
assert.match(text, /position:\s*absolute/, '.voyage-anchor-badge must use absolute positioning');
assert.match(text, /var\(--color-scope-voyage\)/, 'badge must use --color-scope-voyage token');
});
test('voyage-playground.html declares .voyage-anchor-active yellow-tint highlight (v4.3 Step 18)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /\.voyage-anchor-active\s*\{/, '.voyage-anchor-active CSS class required');
assert.match(text, /rgba\(255,\s*235,\s*59,\s*0\.25\)/, 'yellow-tint rgba(255, 235, 59, 0.25) required');
});
test('voyage-playground.html does NOT contain v4.2 pencil-icon references (v4.3 Step 18 cleanup)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.doesNotMatch(text, /voyage-pencil-btn/, 'pencil-btn class must be removed');
assert.doesNotMatch(text, /injectPencilIcons/, 'injectPencilIcons function must be replaced by injectAnchorBadges');
});
test('voyage-playground.html declares injectAnchorBadges JS function (v4.3 Step 18)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+injectAnchorBadges\s*\(\s*\)/, 'injectAnchorBadges() function required');
});