diff --git a/experiment_designer_v3.html b/experiment_designer_v3.html index cfb16c4..761cbbb 100644 --- a/experiment_designer_v3.html +++ b/experiment_designer_v3.html @@ -1194,9 +1194,8 @@

v3 Experiment Designer

- - @@ -1209,6 +1208,7 @@

v3 Experiment Designer

+ @@ -1332,7 +1332,7 @@

v3 Experiment Designer

@@ -1383,14 +1383,18 @@

Import error

findAliasesTo, variableIsComplex, isValidAnchorName, - anchorExists + anchorExists, + docInsertPluginNode, + docRemovePlugin } from './js/protocol-yaml-v3.js'; import { CONTROLLER_COMMANDS, getV3PluginCommands, listV3PluginNames, - getV3CommandParams + getV3CommandParams, + BUILTIN_PLUGINS, + createPluginEntry } from './js/plugin-registry.js'; // D4 — cross-library import (Milestone 2 substrate, driven by Milestone 3 UI) @@ -1653,10 +1657,11 @@

Import error

staging.addBareRefs = $('importAddRefs').checked; }); - // Minimum-valid v3 skeleton for the "New (blank)" demo option. + // Minimum-valid v3 skeleton for the "New" button (and Reset). // Empty `commands: []` would violate the spec (commands required) — // ship a wait:1 placeholder so the user has a valid starting point - // that imports cleanly through validateReferences. + // that imports cleanly through validateReferences. Starts with + // `plugins: []`; plugins are added from Settings → Plugins. const BLANK_TEMPLATE = [ 'version: 3', 'experiment_info:', @@ -1674,22 +1679,24 @@

Import error

'' ].join('\n'); + // New — start a protocol from scratch. Loads the blank skeleton so the + // user has a discoverable "create from scratch" path (previously only + // reachable via the demo dropdown). Blocked during import mode. + $('newBtn').addEventListener('click', () => { + if (importMode) return; + if (dirty && !confirm('Discard unsaved edits and start a new protocol?')) return; + loadYamlText(BLANK_TEMPLATE, 'untitled.yaml'); + }); + $('demoSelect').addEventListener('change', async (e) => { const val = e.target.value; if (!val) return; - // Sentinel for "New (blank)" — bypass fetch and load the template - // string directly. Anything else is a URL to a bundled fixture. - const isBlank = val === '__blank__'; - const demoName = isBlank ? 'blank.yaml' : val.split('/').pop(); + // Each option value is a URL to a bundled fixture. + const demoName = val.split('/').pop(); if (dirty && !confirm('Discard unsaved edits and load demo "' + demoName + '"?')) { e.target.value = ''; // reset to placeholder return; } - if (isBlank) { - loadYamlText(BLANK_TEMPLATE, demoName); - e.target.value = ''; - return; - } try { const resp = await fetch(val); if (!resp.ok) { @@ -1911,18 +1918,36 @@

Import error

rigBox.appendChild(rigFile); root.appendChild(rigBox); - // Plugins — read-only. Per the v3 spec the protocol's plugins: list - // is self-contained (the rig path is a separate file MATLAB loads; - // the web tool can't read it). Edit plugin entries in YAML for now. - if (experiment.plugins.length > 0) { + // Plugins — add/remove from the supported-plugin registry. Each + // entry's commands become available in the "Add command" picker as + // soon as it lands in experiment.plugins[]. Config VALUES (device_id, + // channels, ports, …) are still edited in the exported YAML — the + // web tool ships registry defaults only. Locked in import mode. + { const pluginBox = el('div', { class: 'settings-section' }); pluginBox.appendChild(el('h3', {}, 'Plugins (' + experiment.plugins.length + ')')); + if (experiment.plugins.length === 0) { + pluginBox.appendChild(el('div', + { class: 'kv', style: 'color: var(--text-dim); margin-bottom: 0.5rem;' }, + 'No plugins declared. Add one below to make its commands available.')); + } for (const p of experiment.plugins) { const pluginCard = el('div', { class: 'kv', style: 'margin-bottom: 0.5rem;' }); - pluginCard.appendChild(el('div', {}, - el('span', { class: 'k' }, p.name + ': '), - el('span', { class: 'v' }, p.type + (p.matlab ? ' / ' + p.matlab.class : '')) - )); + const headRow = el('div', + { style: 'display:flex; align-items:center; justify-content:space-between; gap:0.4rem;' }, + el('div', {}, + el('span', { class: 'k' }, p.name + ': '), + el('span', { class: 'v' }, p.type + (p.matlab ? ' / ' + p.matlab.class : '')) + )); + if (!importMode) { + headRow.appendChild(el('button', { + class: 'header-btn', + style: 'padding: 0.05rem 0.45rem; font-size: 0.85rem; line-height: 1.4;', + title: 'Remove the "' + p.name + '" plugin from this protocol', + onClick: () => onRemovePlugin(p.name) + }, '✕')); + } + pluginCard.appendChild(headRow); if (p.config) { for (const [ck, cv] of Object.entries(p.config)) { pluginCard.appendChild(el('div', { style: 'padding-left: 1rem;' }, @@ -1933,6 +1958,43 @@

Import error

} pluginBox.appendChild(pluginCard); } + + // Add-plugin control (hidden in import mode — target doc locked). + if (!importMode) { + const declared = new Set(experiment.plugins.map((p) => p.name)); + const available = Object.keys(BUILTIN_PLUGINS).filter((n) => !declared.has(n)); + const addRow = el('div', + { style: 'display:flex; gap:0.3rem; margin-top:0.5rem;' }); + const sel = el('select', { + style: 'flex:1; background: var(--bg); color: var(--text); ' + + 'border:1px solid var(--border); border-radius:4px; ' + + 'padding:0.3rem 0.4rem; font: inherit;', + title: 'Choose a supported plugin to add to this protocol' + }); + if (available.length === 0) { + sel.appendChild(el('option', { value: '' }, + 'All supported plugins already added')); + sel.disabled = true; + } else { + sel.appendChild(el('option', { value: '' }, '+ Add plugin…')); + for (const n of available) { + const def = BUILTIN_PLUGINS[n]; + const cls = def.matlab ? def.matlab.class + : (def.python ? def.python.class : ''); + sel.appendChild(el('option', { value: n }, + n + (cls ? ' — ' + cls : ''))); + } + } + addRow.appendChild(sel); + addRow.appendChild(el('button', { + class: 'header-btn', + disabled: available.length === 0, + title: 'Add the selected plugin (its commands become available immediately)', + onClick: () => { if (sel.value) onAddPlugin(sel.value); } + }, 'Add')); + pluginBox.appendChild(addRow); + } + root.appendChild(pluginBox); } @@ -2684,7 +2746,7 @@

Import error

// UI, not by suppressing pushUndo, since commit itself pushes one undo). const IMPORT_LOCKED_CONTROLS = [ 'addCondBtn', 'addRefBtn', 'addBlockBtn', 'exportBtn', 'resetBtn', - 'undoBtn', 'redoBtn', 'importBtn', 'importFromBtn', 'demoSelect', + 'undoBtn', 'redoBtn', 'newBtn', 'importBtn', 'importFromBtn', 'demoSelect', 'settingsToggle', 'librarySearch' ]; @@ -2733,7 +2795,7 @@

Import error

if (!node) continue; if (id === 'undoBtn') node.disabled = undoStack.length === 0; else if (id === 'redoBtn') node.disabled = redoStack.length === 0; - else if (id === 'importBtn' || id === 'demoSelect' || id === 'settingsToggle') node.disabled = false; + else if (id === 'newBtn' || id === 'importBtn' || id === 'demoSelect' || id === 'settingsToggle') node.disabled = false; else node.disabled = !hasExp; } renderAll(); @@ -4294,6 +4356,75 @@

Import error

} } + // Count plugin commands that reference a plugin by name (best-effort — + // used to warn before removing a plugin that's still in use). + function countPluginUsage(pluginName) { + let count = 0; + if (!experiment || !Array.isArray(experiment.conditions)) return 0; + for (const cond of experiment.conditions) { + if (!Array.isArray(cond.commands)) continue; + for (const cmd of cond.commands) { + if (cmd && cmd.type === 'plugin' && cmd.plugin_name === pluginName) count++; + } + } + return count; + } + + // Add a supported plugin (from the registry) to the protocol's plugins: + // list. Its commands become available in the "Add command" picker as + // soon as it lands in experiment.plugins[] (listV3PluginNames reads it). + function onAddPlugin(pluginName) { + if (!experiment || importMode || !pluginName) return; + if (experiment.plugins.some((p) => p.name === pluginName)) { + showError('Add plugin failed', + 'A plugin named "' + pluginName + '" is already declared.'); + return; + } + const entry = createPluginEntry(pluginName); + if (!entry) { + showError('Add plugin failed', 'Unknown plugin: ' + pluginName); + return; + } + pushUndo(); + try { + const node = experiment._doc.createNode(entry); + docInsertPluginNode(experiment, node); + setDirty(true); + renderAll(); + } catch (err) { + undoStack.pop(); // mutation failed — drop the snapshot + showError('Add plugin failed', err.message); + } + } + + async function onRemovePlugin(pluginName) { + if (!experiment || importMode) return; + const uses = countPluginUsage(pluginName); + const proceed = await confirmModal({ + title: 'Remove plugin "' + pluginName + '"?', + body: uses > 0 + ? 'This plugin is referenced by ' + uses + ' command' + (uses === 1 ? '' : 's') + + '. Removing it will leave those commands pointing at a missing plugin — ' + + 'fix or delete them before exporting.' + : 'Remove this plugin declaration from the protocol?', + confirmLabel: 'Remove', + cancelLabel: 'Cancel' + }); + if (!proceed) return; + pushUndo(); + try { + if (!docRemovePlugin(experiment, pluginName)) { + undoStack.pop(); // nothing matched — no-op + return; + } + setDirty(true); + renderAll(); + } catch (err) { + undoStack.pop(); + showError('Remove plugin failed', err.message); + } + } + function onDuplicateCondition(srcIdx) { if (!experiment || importMode) return; const src = experiment.conditions[srcIdx]; diff --git a/experiment_designer_v3_quickstart.html b/experiment_designer_v3_quickstart.html index 6821ba5..0d0d07e 100644 --- a/experiment_designer_v3_quickstart.html +++ b/experiment_designer_v3_quickstart.html @@ -215,12 +215,12 @@ ← Back to v3 Experiment Designer

v3 Experiment Designer Quick Start

-

v0.18 | 2026-06-01 09:10 ET — Edit Protocol v3 YAML directly, with anchors and comments preserved on round-trip.

+

v0.20 | 2026-06-01 15:25 ET — Edit Protocol v3 YAML directly, with anchors and comments preserved on round-trip.

The v3 Experiment Designer is a YAML round-trip editor for Protocol v3. - Unlike the v2 designer (which builds a protocol from scratch in a form), v3 loads an existing - v3 YAML, lets you edit it through a structured UI, and exports it back — preserving your - anchors, comments, and key order. The on-disk YAML is the single source of truth.

+ It loads an existing v3 YAML (or starts a fresh one with + New), lets you edit it + through a structured UI, and exports it back — preserving your anchors, comments, and + key order. The on-disk YAML is the single source of truth.

The interface

Three columns over a flattened-timeline preview. The left column is split into a @@ -246,15 +246,16 @@

The interface

  • Experiment Sequence — the ordered run: bare refs to conditions and blocks (groups of trials with repetitions / randomize / intertrial).
  • Inspector — edits the selected item: a condition's commands, a block's properties, a ref.
  • Timeline — a read-only preview of the flattened run order.
  • -
  • Settings ▾ (header) — experiment metadata, the rig path, and the read-only plugins list.
  • +
  • Settings ▾ (header) — experiment metadata, the rig path, and the plugins list, where you can add or remove plugins (their commands then appear in the Inspector's "+ add" picker).
  • Walkthrough: edit and export a protocol

    1 -

    Load a protocol. Use Import YAML to open your own file, or - Load demo ▾ to start from a bundled example (e.g. canonical_a).

    +

    Load or create a protocol. Use Import YAML to open your own file, + + New to start from a minimum-valid blank skeleton, or Load demo ▾ to + start from a bundled example (e.g. canonical_a).

    @@ -262,6 +263,10 @@

    Walkthrough: edit and export a protocol

    Inspect a condition. Click any row in the Library. Its commands appear in the Inspector — controller (trialParams), wait, and plugin commands. Edit fields inline; changes write straight to the YAML model.

    +

    Need a plugin command that isn't listed (e.g. a thermometer for temperature + monitoring)? Open Settings ▾Plugins, pick the plugin from the + dropdown, and click Add — its commands appear in the "+ add" picker immediately. Set + config values (device id, channels, ports) in the exported YAML.

    diff --git a/js/protocol-yaml-v3.js b/js/protocol-yaml-v3.js index 02a3892..b47a7bb 100644 --- a/js/protocol-yaml-v3.js +++ b/js/protocol-yaml-v3.js @@ -2050,6 +2050,44 @@ function docInsertPluginNode(experiment, clonedPluginNode) { experiment.plugins.push(extractPlugin(jsObj)); } +/** + * docRemovePlugin(experiment, pluginName) + * + * Remove the plugin entry named `pluginName` from the `plugins:` seq and from + * the experiment.plugins[] mirror. No-op (returns false) if not found. Does NOT + * touch commands that reference the plugin — callers warn the user first. The + * `plugins:` seq node is left in place even when it becomes empty (matches the + * blank-template `plugins: []` shape). + * + * @returns {boolean} true if a plugin was removed + */ +function docRemovePlugin(experiment, pluginName) { + if (!experiment || !experiment._doc) { + throw new V3ParseError('docRemovePlugin: experiment has no _doc handle', 'NO_DOC'); + } + const pluginsNode = experiment._doc.getIn(['plugins'], true); + let removed = false; + if (pluginsNode && Array.isArray(pluginsNode.items)) { + for (let i = 0; i < pluginsNode.items.length; i++) { + const item = pluginsNode.items[i]; + const name = item && typeof item.get === 'function' ? item.get('name') : undefined; + if (name === pluginName) { + pluginsNode.items.splice(i, 1); + removed = true; + break; + } + } + } + if (Array.isArray(experiment.plugins)) { + const idx = experiment.plugins.findIndex((p) => p && p.name === pluginName); + if (idx !== -1) { + experiment.plugins.splice(idx, 1); + removed = true; + } + } + return removed; +} + // ════════════════════════════════════════════════════ // Exports // ════════════════════════════════════════════════════ @@ -2095,7 +2133,8 @@ const ProtocolV3 = { ensureTopLevelSection, docInsertConditionNode, docInsertVariableNode, - docInsertPluginNode + docInsertPluginNode, + docRemovePlugin }; // Browser global @@ -2150,6 +2189,7 @@ export { ensureTopLevelSection, docInsertConditionNode, docInsertVariableNode, - docInsertPluginNode + docInsertPluginNode, + docRemovePlugin }; export default ProtocolV3; diff --git a/tests/test-protocol-roundtrip-v3.js b/tests/test-protocol-roundtrip-v3.js index cca5fab..f93173f 100644 --- a/tests/test-protocol-roundtrip-v3.js +++ b/tests/test-protocol-roundtrip-v3.js @@ -62,7 +62,8 @@ const { ensureTopLevelSection, docInsertConditionNode, docInsertVariableNode, - docInsertPluginNode + docInsertPluginNode, + docRemovePlugin } = require('../js/protocol-yaml-v3.js'); const { @@ -71,6 +72,7 @@ const { getV3PluginCommands, listV3PluginNames, getV3CommandParams, + createPluginEntry, LOG_PLUGIN } = require('../js/plugin-registry.js'); @@ -3265,6 +3267,66 @@ console.log('\n--- Suite N10: preflight rejection ---'); check('N10: suggestUniqueName bumps suffix', suggestUniqueName('x', new Set(['x', 'x_2'])), 'x_3'); } +// ─── Suite N11: add/remove plugin from registry (v0.20 UI feature) ─────────── +console.log('\n--- Suite N11: add/remove plugin from registry ---'); +{ + // Add a registry plugin to a protocol that starts with `plugins: []` + // (the blank-template / from-scratch shape). The UI builds the entry via + // createPluginEntry → _doc.createNode → docInsertPluginNode. + const yaml = mkProtocol({ + name: 'scratch', + plugins: 'plugins: []', + conditions: ' - name: c0\n commands: [{type: wait, duration: 1}]' + }); + const exp = parseV3Protocol(yaml); + check('N11: starts with zero plugins', exp.plugins.length, 0); + + const entry = createPluginEntry('thermometer'); + checkTrue( + 'N11: createPluginEntry builds thermometer w/ matlab class', + !!entry && entry.matlab && entry.matlab.class === 'DAQThermometerPlugin' + ); + + const node = exp._doc.createNode(entry); + docInsertPluginNode(exp, node); + check('N11: mirror has the plugin', exp.plugins.length, 1); + check('N11: mirror plugin name', exp.plugins[0].name, 'thermometer'); + + // Commands now resolve via matlab.class → available in the Add-command picker + checkTrue('N11: thermometer in listV3PluginNames', listV3PluginNames(exp).includes('thermometer')); + checkTrue( + 'N11: thermometer commands resolve', + Object.keys(getV3PluginCommands(exp, 'thermometer')).length > 0 + ); + + // Re-parse the regenerated YAML — the new plugin survives a roundtrip + const reparsed = parseV3Protocol(generateV3Protocol(exp)); + check('N11: plugin survives roundtrip', reparsed.plugins.length, 1); + check('N11: roundtrip plugin class', reparsed.plugins[0].matlab.class, 'DAQThermometerPlugin'); + + // Now remove it — both the doc node and the mirror drop + checkTrue('N11: docRemovePlugin returns true', docRemovePlugin(exp, 'thermometer') === true); + check('N11: mirror empty after remove', exp.plugins.length, 0); + checkTrue( + 'N11: plugins: seq empty after remove', + exp._doc.getIn(['plugins'], true).items.length === 0 + ); + const reparsed2 = parseV3Protocol(generateV3Protocol(exp)); + check('N11: removal survives roundtrip', reparsed2.plugins.length, 0); + + // Removing a non-existent plugin is a no-op + checkTrue('N11: remove missing plugin → false', docRemovePlugin(exp, 'nope') === false); + + // Add into a protocol that has NO plugins: key at all — section is created + const noPlug = parseV3Protocol(readFixture('v3_no_variables.yaml')); + const before = noPlug.plugins.length; + docInsertPluginNode(noPlug, noPlug._doc.createNode(createPluginEntry('camera'))); + check('N11: camera added to no-plugins-key protocol', noPlug.plugins.length, before + 1); + checkTrue('N11: plugins: section now exists', !!noPlug._doc.getIn(['plugins'], true)); + parseV3Protocol(generateV3Protocol(noPlug)); // must not throw + pass('N11: no-plugins-key protocol re-parses after add'); +} + // ─── Results ──────────────────────────────────────────────────────────────── console.log('\n=== Results: ' + passedTests + '/' + totalTests + ' passed ==='); if (failedTests.length > 0) {