diff --git a/playground/voyage-playground.html b/playground/voyage-playground.html
index 4242d61..b8f7101 100644
--- a/playground/voyage-playground.html
+++ b/playground/voyage-playground.html
@@ -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 =
- '';
- (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 ta = $('voyage-paste-input');
+ var fm = quickParseFrontmatter(ta ? ta.value : '');
+ var key = deriveStorageKey(fm);
+ 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);
+ });
+ 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);
- // Insert at start so it floats in left margin via CSS
- p.style.position = 'relative';
- p.insertBefore(btn, p.firstChild);
+ })(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();
};
diff --git a/tests/playground/voyage-playground.test.mjs b/tests/playground/voyage-playground.test.mjs
index c754868..1b0d323 100644
--- a/tests/playground/voyage-playground.test.mjs
+++ b/tests/playground/voyage-playground.test.mjs
@@ -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');
+});