@worca/app 1.2.0-rc.2 → 1.2.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -829,8 +829,9 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
829
829
  };
830
830
  const list = st.catalog && Array.isArray(st.catalog.models) ? st.catalog.models : [];
831
831
  const wantedEntry = catalogEntry(wanted.model);
832
- // Unknown stored/default id -> the backend default -> the first model we do have.
833
- const entry = wantedEntry || catalogEntry(fallback.model) || list[0] || null;
832
+ // Unknown stored/default id -> the backend default -> the first model we do
833
+ // have that is not a hidden built-in (#422; a hidden id is still a valid pick).
834
+ const entry = wantedEntry || catalogEntry(fallback.model) || list.find((m) => m && !m.hidden) || list[0] || null;
834
835
  if (!entry) { updatePickerButton(); return; } // empty catalog: keep what we have
835
836
  const effort = wantedEntry ? wanted.effort : fallback.effort;
836
837
  const next = { model: entry.id, effort: coerceEffort(entry, effort) };
@@ -885,6 +886,7 @@ export function createAskPanel({ doc, win, fetch, sendWs, confirm, getPageContex
885
886
  const seen = new Set();
886
887
  for (const m of st.catalog ? st.catalog.models : []) {
887
888
  if (!m || typeof m.id !== 'string') continue;
889
+ if (m.hidden && m.id !== st.picker.model) continue; // hidden built-in (#422); the current pick stays
888
890
  if (m.custom === 'global') { primary.push(m); continue; } // user models are never demoted
889
891
  const fam = familyKey(m);
890
892
  // The picked model always shows up front so its ✓ is visible and it is one click away.
@@ -0,0 +1,30 @@
1
+ // ui/public/export-slug.mjs
2
+ // Pure, DOM-free slug logic for the Export-to-Claude-Code modal, in its own module so
3
+ // it can be unit-tested (and drift-guarded against src/core/skills.mjs isValidSkillName)
4
+ // without importing the whole browser app. app.js imports exportSlugPreview from here.
5
+
6
+ // slugifyLocal(s) -> mirrors src/core/artifacts.mjs slugify so the export modal previews
7
+ // the same default slug the server would derive from a workflow name.
8
+ export function slugifyLocal(s) {
9
+ const out = String(s ?? '')
10
+ .normalize('NFKD')
11
+ .replace(/[̀-ͯ]/g, '') // strip combining marks
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]+/g, '-')
14
+ .replace(/-{2,}/g, '-')
15
+ .replace(/^-+|-+$/g, '');
16
+ return out || 'untitled';
17
+ }
18
+
19
+ // exportSlugPreview(rawSlug, workflowName) -> { slug, preview, valid }
20
+ // An explicit slug is used verbatim (and must be a valid skill name); otherwise the
21
+ // workflow name is slugified and capped at 48. `preview` is the "/<slug>" the user types.
22
+ export function exportSlugPreview(rawSlug, workflowName) {
23
+ const explicit = typeof rawSlug === 'string' && rawSlug.trim();
24
+ const slug = explicit ? rawSlug.trim() : (slugifyLocal(workflowName).slice(0, 48) || 'workflow');
25
+ // MUST stay in step with isValidSkillName in src/core/skills.mjs — the core's assertName
26
+ // rejects on Apply what this reports invalid. skills.mjs pulls node:fs so it cannot be
27
+ // imported in the browser; export-slug.test.mjs guards these two against drift.
28
+ const valid = /^[A-Za-z0-9._-]+$/.test(slug) && slug !== '.' && slug !== '..';
29
+ return { slug, preview: `/${slug}`, valid };
30
+ }
@@ -175,12 +175,33 @@ export function createComposer(hostEls, { doc = globalThis.document, api, raf =
175
175
  paintInspector();
176
176
  if (hooks.onRender) hooks.onRender();
177
177
  }
178
+ /** A canvas nobody has touched: the two bookends, no wires, no edits. */
179
+ function isPristine() {
180
+ return !dirty && tpl.wires.length === 0 && tpl.nodes.length === 2
181
+ && tpl.nodes.every((n) => n.kind === 'task' || n.kind === 'end');
182
+ }
178
183
  function paintChrome() {
184
+ // "Unfinished" (a mandatory port still to wire — validate.mjs flags those
185
+ // `incomplete`) is not "wrong". Save is gated by BOTH, but only real mistakes
186
+ // earn the red chip; unfinished wiring gets a quiet to-do chip, and a canvas
187
+ // nobody has touched yet shows no chip at all — a fresh drawing has nothing
188
+ // to shout about.
179
189
  const n = lastReport.errors.length;
180
- if (hostEls.saveBtn) hostEls.saveBtn.disabled = n > 0 || !ready;
190
+ const real = lastReport.errors.filter((e) => !e.incomplete).length;
191
+ const todo = n - real;
192
+ if (hostEls.saveBtn) {
193
+ hostEls.saveBtn.disabled = n > 0 || !ready;
194
+ hostEls.saveBtn.title = real
195
+ ? 'Fix the errors to save'
196
+ : todo ? 'Wire Task → … → End to save' : '';
197
+ }
181
198
  if (hostEls.errors) {
182
- hostEls.errors.hidden = n === 0;
183
- hostEls.errors.textContent = n ? `${n} error${n === 1 ? '' : 's'}` : '';
199
+ const quiet = !real && todo > 0 && !isPristine();
200
+ hostEls.errors.hidden = !real && !quiet;
201
+ hostEls.errors.classList.toggle('is-incomplete', quiet);
202
+ hostEls.errors.textContent = real
203
+ ? `${real} error${real === 1 ? '' : 's'}`
204
+ : quiet ? `${todo} port${todo === 1 ? '' : 's'} to wire` : '';
184
205
  }
185
206
  }
186
207
 
@@ -93,8 +93,11 @@ export function renderNodeInspector(node, { template, portsFn, meta = null, mode
93
93
 
94
94
  if (node.kind === 'agent') {
95
95
  root.appendChild(head(doc, (meta && meta.displayName) || node.key || node.id, `${node.key} · ${node.id}`));
96
+ // Hidden built-ins (#422) leave the list unless one is THIS node's stored
97
+ // pick — it still resolves at run time and must stay visible here.
98
+ const offered = models.filter((m) => m && (!m.hidden || m.id === node.config.model));
96
99
  body.appendChild(select(doc, 'ins-model', 'model', 'Model',
97
- [{ value: '', text: 'inherit' }, ...models.map((m) => ({ value: m.id, text: m.label || m.id }))], node.config.model));
100
+ [{ value: '', text: 'inherit' }, ...offered.map((m) => ({ value: m.id, text: m.label || m.id }))], node.config.model));
98
101
  body.appendChild(select(doc, 'ins-effort', 'effort', 'Effort',
99
102
  [{ value: '', text: 'default' }, ...efforts.map((e) => ({ value: e, text: e }))], node.config.effort));
100
103
  if (meta && meta.fanOut) {
@@ -975,7 +975,17 @@
975
975
  <section class="card gv-saved">
976
976
  <div class="saved-head">
977
977
  <div><b>Saved pipelines</b><span class="cnt" id="gv-saved-count"></span></div>
978
+ <!-- Import a workflow another Worca user shared as JSON (#421): the
979
+ file goes through POST /api/workflows/import-json — the same validator
980
+ the composer's Save uses; a taken name gets a " (2)" suffix. -->
981
+ <div class="saved-head-actions">
982
+ <button type="button" id="gv-import-btn" class="btn btn-ghost btn-mini" title="Import a pipeline shared as a JSON file">Import…</button>
983
+ <input id="gv-import-file" type="file" accept=".json,application/json" hidden />
984
+ </div>
978
985
  </div>
986
+ <!-- One tab per domain; the row itself no longer repeats the domain.
987
+ Rendered by gvRenderSaved(); hidden while the library is empty. -->
988
+ <div class="gv-saved-tabs" id="gv-saved-tabs" role="tablist" aria-label="Pipeline domains" hidden></div>
979
989
  <div class="saved-list" id="gv-saved-list"></div>
980
990
  <div class="gv-archived" id="gv-archived" hidden></div>
981
991
  <!-- A refused DELETE (the built-in, a 404, a 500) has to land
@@ -1146,6 +1156,39 @@
1146
1156
  </div>
1147
1157
  </section>
1148
1158
 
1159
+ <!-- Title generation (#422): which model writes run/chat titles.
1160
+ Empty = the model of the run or chat itself (title.mjs
1161
+ resolveTitleModel), so a first-party-free install needs no setup.
1162
+ The select is built from the project-less catalog; a stored id
1163
+ that left the catalog paints disabled + "not installed". -->
1164
+ <section class="card settings-card" id="title-model-settings-card">
1165
+ <div class="card-head">
1166
+ <div class="label-row">
1167
+ <h2>Title generation</h2>
1168
+ <button type="button" class="info-tip" aria-label="About title generation">i<span class="tip-content hidden">
1169
+ Every run and chat gets a short LLM-written title. By default it is written by the
1170
+ model the run or chat itself uses, so an install without a first-party Anthropic
1171
+ account needs no setup here. Pick a model to write every title with it instead;
1172
+ the change applies to the next title, no restart. A non-empty
1173
+ <code>WORCA_TITLE_MODEL</code> in the environment overrides this setting.
1174
+ </span></button>
1175
+ </div>
1176
+ </div>
1177
+ <div class="field">
1178
+ <div class="label-row">
1179
+ <label for="titleModel">Model</label>
1180
+ </div>
1181
+ <select id="titleModel" class="select" aria-label="Title model"></select>
1182
+ </div>
1183
+ <small class="hint" id="titleModelEnvNote"></small>
1184
+ <div class="add-project-actions" style="margin-top:14px">
1185
+ <button type="button" id="titleModelTest" class="btn btn-ghost btn-mini" title="Send one tiny prompt to the selected model">Test</button>
1186
+ <button type="button" id="titleModelReset" class="btn btn-ghost btn-mini">Use default</button>
1187
+ <button type="button" id="titleModelSave" class="btn btn-primary btn-mini">Save</button>
1188
+ </div>
1189
+ <small class="hint" id="titleModelMsg"></small>
1190
+ </section>
1191
+
1149
1192
  <!-- Spawn-debug diagnostics: the stored side of WORCA_DEBUG_SPAWN.
1150
1193
  claude-runner.mjs reads the setting per spawn (UI server AND CLI);
1151
1194
  a non-empty env var overrides it. This card only flips the gate —
@@ -1243,7 +1286,7 @@
1243
1286
  <p id="plugins-msg" class="form-msg" aria-live="polite"></p>
1244
1287
  <div class="card hidden" id="marketplace-add-row" style="padding:16px 20px;margin-bottom:14px">
1245
1288
  <div class="path-row">
1246
- <input id="marketplace-url" class="input" type="text" placeholder="https://github.com/owner/repo · owner/repo · /abs/path/to/repo" spellcheck="false" />
1289
+ <input id="marketplace-url" class="input" type="text" placeholder="https://github.com/owner/repo · owner/repo · /abs/path/to/repo · /path/to/a/plugin-folder" spellcheck="false" />
1247
1290
  <button type="button" id="marketplace-add" class="btn btn-primary btn-mini">Add</button>
1248
1291
  </div>
1249
1292
  </div>
@@ -1311,6 +1354,89 @@
1311
1354
  </div>
1312
1355
  </div>
1313
1356
 
1357
+ <!-- ===== Export workflow → Claude Code skill modal ===== -->
1358
+ <div id="export-modal" class="viewer-modal hidden" role="dialog" aria-modal="true" aria-labelledby="export-title">
1359
+ <div class="card">
1360
+ <div class="card-head">
1361
+ <h2 id="export-title">Export pipeline</h2>
1362
+ </div>
1363
+ <p id="export-subtitle" class="confirm-message"></p>
1364
+ <!-- The form; swapped for #export-done after a successful export. -->
1365
+ <div id="export-form">
1366
+ <!-- ONE Export… entry point per row; the dialog asks for the format first.
1367
+ json = the saved graph as a file another Worca user imports (#421);
1368
+ skill = a runnable Claude Code skill under .claude/ (#403);
1369
+ plugin = a Worca plugin folder bundling agents + skills (#421). -->
1370
+ <div class="export-field">
1371
+ <span class="export-label">Format</span>
1372
+ <div id="export-format" class="seg" role="group" aria-label="Export format">
1373
+ <button type="button" class="seg-btn on" data-format="json">JSON file</button>
1374
+ <button type="button" class="seg-btn" data-format="skill">Claude Code skill</button>
1375
+ <button type="button" class="seg-btn" data-format="plugin">Worca plugin</button>
1376
+ </div>
1377
+ <span id="export-format-hint" class="hint"></span>
1378
+ </div>
1379
+ <div id="export-dest-field" class="export-field hidden">
1380
+ <span class="export-label">Location</span>
1381
+ <div id="export-dest" class="seg" role="group" aria-label="Skill location">
1382
+ <button type="button" class="seg-btn on" data-dest="global">Global (~/.claude)</button>
1383
+ <button type="button" class="seg-btn" data-dest="project">Project folder</button>
1384
+ </div>
1385
+ </div>
1386
+ <div id="export-folder-field" class="export-field hidden">
1387
+ <span class="export-label" id="export-folder-label">Folder</span>
1388
+ <div class="export-folder-row">
1389
+ <input type="text" id="export-folder" class="input" placeholder="/path/to/repo" />
1390
+ <button type="button" id="export-browse" class="btn btn-ghost btn-mini">Browse…</button>
1391
+ </div>
1392
+ </div>
1393
+ <div id="export-plugin-field" class="export-field hidden">
1394
+ <span class="export-label">Plugin name</span>
1395
+ <input type="text" id="export-plugin-name" class="input" placeholder="(defaults to the folder name)" />
1396
+ <!-- Live: what the manifest will actually be called — kebab-case, derived
1397
+ from the typed name or, when blank, from the folder's basename. -->
1398
+ <span id="export-plugin-name-preview" class="hint"></span>
1399
+ <!-- Re-export only: a changed export into an existing plugin folder bumps
1400
+ the manifest's patch version (0.1.0 → 0.1.1) so the recipient's
1401
+ `worca plugin update` sees it. A first export is always 0.1.0. -->
1402
+ <label class="confirm-checkbox">
1403
+ <input type="checkbox" id="export-keep-version" />
1404
+ <span>Don't bump the plugin version when updating an existing plugin folder<br><span class="hint">A changed re-export normally goes 0.1.0 → 0.1.1 so recipients see an update. A first export is always 0.1.0.</span></span>
1405
+ </label>
1406
+ </div>
1407
+ <div class="export-field" id="export-slug-field">
1408
+ <span class="export-label">Slug</span>
1409
+ <input type="text" id="export-slug" class="input" placeholder="(defaults to the workflow name)" />
1410
+ <span id="export-slug-preview" class="hint"></span>
1411
+ </div>
1412
+ <label class="confirm-checkbox" id="export-agents-field">
1413
+ <input type="checkbox" id="export-include-agents" checked />
1414
+ <span>Include referenced agents (recommended)</span>
1415
+ </label>
1416
+ <div id="export-plan" class="export-plan hidden"></div>
1417
+ <p id="export-msg" class="confirm-message"></p>
1418
+ </div>
1419
+ <!-- Result view: what was written, where, and the next step for the format.
1420
+ Replaces the form (not a second dialog) once an export succeeded. -->
1421
+ <div id="export-done" class="export-done hidden" role="status" aria-live="polite">
1422
+ <div class="export-done-head">
1423
+ <span class="export-done-check" aria-hidden="true">✓</span>
1424
+ <b id="export-done-title"></b>
1425
+ </div>
1426
+ <dl id="export-done-lines" class="export-done-lines"></dl>
1427
+ <p id="export-done-next" class="export-done-next"></p>
1428
+ </div>
1429
+ <div class="confirm-actions">
1430
+ <button type="button" id="export-cancel" class="btn btn-ghost btn-mini">Cancel</button>
1431
+ <!-- Preview = dry run (optional). Export runs the dry run itself and writes
1432
+ unless it hits a conflict, which it then shows for per-file resolution. -->
1433
+ <button type="button" id="export-plan-btn" class="btn btn-ghost btn-mini">Preview</button>
1434
+ <button type="button" id="export-apply-btn" class="btn btn-primary btn-mini">Export</button>
1435
+ <button type="button" id="export-done-close" class="btn btn-primary btn-mini hidden">Done</button>
1436
+ </div>
1437
+ </div>
1438
+ </div>
1439
+
1314
1440
  <!-- ===== "Ship it?" PR confirm modal (History detail) ===== -->
1315
1441
  <!-- No z-index of its own: #confirm-modal.viewer-modal{z-index:60} (style.css:781)
1316
1442
  exists because #confirm-modal precedes #plugin-modal in the DOM, and this
@@ -77,7 +77,7 @@ export function suggestDuplicateId(id, takenIds = []) {
77
77
  * `globals` come MASKED from GET /api/models. `predefinedShadowedIds` marks
78
78
  * built-ins currently overridden by a global entry.
79
79
  */
80
- export function renderModelsList({ globals = [], legacy = [], plugins = [], predefined = [], efforts = [], projectName = '' } = {}, { doc = globalThis.document } = {}) {
80
+ export function renderModelsList({ globals = [], legacy = [], plugins = [], predefined = [], efforts = [], hideBuiltin = false, projectName = '' } = {}, { doc = globalThis.document } = {}) {
81
81
  const root = h(doc, 'div', 'mv-list');
82
82
  const predefLc = new Set(predefined.map((m) => m.id.toLowerCase()));
83
83
  const pluginLc = new Set(plugins.map((m) => m.id.toLowerCase()));
@@ -89,6 +89,30 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
89
89
  return s;
90
90
  };
91
91
 
92
+ // The endpoint-routed badge (#422): honest disclosure that worca points the
93
+ // CLI's internal haiku/sonnet/opus/fable lookups at this model's own id
94
+ // (config.mjs#resolveModelEnv), so nothing falls back to the Anthropic API.
95
+ // The masked env still carries its KEYS, so presence is decidable here.
96
+ const routedBadge = (m) => {
97
+ if (!m.env || !Object.prototype.hasOwnProperty.call(m.env, 'ANTHROPIC_BASE_URL')) return null;
98
+ const b = h(doc, 'span', 'badge waiting mv-routed', 'endpoint-routed');
99
+ b.title = "worca points Claude Code's internal haiku/sonnet/opus/fable lookups at this model, so it never falls back to the Anthropic API.";
100
+ return b;
101
+ };
102
+
103
+ // ── Hide built-in models (#422) — one checkbox, top of the pane ──
104
+ const hideRow = h(doc, 'div', 'mv-hide-builtin-row');
105
+ const hideLabel = h(doc, 'label', 'check-row');
106
+ const hideCb = h(doc, 'input', 'mv-hide-builtin');
107
+ hideCb.type = 'checkbox';
108
+ hideCb.checked = !!hideBuiltin;
109
+ hideLabel.appendChild(hideCb);
110
+ hideLabel.appendChild(doc.createTextNode(" Hide built-in models (you don't use a first-party Anthropic account)"));
111
+ hideRow.appendChild(hideLabel);
112
+ hideRow.appendChild(h(doc, 'small', 'hint',
113
+ 'Drops the built-ins from every model picker — new pipelines, the composer, Ask Worca, title generation. Cosmetic only: a run or reference that already names one keeps working.'));
114
+ root.appendChild(hideRow);
115
+
92
116
  // ── Your models (global) ──
93
117
  const yours = section('Your models', 'Defined once, available in every project. An entry with a built-in id overrides that built-in.');
94
118
  if (!globals.length) {
@@ -102,6 +126,8 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
102
126
  head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
103
127
  if (predefLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides built-in'));
104
128
  else if (pluginLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadow', 'overrides plugin'));
129
+ const rb = routedBadge(m);
130
+ if (rb) head.appendChild(rb);
105
131
  // The §4.6 "unreliable" badge is meaningless once an override GOVERNS this
106
132
  // model's spend — and the backend only lifts the stored flag on the model's
107
133
  // next result event, so suppress it here the moment pricing is pinned.
@@ -169,6 +195,8 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
169
195
  head.appendChild(h(doc, 'b', 'mv-name', m.label || m.id));
170
196
  head.appendChild(h(doc, 'span', 'badge waiting mv-origin', `plugin: ${m.plugin}`));
171
197
  if (globalLc.has(m.id.toLowerCase())) head.appendChild(h(doc, 'span', 'badge violet mv-shadowed', 'overridden by your copy'));
198
+ const prb = routedBadge(m);
199
+ if (prb) head.appendChild(prb);
172
200
  // Same rule as a global card: a manifest-pinned price governs the spend,
173
201
  // so the §4.6 "unreliable" flag says nothing about it.
174
202
  if (m.costUnreliable && !m.cost) head.appendChild(h(doc, 'span', 'badge waiting mv-cost', 'cost not verified'));
@@ -199,8 +227,11 @@ export function renderModelsList({ globals = [], legacy = [], plugins = [], pred
199
227
  }
200
228
 
201
229
  // ── Built-ins (read-only) ──
202
- const builtins = section('Built-in models', 'Shipped with worca. Add a model with the same id to override its label, efforts, or routing.');
203
- for (const m of predefined) {
230
+ const builtins = section('Built-in models', hideBuiltin
231
+ ? `Hidden from every picker (${predefined.length} built-in${predefined.length === 1 ? '' : 's'}) — untick the box above to show them.`
232
+ : 'Shipped with worca. Add a model with the same id to override its label, efforts, or routing.');
233
+ builtins.classList.add(hideBuiltin ? 'mv-builtins-hidden' : 'mv-builtins-shown');
234
+ for (const m of hideBuiltin ? [] : predefined) {
204
235
  const row = h(doc, 'div', 'mv-builtin');
205
236
  row.dataset.id = m.id;
206
237
  row.appendChild(h(doc, 'b', 'mv-name', m.label));
@@ -959,6 +959,11 @@ fieldset.source legend{font-weight:600;font-size:13px;padding:0 6px;color:var(--
959
959
  /* Confirm dialog must stack ABOVE the plugin/wizard modal (equal .viewer-modal z-index,
960
960
  but #confirm-modal precedes #plugin-modal in the DOM). */
961
961
  #confirm-modal.viewer-modal{z-index:60;}
962
+ /* The folder browser is a leaf picker opened FROM other modals (add-project,
963
+ export). It precedes #export-modal in the DOM, so at equal .viewer-modal
964
+ z-index the export modal would paint over it, hiding the Browse… picker.
965
+ Elevate it above every other modal so it is always usable when open. */
966
+ #folder-browser.viewer-modal{z-index:70;}
962
967
  .viewer-modal .card{width:min(860px,100%);max-height:86vh;overflow:auto;}
963
968
  /* folder browser modal (native-dialog fallback). Must come AFTER the
964
969
  .viewer-modal rules: `.folder-modal .card` ties with `.viewer-modal .card`
@@ -1056,6 +1061,39 @@ fieldset.source legend{font-weight:600;font-size:13px;padding:0 6px;color:var(--
1056
1061
  .pl-remove:disabled{opacity:.6;cursor:default;}
1057
1062
  .pl-remove svg{display:block;}
1058
1063
 
1064
+ /* Export dialog (JSON file / Claude Code skill / Worca plugin). The row's
1065
+ Export… button itself is a .btn-ghost (see .gv-saved .pl-export). */
1066
+ .export-field{margin:12px 0;}
1067
+ .export-field .hint{display:block;margin-top:6px;}
1068
+ /* A checkbox row directly under a text input needs its own breathing room:
1069
+ .confirm-checkbox ships margin:0 (it is built for the confirm dialog's stack). */
1070
+ .export-field .confirm-checkbox{margin-top:10px;}
1071
+ /* Result view after a successful export (replaces #export-form in the same card). */
1072
+ .export-done{margin:12px 0 4px;}
1073
+ .export-done.hidden{display:none;}
1074
+ .export-done-head{display:flex;align-items:center;gap:10px;font-size:14px;margin-bottom:12px;}
1075
+ .export-done-check{display:grid;place-items:center;width:26px;height:26px;border-radius:999px;
1076
+ background:var(--green-bg,#e6f4ea);color:var(--green-ink,#1e6b3a);font-weight:700;font-size:14px;}
1077
+ .export-done-lines{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin:0;font-size:12.5px;}
1078
+ .export-done-lines dt{color:var(--ink-3);font-weight:600;}
1079
+ .export-done-lines dd{margin:0;color:var(--ink);word-break:break-all;}
1080
+ .export-done-lines code{font:12px var(--mono);background:var(--field);border-radius:6px;padding:1px 6px;}
1081
+ .export-done-next{margin:14px 0 0;padding:10px 12px;border-radius:10px;background:var(--field);font-size:12.5px;color:var(--ink-2);}
1082
+ .export-done-next code{font:12px var(--mono);}
1083
+ .export-label{display:block;font-weight:600;font-size:12.5px;color:var(--ink-2);margin-bottom:6px;}
1084
+ .export-folder-row{display:flex;gap:8px;align-items:center;}
1085
+ .export-folder-row .input{flex:1 1 auto;}
1086
+ .export-plan{margin:12px 0;max-height:240px;overflow:auto;border:1px solid var(--line-2);border-radius:10px;padding:8px;font-size:12.5px;}
1087
+ .export-row{padding:4px 2px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
1088
+ .export-conflict-row{flex-direction:column;align-items:flex-start;}
1089
+ .export-tag{display:inline-block;min-width:58px;text-align:center;border-radius:6px;padding:1px 6px;font-weight:700;font-size:11px;}
1090
+ .export-create{background:var(--green-bg,#e6f7ec);color:var(--green-ink,#137a3b);}
1091
+ .export-update{background:var(--field);color:var(--ink);}
1092
+ .export-no-op{background:var(--field);color:var(--ink-3);}
1093
+ .export-warn,.export-conflict{background:var(--red-bg,#fde8e8);color:var(--red-ink,#b42323);}
1094
+ .export-orphan{background:var(--field);color:var(--ink-3);}
1095
+ .export-choices{display:flex;gap:12px;margin:4px 0 2px 64px;}
1096
+ .export-choice{font-size:12.5px;color:var(--ink-2);cursor:pointer;}
1059
1097
  /* Per-agent fan-out toggle in the New Pipeline rows. */
1060
1098
  .fanout-toggle {
1061
1099
  display: inline-flex;
@@ -1586,6 +1624,8 @@ fieldset.source legend{font-weight:600;font-size:13px;padding:0 6px;color:var(--
1586
1624
  /* ---- Models view (configurable-models-design.md §4.10) ---- */
1587
1625
  .mv-list{display:grid;gap:18px;}
1588
1626
  .mv-section{display:grid;gap:10px;}
1627
+ .mv-hide-builtin-row{display:grid;gap:6px;padding:10px 12px;border:1px solid var(--line-2,#e6e6ec);border-radius:10px;}
1628
+ .mv-hide-builtin-row .hint{margin:0;}
1589
1629
  .mv-section-title{margin:4px 0 0;font-size:13px;text-transform:uppercase;letter-spacing:.04em;opacity:.75;}
1590
1630
  .mv-card{padding:16px 20px;display:flex;align-items:center;gap:14px;}
1591
1631
  .mv-body{flex:1 1 auto;min-width:0;}
@@ -3459,6 +3499,8 @@ input.gv-name:focus-visible{outline:none;}
3459
3499
  padding:4px 11px;font:600 11.5px var(--sans);cursor:pointer;flex:0 0 auto;white-space:nowrap;}
3460
3500
  .gv-errchip:hover{filter:brightness(.97);}
3461
3501
  .gv-errchip[hidden]{display:none;}
3502
+ /* Unfinished wiring is a to-do, not a mistake: the same chip in a quiet dress. */
3503
+ .gv-errchip.is-incomplete{background:var(--field);color:var(--ink-2);border-color:var(--line-2);}
3462
3504
  /* Toolbar density: .btn-ghost/.btn-go are PAGE-level pills (13–14px of vertical
3463
3505
  padding, 20–28px horizontal). In a 52px bar they wrap their own labels, so
3464
3506
  the bar re-states them at .btn-mini metrics — same skin, toolbar size. */
@@ -3637,11 +3679,27 @@ input.gv-name:focus-visible{outline:none;}
3637
3679
  padding:16px 20px;border-bottom:1px solid var(--line);}
3638
3680
  .saved-head b{font-size:15px;font-weight:600;letter-spacing:-.01em;}
3639
3681
  .saved-head .cnt{color:var(--ink-3);font-size:12.5px;font-weight:600;margin-left:9px;}
3682
+ .saved-head-actions{display:flex;align-items:center;gap:8px;}
3683
+ /* One tab per domain — the same pill tabs the History detail uses, one size down. */
3684
+ .gv-saved-tabs{display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:12px 20px;border-bottom:1px solid var(--line);}
3685
+ .gv-saved-tabs[hidden]{display:none;}
3686
+ .gv-saved-tab{display:flex;align-items:center;gap:8px;padding:7px 13px;border:1.5px solid var(--line);
3687
+ border-radius:999px;background:var(--panel);font:600 12.5px var(--sans);color:var(--ink-2);cursor:pointer;}
3688
+ .gv-saved-tab:hover{border-color:var(--ink-3);color:var(--ink);}
3689
+ .gv-saved-tab.active{background:var(--ink);color:#fff;border-color:var(--ink);}
3690
+ .gv-saved-tab:focus-visible{outline:2px solid var(--ink);outline-offset:2px;}
3691
+ .gv-saved-tab-badge{padding:1px 7px;border-radius:999px;background:var(--field);color:var(--ink-3);font:600 10.5px var(--mono);}
3692
+ .gv-saved-tab.active .gv-saved-tab-badge{background:var(--blue-bg);color:var(--blue-ink);}
3640
3693
  .saved-list{display:flex;flex-direction:column;}
3641
3694
  .gv-saved .pl-item{border-bottom:1px solid var(--line);}
3642
3695
  .gv-saved .pl-item:last-child{border-bottom:none;}
3643
3696
  .gv-saved .pl-row{display:flex;align-items:center;gap:14px;padding:12px 20px;transition:background .12s;}
3644
3697
  .gv-saved .pl-row:hover{background:var(--field);}
3698
+ /* The row IS the Open action for a v2 pipeline. */
3699
+ .gv-saved .pl-row.pl-openable{cursor:pointer;}
3700
+ .gv-saved .pl-row.pl-openable:focus-visible{outline:2px solid var(--ink);outline-offset:-2px;}
3701
+ .gv-saved .pl-new{display:inline-block;margin-left:8px;padding:2px 7px;border-radius:999px;vertical-align:middle;
3702
+ background:var(--blue-bg);color:var(--blue-ink);font:700 10px var(--mono);letter-spacing:.06em;}
3645
3703
  /* The thumbnail is a fixed TILE at the head of the row: the SVG carries its own
3646
3704
  width/height, so a percentage box would leave it stranded in dead space. */
3647
3705
  .gv-saved .pl-thumb{flex:0 0 auto;width:140px;height:54px;display:grid;place-items:center;overflow:hidden;
@@ -3654,14 +3712,16 @@ input.gv-name:focus-visible{outline:none;}
3654
3712
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
3655
3713
  .gv-saved .pl-meta{margin-top:4px;font-size:10.5px;font-weight:700;letter-spacing:.05em;
3656
3714
  text-transform:uppercase;color:var(--ink-3);}
3657
- .gv-saved .pl-open{flex:0 0 auto;padding:7px 15px;font-size:12.5px;}
3715
+ .gv-saved .pl-export{flex:0 0 auto;padding:7px 15px;font-size:12.5px;}
3658
3716
  .gv-saved .pl-legacy,.gv-saved .pl-origin{flex:0 0 auto;background:var(--field);border:1px solid var(--line);color:var(--ink-3);
3659
3717
  border-radius:999px;padding:3px 10px;font-size:11px;font-weight:600;}
3660
3718
  .gv-saved .pl-origin{font-family:var(--mono);letter-spacing:-.01em;}
3661
- .gv-saved .pl-del{flex:0 0 auto;width:34px;height:34px;border-radius:10px;border:1.5px solid var(--line-2);
3662
- background:#fff;display:grid;place-items:center;cursor:pointer;color:var(--ink-3);
3663
- font-family:inherit;font-size:15px;line-height:1;transition:.12s;}
3664
- .gv-saved .pl-del:hover{border-color:var(--red);color:var(--red-ink);background:var(--red-bg);}
3719
+ /* Delete: the bin icon in the pipeline Archive button's dress (.hd-archive — red
3720
+ ink on a red-bg hairline pill, fills on hover), icon-only at row-action size. */
3721
+ .gv-saved .pl-del{flex:0 0 auto;width:34px;height:34px;border-radius:999px;border:1.5px solid var(--red-bg);
3722
+ background:var(--panel);display:grid;place-items:center;cursor:pointer;color:var(--red-ink);transition:.12s;}
3723
+ .gv-saved .pl-del svg{width:15px;height:15px;stroke-width:1.9;}
3724
+ .gv-saved .pl-del:hover{border-color:var(--red);background:var(--red-bg);}
3665
3725
  .gv-saved .pl-del:focus-visible{outline:2px solid var(--ink);outline-offset:2px;}
3666
3726
  .gv-saved .pl-empty{padding:30px 20px;text-align:center;color:var(--ink-3);font-size:13px;}
3667
3727
  .gv-archived{display:flex;flex-wrap:wrap;align-items:center;gap:7px;