feat(voyage): implement hidden-by-default sidebar-rail with ordered list + filter + jumplist count

Step 19 of v4.3 playground plan. Sidebar now default aria-hidden=true
(translateX collapses panel, leaves 40px FAB rail). FAB toggle has
data-action=toggle-sidebar for keyboard binding (] in Step 20).

New annotation-list section in sidebar panel:
  - filter radiogroup: Alle (default), Åpne (unresolved), Resolved
  - voyage-jumplist ordered list with numbered badges matching the
    gutter-badge ordering (sorted by line ASC)
  - aria-live jumplist count: "X av N" (filtered/total)
  - click list-item -> setActiveAnchor + scrollIntoView + data-active

renderAnnotationList wires into mountRender so list refreshes on every
render. Filter state (voyageFilterState) persists across renders within
the session.

Trace: SC6, research/04 Dim 1 (hidden-by-default) + Insight 1 +
Recommendation sidebar/navigation.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 17:09:26 +02:00
commit 6db7c72511
2 changed files with 201 additions and 9 deletions

View file

@ -555,6 +555,78 @@
}
.voyage-fab__badge[hidden] { display: none; }
/* v4.3 Step 19 — annotation-list (sidebar): filter buttons + ordered list + jumplist count */
.voyage-annotation-list {
display: flex;
flex-direction: column;
padding: var(--space-3);
border-bottom: 1px solid var(--color-border-subtle);
}
.voyage-annotation-list__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.voyage-annotation-list__filter {
display: inline-flex;
gap: var(--space-1);
}
.voyage-filter-btn {
padding: 0.15rem 0.5rem;
border: 1px solid var(--color-border-subtle);
background: var(--color-surface);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-xs);
}
.voyage-filter-btn[aria-pressed="true"] {
background: var(--color-scope-voyage);
color: #fff;
border-color: var(--color-scope-voyage);
}
.voyage-annotation-list__count {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary, var(--color-text-secondary));
white-space: nowrap;
}
.voyage-annotation-list__items {
list-style: none;
padding: 0;
margin: 0;
max-height: 30vh;
overflow-y: auto;
}
.voyage-annotation-list__items li {
padding: var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: var(--font-size-sm);
display: flex;
align-items: center;
gap: var(--space-2);
}
.voyage-annotation-list__items li:hover {
background: var(--color-bg-soft);
}
.voyage-annotation-list__items li[data-active="true"] {
background: rgba(255, 235, 59, 0.18);
}
.voyage-annotation-list__items .voyage-jumplist-num {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: var(--color-scope-voyage);
color: #fff;
font-size: 0.7rem;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Tabs */
[role="tablist"].voyage-tabs {
display: flex;
@ -816,20 +888,23 @@
</section>
</main>
<!-- Step 10 — sidebar with tabs + critique-card-list -->
<!-- v4.3 Step 19 — sidebar-rail (hidden-by-default) with ordered annotation-list,
filter (Alle/Åpne/Resolved), and jumplist count "X av N". Toggle via
FAB data-action="toggle-sidebar" or ] keyboard shortcut (Step 20). -->
<aside
id="voyage-sidebar"
class="voyage-sidebar"
aria-label="Annotation drafts sidebar"
aria-hidden="false"
aria-hidden="true"
>
<div class="voyage-sidebar__rail">
<button
type="button"
id="voyage-sidebar-toggle"
class="voyage-fab"
data-action="toggle-sidebar"
aria-controls="voyage-sidebar"
aria-expanded="true"
aria-expanded="false"
aria-label="Skjul/vis annotation-panel"
>
<span
@ -842,6 +917,26 @@
</button>
</div>
<div class="voyage-sidebar__panel">
<!-- v4.3 Step 19 — ordered annotation list with filter + jumplist count -->
<div class="voyage-annotation-list" aria-label="Ordered annotation list">
<div class="voyage-annotation-list__header">
<div
role="radiogroup"
class="voyage-annotation-list__filter"
aria-label="Filtrer annotations"
>
<button type="button" class="voyage-filter-btn" data-filter="all" aria-pressed="true">Alle</button>
<button type="button" class="voyage-filter-btn" data-filter="open" aria-pressed="false">Åpne</button>
<button type="button" class="voyage-filter-btn" data-filter="resolved" aria-pressed="false">Resolved</button>
</div>
<div
id="voyage-jumplist-count"
class="voyage-annotation-list__count"
aria-live="polite"
>0 av 0</div>
</div>
<ol id="voyage-jumplist" class="voyage-annotation-list__items"></ol>
</div>
<div role="tablist" class="voyage-tabs" aria-label="Draft og revisjons-faner">
<button
type="button"
@ -2049,12 +2144,13 @@ playground first-run shows a complete round-trip-able artifact.
return btn;
}
// Re-run anchor-badge (Step 18) + gesture-3 wiring after each render.
// Re-run anchor-badge (Step 18) + gesture-3 + sidebar list (Step 19) wiring after each render.
var originalMountRender = mountRender;
mountRender = function (text) {
originalMountRender(text);
injectAnchorBadges();
ensurePageNoteButton();
renderAnnotationList();
};
// ---- Modal event wiring -------------------------------------------
@ -2216,16 +2312,83 @@ playground first-run shows a complete round-trip-able artifact.
}
}
// v4.3 Step 19 — current sidebar filter state ('all' | 'open' | 'resolved')
var voyageFilterState = 'all';
function toggleSidebar() {
var toggle = $('voyage-sidebar-toggle');
var sidebar = $('voyage-sidebar');
if (!toggle || !sidebar) return;
var hidden = sidebar.getAttribute('aria-hidden') === 'true';
sidebar.setAttribute('aria-hidden', hidden ? 'false' : 'true');
toggle.setAttribute('aria-expanded', hidden ? 'true' : 'false');
}
// v4.3 Step 19 — render ordered annotation-list inside sidebar.
// Item ordering matches the gutter-badge numbering 1..N (sorted by line ASC).
// Filter state ('all' | 'open' | 'resolved') controls which items render.
// Click a list-item -> scrollIntoView + setActiveAnchor on the matching badge.
function renderAnnotationList() {
var list = $('voyage-jumplist');
var countEl = $('voyage-jumplist-count');
if (!list || !countEl) return;
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
var drafts = loadDrafts(key);
var sorted = (Array.isArray(drafts) ? drafts.slice() : []).sort(function (a, b) {
return (Number(a.line) || 0) - (Number(b.line) || 0);
});
var filtered = sorted.filter(function (d) {
if (voyageFilterState === 'all') return true;
if (voyageFilterState === 'resolved') return Boolean(d.resolved);
return !d.resolved; // 'open'
});
list.innerHTML = '';
for (var i = 0; i < filtered.length; i++) {
var d = filtered[i];
var origIdx = sorted.indexOf(d);
var li = document.createElement('li');
li.setAttribute('data-anchor-id', d.id);
var num = document.createElement('span');
num.className = 'voyage-jumplist-num';
num.textContent = String(origIdx + 1);
var label = document.createElement('span');
label.className = 'voyage-jumplist-label';
label.textContent = (d.target_anchor || 'page') + (d.line ? ' · linje ' + d.line : '');
li.appendChild(num);
li.appendChild(label);
(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);
}
countEl.textContent = filtered.length + ' av ' + sorted.length;
}
function wireSidebar() {
var toggle = $('voyage-sidebar-toggle');
var sidebar = $('voyage-sidebar');
if (toggle && sidebar) {
toggle.addEventListener('click', function () {
var hidden = sidebar.getAttribute('aria-hidden') === 'true';
sidebar.setAttribute('aria-hidden', hidden ? 'false' : 'true');
toggle.setAttribute('aria-expanded', hidden ? 'true' : 'false');
});
toggle.addEventListener('click', toggleSidebar);
}
// Filter buttons (Alle / Åpne / Resolved)
document.querySelectorAll('.voyage-filter-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var f = btn.getAttribute('data-filter');
if (!f) return;
voyageFilterState = f;
document.querySelectorAll('.voyage-filter-btn').forEach(function (b) {
b.setAttribute('aria-pressed', b === btn ? 'true' : 'false');
});
renderAnnotationList();
});
});
var draftsTab = $('voyage-tab-drafts');
var historyTab = $('voyage-tab-history');
if (draftsTab) draftsTab.addEventListener('click', function () { selectTab('voyage-tab-drafts'); });

View file

@ -250,3 +250,32 @@ test('voyage-playground.html declares injectAnchorBadges JS function (v4.3 Step
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+injectAnchorBadges\s*\(\s*\)/, 'injectAnchorBadges() function required');
});
test('voyage-playground.html declares voyage-sidebar hidden-by-default (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /id="voyage-sidebar"[\s\S]{0,200}aria-hidden="true"/, 'voyage-sidebar must default aria-hidden="true"');
});
test('voyage-playground.html declares data-action="toggle-sidebar" on FAB (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /data-action="toggle-sidebar"/, 'data-action="toggle-sidebar" required on FAB toggle button');
});
test('voyage-playground.html declares voyage-jumplist + count "X av N" (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /id="voyage-jumplist"/, 'voyage-jumplist ordered list required');
assert.match(text, /id="voyage-jumplist-count"/, 'voyage-jumplist-count container required');
assert.match(text, /' av '/, '"X av N" jumplist count format string required in JS');
});
test('voyage-playground.html declares filter-buttons Alle/Åpne/Resolved (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /data-filter="all"/, 'filter button data-filter="all" required');
assert.match(text, /data-filter="open"/, 'filter button data-filter="open" required');
assert.match(text, /data-filter="resolved"/, 'filter button data-filter="resolved" required');
});
test('voyage-playground.html declares renderAnnotationList JS function (v4.3 Step 19)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+renderAnnotationList\s*\(\s*\)/, 'renderAnnotationList() function required');
});