diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html
index 109b040..8700a84 100644
--- a/plugins/voyage/playground/voyage-playground.html
+++ b/plugins/voyage/playground/voyage-playground.html
@@ -96,6 +96,149 @@
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);
+ }
@@ -144,6 +287,53 @@
+
+
+
+
+
+
@@ -319,6 +509,299 @@ playground first-run shows a complete round-trip-able artifact.
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 =
+ '';
+ (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();
+ });
+ }
+
// Event wiring on DOMContentLoaded
function init() {
var renderBtn = $('voyage-render-btn');
@@ -336,6 +819,18 @@ playground first-run shows a complete round-trip-able artifact.
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();
}
if (document.readyState === 'loading') {
diff --git a/plugins/voyage/tests/playground/voyage-playground.test.mjs b/plugins/voyage/tests/playground/voyage-playground.test.mjs
index 6a60baf..ee88670 100644
--- a/plugins/voyage/tests/playground/voyage-playground.test.mjs
+++ b/plugins/voyage/tests/playground/voyage-playground.test.mjs
@@ -95,3 +95,20 @@ test('playground/lib/ contains vendored markdown-it + front-matter + highlight b
assert.ok(existsSync(join(PLAYGROUND_LIB, f)), `playground/lib/${f} expected from vendor-playground-libs.mjs`);
}
});
+
+// --- Step 9 — annotation creation gestures + form modal ---------------
+
+test('voyage-playground.html declares aria-modal="true" (Step 9 form modal A11Y)', () => {
+ const text = readFileSync(HTML, 'utf-8');
+ assert.match(text, /aria-modal="true"/, 'form modal must carry aria-modal="true"');
+});
+
+test('voyage-playground.html declares ANN- anchor-ID prefix (Step 9 ID generation)', () => {
+ const text = readFileSync(HTML, 'utf-8');
+ assert.match(text, /ANN-/, 'sequential ANN-NNNN ID generation must appear in playground JS');
+});
+
+test('voyage-playground.html declares 300ms grace constant (Step 9 adder-popup grace)', () => {
+ 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');
+});