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; }
/* Gesture 2 — paragraph-anchored always-visible-icon (hover-reveal) */
.voyage-viewport p {
/* v4.3 Step 18 — numbered-badge gutter + yellow-tint highlight.
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;
}
.voyage-pencil-btn {
.voyage-anchor-badge {
position: absolute;
left: -32px;
top: 0;
width: 24px;
height: 24px;
left: -2.5rem;
top: 0.15rem;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
opacity: 0;
border-radius: 50%;
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;
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-pencil-btn:focus-visible {
opacity: 1;
.voyage-anchor-badge:hover {
transform: scale(1.1);
}
.voyage-pencil-btn svg {
width: 16px;
height: 16px;
fill: var(--color-text-tertiary);
.voyage-anchor-badge:focus-visible {
outline: 2px solid var(--color-focus-ring, #4d90fe);
outline-offset: 2px;
}
/* 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) */
@ -1946,35 +1971,61 @@ playground first-run shows a complete round-trip-able artifact.
openModal({ target: target, line: line, snippet: snippet, storageKey: key });
}
// ---- Gesture 2 — paragraph-anchored pencil icon (hover-reveal) -----
function injectPencilIcons() {
// ---- v4.3 Step 18 — numbered-badge gutter (replaces pencil-icon) ---
// 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');
if (!viewport) return;
var paras = viewport.querySelectorAll('p, li');
for (var i = 0; i < paras.length; i++) {
var p = paras[i];
if (p.querySelector('.voyage-pencil-btn')) continue;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'voyage-pencil-btn';
btn.setAttribute('aria-label', 'Annotér dette avsnittet');
btn.innerHTML =
'<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"/>' +
'</svg>';
(function (el) {
btn.addEventListener('click', function () {
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
var target = deriveHeadingPath(el);
var line = deriveLineNumber(el);
openModal({ target: target, line: line, snippet: '', storageKey: key });
var drafts = loadDrafts(key);
if (!Array.isArray(drafts) || drafts.length === 0) return;
var paras = viewport.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, pre');
// Sort drafts by line ASC for stable 1..N numbering
var sorted = drafts.slice().sort(function (a, b) {
return (Number(a.line) || 0) - (Number(b.line) || 0);
});
})(p);
// Insert at start so it floats in left margin via CSS
p.style.position = 'relative';
p.insertBefore(btn, p.firstChild);
for (var i = 0; i < sorted.length; i++) {
var d = sorted[i];
var ln = Number(d.line);
if (!ln || ln < 1 || ln > paras.length) continue;
var p = paras[ln - 1];
if (!p) continue;
if (p.querySelector('.voyage-anchor-badge')) continue;
var badge = document.createElement('button');
badge.type = 'button';
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, d.id);
p.insertBefore(badge, 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;
}
// 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;
mountRender = function (text) {
originalMountRender(text);
injectPencilIcons();
injectAnchorBadges();
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*\)/,
'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');
});