ktg-plugin-marketplace/plugins/voyage/playground/voyage-playground.html
Kjell Tore Guttormsen 125bfb02b2 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.
2026-05-09 15:25:01 +02:00

1244 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="nb">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Voyage Annotation Playground v4.2</title>
<link rel="stylesheet" href="vendor/playground-design-system/tokens.css">
<link rel="stylesheet" href="vendor/playground-design-system/base.css">
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">
<link rel="stylesheet" href="vendor/playground-design-system/components.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3.css">
<link rel="stylesheet" href="vendor/playground-design-system/components-tier3-supplement.css">
<link rel="stylesheet" href="vendor/playground-design-system/print.css" media="print">
<style>
/* Voyage-specific overrides — Step 8 (render pipeline) */
body { margin: 0; }
main {
max-width: 1280px;
margin: 0 auto;
padding: var(--space-6, 2rem);
}
.voyage-skeleton-msg { padding: var(--space-6, 2rem); }
/* Render-pipeline layout (left 70% viewport / right 30% sidebar reserved
for Step 10). The viewport panel mounts the rendered markdown. */
.voyage-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4, 1rem);
align-items: start;
}
.voyage-viewport {
min-height: 60vh;
padding: var(--space-4, 1rem);
background: var(--color-bg-soft, #fafbfc);
border: 1px solid var(--color-border, #e3e6eb);
border-radius: var(--radius-md, 6px);
overflow: auto;
}
.voyage-viewport pre {
overflow: auto;
padding: var(--space-3, 0.75rem);
background: var(--color-bg-code, #f3f5f7);
border-radius: var(--radius-sm, 4px);
}
.voyage-viewport details {
margin: var(--space-3, 0.75rem) 0;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
background: var(--color-bg-soft, #fafbfc);
border: 1px solid var(--color-border, #e3e6eb);
border-radius: var(--radius-sm, 4px);
}
.voyage-viewport details > summary {
cursor: pointer;
font-weight: 600;
}
/* Paste-import row (mirrors llm-security playground:81-82 pattern) */
.paste-import-row {
display: flex;
flex-direction: column;
gap: var(--space-2, 0.5rem);
margin-bottom: var(--space-4, 1rem);
}
.paste-import-row textarea {
width: 100%;
min-height: 8rem;
padding: var(--space-3, 0.75rem);
font-family: var(--font-mono, "JetBrains Mono", monospace);
font-size: 0.875rem;
border: 1px solid var(--color-border, #e3e6eb);
border-radius: var(--radius-sm, 4px);
resize: vertical;
}
.paste-import-row__actions {
display: flex;
gap: var(--space-2, 0.5rem);
flex-wrap: wrap;
}
.paste-import-row__actions button {
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
border: 1px solid var(--color-border, #e3e6eb);
background: var(--color-bg, #fff);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font: inherit;
}
.paste-import-row__actions button:hover {
background: var(--color-bg-hover, #f0f2f5);
}
/* Annotation visual markers — extended in Steps 9-11 */
.lint-annotation {
border-left: 3px solid var(--color-accent, #4a86e8);
padding-left: var(--space-2, 0.5rem);
margin-left: calc(var(--space-2, 0.5rem) * -1);
}
/* Step 9 — annotation creation gestures + form modal */
/* Gesture 1 — text-anchored adder-popup (mouseup-debounce 200ms; 300ms grace) */
.voyage-adder-popup {
position: fixed;
z-index: 1000;
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e3e6eb);
border-radius: var(--radius-md, 6px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
cursor: pointer;
font-size: 0.875rem;
transition: opacity 200ms ease;
}
.voyage-adder-popup[hidden] { display: none; }
/* Gesture 2 — paragraph-anchored always-visible-icon (hover-reveal) */
.voyage-viewport p {
position: relative;
}
.voyage-pencil-btn {
position: absolute;
left: -32px;
top: 0;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
opacity: 0;
cursor: pointer;
transition: opacity 150ms ease;
}
.voyage-viewport p:hover .voyage-pencil-btn,
.voyage-pencil-btn:focus-visible {
opacity: 1;
}
.voyage-pencil-btn svg {
width: 16px;
height: 16px;
fill: var(--color-text-muted, #5e6470);
}
/* Gesture 3 — page-level note button (placeholder; sidebar is Step 10) */
.voyage-page-note-btn {
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
border: 1px dashed var(--color-border, #e3e6eb);
background: var(--color-bg, #fff);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font: inherit;
}
/* Form modal — role="dialog" aria-modal="true" */
.voyage-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1100;
display: flex;
align-items: center;
justify-content: flex-end;
}
.voyage-modal-backdrop[hidden] { display: none; }
.voyage-modal {
width: 400px;
max-width: 90vw;
max-height: 90vh;
margin: var(--space-6, 2rem);
background: var(--color-bg, #fff);
border-radius: var(--radius-md, 6px);
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.voyage-modal__header {
padding: var(--space-4, 1rem);
border-bottom: 1px solid var(--color-border, #e3e6eb);
font-weight: 600;
}
.voyage-modal__body {
padding: var(--space-4, 1rem);
overflow: auto;
flex: 1;
}
.voyage-modal__snippet {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
background: var(--color-bg-soft, #fafbfc);
border-left: 3px solid var(--color-accent, #4a86e8);
font-style: italic;
font-size: 0.875rem;
margin-bottom: var(--space-3, 0.75rem);
}
.voyage-modal__intents {
display: flex;
gap: var(--space-2, 0.5rem);
flex-wrap: wrap;
margin-bottom: var(--space-3, 0.75rem);
}
.voyage-modal__intent-btn {
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
border: 1px solid var(--color-border, #e3e6eb);
background: var(--color-bg, #fff);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font: inherit;
}
.voyage-modal__intent-btn[aria-pressed="true"] {
background: var(--color-accent, #4a86e8);
color: #fff;
border-color: var(--color-accent, #4a86e8);
}
.voyage-modal textarea {
width: 100%;
min-height: 6rem;
padding: var(--space-3, 0.75rem);
font: inherit;
border: 1px solid var(--color-border, #e3e6eb);
border-radius: var(--radius-sm, 4px);
resize: vertical;
}
.voyage-modal__footer {
display: flex;
gap: var(--space-2, 0.5rem);
padding: var(--space-4, 1rem);
border-top: 1px solid var(--color-border, #e3e6eb);
justify-content: flex-end;
}
.voyage-modal__footer button {
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
border: 1px solid var(--color-border, #e3e6eb);
background: var(--color-bg, #fff);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font: inherit;
}
.voyage-modal__footer button[type="submit"] {
background: var(--color-accent, #4a86e8);
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>
<a class="visually-hidden" href="#main">Skip to main content</a>
<header class="app-header">
<div class="app-header__title">Voyage Annotation Playground</div>
<div class="app-header__meta">v4.2 · brief / plan / review</div>
</header>
<main id="main">
<section
class="guide-panel guide-panel--info"
role="status"
aria-label="Voyage playground status"
id="empty-state"
>
<div class="guide-panel__title">Voyage playground v4.2 — annotation pipeline</div>
<div class="guide-panel__body">
<p>Lim inn <code>brief.md</code>, <code>plan.md</code> eller <code>review.md</code> nedenfor, eller bruk knappen «Last sample plan.md» for å starte med eksempelinnhold. Render-pipeline (markdown-it@14.1.0 + highlight.js@11.11.1) parser markdown og preserverer <code>&lt;!-- voyage:anchor --&gt;</code>-kommentarer for senere annotation creation gestures (Step 9-11).</p>
</div>
</section>
<section class="paste-import-row" aria-label="Import artifact for annotation">
<label for="voyage-paste-input" class="visually-hidden">Lim inn artifact-innhold (brief.md / plan.md / review.md)</label>
<textarea
id="voyage-paste-input"
placeholder="--- type: trekplan-fixture &#10;# Lim inn artifact-innhold her ..."
spellcheck="false"
></textarea>
<div class="paste-import-row__actions">
<button type="button" id="voyage-render-btn">Render</button>
<button type="button" id="voyage-load-sample-btn">Last sample plan.md</button>
<button type="button" id="voyage-clear-btn">Tøm</button>
</div>
</section>
<section class="voyage-layout" aria-label="Render output">
<div
id="voyage-viewport"
class="voyage-viewport"
role="region"
aria-label="Rendered artifact"
aria-live="polite"
>
<p class="voyage-skeleton-msg"><em>Ingen artifact lastet enda. Lim inn innhold over og trykk «Render».</em></p>
</div>
</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"
class="voyage-adder-popup"
role="button"
tabindex="0"
hidden
>Annotér</div>
<!-- Step 9 — form modal (all 3 gestures share this modal) -->
<div
id="voyage-modal-backdrop"
class="voyage-modal-backdrop"
hidden
>
<form
id="voyage-modal"
class="voyage-modal"
role="dialog"
aria-modal="true"
aria-labelledby="voyage-modal-title"
>
<div id="voyage-modal-title" class="voyage-modal__header">Ny annotation</div>
<div class="voyage-modal__body">
<div id="voyage-modal-snippet" class="voyage-modal__snippet" hidden></div>
<div id="voyage-modal-anchor-info" style="font-size: 0.875rem; color: var(--color-text-muted,#5e6470); margin-bottom: var(--space-3,0.75rem);"></div>
<div role="group" aria-label="Velg intent" class="voyage-modal__intents">
<button type="button" class="voyage-modal__intent-btn" data-intent="fix" aria-pressed="false">Fiks</button>
<button type="button" class="voyage-modal__intent-btn" data-intent="change" aria-pressed="true">Endre</button>
<button type="button" class="voyage-modal__intent-btn" data-intent="question" aria-pressed="false">Spørsmål</button>
<button type="button" class="voyage-modal__intent-btn" data-intent="block" aria-pressed="false">Block</button>
</div>
<label for="voyage-modal-comment" class="visually-hidden">Kommentar</label>
<textarea
id="voyage-modal-comment"
name="comment"
placeholder="Beskriv endringen ..."
required
></textarea>
</div>
<div class="voyage-modal__footer">
<button type="button" id="voyage-modal-cancel">Avbryt</button>
<button type="submit" id="voyage-modal-save">Lagre</button>
</div>
</form>
</div>
<div id="aria-live-region" aria-live="polite" aria-atomic="true" class="visually-hidden"></div>
<!-- Vendored libs from playground/lib/ (zero-network) -->
<script src="lib/markdown-it.min.js"></script>
<script src="lib/markdown-it-front-matter.min.js"></script>
<script src="lib/highlight.min.js"></script>
<!-- Sample plan inlined for Step 8 first-run experience.
Same content as tests/fixtures/annotation/annotation-plan.md (truncated). -->
<script id="voyage-sample-plan" type="text/markdown">
---
plan_version: 1.7
profile: balanced
---
# Demo plan for annotation round-trip
This sample mirrors `tests/fixtures/annotation/annotation-plan.md` so the
playground first-run shows a complete round-trip-able artifact.
## Implementation Plan
### Step 1: Touch a sentinel file
- **Files:** `tmp/sentinel-1.txt` (new)
- **Verify:** `test -f tmp/sentinel-1.txt`
- **Manifest:**
```yaml
manifest:
expected_paths:
- tmp/sentinel-1.txt
min_file_count: 1
```
## Verification
- `npm test` passes.
</script>
<script>
/* Voyage Playground v4.2 — Step 8 render pipeline.
Steps 9-11 will extend this script with creation gestures, sidebar,
export flow, and A11Y baseline.
localStorage key contract (per risk-assessor H7):
voyage_ann_<project-dir-basename>__<filename>
Project-dir-basename derives from URL `?project=...` or fragment;
filename derives from frontmatter `slug:` if present, else from URL. */
(function () {
'use strict';
// ---- localStorage key derivation -----------------------------------
function deriveStorageKey(frontmatter) {
var qs = new URLSearchParams(window.location.search);
var project = qs.get('project') || (window.location.hash || '').replace(/^#/, '') || 'unknown-project';
var filename = (frontmatter && frontmatter.slug) ? frontmatter.slug : 'unknown';
return 'voyage_ann_' + project + '__' + filename;
}
// ---- markdown-it initialization ------------------------------------
// html: true preserves <!-- voyage:anchor --> comments verbatim so
// anchor-parser can find them after rendering. linkify off to keep
// raw markdown stable; typographer off for byte-exact round-trip.
var md = window.markdownit({
html: true,
linkify: false,
typographer: false,
highlight: function (code, lang) {
if (window.hljs && lang && window.hljs.getLanguage(lang)) {
try {
return window.hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
} catch (e) {
// fall through to plain
}
}
return '';
},
});
// Front-matter plugin: capture YAML and wrap in <details> via
// pre-render-then-wrap pattern (research/03 — GFM Type 6 quirk).
var capturedFrontmatter = '';
try {
md.use(window.markdownitFrontMatter, function (fm) {
capturedFrontmatter = fm || '';
});
} catch (e) {
// Plugin failed to load — surface but continue without frontmatter folding.
console.warn('markdown-it-front-matter plugin not loaded:', e && e.message);
}
// ---- render pipeline ----------------------------------------------
function renderArtifact(text) {
capturedFrontmatter = '';
var bodyHtml = md.render(text || '');
// Pre-render-then-wrap for <details>: prepend a folded frontmatter
// <details> block at the top if the front-matter plugin captured one.
var fmHtml = '';
if (capturedFrontmatter) {
fmHtml = '<details><summary>Frontmatter</summary><pre><code>' +
escapeHtml(capturedFrontmatter) + '</code></pre></details>';
}
return fmHtml + bodyHtml;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Parse frontmatter yourself (cheap line-walk) so deriveStorageKey can
// see slug/type without wire-tapping the plugin.
function quickParseFrontmatter(text) {
if (!text) return null;
var lines = String(text).split(/\r?\n/);
if (lines[0] !== '---') return null;
var end = lines.indexOf('---', 1);
if (end <= 0) return null;
var fm = {};
for (var i = 1; i < end; i++) {
var m = lines[i].match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
if (m) fm[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return fm;
}
// ---- DOM wiring ----------------------------------------------------
function $(id) { return document.getElementById(id); }
function announce(msg) {
var live = $('aria-live-region');
if (live) {
live.textContent = '';
// Yield to allow AT to register the change
setTimeout(function () { live.textContent = msg; }, 50);
}
}
function mountRender(text) {
var viewport = $('voyage-viewport');
if (!viewport) return;
if (!text) {
viewport.innerHTML = '<p class="voyage-skeleton-msg"><em>Ingen artifact lastet enda. Lim inn innhold over og trykk «Render».</em></p>';
return;
}
var fm = quickParseFrontmatter(text);
var key = deriveStorageKey(fm);
try {
window.localStorage.setItem(key + '.last-text', text);
} catch (e) {
// localStorage may be unavailable (private mode, file:// in some browsers)
}
viewport.innerHTML = renderArtifact(text);
announce('Render fullført. Tegnsett: ' + (text.length) + ' tegn.');
}
function loadSample() {
var node = $('voyage-sample-plan');
var text = node ? node.textContent.trim() : '';
var ta = $('voyage-paste-input');
if (ta) ta.value = text;
mountRender(text);
}
function clearAll() {
var ta = $('voyage-paste-input');
if (ta) ta.value = '';
mountRender('');
}
// ---- Step 9 — annotation creation gestures + modal ----------------
// Anchor-ID generation: sequential ANN-NNNN per project+file.
// The "drafts" namespace under localStorage is per-project per-file.
var DRAFTS_KEY_SUFFIX = '.drafts';
var ADDER_GRACE_MS = 300; // 300ms grace before popup hides on mouse-out
var ADDER_DEBOUNCE_MS = 200; // 200ms after mouseup to settle selection
var INTENT_DEFAULT = 'change';
function loadDrafts(key) {
try {
var raw = window.localStorage.getItem(key + DRAFTS_KEY_SUFFIX);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveDrafts(key, list) {
try {
window.localStorage.setItem(key + DRAFTS_KEY_SUFFIX, JSON.stringify(list));
} catch (e) {
/* ignore quota errors */
}
}
function nextAnchorId(drafts) {
var max = 0;
for (var i = 0; i < drafts.length; i++) {
var m = String(drafts[i].id).match(/^ANN-(\d+)$/);
if (m) max = Math.max(max, parseInt(m[1], 10));
}
var next = max + 1;
return 'ANN-' + String(next).padStart(4, '0');
}
function deriveHeadingPath(node) {
// Walk previous siblings + ancestors to find the nearest heading.
var n = node;
while (n) {
while (n && !n.previousElementSibling && n.parentElement) n = n.parentElement;
if (!n) break;
n = n.previousElementSibling || (n.parentElement && n.parentElement.previousElementSibling);
if (n && /^H[1-6]$/.test(n.tagName)) {
return slugify(n.textContent || '');
}
if (!n) break;
}
return 'page';
}
function slugify(s) {
return String(s)
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 60) || 'page';
}
function deriveLineNumber(node) {
// Approximate line from the running paragraph index — a real
// line-mapping would require pre-render line annotations. The export
// flow (Step 11) substitutes more accurate line numbers from the
// raw markdown source.
var viewport = $('voyage-viewport');
if (!viewport) return null;
var ps = viewport.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, pre');
for (var i = 0; i < ps.length; i++) {
if (ps[i] === node || ps[i].contains(node)) return i + 1;
}
return null;
}
// ---- Modal control -------------------------------------------------
var modalState = {
target: 'page',
line: null,
snippet: '',
intent: INTENT_DEFAULT,
currentStorageKey: '',
};
function openModal(opts) {
modalState.target = opts.target || 'page';
modalState.line = opts.line || null;
modalState.snippet = opts.snippet || '';
modalState.intent = INTENT_DEFAULT;
modalState.currentStorageKey = opts.storageKey || '';
var snippetEl = $('voyage-modal-snippet');
if (snippetEl) {
if (modalState.snippet) {
snippetEl.textContent = modalState.snippet;
snippetEl.hidden = false;
} else {
snippetEl.hidden = true;
snippetEl.textContent = '';
}
}
var info = $('voyage-modal-anchor-info');
if (info) {
info.textContent = 'Forankret til: ' + modalState.target +
(modalState.line ? ' (linje ' + modalState.line + ')' : '');
}
var ta = $('voyage-modal-comment');
if (ta) ta.value = '';
// Reset intent buttons
var btns = document.querySelectorAll('.voyage-modal__intent-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].setAttribute('aria-pressed', btns[i].getAttribute('data-intent') === INTENT_DEFAULT ? 'true' : 'false');
}
var bd = $('voyage-modal-backdrop');
if (bd) bd.hidden = false;
if (ta) ta.focus();
}
function closeModal() {
var bd = $('voyage-modal-backdrop');
if (bd) bd.hidden = true;
}
function saveModalAsAnnotation() {
var ta = $('voyage-modal-comment');
if (!ta || !ta.value.trim()) return false;
var key = modalState.currentStorageKey;
if (!key) return false;
var drafts = loadDrafts(key);
var annot = {
id: nextAnchorId(drafts),
target_anchor: modalState.target,
line: modalState.line,
intent: modalState.intent,
snippet: modalState.snippet || '',
comment: ta.value.trim(),
created_at: new Date().toISOString(),
exported: false,
};
drafts.push(annot);
saveDrafts(key, drafts);
announce('Annotation lagret: ' + annot.id);
return annot;
}
// ---- Gesture 1 — text-anchored adder-popup (mouseup-debounce 200ms; 300ms grace) ----
var adderPopup;
var adderTimer;
var adderGraceTimer;
function showAdderPopup(rect, sel) {
if (!adderPopup) adderPopup = $('voyage-adder-popup');
if (!adderPopup) return;
adderPopup.style.left = (rect.left + rect.width + 12) + 'px';
adderPopup.style.top = (rect.top + rect.height + 4) + 'px';
adderPopup._selection = sel;
adderPopup.hidden = false;
}
function hideAdderPopup() {
if (adderPopup) adderPopup.hidden = true;
}
function onSelectionMaybeChanged() {
clearTimeout(adderTimer);
adderTimer = setTimeout(function () {
var sel = window.getSelection ? window.getSelection() : null;
if (!sel || sel.isCollapsed || !sel.rangeCount) {
// 300ms grace before hiding (per critical decision #2)
clearTimeout(adderGraceTimer);
adderGraceTimer = setTimeout(hideAdderPopup, ADDER_GRACE_MS);
return;
}
var range = sel.getRangeAt(0);
var viewport = $('voyage-viewport');
if (!viewport || !viewport.contains(range.commonAncestorContainer)) {
hideAdderPopup();
return;
}
var rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
hideAdderPopup();
return;
}
showAdderPopup(rect, sel.toString().slice(0, 80));
}, ADDER_DEBOUNCE_MS);
}
function onAdderClick() {
if (!adderPopup) return;
var snippet = adderPopup._selection || '';
var sel = window.getSelection ? window.getSelection() : null;
var node = sel && sel.rangeCount ? sel.getRangeAt(0).startContainer : null;
var target = deriveHeadingPath(node && node.parentElement);
var line = deriveLineNumber(node && node.parentElement);
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
hideAdderPopup();
openModal({ target: target, line: line, snippet: snippet, storageKey: key });
}
// ---- Gesture 2 — paragraph-anchored pencil icon (hover-reveal) -----
function injectPencilIcons() {
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 });
});
})(p);
// Insert at start so it floats in left margin via CSS
p.style.position = 'relative';
p.insertBefore(btn, p.firstChild);
}
}
// ---- Gesture 3 — page-level note (button injected near viewport) ---
function ensurePageNoteButton() {
var btn = document.getElementById('voyage-page-note-btn');
if (btn) return btn;
btn = document.createElement('button');
btn.id = 'voyage-page-note-btn';
btn.type = 'button';
btn.className = 'voyage-page-note-btn';
btn.textContent = '+ Legg til note (page-level)';
btn.addEventListener('click', function () {
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
openModal({ target: 'page', line: null, snippet: '', storageKey: key });
});
var layout = document.querySelector('.voyage-layout');
if (layout) layout.appendChild(btn);
return btn;
}
// Re-run gesture-2 + gesture-3 wiring after each render.
var originalMountRender = mountRender;
mountRender = function (text) {
originalMountRender(text);
injectPencilIcons();
ensurePageNoteButton();
};
// ---- Modal event wiring -------------------------------------------
function wireModal() {
var bd = $('voyage-modal-backdrop');
var form = $('voyage-modal');
if (!bd || !form) return;
var cancelBtn = $('voyage-modal-cancel');
if (cancelBtn) cancelBtn.addEventListener('click', closeModal);
// Click on backdrop (outside modal) closes
bd.addEventListener('click', function (e) {
if (e.target === bd) closeModal();
});
// ESC closes
document.addEventListener('keydown', function (e) {
if (!bd.hidden && e.key === 'Escape') {
closeModal();
}
});
// Intent buttons (radiogroup-like via aria-pressed)
var intents = document.querySelectorAll('.voyage-modal__intent-btn');
for (var i = 0; i < intents.length; i++) {
intents[i].addEventListener('click', function (e) {
for (var j = 0; j < intents.length; j++) {
intents[j].setAttribute('aria-pressed', intents[j] === e.currentTarget ? 'true' : 'false');
}
modalState.intent = e.currentTarget.getAttribute('data-intent') || INTENT_DEFAULT;
});
}
// Save (form submit)
form.addEventListener('submit', function (e) {
e.preventDefault();
var saved = saveModalAsAnnotation();
if (saved) closeModal();
});
}
// ---- 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');
var sampleBtn = $('voyage-load-sample-btn');
var clearBtn = $('voyage-clear-btn');
var ta = $('voyage-paste-input');
if (renderBtn) {
renderBtn.addEventListener('click', function () {
mountRender(ta ? ta.value : '');
});
}
if (sampleBtn) {
sampleBtn.addEventListener('click', loadSample);
}
if (clearBtn) {
clearBtn.addEventListener('click', clearAll);
}
// Step 9 — gesture wiring
document.addEventListener('mouseup', onSelectionMaybeChanged);
document.addEventListener('selectionchange', onSelectionMaybeChanged);
var adder = $('voyage-adder-popup');
if (adder) {
adder.addEventListener('click', onAdderClick);
adder.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdderClick(); }
});
}
wireModal();
// Step 10 — sidebar wiring + initial render
wireSidebar();
refreshSidebar();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>