diff --git a/browse/build.sh b/browse/build.sh
index 8ffa917..41a3536 100755
--- a/browse/build.sh
+++ b/browse/build.sh
@@ -71,6 +71,7 @@ concat_files \
"js/preview.js" \
"js/preview-markdown.js" \
"js/preview-yaml.js" \
+ "js/preview-zddc-form.js" \
"js/hovercard.js" \
"js/grid.js" \
"js/upload.js" \
diff --git a/browse/js/preview-zddc-form.js b/browse/js/preview-zddc-form.js
new file mode 100644
index 0000000..6b5efd8
--- /dev/null
+++ b/browse/js/preview-zddc-form.js
@@ -0,0 +1,322 @@
+// preview-zddc-form.js — schema-driven FORM view for .zddc files.
+//
+// The user shouldn't have to understand YAML cascades to configure a project.
+// This renders the .zddc as a form: the OPTION fields (the blanks an operator
+// fills — title, admins, role members) are editable widgets; the STRUCTURE
+// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only
+// for context. The split is driven by the server's .zddc JSON Schema
+// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited
+// option values back into the file (preserving all structure keys) and PUTs the
+// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member
+// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the
+// CodeMirror editor for anything the form doesn't cover (field_codes, display,
+// convert, advanced acl).
+//
+// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is
+// the power-user fallback.
+(function (app) {
+ 'use strict';
+
+ var util = app.modules.util || window.app.modules.util;
+ var escapeHtml = util.escapeHtml;
+ var saveFile = util.saveFile;
+ var isEditableZipMember = util.isEditableZipMember;
+
+ var current = null; // { node, dirty, etag, lastModified }
+
+ // Cached .zddc schema (property → {tier, description}).
+ var schemaProps = null;
+ function loadSchema() {
+ if (schemaProps) return Promise.resolve(schemaProps);
+ return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; })
+ .catch(function () { schemaProps = {}; return schemaProps; });
+ }
+
+ function handles(node) {
+ return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || ''));
+ }
+
+ function canSave(node) {
+ if (isEditableZipMember(node)) return true;
+ if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
+ // A .zddc edit is an ActionAdmin write — needs the 'a' verb.
+ return window.zddc.cap.has(node, 'a');
+ }
+ return false;
+ }
+
+ function isDirty() { return !!(current && current.dirty); }
+ function currentNode() { return current ? current.node : null; }
+ function dispose() { current = null; }
+
+ function desc(name) {
+ return (schemaProps && schemaProps[name] && schemaProps[name].description) || '';
+ }
+
+ // ── small DOM helpers ───────────────────────────────────────────────────
+ function el(tag, cls, text) {
+ var e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ }
+ // A growable list of single-string rows (used for admins + role members).
+ function listEditor(values, placeholder, onChange, readOnly) {
+ var wrap = el('div', 'zf-list');
+ function addRow(val) {
+ var row = el('div', 'zf-list__row');
+ row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;';
+ var input = el('input');
+ input.type = 'text';
+ input.value = val || '';
+ input.placeholder = placeholder || '';
+ input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);';
+ input.disabled = !!readOnly;
+ input.addEventListener('input', onChange);
+ row.appendChild(input);
+ if (!readOnly) {
+ var del = el('button', null, '−');
+ del.type = 'button';
+ del.title = 'Remove';
+ del.addEventListener('click', function () { row.remove(); onChange(); });
+ row.appendChild(del);
+ }
+ wrap.appendChild(row);
+ }
+ (values || []).forEach(addRow);
+ if (!readOnly) {
+ var add = el('button', 'zf-add', '+ add');
+ add.type = 'button';
+ add.style.cssText = 'margin-top:.2rem;';
+ add.addEventListener('click', function () { addRow(''); onChange(); });
+ wrap.appendChild(add);
+ }
+ wrap._values = function () {
+ return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input'))
+ .map(function (i) { return i.value.trim(); })
+ .filter(function (v) { return v; });
+ };
+ return wrap;
+ }
+
+ async function render(node, container, ctx) {
+ dispose();
+ var text, etag = null, lastModified = null;
+ try {
+ if (ctx.getContentWithVersion) {
+ var loaded = await ctx.getContentWithVersion(node);
+ text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
+ etag = loaded.etag;
+ lastModified = loaded.lastModified;
+ } else {
+ text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node));
+ }
+ } catch (e) {
+ container.innerHTML = '
'
+ + 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '
';
+ return;
+ }
+
+ var data = {};
+ try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; }
+ if (typeof data !== 'object' || Array.isArray(data)) data = {};
+ await loadSchema();
+
+ var editable = canSave(node);
+ current = { node: node, dirty: false, etag: etag, lastModified: lastModified };
+
+ container.innerHTML = '';
+ var shell = el('div', 'yaml-shell zddc-form');
+ shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;';
+ container.appendChild(shell);
+
+ // Header.
+ var hdr = el('div', 'md-shell__infohdr');
+ hdr.appendChild(el('span', 'md-shell__title', node.name));
+ var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only'));
+ hdr.appendChild(srcTag);
+ var dirtyEl = el('span', 'md-shell__dirty');
+ hdr.appendChild(dirtyEl);
+ var statusEl = el('span', 'md-shell__status');
+ hdr.appendChild(statusEl);
+ var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML');
+ rawBtn.type = 'button';
+ rawBtn.title = 'Switch to the raw YAML editor (covers every key).';
+ rawBtn.addEventListener('click', function () {
+ var ym = window.app.modules.yamledit;
+ if (ym && ym.render) { dispose(); ym.render(node, container, ctx); }
+ });
+ hdr.appendChild(rawBtn);
+ var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save');
+ saveBtn.type = 'button';
+ saveBtn.disabled = true;
+ hdr.appendChild(saveBtn);
+ shell.appendChild(hdr);
+
+ function markDirty() {
+ if (!current) return;
+ current.dirty = true;
+ dirtyEl.textContent = '● modified';
+ if (editable) saveBtn.disabled = false;
+ }
+
+ var help = el('p', 'help');
+ help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .8rem;';
+ help.textContent = editable
+ ? 'Fill in the project options below. Structure (the folder shape, WORM, tools) is managed by the baseline and shown read-only — edit it via raw YAML if you must.'
+ : 'Read-only — you need admin authority over this path to edit it.';
+ shell.appendChild(help);
+
+ // ── OPTION fields ───────────────────────────────────────────────────
+ function section(title, hint) {
+ var s = el('section', 'zf-section');
+ s.style.cssText = 'margin:0 0 1rem;';
+ var h = el('h3', null, title);
+ h.style.cssText = 'font-size:1em;margin:.6rem 0 .2rem;';
+ s.appendChild(h);
+ if (hint) {
+ var p = el('p', 'help', hint);
+ p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;';
+ s.appendChild(p);
+ }
+ shell.appendChild(s);
+ return s;
+ }
+
+ // title
+ var titleSec = section('Title', desc('title'));
+ var titleInput = el('input');
+ titleInput.type = 'text';
+ titleInput.value = (typeof data.title === 'string') ? data.title : '';
+ titleInput.disabled = !editable;
+ titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;';
+ titleInput.addEventListener('input', markDirty);
+ titleSec.appendChild(titleInput);
+
+ // admins
+ var adminsSec = section('Admins', desc('admins'));
+ var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable);
+ adminsSec.appendChild(adminsList);
+
+ // roles (map name → {members:[]})
+ var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.');
+ var rolesHost = el('div', 'zf-roles');
+ rolesSec.appendChild(rolesHost);
+ var roleEditors = []; // {name, membersEl, getName}
+ function addRole(name, members) {
+ var box = el('div', 'zf-role');
+ box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;';
+ var nameRow = el('div');
+ nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;';
+ var nameInput = el('input');
+ nameInput.type = 'text';
+ nameInput.value = name || '';
+ nameInput.placeholder = 'role name (e.g. document_controller)';
+ nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;';
+ nameInput.disabled = !editable;
+ nameInput.addEventListener('input', markDirty);
+ nameRow.appendChild(el('span', null, '👥'));
+ nameRow.appendChild(nameInput);
+ box.appendChild(nameRow);
+ var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable);
+ box.appendChild(membersList);
+ rolesHost.appendChild(box);
+ roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList });
+ }
+ var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {};
+ Object.keys(roles).forEach(function (rn) {
+ var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : [];
+ addRole(rn, m);
+ });
+ if (editable) {
+ var addRoleBtn = el('button', 'zf-add', '+ add role');
+ addRoleBtn.type = 'button';
+ addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); });
+ rolesSec.appendChild(addRoleBtn);
+ }
+
+ // ── STRUCTURE (read-only) ───────────────────────────────────────────
+ var structKeys = Object.keys(data).filter(function (k) {
+ return schemaProps[k] && schemaProps[k].tier === 'structure';
+ });
+ // Also surface option keys this form doesn't render yet, as read-only.
+ var rawHandled = { title: 1, admins: 1, roles: 1 };
+ var otherKeys = Object.keys(data).filter(function (k) {
+ return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure');
+ });
+ if (structKeys.length || otherKeys.length) {
+ var det = el('details', 'zf-structure');
+ det.style.cssText = 'margin-top:.5rem;';
+ var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)');
+ sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;';
+ det.appendChild(sum);
+ var subset = {};
+ structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; });
+ var pre = el('pre');
+ pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;';
+ try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); }
+ catch (_) { pre.textContent = JSON.stringify(subset, null, 2); }
+ det.appendChild(pre);
+ shell.appendChild(det);
+ }
+
+ // ── Save ────────────────────────────────────────────────────────────
+ function buildContent() {
+ var out = {};
+ // Preserve everything not managed by the form (structure + unrendered options).
+ Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; });
+ var t = titleInput.value.trim();
+ if (t) out.title = t;
+ var admins = adminsList._values();
+ if (admins.length) out.admins = admins;
+ var rolesOut = {};
+ roleEditors.forEach(function (re) {
+ var n = re.getName();
+ if (!n) return;
+ var mem = re.members._values();
+ rolesOut[n] = mem.length ? { members: mem } : { members: [] };
+ });
+ if (Object.keys(rolesOut).length) out.roles = rolesOut;
+ return window.jsyaml.dump(out);
+ }
+
+ saveBtn.addEventListener('click', async function () {
+ if (!current || !editable) return;
+ saveBtn.disabled = true;
+ statusEl.textContent = 'Saving…';
+ var content;
+ try { content = buildContent(); }
+ catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; }
+ try {
+ var res = await saveFile(node, content, 'application/yaml; charset=utf-8',
+ { etag: current.etag, lastModified: current.lastModified });
+ if (!current) return;
+ current.etag = (res && res.etag) || current.etag;
+ current.dirty = false;
+ dirtyEl.textContent = '';
+ statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
+ if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success');
+ } catch (e) {
+ if (e && e.status === 412 && window.app.modules.conflict) {
+ window.app.modules.conflict.open({
+ name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); }
+ });
+ statusEl.textContent = 'Conflict — changed on server.';
+ } else {
+ statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e);
+ }
+ saveBtn.disabled = false;
+ }
+ });
+ }
+
+ app.modules.zddcform = {
+ handles: handles,
+ render: render,
+ isDirty: isDirty,
+ currentNode: currentNode,
+ dispose: dispose
+ };
+})(window.app);
diff --git a/browse/js/preview.js b/browse/js/preview.js
index 64277a9..8f767ea 100644
--- a/browse/js/preview.js
+++ b/browse/js/preview.js
@@ -100,7 +100,7 @@
function editorModules() {
var m = window.app.modules;
- return [m.markdown, m.yamledit].filter(Boolean);
+ return [m.markdown, m.yamledit, m.zddcform].filter(Boolean);
}
function disposeEditors() {
@@ -211,6 +211,19 @@
return;
}
+ // .zddc form view: a schema-driven form (option fields editable,
+ // structure read-only) is the PRIMARY editor for .zddc files. It hands
+ // off to the raw YAML editor on demand. Other YAML files skip it.
+ var zddcForm = window.app.modules.zddcform;
+ if (zddcForm && zddcForm.handles(node)) {
+ try {
+ await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
+ } catch (e) {
+ renderError(container, '.zddc form render failed: ' + (e.message || e));
+ }
+ return;
+ }
+
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
// CodeMirror 5 editor with js-yaml linting; .zddc files also
// get a schema-aware lint pass.