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:
parent
a7a6a53686
commit
125bfb02b2
2 changed files with 412 additions and 0 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue