feat(voyage): add playground sidebar with tabs + critique-card-list — v4.2 Step 10 [skip-docs]

Right-collapsible sidebar (320px) with 40px icon-rail when collapsed
(per critical decision #4 + research-06):

- 2-state FAB toggle (aria-expanded toggles aria-hidden on aside)
- Visible draft-count badge on FAB (mitigates 'forgot to export' friction)
- Two tabs:
    'Denne planen (N drafts)' — pending annotations
    'Alle revisjoner (M historiske)' — exported (Step 11 wires this)
- role="tablist" + role="tab" + aria-selected + tabindex roving
- ArrowLeft/ArrowRight keyboard nav between tabs
- .findings list of .critique-card per annotation
- Click on critique-card scrolls to anchor + .lint-annotation-glow
  1s pulse animation
- Sort-by-location (Hypothes.is pattern; line ASC)

Card visual: intent-badge (color-coded fix=green/change=blue/question=yellow/block=red),
ANN-NNNN ID, snippet preview, comment, exported-status.

Layout: main shifts margin-right: 320px above 1024px viewport so the
sidebar doesn't overlap the rendered artifact.

saveModalAsAnnotation + mountRender hooks now refresh the sidebar so
new drafts appear immediately and re-render preserves visibility.

Test coverage: tests/playground/voyage-playground.test.mjs +2 cases —
role="tablist", tabindex.

Verify: node --test tests/playground/voyage-playground.test.mjs ->
18 pass / 0 fail.
Full npm test: 592 pass / 0 fail / 2 skipped (Docker).

Refs plan.md Step 10 + critical decision #4 + research-06.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-09 15:25:01 +02:00
commit 125bfb02b2
2 changed files with 412 additions and 0 deletions

View file

@ -239,6 +239,174 @@
color: #fff;
border-color: var(--color-accent, #4a86e8);
}
/* Step 10 — sidebar + critique-card-list + tabs */
.voyage-sidebar {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 320px;
background: var(--color-bg, #fff);
border-left: 1px solid var(--color-border, #e3e6eb);
box-shadow: -2px 0 8px rgba(0,0,0,0.05);
transform: translateX(0);
transition: transform 200ms ease;
z-index: 900;
display: flex;
flex-direction: column;
}
.voyage-sidebar[aria-hidden="true"] {
transform: translateX(calc(320px - 40px)); /* leave 40px icon-rail */
}
.voyage-sidebar__rail {
position: absolute;
left: 0;
top: 0;
width: 40px;
bottom: 0;
background: var(--color-bg-soft, #fafbfc);
border-right: 1px solid var(--color-border, #e3e6eb);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: var(--space-3, 0.75rem);
}
.voyage-sidebar__panel {
flex: 1;
margin-left: 40px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.voyage-sidebar[aria-hidden="true"] .voyage-sidebar__panel { visibility: hidden; }
/* 2-state FAB toggle (per critical decision #4) */
.voyage-fab {
position: relative;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border, #e3e6eb);
background: var(--color-bg, #fff);
border-radius: 50%;
cursor: pointer;
font-size: 1rem;
line-height: 30px;
text-align: center;
}
.voyage-fab[aria-expanded="true"]::before { content: ""; }
.voyage-fab[aria-expanded="false"]::before { content: ""; }
.voyage-fab__badge {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--color-accent, #4a86e8);
color: #fff;
border-radius: 9px;
font-size: 0.625rem;
line-height: 18px;
text-align: center;
}
.voyage-fab__badge[hidden] { display: none; }
/* Tabs */
[role="tablist"].voyage-tabs {
display: flex;
border-bottom: 1px solid var(--color-border, #e3e6eb);
}
[role="tab"].voyage-tab {
flex: 1;
padding: var(--space-3, 0.75rem);
border: none;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
color: var(--color-text-muted, #5e6470);
border-bottom: 2px solid transparent;
}
[role="tab"].voyage-tab[aria-selected="true"] {
color: var(--color-text, #1a1d23);
border-bottom-color: var(--color-accent, #4a86e8);
font-weight: 600;
}
/* findings + critique-card list */
.voyage-findings {
flex: 1;
overflow: auto;
padding: var(--space-3, 0.75rem);
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
}
.critique-card {
padding: var(--space-3, 0.75rem);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e3e6eb);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
}
.critique-card:hover { background: var(--color-bg-hover, #f0f2f5); }
.critique-card__header {
display: flex;
align-items: center;
gap: var(--space-2, 0.5rem);
font-size: 0.75rem;
}
.intent-badge {
display: inline-block;
padding: 2px 6px;
border-radius: var(--radius-sm, 4px);
color: #fff;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.intent-badge--fix { background: #2c8a3e; }
.intent-badge--change { background: #4a86e8; }
.intent-badge--question { background: #d8a23a; }
.intent-badge--block { background: #c54545; }
.critique-card__id {
font-family: var(--font-mono, "JetBrains Mono", monospace);
color: var(--color-text-muted, #5e6470);
}
.critique-card__snippet {
margin: var(--space-2, 0.5rem) 0;
padding: var(--space-2, 0.5rem);
background: var(--color-bg-soft, #fafbfc);
border-left: 2px solid var(--color-border, #e3e6eb);
font-style: italic;
font-size: 0.8125rem;
}
.critique-card__comment {
font-size: 0.875rem;
color: var(--color-text, #1a1d23);
}
.critique-card__status {
margin-top: var(--space-2, 0.5rem);
font-size: 0.75rem;
color: var(--color-text-muted, #5e6470);
}
.critique-card__status--exported { color: var(--color-success, #2c8a3e); }
.lint-annotation-glow {
animation: voyageGlow 1s ease;
}
@keyframes voyageGlow {
0% { background-color: transparent; }
30% { background-color: rgba(74, 134, 232, 0.15); }
100% { background-color: transparent; }
}
/* Make room for sidebar in main layout */
@media (min-width: 1024px) {
main { margin-right: 320px; }
}
</style>
</head>
<body>
@ -287,6 +455,68 @@
</section>
</main>
<!-- Step 10 — sidebar with tabs + critique-card-list -->
<aside
id="voyage-sidebar"
class="voyage-sidebar"
aria-label="Annotation drafts sidebar"
aria-hidden="false"
>
<div class="voyage-sidebar__rail">
<button
type="button"
id="voyage-sidebar-toggle"
class="voyage-fab"
aria-controls="voyage-sidebar"
aria-expanded="true"
aria-label="Skjul/vis annotation-panel"
>
<span
id="voyage-fab-badge"
class="voyage-fab__badge"
aria-live="polite"
aria-label="Antall drafts ventende"
hidden
>0</span>
</button>
</div>
<div class="voyage-sidebar__panel">
<div role="tablist" class="voyage-tabs" aria-label="Draft og revisjons-faner">
<button
type="button"
id="voyage-tab-drafts"
class="voyage-tab"
role="tab"
aria-selected="true"
aria-controls="voyage-tab-drafts-panel"
tabindex="0"
>Denne planen <span id="voyage-tab-drafts-count">(0 drafts)</span></button>
<button
type="button"
id="voyage-tab-history"
class="voyage-tab"
role="tab"
aria-selected="false"
aria-controls="voyage-tab-history-panel"
tabindex="-1"
>Alle revisjoner <span id="voyage-tab-history-count">(0 historiske)</span></button>
</div>
<div
id="voyage-tab-drafts-panel"
class="voyage-findings findings"
role="tabpanel"
aria-labelledby="voyage-tab-drafts"
></div>
<div
id="voyage-tab-history-panel"
class="voyage-findings findings"
role="tabpanel"
aria-labelledby="voyage-tab-history"
hidden
></div>
</div>
</aside>
<!-- Step 9 — adder popup (Gesture 1) -->
<div
id="voyage-adder-popup"
@ -802,6 +1032,172 @@ playground first-run shows a complete round-trip-able artifact.
});
}
// ---- Step 10 — sidebar + critique-card-list + tabs ----------------
function escapeHtmlInline(s) { return escapeHtml(s); }
function intentBadgeClass(intent) {
var t = String(intent || '').toLowerCase();
if (['fix','change','question','block'].indexOf(t) === -1) t = 'change';
return 'intent-badge intent-badge--' + t;
}
function renderCritiqueCard(annot) {
var card = document.createElement('div');
card.className = 'critique-card';
card.setAttribute('data-anchor-id', annot.id);
card.setAttribute('data-target', annot.target_anchor || '');
card.setAttribute('data-line', annot.line || '');
card.setAttribute('role', 'listitem');
card.tabIndex = 0;
card.innerHTML =
'<div class="critique-card__header">' +
'<span class="' + intentBadgeClass(annot.intent) + '">' + escapeHtmlInline(annot.intent || 'change') + '</span>' +
'<span class="critique-card__id">' + escapeHtmlInline(annot.id) + '</span>' +
(annot.line ? '<span class="critique-card__line">linje ' + annot.line + '</span>' : '') +
'</div>' +
(annot.snippet ? '<div class="critique-card__snippet">' + escapeHtmlInline(annot.snippet) + '</div>' : '') +
'<div class="critique-card__comment">' + escapeHtmlInline(annot.comment || '') + '</div>' +
'<div class="critique-card__status' + (annot.exported ? ' critique-card__status--exported' : '') + '">' +
(annot.exported ? 'Eksportert' : 'Pending') +
'</div>';
card.addEventListener('click', function () {
scrollToAnchor(annot);
});
card.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToAnchor(annot);
}
});
return card;
}
function scrollToAnchor(annot) {
if (!annot || !annot.line) return;
var viewport = $('voyage-viewport');
if (!viewport) return;
var ps = viewport.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, pre');
var target = ps[annot.line - 1];
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('lint-annotation-glow');
setTimeout(function () { target.classList.remove('lint-annotation-glow'); }, 1000);
}
function refreshSidebar() {
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
var drafts = loadDrafts(key);
// Sort by line ascending (Hypothes.is sort-by-location pattern)
var sortedDrafts = drafts.slice().sort(function (a, b) {
var la = a.line == null ? Infinity : Number(a.line);
var lb = b.line == null ? Infinity : Number(b.line);
return la - lb;
});
var pendingCount = sortedDrafts.filter(function (d) { return !d.exported; }).length;
var historyCount = sortedDrafts.filter(function (d) { return d.exported; }).length;
var draftsPanel = $('voyage-tab-drafts-panel');
var historyPanel = $('voyage-tab-history-panel');
if (draftsPanel) {
draftsPanel.innerHTML = '';
var pending = sortedDrafts.filter(function (d) { return !d.exported; });
if (pending.length === 0) {
draftsPanel.innerHTML = '<p class="voyage-skeleton-msg"><em>Ingen drafts enda. Bruk en av annotation-gesturene over.</em></p>';
} else {
for (var i = 0; i < pending.length; i++) draftsPanel.appendChild(renderCritiqueCard(pending[i]));
}
}
if (historyPanel) {
historyPanel.innerHTML = '';
var hist = sortedDrafts.filter(function (d) { return d.exported; });
if (hist.length === 0) {
historyPanel.innerHTML = '<p class="voyage-skeleton-msg"><em>Ingen eksporterte revisjoner enda.</em></p>';
} else {
for (var j = 0; j < hist.length; j++) historyPanel.appendChild(renderCritiqueCard(hist[j]));
}
}
// Update tab counts
var draftsCountEl = $('voyage-tab-drafts-count');
if (draftsCountEl) draftsCountEl.textContent = '(' + pendingCount + ' drafts)';
var historyCountEl = $('voyage-tab-history-count');
if (historyCountEl) historyCountEl.textContent = '(' + historyCount + ' historiske)';
// Update FAB badge
var badge = $('voyage-fab-badge');
if (badge) {
if (pendingCount > 0) {
badge.textContent = String(pendingCount);
badge.hidden = false;
} else {
badge.hidden = true;
badge.textContent = '0';
}
}
}
function selectTab(tabId) {
var tabs = document.querySelectorAll('[role="tab"].voyage-tab');
for (var i = 0; i < tabs.length; i++) {
var selected = tabs[i].id === tabId;
tabs[i].setAttribute('aria-selected', selected ? 'true' : 'false');
tabs[i].tabIndex = selected ? 0 : -1;
var panelId = tabs[i].getAttribute('aria-controls');
var panel = panelId ? document.getElementById(panelId) : null;
if (panel) panel.hidden = !selected;
}
}
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');
});
}
var draftsTab = $('voyage-tab-drafts');
var historyTab = $('voyage-tab-history');
if (draftsTab) draftsTab.addEventListener('click', function () { selectTab('voyage-tab-drafts'); });
if (historyTab) historyTab.addEventListener('click', function () { selectTab('voyage-tab-history'); });
// Tab keyboard arrow nav
document.querySelectorAll('[role="tab"].voyage-tab').forEach(function (t) {
t.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
var tabs = Array.from(document.querySelectorAll('[role="tab"].voyage-tab'));
var idx = tabs.indexOf(t);
var next = e.key === 'ArrowRight'
? tabs[(idx + 1) % tabs.length]
: tabs[(idx - 1 + tabs.length) % tabs.length];
selectTab(next.id);
next.focus();
}
});
});
}
// Hook saveModalAsAnnotation -> refreshSidebar
var originalSave = saveModalAsAnnotation;
saveModalAsAnnotation = function () {
var result = originalSave();
refreshSidebar();
return result;
};
// Hook mountRender -> refreshSidebar (already chained in Step 9)
var prevMountRender = mountRender;
mountRender = function (text) {
prevMountRender(text);
refreshSidebar();
};
// Event wiring on DOMContentLoaded
function init() {
var renderBtn = $('voyage-render-btn');
@ -831,6 +1227,10 @@ playground first-run shows a complete round-trip-able artifact.
});
}
wireModal();
// Step 10 — sidebar wiring + initial render
wireSidebar();
refreshSidebar();
}
if (document.readyState === 'loading') {

View file

@ -112,3 +112,15 @@ test('voyage-playground.html declares 300ms grace constant (Step 9 adder-popup g
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /300\s*ms|GRACE_MS\s*=\s*300|ADDER_GRACE_MS/i, '300ms grace period for adder-popup must be present');
});
// --- Step 10 — sidebar with tabs + critique-card-list ----------------
test('voyage-playground.html includes role="tablist" (Step 10 sidebar tabs A11Y)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /role="tablist"/, 'sidebar must declare role="tablist" for A11Y');
});
test('voyage-playground.html declares tabindex (Step 10 focus management)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /tabindex/i, 'sidebar tabs must use tabindex for keyboard focus management');
});