diff --git a/playground/voyage-playground.html b/playground/voyage-playground.html
index 6ad4fcb..dcf0e33 100644
--- a/playground/voyage-playground.html
+++ b/playground/voyage-playground.html
@@ -964,6 +964,114 @@ playground first-run shows a complete round-trip-able artifact.
});
}
+ // ---- v4.3 Step 13 — loadProjectDirectory pipeline -----------------
+ // Validate → classify → read → parse-frontmatter → build
+ // ProjectArtifacts. Mirrors lib/parsers/project-discovery.mjs:15-24
+ // typedef on the browser side; storage-key derived deterministically
+ // from basePath so per-project draft state cannot leak across projects.
+ async function loadProjectDirectory(files, basePath) {
+ if (!files || !files.length) return null;
+ var prefix = basePath ? basePath + '/' : '';
+ var artifacts = {
+ basePath: basePath || '',
+ storageKey: '',
+ brief: null,
+ plan: null,
+ review: null,
+ progress: null,
+ research: [],
+ architecture: { overview: null, gaps: null, looseFiles: [] },
+ looseFiles: []
+ };
+
+ // Phase 1 — validate + classify (sync).
+ var classified = [];
+ for (var i = 0; i < files.length; i++) {
+ var f = files[i];
+ var rel = f.webkitRelativePath || '';
+ // Validate: path must start with basePath prefix; reject parent traversal.
+ if (basePath && rel.indexOf(prefix) !== 0) continue;
+ if (rel.indexOf('..') !== -1) continue;
+ var inside = rel.slice(prefix.length);
+ if (!inside) continue;
+ var parts = inside.split('/');
+ var name = parts[parts.length - 1];
+ var bucket = null;
+ if (parts.length === 1 && name === 'brief.md') bucket = 'brief';
+ else if (parts.length === 1 && name === 'plan.md') bucket = 'plan';
+ else if (parts.length === 1 && name === 'review.md') bucket = 'review';
+ else if (parts.length === 1 && name === 'progress.json') bucket = 'progress';
+ else if (parts.length === 2 && parts[0] === 'research' && /\.md$/.test(name)) bucket = 'research';
+ else if (parts.length === 2 && parts[0] === 'architecture' && name === 'overview.md') bucket = 'arch_overview';
+ else if (parts.length === 2 && parts[0] === 'architecture' && name === 'gaps.md') bucket = 'arch_gaps';
+ else if (parts.length === 2 && parts[0] === 'architecture' && /\.md$/.test(name)) bucket = 'arch_loose';
+ else bucket = 'loose';
+ classified.push({ file: f, rel: inside, bucket: bucket });
+ }
+
+ // Phase 2 — read content + parse frontmatter (async).
+ for (var j = 0; j < classified.length; j++) {
+ var c = classified[j];
+ var text = '';
+ try { text = await c.file.text(); } catch (_) { text = ''; }
+ var fm = quickParseFrontmatter(text);
+ var entry = { path: c.rel, content: text, frontmatter: fm };
+ switch (c.bucket) {
+ case 'brief': artifacts.brief = entry; break;
+ case 'plan': artifacts.plan = entry; break;
+ case 'review': artifacts.review = entry; break;
+ case 'progress': artifacts.progress = entry; break;
+ case 'research': artifacts.research.push(entry); break;
+ case 'arch_overview': artifacts.architecture.overview = entry; break;
+ case 'arch_gaps': artifacts.architecture.gaps = entry; break;
+ case 'arch_loose': artifacts.architecture.looseFiles.push(entry); break;
+ default: artifacts.looseFiles.push(entry); break;
+ }
+ }
+ artifacts.research.sort(function (a, b) { return a.path.localeCompare(b.path); });
+
+ // Phase 3 — deterministic storage-key from basePath (NOT URL params)
+ // so draft-annotation state is isolated per project.
+ artifacts.storageKey = 'voyage_proj_' + djb2Hash(artifacts.basePath || '(unnamed)');
+
+ // Phase 4 — warn if brief missing (operator may still drop a partial dir).
+ if (!artifacts.brief) {
+ announce('Advarsel: brief.md mangler i prosjektmappen.');
+ }
+
+ // Phase 5 — render dashboard if available, else log.
+ if (typeof renderDashboard === 'function') {
+ renderDashboard(artifacts);
+ } else {
+ try {
+ console.log('[voyage] projectArtifacts loaded', {
+ basePath: artifacts.basePath,
+ brief: !!artifacts.brief,
+ plan: !!artifacts.plan,
+ review: !!artifacts.review,
+ research: artifacts.research.length,
+ architecture: {
+ overview: !!artifacts.architecture.overview,
+ gaps: !!artifacts.architecture.gaps,
+ loose: artifacts.architecture.looseFiles.length
+ },
+ progress: !!artifacts.progress,
+ loose: artifacts.looseFiles.length,
+ storageKey: artifacts.storageKey
+ });
+ } catch (_) {}
+ }
+ return artifacts;
+ }
+
+ // djb2 — small deterministic non-cryptographic hash for storage-keys.
+ // Not security-sensitive; only needs to map basePath → stable string.
+ function djb2Hash(s) {
+ var h = 5381;
+ for (var i = 0; i < s.length; i++) h = ((h << 5) + h) + s.charCodeAt(i);
+ return (h >>> 0).toString(36);
+ }
+
// ---- v4.3 Step 12 — drag-drop with webkitGetAsEntry ---------------
// Document-level dragenter shows the overlay; drop iterates
// dataTransfer.items, walks DirectoryEntry.readEntries() recursively