Vendored libs (locked headless via scripts/vendor-playground-libs.mjs;
plan-critic B3 — never use highlightjs.org website builder):
- playground/lib/markdown-it.min.js — markdown-it@14.1.0 UMD bundle
- playground/lib/markdown-it-front-matter.min.js — markdown-it-front-matter@0.2.4 IIFE-wrapped
- playground/lib/highlight.min.js — highlight.js@11.11.1 (5-lang bundle:
yaml/json/javascript/bash/markdown/diff)
- playground/lib/VENDOR-MANIFEST.json — pin record + audit trail
scripts/vendor-playground-libs.mjs implements the reproducible
CommonJS-to-IIFE wrapping. Re-vendoring requires only:
node scripts/vendor-playground-libs.mjs
Render pipeline in playground/voyage-playground.html (~330 LoC total):
- inline <script src=lib/...> for the three vendored bundles
- markdown-it init with html: true (preserves voyage:anchor comments)
- front-matter plugin with pre-render-then-wrap pattern (research/03)
- paste-import-row textarea + Render/Sample/Clear buttons
- voyage-viewport region with role + aria-live for A11Y
- localStorage key pattern: voyage_ann_<project>__<slug> (risk-assessor H7)
- inline sample plan (mirrors annotation-plan.md fixture)
scripts/render-artifact.mjs CLI (~200 LoC) — brief SC1 + SC11:
- reads input.md, runs same vendored pipeline server-side
- inlines DS CSS + (URL-stripped) highlight.js into output
- zero http://https:// URLs in output (verified by test)
- deterministic: two invocations -> byte-identical sha256
- default output: <input>.html next to input
Test coverage:
- tests/scripts/render-artifact.test.mjs — 5 cases (SC1/SC11)
- tests/playground/voyage-playground.test.mjs — +5 cases (Step 8 extension)
Verify: node --test tests/playground/voyage-playground.test.mjs
tests/scripts/render-artifact.test.mjs -> 18 pass / 0 fail.
Full npm test: 587 pass / 0 fail / 2 skipped (Docker).
Refs plan.md Step 8 + plan-critic B3 + scope-guardian B1.
349 lines
12 KiB
HTML
349 lines
12 KiB
HTML
<!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);
|
|
}
|
|
</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><!-- voyage:anchor --></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 # 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>
|
|
|
|
<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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// 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('');
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|