feat(ms-ai-architect): playground v3 generic command form renderer + buildCommand [skip-docs]

Step 8 of v3 plan. renderCommandForm(commandId, opts) reads
CATALOG[id].input_fields and emits a form with all 6 supported field types
(text/textarea/select/multiSelect/boolean/number). Shared fields
auto-prefill from state.shared via field.shared_path dot-lookup; local
fields prefill from project.reports[id].input when opts.projectId is set.

window.__buildCommand(commandId, formData) builds /architect:<id>
key="value" key="value" ... — shared fields merged first (CATALOG order),
formData overrides and may include keys outside the catalog (passthrough).
Empty/null/empty-array values omitted. Multi-values comma-joined inside
quotes; quotes/backslashes escaped.

Copy-button writes via navigator.clipboard.writeText with graceful
fallback to inline preview when clipboard is blocked (file:// in some
browsers). Preview-button shows the same string without copying.

Replaces the form-zone-placeholder in renderCommandSubCard. All 24
command-cards in project-detail now render real forms (verified:
data-command-card === 24, data-command-form === 24, copy-command
buttons === 24, field-from-tag === 39, paste-import === 17,
report-slot === 17, buildCommand('classify',{riskLevel:'høy'}) →
'/architect:classify organisation_name="Vegvesen" sector="Statlig"
riskLevel="høy"').
This commit is contained in:
Kjell Tore Guttormsen 2026-05-03 18:33:19 +02:00
commit f55a0e9513

View file

@ -86,9 +86,40 @@
/* Modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; padding: var(--space-4); }
.modal { background: var(--color-surface); border-radius: var(--radius-lg); padding: var(--space-5); max-width: 560px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: var(--shadow-lg); display: flex; flex-direction: column; gap: var(--space-4); }
.modal--wide { max-width: 760px; }
.modal__title { margin: 0; font-size: var(--font-size-xl); }
.modal__actions { display: flex; gap: var(--space-2); justify-content: flex-end; padding-top: var(--space-3); border-top: 1px solid var(--color-border-subtle); }
[data-theme="dark"] .modal-backdrop { background: rgba(0,0,0,0.7); }
/* Command form (Step 8) */
.command-form { display: flex; flex-direction: column; gap: var(--space-3); }
.command-form__fields { display: flex; flex-direction: column; gap: var(--space-3); }
.command-form__actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; padding-top: var(--space-2); border-top: 1px dashed var(--color-border-subtle); }
.command-form__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); }
.command-form__copy-confirm { font-size: var(--font-size-xs); color: var(--color-text-secondary); }
.field-from-tag { display: inline-block; padding: 1px 6px; background: var(--color-bg-soft); color: var(--color-text-tertiary); border-radius: var(--radius-sm); font-size: 10px; font-weight: var(--font-weight-medium); margin-left: 6px; letter-spacing: 0.04em; text-transform: uppercase; }
.form-preview { padding: var(--space-3); background: var(--color-bg-soft); border-radius: var(--radius-sm); margin-top: var(--space-2); overflow-x: auto; }
.form-preview__heading { font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.06em; color: var(--color-text-tertiary); margin: 0 0 var(--space-2); }
.code-block { font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-primary); white-space: pre-wrap; word-break: break-all; margin: 0; }
/* Catalog (Step 9) */
.catalog-header { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-3) 0 var(--space-4); }
.catalog-header h1 { font-size: var(--font-size-2xl); margin: 0; }
.catalog-header p { color: var(--color-text-secondary); margin: 0; max-width: 70ch; }
.catalog-toolbar { display: flex; gap: var(--space-3); align-items: center; margin-bottom: var(--space-4); flex-wrap: wrap; }
.catalog-toolbar .input { max-width: 480px; flex: 1 1 280px; }
.catalog-toolbar__count { font-size: var(--font-size-sm); color: var(--color-text-tertiary); }
.catalog-groups { display: flex; flex-direction: column; gap: var(--space-3); }
.catalog-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3); padding: var(--space-2) 0; }
.catalog-card { background: var(--color-surface); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
.catalog-card__head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-2); }
.catalog-card__title { font-size: var(--font-size-md); margin: 0; font-weight: var(--font-weight-semibold); }
.catalog-card__desc { font-size: var(--font-size-sm); color: var(--color-text-secondary); margin: 4px 0 0; }
.catalog-card__pill { padding: 2px 8px; background: var(--color-bg-soft); color: var(--color-text-secondary); border-radius: var(--radius-sm); font-size: 10px; font-weight: var(--font-weight-medium); flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.04em; }
.catalog-card__meta { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }
.catalog-card__hint { font-size: var(--font-size-xs); color: var(--color-text-tertiary); font-family: var(--font-family-mono); }
.catalog-card__actions { display: flex; gap: var(--space-2); margin-top: auto; padding-top: var(--space-2); }
.catalog-tool-notice { padding: var(--space-2) var(--space-3); background: var(--color-bg-soft); border-left: 3px solid var(--color-primary-500); border-radius: var(--radius-sm); font-size: var(--font-size-xs); color: var(--color-text-secondary); }
</style>
</head>
<body>
@ -1131,6 +1162,244 @@
}
function escapeAttr(str) { return escapeHtml(str); }
// ============================================================
// COMMAND FORM RENDERER + __buildCommand (Step 8)
// ============================================================
//
// renderCommandForm(commandId, opts) genererer HTML for ett command-skjema
// basert på CATALOG[id].input_fields. Brukes både i prosjekt-detalj
// (Step 7 form-zone) og i katalog-modal (Step 9). Felter med from='shared'
// pre-fylles fra state.shared via field.shared_path; lokale felter
// pre-fylles fra project.reports[id].input når opts.projectId er gitt.
//
// window.__buildCommand(commandId, formData) bygger '/architect:<id>
// key="value" ...'-streng. Shared-felter merges inn først, formData
// overstyrer hvis samme nøkkel. Tomme/null-verdier hoppes over. formData
// kan inneholde nøkler som ikke finnes i CATALOG (passthrough).
function resolveSharedPath(path) {
if (!path || !store || !store.state || !store.state.shared) return undefined;
const parts = String(path).split('.');
let cur = store.state.shared;
for (let i = 0; i < parts.length; i++) {
if (cur == null || typeof cur !== 'object') return undefined;
cur = cur[parts[i]];
}
return cur;
}
function isFilledArg(v, type) {
if (v == null) return false;
if (type === 'multiSelect' || Array.isArray(v)) return Array.isArray(v) && v.length > 0;
if (type === 'boolean' || typeof v === 'boolean') return v === true;
if (type === 'number' || typeof v === 'number') return !isNaN(v);
return String(v).trim() !== '';
}
function serializeArgValue(v) {
if (Array.isArray(v)) {
return '"' + v.map(function (x) { return String(x).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }).join(',') + '"';
}
if (typeof v === 'boolean') return String(v);
if (typeof v === 'number') return String(v);
const s = String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return '"' + s + '"';
}
function buildCommand(commandId, formData) {
formData = formData || {};
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
const args = {};
// 1. Pre-fyll fra shared (CATALOG-definerte felles felter).
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (f.from === 'shared' && f.shared_path) {
const v = resolveSharedPath(f.shared_path);
if (isFilledArg(v, f.type)) args[f.id] = v;
}
});
}
// 2. formData overstyrer / utvider. Tillater nøkler som ikke er i CATALOG.
Object.keys(formData).forEach(function (k) {
const v = formData[k];
if (isFilledArg(v)) args[k] = v;
else delete args[k];
});
// 3. Bygg streng. Stable order: shared-felter først (i CATALOG-rekkefølge),
// så resten i insertion-order.
const orderedKeys = [];
const seen = {};
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (Object.prototype.hasOwnProperty.call(args, f.id) && !seen[f.id]) {
orderedKeys.push(f.id);
seen[f.id] = true;
}
});
}
Object.keys(args).forEach(function (k) {
if (!seen[k]) {
orderedKeys.push(k);
seen[k] = true;
}
});
const parts = ['/architect:' + commandId];
orderedKeys.forEach(function (k) {
parts.push(k + '=' + serializeArgValue(args[k]));
});
return parts.join(' ');
}
function renderCommandFormField(field, domId, value) {
const fromAttr = field.from === 'shared' ? 'shared' : 'local';
const dataAttrs = 'data-cf-field="' + escapeAttr(field.id) + '" data-cf-from="' + fromAttr + '" data-cf-type="' + escapeAttr(field.type) + '"';
const fromTag = field.from === 'shared'
? '<span class="field-from-tag" title="Forhåndsutfylt fra onboarding (state.shared.' + escapeAttr(field.shared_path || '') + ')">felles</span>'
: '';
const labelHtml = '<label for="' + domId + '" class="field-label">' + escapeHtml(field.label) + fromTag + '</label>';
let inputHtml = '';
if (field.type === 'text') {
inputHtml = '<input type="text" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null ? '' : String(value)) + '" class="input">';
} else if (field.type === 'textarea') {
inputHtml = '<textarea id="' + domId + '" ' + dataAttrs + ' class="textarea" rows="3">' + escapeHtml(value == null ? '' : String(value)) + '</textarea>';
} else if (field.type === 'number') {
inputHtml = '<input type="number" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null || value === '' ? '' : String(value)) + '" class="input">';
} else if (field.type === 'select') {
const opts = ['<option value="">(velg)</option>'].concat((field.options || []).map(function (o) {
const sel = (o === value) ? ' selected' : '';
return '<option value="' + escapeAttr(o) + '"' + sel + '>' + escapeHtml(o) + '</option>';
})).join('');
inputHtml = '<select id="' + domId + '" ' + dataAttrs + ' class="select">' + opts + '</select>';
} else if (field.type === 'multiSelect') {
const arr = Array.isArray(value) ? value : [];
const opts = (field.options || []).map(function (o, i) {
const checked = arr.indexOf(o) >= 0 ? ' checked' : '';
const cbId = domId + '-' + i;
return (
'<label class="checkbox-row" for="' + cbId + '">' +
'<input type="checkbox" id="' + cbId + '" ' + dataAttrs + ' data-cf-multi="' + escapeAttr(o) + '"' + checked + '>' +
'<span>' + escapeHtml(o) + '</span>' +
'</label>'
);
}).join('');
inputHtml = (
'<fieldset class="multi-select" aria-labelledby="' + domId + '-legend">' +
'<legend id="' + domId + '-legend" class="visually-hidden">' + escapeHtml(field.label) + '</legend>' +
opts +
'</fieldset>'
);
} else if (field.type === 'boolean') {
const checked = value === true ? ' checked' : '';
inputHtml = (
'<label class="checkbox-row" for="' + domId + '">' +
'<input type="checkbox" id="' + domId + '" ' + dataAttrs + checked + '>' +
'<span>Ja</span>' +
'</label>'
);
} else {
// Ukjent type — fall tilbake til text.
inputHtml = '<input type="text" id="' + domId + '" ' + dataAttrs + ' value="' + escapeAttr(value == null ? '' : String(value)) + '" class="input">';
}
return '<div class="field-row" data-cf-field-row="' + escapeAttr(field.id) + '">' + labelHtml + inputHtml + '</div>';
}
function renderCommandForm(commandId, opts) {
opts = opts || {};
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === commandId; });
if (!cmd) {
return '<div class="guide-panel guide-panel--warn"><div class="guide-panel__icon" aria-hidden="true">!</div><div class="guide-panel__body"><p class="guide-panel__text">Ukjent command: ' + escapeHtml(commandId) + '</p></div></div>';
}
const project = opts.projectId ? findProject(opts.projectId) : null;
const savedInput = (project && project.reports && project.reports[commandId] && project.reports[commandId].input) || {};
const scope = opts.scope || 'p';
const fieldRows = (cmd.input_fields || []).map(function (f) {
const domId = 'cf-' + scope + '-' + cmd.id + '-' + f.id;
let value;
if (f.from === 'shared' && f.shared_path) {
value = resolveSharedPath(f.shared_path);
}
if (value === undefined || value === null || value === '') {
if (Object.prototype.hasOwnProperty.call(savedInput, f.id)) value = savedInput[f.id];
}
return renderCommandFormField(f, domId, value);
}).join('');
const sharedCount = (cmd.input_fields || []).filter(function (f) { return f.from === 'shared'; }).length;
const fieldCount = (cmd.input_fields || []).length;
return (
'<form class="command-form" data-command-form="' + escapeAttr(cmd.id) + '" data-command-form-scope="' + escapeAttr(scope) + '" autocomplete="off" onsubmit="return false;">' +
'<div class="command-form__fields">' + fieldRows + '</div>' +
'<div class="command-form__actions">' +
'<button type="button" class="btn btn--primary btn--sm" data-action="copy-command" data-command="' + escapeAttr(cmd.id) + '">Kopier kommando</button>' +
'<button type="button" class="btn btn--secondary btn--sm" data-action="preview-command" data-command="' + escapeAttr(cmd.id) + '">Forhåndsvis</button>' +
'<span class="command-form__hint">' + fieldCount + ' felter (' + sharedCount + ' fra shared).</span>' +
'<span class="command-form__copy-confirm" data-copy-confirm hidden></span>' +
'</div>' +
'<div class="form-preview" data-form-preview hidden>' +
'<h5 class="form-preview__heading">Pipeline-streng</h5>' +
'<pre class="code-block" data-form-preview-text></pre>' +
'</div>' +
'</form>'
);
}
function readCommandFormValues(formEl) {
const data = {};
if (!formEl) return data;
const cmdId = formEl.dataset.commandForm;
const cmd = (CATALOG.commands || []).find(function (c) { return c.id === cmdId; });
// Initialiser multiSelect til [] så uavkryssede ender opp tomme.
if (cmd && cmd.input_fields) {
cmd.input_fields.forEach(function (f) {
if (f.type === 'multiSelect') data[f.id] = [];
});
}
const inputs = formEl.querySelectorAll('[data-cf-field]');
for (let i = 0; i < inputs.length; i++) {
const el = inputs[i];
const id = el.dataset.cfField;
if (el.matches('input[type="checkbox"][data-cf-multi]')) {
if (el.checked) {
if (!Array.isArray(data[id])) data[id] = [];
data[id].push(el.dataset.cfMulti);
}
} else if (el.matches('input[type="checkbox"]')) {
data[id] = el.checked;
} else if (el.matches('input[type="number"]')) {
if (el.value === '' || el.value == null) {
data[id] = null;
} else {
const n = Number(el.value);
data[id] = isNaN(n) ? null : n;
}
} else {
data[id] = el.value;
}
}
return data;
}
function showCommandPreview(formEl, str) {
if (!formEl) return;
const box = formEl.querySelector('[data-form-preview]');
const text = formEl.querySelector('[data-form-preview-text]');
if (!box || !text) return;
text.textContent = str;
box.hidden = false;
}
function flashCopyConfirm(formEl, message) {
if (!formEl) return;
const tag = formEl.querySelector('[data-copy-confirm]');
if (!tag) return;
tag.textContent = message || 'Kopiert til utklippstavle.';
tag.hidden = false;
clearTimeout(tag.__hideTimer);
tag.__hideTimer = setTimeout(function () { tag.hidden = true; }, 2400);
}
// ============================================================
// SURFACE ROUTING (Step 5)
// ============================================================
@ -1503,7 +1772,7 @@
// ---- Sub-card rendering ----
function renderCommandSubCard(cmd) {
function renderCommandSubCard(cmd, projectId) {
const titleHtml = (
'<div class="command-card__head">' +
'<div>' +
@ -1517,7 +1786,9 @@
const formZone = (
'<div class="sub-zone">' +
'<h4 class="sub-zone__heading">Skjema</h4>' +
'<div class="form-zone-placeholder" data-form-zone="' + escapeAttr(cmd.id) + '">Skjema-renderer kommer i Step 8 (' + cmd.input_fields.length + ' felter, ' + (cmd.input_fields.filter(function (f) { return f.from === 'shared'; }).length) + ' fra shared).</div>' +
'<div data-form-zone="' + escapeAttr(cmd.id) + '">' +
renderCommandForm(cmd.id, { context: 'project', projectId: projectId, scope: 'p' }) +
'</div>' +
'</div>'
);
@ -1631,7 +1902,7 @@
const isActive = currentProjectTab === cat.id;
const cards = CATALOG.commands
.filter(function (c) { return c.category === cat.id; })
.map(renderCommandSubCard).join('');
.map(function (c) { return renderCommandSubCard(c, project.id); }).join('');
return (
'<div class="command-cards" role="tabpanel" data-tab-panel="' + escapeAttr(cat.id) + '"' + (isActive ? '' : ' hidden') + '>' +
cards +
@ -2147,21 +2418,56 @@
ACTIONS['parse'] = function (ev, el) {
const commandId = el.dataset.command;
if (!commandId) return;
const root = getSurfaceEl('project');
if (!root) return;
const textarea = root.querySelector('[data-paste-import="' + commandId + '"]');
// Finn nærmeste paste-import textarea (project-overflate eller modal — Step 9
// bruker ikke parse-knapp, men vi holder oss generisk via closest()).
const scope = el.closest('[data-modal-root], [data-surface]') || document;
const textarea = scope.querySelector('[data-paste-import="' + commandId + '"]');
if (!textarea) return;
const markdown = textarea.value || '';
handlePasteImport(commandId, markdown);
};
// Eksponer for Verify-asserts og Step 8/12.
// ---- Step 8: copy-command + preview-command ----
ACTIONS['copy-command'] = function (ev, el) {
const commandId = el.dataset.command;
const formEl = el.closest('[data-command-form]');
if (!commandId || !formEl) return;
const data = readCommandFormValues(formEl);
const cmdString = buildCommand(commandId, data);
// Vis preview alltid — clipboard kan feile på file://-protokoll i noen browsers.
showCommandPreview(formEl, cmdString);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(cmdString).then(function () {
flashCopyConfirm(formEl, 'Kopiert til utklippstavle.');
}).catch(function (err) {
console.warn('[playground v3] clipboard write feilet:', err);
flashCopyConfirm(formEl, 'Kunne ikke kopiere — bruk forhåndsvisningen under.');
});
} else {
flashCopyConfirm(formEl, 'Clipboard utilgjengelig — bruk forhåndsvisningen under.');
}
};
ACTIONS['preview-command'] = function (ev, el) {
const commandId = el.dataset.command;
const formEl = el.closest('[data-command-form]');
if (!commandId || !formEl) return;
const data = readCommandFormValues(formEl);
showCommandPreview(formEl, buildCommand(commandId, data));
};
// Eksponer for Verify-asserts og Step 8/9/12.
window.__SCENARIOS = SCENARIOS;
window.__createProject = createProject;
window.__deleteProject = deleteProject;
window.__findProject = findProject;
window.__mountModal = mountModal;
window.__unmountModal = unmountModal;
window.__buildCommand = buildCommand;
window.__renderCommandForm = renderCommandForm;
window.__readCommandFormValues = readCommandFormValues;
window.__resolveSharedPath = resolveSharedPath;
ACTIONS['export-state'] = function () {
try { exportState(); }