toolroll 0.9.83 → 0.9.85

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.
Files changed (137) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +94 -1192
  3. package/dist/agent-fence.d.ts +3 -3
  4. package/dist/agent-fence.js +3 -3
  5. package/dist/agent-onboard.d.ts +2 -0
  6. package/dist/agent-onboard.js +3 -0
  7. package/dist/assignment-ui.js +5 -1
  8. package/dist/browser/workspace.css +2 -2
  9. package/dist/browser/workspace.js +31 -31
  10. package/dist/browser-crew.js +5 -1
  11. package/dist/browser-workspace.d.ts +12 -0
  12. package/dist/chat-channel.d.ts +10 -1
  13. package/dist/chat-channel.js +35 -5
  14. package/dist/chat-decide.d.ts +16 -4
  15. package/dist/chat-decide.js +239 -31
  16. package/dist/chat-messages.d.ts +5 -0
  17. package/dist/chat-messages.js +1 -1
  18. package/dist/chat-quiet.js +3 -2
  19. package/dist/chat-review.d.ts +4 -0
  20. package/dist/chat-review.js +13 -4
  21. package/dist/chat-terms.d.ts +9 -0
  22. package/dist/chat-terms.js +46 -0
  23. package/dist/contracts/console-api.d.ts +4 -2
  24. package/dist/contracts/console-api.js +7 -2
  25. package/dist/contracts/lead-tools.d.ts +30 -0
  26. package/dist/contracts/lead-tools.js +3 -0
  27. package/dist/contracts/youiyoux-request.d.ts +248 -0
  28. package/dist/contracts/youiyoux-request.js +152 -0
  29. package/dist/control-setup.d.ts +9 -0
  30. package/dist/control-setup.js +31 -4
  31. package/dist/control-ui.d.ts +7 -1
  32. package/dist/control-ui.js +3 -2
  33. package/dist/desktop-update.d.ts +1 -1
  34. package/dist/dispatch.d.ts +15 -0
  35. package/dist/dispatch.js +26 -4
  36. package/dist/first-run.d.ts +5 -2
  37. package/dist/first-run.js +13 -2
  38. package/dist/flow-cadence.d.ts +8 -2
  39. package/dist/flow-cadence.js +16 -3
  40. package/dist/flow-card-task.d.ts +9 -0
  41. package/dist/flow-card-task.js +15 -6
  42. package/dist/folder-look.d.ts +3 -0
  43. package/dist/folder-look.js +8 -4
  44. package/dist/guides.js +4 -3
  45. package/dist/integrations-ui.js +9 -4
  46. package/dist/integrations.d.ts +9 -1
  47. package/dist/integrations.js +43 -2
  48. package/dist/landed-cleanup.d.ts +4 -0
  49. package/dist/landed-cleanup.js +3 -2
  50. package/dist/lead-capabilities.js +2 -1
  51. package/dist/lead-cli.js +3 -1
  52. package/dist/lead-tools.d.ts +2 -0
  53. package/dist/lead-tools.js +30 -4
  54. package/dist/lead.d.ts +2 -0
  55. package/dist/lead.js +3 -3
  56. package/dist/model-catalog.d.ts +5 -0
  57. package/dist/model-catalog.js +11 -2
  58. package/dist/model-choices.d.ts +188 -0
  59. package/dist/model-choices.js +405 -0
  60. package/dist/model-names.d.ts +6 -0
  61. package/dist/model-names.js +34 -0
  62. package/dist/models-ui.d.ts +69 -5
  63. package/dist/models-ui.js +53 -10
  64. package/dist/needs-you.d.ts +8 -0
  65. package/dist/needs-you.js +8 -0
  66. package/dist/operate.d.ts +22 -2
  67. package/dist/operate.js +107 -41
  68. package/dist/project-activity.d.ts +20 -0
  69. package/dist/project-activity.js +23 -0
  70. package/dist/project-admission.d.ts +4 -1
  71. package/dist/project-admission.js +23 -2
  72. package/dist/project-cli.d.ts +1 -0
  73. package/dist/project-cli.js +36 -19
  74. package/dist/project-identity.d.ts +9 -2
  75. package/dist/project-identity.js +80 -33
  76. package/dist/project-knowledge.d.ts +9 -9
  77. package/dist/project-outcomes.d.ts +119 -0
  78. package/dist/project-outcomes.js +79 -0
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +41 -9
  81. package/dist/result-review.d.ts +9 -0
  82. package/dist/result-review.js +45 -5
  83. package/dist/serve.js +60 -13
  84. package/dist/server/chat.js +5 -4
  85. package/dist/server/chrome.d.ts +3 -0
  86. package/dist/server/chrome.js +5 -2
  87. package/dist/server/console.css +17 -2
  88. package/dist/server/flows.js +4 -0
  89. package/dist/server/http.d.ts +1 -0
  90. package/dist/server/http.js +6 -0
  91. package/dist/server/pages.js +53 -7
  92. package/dist/server/people-tokens.js +40 -20
  93. package/dist/server/render-chat.js +3 -2
  94. package/dist/server/render-pages.d.ts +12 -2
  95. package/dist/server/render-pages.js +36 -16
  96. package/dist/server/render-people.d.ts +14 -2
  97. package/dist/server/render-people.js +35 -12
  98. package/dist/server/render-results.d.ts +2 -0
  99. package/dist/server/render-results.js +20 -7
  100. package/dist/server/render-settings.js +1 -0
  101. package/dist/server/render-tasks.d.ts +1 -0
  102. package/dist/server/render-tasks.js +22 -19
  103. package/dist/server/request-context.d.ts +2 -0
  104. package/dist/server/route-table.js +9 -1
  105. package/dist/server/settings.d.ts +58 -1
  106. package/dist/server/settings.js +216 -70
  107. package/dist/server/signup-gate.d.ts +9 -0
  108. package/dist/server/signup-gate.js +40 -0
  109. package/dist/server/task-views.js +6 -0
  110. package/dist/server/tasks.js +54 -8
  111. package/dist/settings-kit-script.d.ts +21 -0
  112. package/dist/settings-kit-script.js +67 -0
  113. package/dist/settings-kit.d.ts +137 -0
  114. package/dist/settings-kit.js +186 -0
  115. package/dist/settings-proofs.d.ts +64 -0
  116. package/dist/settings-proofs.js +101 -0
  117. package/dist/setup-guide.js +4 -2
  118. package/dist/spend-report.d.ts +87 -0
  119. package/dist/spend-report.js +265 -0
  120. package/dist/spend-ui.d.ts +29 -8
  121. package/dist/spend-ui.js +74 -39
  122. package/dist/store.d.ts +46 -3
  123. package/dist/store.js +165 -6
  124. package/dist/task-sizing.d.ts +5 -3
  125. package/dist/task-sizing.js +4 -4
  126. package/dist/task-source.d.ts +13 -1
  127. package/dist/task-source.js +17 -1
  128. package/dist/telegram-lead.js +7 -5
  129. package/dist/telegram.js +1 -1
  130. package/dist/toolroll-update.js +2 -2
  131. package/dist/webhooks.d.ts +6 -0
  132. package/dist/webhooks.js +25 -0
  133. package/dist/work-index.d.ts +4 -0
  134. package/dist/work-index.js +32 -0
  135. package/dist/youiyoux-intake.d.ts +234 -0
  136. package/dist/youiyoux-intake.js +908 -0
  137. package/package.json +11 -8
@@ -1,4 +1,5 @@
1
- import { releaseAuthorityOfTask, revokeRelease, runLiveCommands } from "../store.js";
1
+ import { parseFeedbackImage, sameFeedbackImage } from "../result-review.js";
2
+ import { dropFeedbackImage, releaseAuthorityOfTask, revokeRelease, runLiveCommands } from "../store.js";
2
3
  import { steerCapabilityOf } from "../steer-capabilities.js";
3
4
  import { canReveal, localCheckoutOf, revealInFinder } from "../checkout-proof.js";
4
5
  /** A form's way back to a flow card ("flow:<flow>:<card>"), read strictly: numbers only, never a path. */
@@ -27,7 +28,8 @@ import { codingWorkspaceHtml, codingWorkspaceScript } from '../coding-ui.js';
27
28
  import { CodingActionError } from '../coding-workspace.js';
28
29
  import { checkResponse, CONSOLE_FORMS, readForm } from "../contracts/console-api.js";
29
30
  import { hasForbiddenControls, validateNote } from "../decision.js";
30
- import { diagnoseTaskDispatch } from "../dispatch.js";
31
+ import { builderReadyFor, diagnoseTaskDispatch, firstRunBuilderRepo } from "../dispatch.js";
32
+ import { withBuilderStep } from "../first-run.js";
31
33
  import { evidencePack, evidencePackHtml } from "../evidence-pack.js";
32
34
  import { readVerifiedArtifact, readVerifiedReport, writeEvidenceFile } from "../evidence.js";
33
35
  import { computeGaps } from "../gaps.js";
@@ -47,7 +49,7 @@ import { fileTaskProposal } from "../proposal.js";
47
49
  import { isProviderId, PROVIDER_IDS, validateSpec } from "../provider.js";
48
50
  import { completeAndOpenPullRequest, mergeAsPerson } from '../pull-request-flow.js';
49
51
  import { isQualityMode } from "../quality.js";
50
- import { createResultRevision, requestResultChanges } from "../result-actions.js";
52
+ import { createResultRevision, requestResultChanges, feedbackImageProblem } from "../result-actions.js";
51
53
  import { structuredHandoffView, terminalDiffView } from "../result-evidence-readers.js";
52
54
  import { fileAddTestsTask, requestFollowUpChecks } from '../result-follow-ups.js';
53
55
  import { commentSourceKey, isRevisionFeedback, parseResultTab, resultReturnTarget } from "../result-review.js";
@@ -175,7 +177,8 @@ export function createTasksHandlers(runtime) {
175
177
  })),
176
178
  cancelledBlockers: cancelled,
177
179
  gaps: project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10),
178
- wizard: firstRunStepsNow(now),
180
+ // The builder step speaks for the same project as Chat's (firstRunBuilderRepo), as dispatch decides.
181
+ wizard: withBuilderStep(firstRunStepsNow(now), builderReadyFor(store, firstRunBuilderRepo(store, project, visible, managedRepos()), now)),
179
182
  worker: (() => {
180
183
  // The one fact the inbox must never hide (install review): with
181
184
  // no worker answering, nothing here will ever build, and every
@@ -1020,6 +1023,8 @@ export function createTasksHandlers(runtime) {
1020
1023
  }
1021
1024
  let repo = effective;
1022
1025
  let admitted = unscopedMode ? null : admissionList() ?? [];
1026
+ // What this filing is decided on: re-checked inside the write below, after every await (project-identity.ts).
1027
+ const decidedOn = identities.generation();
1023
1028
  const rootMode = !unscopedMode && ceiling.roots.length > 0;
1024
1029
  if (rootMode && effective !== "") {
1025
1030
  // Resolved off the console's thread (project-identity.ts), then proved against the ceiling as that identity.
@@ -1052,6 +1057,10 @@ export function createTasksHandlers(runtime) {
1052
1057
  const after = (body.get("after") ?? "").trim();
1053
1058
  let chainProblem = null;
1054
1059
  const made = store.transact(() => {
1060
+ // A project folder's identity changed while this request waited (a root or link now leads elsewhere): what was
1061
+ // proved above may no longer hold, so nothing is filed on it.
1062
+ if (identities.generation() !== decidedOn || (rootMode && repo !== "" && !visible(repo)))
1063
+ return null;
1055
1064
  const filed = fileTaskProposal(store, {
1056
1065
  ...(id === "" ? {} : { id }),
1057
1066
  title,
@@ -1083,6 +1092,8 @@ export function createTasksHandlers(runtime) {
1083
1092
  applyModeToNewFiling(store, filed.id, who.name, now);
1084
1093
  return filed;
1085
1094
  });
1095
+ if (made === null)
1096
+ return newTaskScreen(ctx, 409, "a project folder changed while this was being filed — check the project and send it again", draft);
1086
1097
  if (!made.ok) {
1087
1098
  return newTaskScreen(ctx, made.reason === "backlog-full" ? 429 : 400, made.message, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent });
1088
1099
  }
@@ -1392,15 +1403,40 @@ export function createTasksHandlers(runtime) {
1392
1403
  const viewParam = returnTo.startsWith("/t/") ? "section" : "tab";
1393
1404
  const back = selectedTab === "summary" ? returnTo
1394
1405
  : `${returnTo.replace(/&section=(?:summary|changes|checks)$/, "")}${returnTo.includes("?") ? "&" : "?"}${viewParam}=${selectedTab}`;
1406
+ if (body.get("intent") === "drop-image") {
1407
+ // A saved note whose screenshot is gone or changed would block every revision that seals it. Its words stay:
1408
+ // the note is superseded by an exact imageless copy, and the original remains in history.
1409
+ // Who: a signed-in browser approver admitted to this run's project (checked above) — the same authority that
1410
+ // seals any saved note into a revision. What: only a screenshot that is unusable right now, re-proved inside
1411
+ // dropFeedbackImage's transaction, and recorded in the ledger under this person.
1412
+ if (who.via !== "cookie" || who.role !== "approver")
1413
+ return refuse(response, who, 403, "Only a signed-in approver can change saved feedback.", back);
1414
+ const comment = Number(body.get("comment") ?? "");
1415
+ if (!Number.isSafeInteger(comment) || comment < 1)
1416
+ return refuse(response, who, 400, "Choose a saved note.", back);
1417
+ const dropped = dropFeedbackImage(store, id, comment, who.name, one => feedbackImageProblem(store, evidenceRoot, id, one), now);
1418
+ if (!dropped.ok && dropped.reason === "not-authorized")
1419
+ return refuse(response, who, 403, "Only a signed-in approver on this project can change saved feedback.", back);
1420
+ if (!dropped.ok)
1421
+ return refuse(response, who, 409, dropped.reason === "image-usable"
1422
+ ? "That screenshot still verifies, so the note keeps it. Reload the result." : "That saved note changed or was already used. Reload the result.", back);
1423
+ return redirect(response, `${back}#request-changes`);
1424
+ }
1425
+ const image = parseFeedbackImage(body.get("image"));
1426
+ if (image === false)
1427
+ return refuse(response, who, 400, "Choose a screenshot from this result again. Your draft is unchanged.", back);
1395
1428
  if (body.get("intent") === "revise") {
1396
1429
  const result = requestResultChanges(store, evidenceRoot, {
1397
1430
  run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
1398
1431
  repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
1399
- note: body.get("note") ?? "", path: body.get("path") ?? "", line: body.get("line") ?? "", request: body.get("request"),
1432
+ note: body.get("note") ?? "", path: body.get("path") ?? "", line: body.get("line") ?? "", request: body.get("request"), image,
1400
1433
  }, now);
1401
1434
  if (!result.ok) {
1402
1435
  const request = body.get("request") ?? "";
1403
- const retryBack = /^[a-f0-9]{32}$/.test(request) ? `${back}${back.includes("?") ? "&" : "?"}conflict=${request}#request-changes` : back;
1436
+ // The queue's link can stop resolving when a newer attempt finishes. The exact
1437
+ // run record still hosts this account/run's recoverable feedback draft.
1438
+ const retryBase = image === null ? back : `/r/${id}?record=1`;
1439
+ const retryBack = /^[a-f0-9]{32}$/.test(request) ? `${retryBase}${retryBase.includes("?") ? "&" : "?"}conflict=${request}#request-changes` : retryBase;
1404
1440
  return refuse(response, who, result.status, result.message, retryBack);
1405
1441
  }
1406
1442
  return redirect(response, revisionDestination(result.id, returnTo));
@@ -1435,7 +1471,17 @@ export function createTasksHandlers(runtime) {
1435
1471
  const request = body.get("request");
1436
1472
  const sourceKey = commentSourceKey(who.name, request);
1437
1473
  const path = rawPath === "" ? null : rawPath;
1438
- const inserted = store.addDiffComment({ artifactId: terminal.id, runId: id, path, line, note: note.note, author: who.name, ...(sourceKey === undefined ? {} : { sourceKey }) }, now);
1474
+ // Freshness, custody and the insert in one write transaction: a newer attempt finishing in between can't leave a
1475
+ // stale screenshot attached (check and write together).
1476
+ const saved = store.transact(() => {
1477
+ const imageProblem = feedbackImageProblem(store, evidenceRoot, id, image);
1478
+ if (imageProblem !== null)
1479
+ return { problem: imageProblem };
1480
+ return { inserted: store.addDiffComment({ artifactId: terminal.id, runId: id, path, line, note: note.note, author: who.name, image, ...(sourceKey === undefined ? {} : { sourceKey }) }, now) };
1481
+ });
1482
+ if ("problem" in saved)
1483
+ return refuse(response, who, 409, saved.problem, `/r/${id}?record=1#request-changes`);
1484
+ const inserted = saved.inserted;
1439
1485
  if (inserted === null && sourceKey !== undefined) {
1440
1486
  // The token was used before (repair 2026-09-14, finding 1). A
1441
1487
  // request identity is IMMUTABLE: it names exactly one note on
@@ -1449,7 +1495,7 @@ export function createTasksHandlers(runtime) {
1449
1495
  if (earlier === null || earlier.run !== id) {
1450
1496
  return refuse(response, who, 409, `this note's request identity was already used on another result (build #${earlier === null ? "?" : earlier.run}) — go back; your words are kept, and the form will carry a new identity`, conflictBack);
1451
1497
  }
1452
- if (earlier.path !== path || earlier.line !== line || earlier.note !== note.note) {
1498
+ if (earlier.path !== path || earlier.line !== line || earlier.note !== note.note || !sameFeedbackImage(earlier.image, image)) {
1453
1499
  return refuse(response, who, 409, `this request identity already recorded a different note on this result (${earlier.path === null ? "no file" : `${earlier.path}${earlier.line === null ? "" : `:${earlier.line}`}`}: "${oneLineOf(earlier.note, 80)}") — go back; your edited words are kept as a new note, and the form will carry a new identity`, conflictBack);
1454
1500
  }
1455
1501
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The settings kit's one script (docs/plans/settings-revamp.md §4), shipped as a page's functional script beside kit
3
+ * forms (settings-kit.ts). Without it every kit form works as today: the proof field shows inline and Save posts.
4
+ *
5
+ * With it, a kit form that asks for a proof:
6
+ * - moves its server-written proof field into the `<dialog>` inside the same form (the field never leaves the form);
7
+ * - opens the dialog on submit instead of posting, remembering the pressed button;
8
+ * - Confirm (its button, or Enter in the field) posts the form with that button (`requestSubmit(submitter)`, after the
9
+ * submit event that asked has finished), so its name=value and every hidden term go along unchanged; Cancel (or
10
+ * Escape) closes it and posts nothing, and focus returns to the button;
11
+ * - once a kit form posts, it is pending: further submits are dropped and its Save and Confirm are disabled (after the
12
+ * post has taken their values), until the page is shown again or the post was cancelled — never on a timer;
13
+ * - asks for nothing when the proof was left out (Passwordless) — there is no dialog then — and shows the
14
+ * identity-provider link when the sign-in swapped the field.
15
+ * Each section's Save bar names what changed. The script never reads or sets a field's value except to check that a
16
+ * required proof isn't empty; the server still proves everything.
17
+ */
18
+ export declare const SETTINGS_KIT_SCRIPT = "(function(){\nif(window.__soKit)return;window.__soKit=1;\nvar d=document;d.documentElement.classList.add(\"kit-js\");\nfunction label(el){var l=el.labels&&el.labels[0];if(!l&&el.closest)l=el.closest(\"fieldset\")&&el.closest(\"fieldset\").querySelector(\"legend\");return l?(l.textContent||\"\").replace(/\\s+/g,\" \").trim():(el.name||\"\");}\nfunction prep(f){if(f.__kit)return;f.__kit=1;\nvar bar=f.querySelector(\"[data-kit-state]\");if(bar&&bar.textContent===\"\")bar.textContent=bar.getAttribute(\"data-idle\")||\"\";\nif(!f.hasAttribute(\"data-kit-step-up\"))return;\nvar dl=f.querySelector(\"[data-kit-dialog]\"),p=f.querySelector(\":scope>[data-kit-proof]\");\nif(!dl||!p||typeof dl.showModal!==\"function\"){f.removeAttribute(\"data-kit-step-up\");return;}\ndl.querySelector(\"[data-kit-slot]\").appendChild(p);\np.querySelectorAll(\"[required]\").forEach(function(i){i.removeAttribute(\"required\");i.setAttribute(\"data-kit-required\",\"\");});\ndl.addEventListener(\"close\",function(){if(f.__kitSent)return;var back=f.__kitBy||f.querySelector(\".kit-save\");if(back&&back.focus)back.focus();});}\nfunction scan(root){if(root.querySelectorAll)root.querySelectorAll(\"form.kit-form\").forEach(prep);}\nscan(d);\nif(typeof MutationObserver!==\"undefined\")new MutationObserver(function(list){list.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches(\"form.kit-form\"))prep(n);scan(n);}});});}).observe(d.documentElement,{childList:true,subtree:true});\nfunction asks(dl){var slot=dl.querySelector(\"[data-kit-slot]\");return !!slot.querySelector(\"input:not([type=hidden]),a,.sso-step-up\");}\nfunction confirm(f){var dl=f.querySelector(\"[data-kit-dialog]\"),miss=null;\ndl.querySelectorAll(\"[data-kit-required]\").forEach(function(i){if(!miss&&i.value===\"\")miss=i;});\nif(miss){miss.setCustomValidity(\"Enter your password.\");miss.reportValidity();miss.focus();return;}\nvar by=f.__kitBy&&f.__kitBy.form===f?f.__kitBy:null;f.__kitSent=1;dl.close();\nsetTimeout(function(){f.__kitGo=1;try{by?f.requestSubmit(by):f.requestSubmit();}finally{f.__kitGo=0;}},0);}\nfunction buttons(f){return f.querySelectorAll(\".kit-save,[data-kit-confirm]\");}\nfunction release(f){f.__kitPending=0;f.__kitSent=0;buttons(f).forEach(function(b){b.disabled=false;});}\nfunction latch(f,e){f.__kitPending=1;setTimeout(function(){if(e.defaultPrevented){release(f);return;}\nbuttons(f).forEach(function(b){b.disabled=true;});},0);}\naddEventListener(\"pageshow\",function(){d.querySelectorAll(\"form.kit-form\").forEach(release);});\nd.addEventListener(\"submit\",function(e){var f=e.target;if(!f||!f.classList||!f.classList.contains(\"kit-form\"))return;prep(f);\nif(f.__kitPending){e.preventDefault();e.stopImmediatePropagation();return;}\nvar dl=f.hasAttribute(\"data-kit-step-up\")?f.querySelector(\"[data-kit-dialog]\"):null;\nif(f.__kitGo||!dl||!asks(dl)){latch(f,e);return;}\ne.preventDefault();e.stopImmediatePropagation();\nif(dl.open){confirm(f);return;}\nf.__kitBy=e.submitter||null;f.__kitSent=0;dl.showModal();\nvar first=dl.querySelector(\"[data-kit-slot] input:not([type=hidden]),[data-kit-slot] a\")||dl.querySelector(\"[data-kit-confirm]\");if(first)first.focus();},true);\nd.addEventListener(\"click\",function(e){var b=e.target&&e.target.closest?e.target.closest(\"[data-kit-confirm],[data-kit-cancel]\"):null;if(!b)return;\nvar dl=b.closest(\"[data-kit-dialog]\"),f=dl&&dl.closest(\"form\");if(!f)return;e.preventDefault();\nif(b.hasAttribute(\"data-kit-cancel\")){dl.close();return;}confirm(f);});\nd.addEventListener(\"input\",function(e){var t=e.target;if(t&&t.setCustomValidity&&t.hasAttribute&&t.hasAttribute(\"data-kit-required\"))t.setCustomValidity(\"\");},true);\nfunction changed(e){var t=e.target,f=t&&t.form;if(!f||!f.classList.contains(\"kit-form\")||t.closest(\"[data-kit-dialog]\")||t.type===\"hidden\")return;prep(f);\nvar bar=f.querySelector(\"[data-kit-save-bar]\"),state=f.querySelector(\"[data-kit-state]\");if(!bar||!state)return;\nf.__kitChanged=f.__kitChanged||[];var name=label(t);if(name&&f.__kitChanged.indexOf(name)<0)f.__kitChanged.push(name);\nbar.setAttribute(\"data-changed\",\"\");state.textContent=\"Changed: \"+f.__kitChanged.join(\", \");}\nd.addEventListener(\"input\",changed);d.addEventListener(\"change\",changed);\n})();";
19
+ /** The pages that wear the settings frame: one column, no Crew sheet, a breadcrumb under Settings. Here (not in
20
+ * settings-kit.ts) so the browser bundle reads it without the server's form contracts. */
21
+ export declare function isSettingsFrame(pathname: string): boolean;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The settings kit's one script (docs/plans/settings-revamp.md §4), shipped as a page's functional script beside kit
3
+ * forms (settings-kit.ts). Without it every kit form works as today: the proof field shows inline and Save posts.
4
+ *
5
+ * With it, a kit form that asks for a proof:
6
+ * - moves its server-written proof field into the `<dialog>` inside the same form (the field never leaves the form);
7
+ * - opens the dialog on submit instead of posting, remembering the pressed button;
8
+ * - Confirm (its button, or Enter in the field) posts the form with that button (`requestSubmit(submitter)`, after the
9
+ * submit event that asked has finished), so its name=value and every hidden term go along unchanged; Cancel (or
10
+ * Escape) closes it and posts nothing, and focus returns to the button;
11
+ * - once a kit form posts, it is pending: further submits are dropped and its Save and Confirm are disabled (after the
12
+ * post has taken their values), until the page is shown again or the post was cancelled — never on a timer;
13
+ * - asks for nothing when the proof was left out (Passwordless) — there is no dialog then — and shows the
14
+ * identity-provider link when the sign-in swapped the field.
15
+ * Each section's Save bar names what changed. The script never reads or sets a field's value except to check that a
16
+ * required proof isn't empty; the server still proves everything.
17
+ */
18
+ export const SETTINGS_KIT_SCRIPT = `(function(){
19
+ if(window.__soKit)return;window.__soKit=1;
20
+ var d=document;d.documentElement.classList.add("kit-js");
21
+ function label(el){var l=el.labels&&el.labels[0];if(!l&&el.closest)l=el.closest("fieldset")&&el.closest("fieldset").querySelector("legend");return l?(l.textContent||"").replace(/\\s+/g," ").trim():(el.name||"");}
22
+ function prep(f){if(f.__kit)return;f.__kit=1;
23
+ var bar=f.querySelector("[data-kit-state]");if(bar&&bar.textContent==="")bar.textContent=bar.getAttribute("data-idle")||"";
24
+ if(!f.hasAttribute("data-kit-step-up"))return;
25
+ var dl=f.querySelector("[data-kit-dialog]"),p=f.querySelector(":scope>[data-kit-proof]");
26
+ if(!dl||!p||typeof dl.showModal!=="function"){f.removeAttribute("data-kit-step-up");return;}
27
+ dl.querySelector("[data-kit-slot]").appendChild(p);
28
+ p.querySelectorAll("[required]").forEach(function(i){i.removeAttribute("required");i.setAttribute("data-kit-required","");});
29
+ dl.addEventListener("close",function(){if(f.__kitSent)return;var back=f.__kitBy||f.querySelector(".kit-save");if(back&&back.focus)back.focus();});}
30
+ function scan(root){if(root.querySelectorAll)root.querySelectorAll("form.kit-form").forEach(prep);}
31
+ scan(d);
32
+ if(typeof MutationObserver!=="undefined")new MutationObserver(function(list){list.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1){if(n.matches&&n.matches("form.kit-form"))prep(n);scan(n);}});});}).observe(d.documentElement,{childList:true,subtree:true});
33
+ function asks(dl){var slot=dl.querySelector("[data-kit-slot]");return !!slot.querySelector("input:not([type=hidden]),a,.sso-step-up");}
34
+ function confirm(f){var dl=f.querySelector("[data-kit-dialog]"),miss=null;
35
+ dl.querySelectorAll("[data-kit-required]").forEach(function(i){if(!miss&&i.value==="")miss=i;});
36
+ if(miss){miss.setCustomValidity("Enter your password.");miss.reportValidity();miss.focus();return;}
37
+ var by=f.__kitBy&&f.__kitBy.form===f?f.__kitBy:null;f.__kitSent=1;dl.close();
38
+ setTimeout(function(){f.__kitGo=1;try{by?f.requestSubmit(by):f.requestSubmit();}finally{f.__kitGo=0;}},0);}
39
+ function buttons(f){return f.querySelectorAll(".kit-save,[data-kit-confirm]");}
40
+ function release(f){f.__kitPending=0;f.__kitSent=0;buttons(f).forEach(function(b){b.disabled=false;});}
41
+ function latch(f,e){f.__kitPending=1;setTimeout(function(){if(e.defaultPrevented){release(f);return;}
42
+ buttons(f).forEach(function(b){b.disabled=true;});},0);}
43
+ addEventListener("pageshow",function(){d.querySelectorAll("form.kit-form").forEach(release);});
44
+ d.addEventListener("submit",function(e){var f=e.target;if(!f||!f.classList||!f.classList.contains("kit-form"))return;prep(f);
45
+ if(f.__kitPending){e.preventDefault();e.stopImmediatePropagation();return;}
46
+ var dl=f.hasAttribute("data-kit-step-up")?f.querySelector("[data-kit-dialog]"):null;
47
+ if(f.__kitGo||!dl||!asks(dl)){latch(f,e);return;}
48
+ e.preventDefault();e.stopImmediatePropagation();
49
+ if(dl.open){confirm(f);return;}
50
+ f.__kitBy=e.submitter||null;f.__kitSent=0;dl.showModal();
51
+ var first=dl.querySelector("[data-kit-slot] input:not([type=hidden]),[data-kit-slot] a")||dl.querySelector("[data-kit-confirm]");if(first)first.focus();},true);
52
+ d.addEventListener("click",function(e){var b=e.target&&e.target.closest?e.target.closest("[data-kit-confirm],[data-kit-cancel]"):null;if(!b)return;
53
+ var dl=b.closest("[data-kit-dialog]"),f=dl&&dl.closest("form");if(!f)return;e.preventDefault();
54
+ if(b.hasAttribute("data-kit-cancel")){dl.close();return;}confirm(f);});
55
+ d.addEventListener("input",function(e){var t=e.target;if(t&&t.setCustomValidity&&t.hasAttribute&&t.hasAttribute("data-kit-required"))t.setCustomValidity("");},true);
56
+ function changed(e){var t=e.target,f=t&&t.form;if(!f||!f.classList.contains("kit-form")||t.closest("[data-kit-dialog]")||t.type==="hidden")return;prep(f);
57
+ var bar=f.querySelector("[data-kit-save-bar]"),state=f.querySelector("[data-kit-state]");if(!bar||!state)return;
58
+ f.__kitChanged=f.__kitChanged||[];var name=label(t);if(name&&f.__kitChanged.indexOf(name)<0)f.__kitChanged.push(name);
59
+ bar.setAttribute("data-changed","");state.textContent="Changed: "+f.__kitChanged.join(", ");}
60
+ d.addEventListener("input",changed);d.addEventListener("change",changed);
61
+ })();`;
62
+ /** The pages that wear the settings frame: one column, no Crew sheet, a breadcrumb under Settings. Here (not in
63
+ * settings-kit.ts) so the browser bundle reads it without the server's form contracts. */
64
+ export function isSettingsFrame(pathname) {
65
+ return pathname === "/settings" || pathname.startsWith("/settings/") || pathname === "/setup" || pathname.startsWith("/setup/")
66
+ || pathname === "/people" || pathname === "/mode" || pathname === "/control" || pathname === "/spend";
67
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The settings kit (docs/plans/settings-revamp.md §4): the frame's breadcrumb, and the pieces every settings page is
3
+ * rebuilt from — a section, its own sticky Save, the step-up dialog drawn from the action's own proof contract
4
+ * (settings-proofs.ts), field errors, secret inputs and choice groups. Server-rendered HTML that works without
5
+ * JavaScript; settings-kit-script.ts only turns the inline proof field into a dialog.
6
+ *
7
+ * Rules the helpers keep (and settings-kit.test.ts pins):
8
+ * - the proof field is written into the form's own server HTML, named as its handler reads it, labelled, and
9
+ * `current-password`; so sensitivity (serve.ts sendScreen) and the identity-provider swap (ssoStepUps) still see it;
10
+ * - a step-up field renders through `reauth`, so Passwordless leaves it out exactly as today; a credential or typed
11
+ * password field always renders;
12
+ * - a CSRF-only action renders no proof field and no dialog;
13
+ * - nothing here ever sets `value` on a proof or secret field;
14
+ * - handlers never read any of this: it only draws.
15
+ */
16
+ import { type Html, type HtmlValue } from "./html.js";
17
+ import { isSettingsFrame } from "./settings-kit-script.js";
18
+ import { type ProofRow } from "./settings-proofs.js";
19
+ export { isSettingsFrame };
20
+ /** One breadcrumb: a link, or (the last) the page itself. */
21
+ export type Crumb = {
22
+ label: string;
23
+ href: string | null;
24
+ };
25
+ /** The text of a page's first h1 (tags dropped, the common entities read), or null. */
26
+ export declare function headingText(page: string): string | null;
27
+ /** The breadcrumb for a settings-frame page: "Settings / <this page>", or nothing on the hub and outside the frame. */
28
+ export declare function settingsCrumbs(pathname: string, title: string, page: string): Crumb[] | undefined;
29
+ /** The project the sidebar's picker names: the session's saved selection while this person may still see it, else
30
+ * the page's own effective project, else none — checked against the visible project list, so a selection that
31
+ * outlived the ceiling is never sent to the page. */
32
+ export declare function openProjectFor(saved: string | null, effective: string | null | undefined, visible: readonly {
33
+ path: string;
34
+ }[]): string | null;
35
+ /** The breadcrumb as server HTML (the page before scripts, and without them). */
36
+ export declare function crumbsHtml(crumbs: readonly Crumb[]): Html;
37
+ /** The password-manager opt-outs every secret input carries (a service's key is not the person's sign-in). */
38
+ export declare const SECRET_OPT_OUTS: {
39
+ readonly "data-1p-ignore": true;
40
+ readonly "data-lpignore": "true";
41
+ readonly "data-bwignore": true;
42
+ readonly "data-form-type": "other";
43
+ };
44
+ /** A field's message under it: tied to the field with aria-describedby, announced, and the field focused. */
45
+ export declare function fieldError(id: string, message: string): Html;
46
+ export type TextField = {
47
+ id: string;
48
+ name: string;
49
+ label: string;
50
+ type?: "text" | "number" | "url" | "email";
51
+ /** What the person typed (kept after a validation error). Never a secret: secrets use secretInput. */
52
+ value?: string | null;
53
+ hint?: string;
54
+ error?: string | null;
55
+ attrs?: Readonly<Record<string, string | number | boolean | null | undefined>>;
56
+ };
57
+ /** A labelled text field; with an error it is aria-invalid, names its message and takes focus. */
58
+ export declare function textField(field: TextField): Html;
59
+ /** A secret that is not the person's own password (an API key, a bot token, a client secret): masked, never given a
60
+ * value, kept out of password managers. The field name is the handler's own wire name. */
61
+ export declare function secretInput(field: {
62
+ id: string;
63
+ name: string;
64
+ label: string;
65
+ hint?: string;
66
+ required?: boolean;
67
+ error?: string | null;
68
+ }): Html;
69
+ /** A choice group: a fieldset with its legend, each option's detail tied to it. Nothing saves until Save. */
70
+ export declare function choiceGroup(group: {
71
+ id: string;
72
+ name: string;
73
+ legend: string;
74
+ checked: string | null;
75
+ options: readonly {
76
+ value: string;
77
+ label: string;
78
+ detail?: string;
79
+ }[];
80
+ error?: string | null;
81
+ }): Html;
82
+ /** Proofs that only a password typed now satisfies: no Passwordless session and no identity-provider re-check. */
83
+ export declare const typedOnly: (row: ProofRow) => boolean;
84
+ /** The proof field a row's handler reads, as today's forms write it; empty for a CSRF-only row. A step-up field goes
85
+ * through `reauth` (Passwordless leaves it out); a credential or typed field always shows. A typed-only field carries
86
+ * `data-so-typed`, so the identity-provider swap leaves it a real password field (keepTypedFields). Never a value. */
87
+ export declare function proofField(row: ProofRow, options?: {
88
+ label?: string;
89
+ }): Html;
90
+ /**
91
+ * The identity-provider swap (render-people.ts ssoStepUps) with typed-only proof fields kept: those handlers take only
92
+ * a password typed now, so the field must stay a real password input. The field is held out of the swap by its
93
+ * marker and put back as it was. Every other step-up field is swapped exactly as before.
94
+ */
95
+ export declare function keepTypedFields<A extends unknown[]>(page: Html, swap: (page: Html, ...rest: A) => Html, ...rest: A): Html;
96
+ /** How the dialog explains a proof that Passwordless doesn't cover, in words; null where nothing needs saying. */
97
+ export declare function proofNote(row: ProofRow): string | null;
98
+ export type StepUpForm = {
99
+ /** The proof inventory row this form posts under. */
100
+ proof: string;
101
+ /** The exact route (one of the row's). */
102
+ action: string;
103
+ /** Unique on the page: the form, its dialog and its section hang from it. */
104
+ id: string;
105
+ /** The section's own fields. */
106
+ body: HtmlValue;
107
+ /** Hidden terms posted back unchanged (nonce, digest, fingerprint, the shown project…). */
108
+ hidden?: Readonly<Record<string, string | number | null | undefined>>;
109
+ /** The Save bar's words before anything changes, and its button. */
110
+ save?: {
111
+ label?: string;
112
+ idle?: string;
113
+ name?: string;
114
+ value?: string;
115
+ };
116
+ /** What the dialog says the person is agreeing to (the action's own words by default). */
117
+ confirm?: string;
118
+ };
119
+ /**
120
+ * A section's form, posted to its existing route with its existing fields, and its sticky Save. When the row needs a
121
+ * proof, the proof field sits in the form's own HTML — inline (as today, which is how it works without JavaScript) and
122
+ * moved into a `<dialog>` inside the same form by settings-kit-script.ts, which opens it on submit, keeps the pressed
123
+ * button, and posts nothing on Cancel. The server still proves everything.
124
+ */
125
+ export declare function stepUpForm(form: StepUpForm): Html;
126
+ /** A section: a heading, a one-line state, a problem the handler sent back (role=alert), then its form. */
127
+ export declare function kitSection(section: {
128
+ id: string;
129
+ title: string;
130
+ state?: HtmlValue;
131
+ problem?: string | null;
132
+ said?: string | null;
133
+ body: HtmlValue;
134
+ }): Html;
135
+ /** The kit's look: one 760px column, sections with no nested wells, the sticky Save, the dialog. The needs-you colour
136
+ * falls back to the warning token until S5 lands `--so-needs-you`. */
137
+ export declare const SETTINGS_KIT_CSS: string;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The settings kit (docs/plans/settings-revamp.md §4): the frame's breadcrumb, and the pieces every settings page is
3
+ * rebuilt from — a section, its own sticky Save, the step-up dialog drawn from the action's own proof contract
4
+ * (settings-proofs.ts), field errors, secret inputs and choice groups. Server-rendered HTML that works without
5
+ * JavaScript; settings-kit-script.ts only turns the inline proof field into a dialog.
6
+ *
7
+ * Rules the helpers keep (and settings-kit.test.ts pins):
8
+ * - the proof field is written into the form's own server HTML, named as its handler reads it, labelled, and
9
+ * `current-password`; so sensitivity (serve.ts sendScreen) and the identity-provider swap (ssoStepUps) still see it;
10
+ * - a step-up field renders through `reauth`, so Passwordless leaves it out exactly as today; a credential or typed
11
+ * password field always renders;
12
+ * - a CSRF-only action renders no proof field and no dialog;
13
+ * - nothing here ever sets `value` on a proof or secret field;
14
+ * - handlers never read any of this: it only draws.
15
+ */
16
+ import { attributes, html, htmlString, joinHtml, postForm, reauth, replaceMarkup } from "./html.js";
17
+ import { isSettingsFrame } from "./settings-kit-script.js";
18
+ import { contractReads, PROOF_INVENTORY, proofRow } from "./settings-proofs.js";
19
+ export { isSettingsFrame };
20
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", "#x27": "'", nbsp: " " };
21
+ /** The text of a page's first h1 (tags dropped, the common entities read), or null. */
22
+ export function headingText(page) {
23
+ const found = /<h1\b[^>]*>([\s\S]*?)<\/h1>/i.exec(page);
24
+ if (found === null)
25
+ return null;
26
+ const text = found[1].replace(/<[^>]*>/g, "").replace(/&(amp|lt|gt|quot|#39|#x27|nbsp);/g, (_whole, name) => ENTITIES[name] ?? "").replace(/\s+/g, " ").trim();
27
+ return text === "" ? null : text.slice(0, 80);
28
+ }
29
+ /** The breadcrumb for a settings-frame page: "Settings / <this page>", or nothing on the hub and outside the frame. */
30
+ export function settingsCrumbs(pathname, title, page) {
31
+ if (!isSettingsFrame(pathname) || pathname === "/settings")
32
+ return undefined;
33
+ const name = headingText(page) ?? (title.charAt(0).toUpperCase() + title.slice(1));
34
+ return [{ label: "Settings", href: "/settings" }, { label: name, href: null }];
35
+ }
36
+ /** The project the sidebar's picker names: the session's saved selection while this person may still see it, else
37
+ * the page's own effective project, else none — checked against the visible project list, so a selection that
38
+ * outlived the ceiling is never sent to the page. */
39
+ export function openProjectFor(saved, effective, visible) {
40
+ return [saved, effective ?? null].find((path) => path !== null && visible.some(one => one.path === path)) ?? null;
41
+ }
42
+ /** The breadcrumb as server HTML (the page before scripts, and without them). */
43
+ export function crumbsHtml(crumbs) {
44
+ return html `<nav class="kit-crumbs" aria-label="Breadcrumb"><ol>${crumbs.map(one => one.href === null
45
+ ? html `<li aria-current="page">${one.label}</li>`
46
+ : html `<li><a href="${one.href}">${one.label}</a></li>`)}</ol></nav>`;
47
+ }
48
+ // ---- fields --------------------------------------------------------------------------------------------------------
49
+ /** The password-manager opt-outs every secret input carries (a service's key is not the person's sign-in). */
50
+ export const SECRET_OPT_OUTS = { "data-1p-ignore": true, "data-lpignore": "true", "data-bwignore": true, "data-form-type": "other" };
51
+ const errorId = (id) => `${id}-error`;
52
+ /** A field's message under it: tied to the field with aria-describedby, announced, and the field focused. */
53
+ export function fieldError(id, message) {
54
+ return html `<p class="kit-field-error" id="${errorId(id)}" role="alert">${message}</p>`;
55
+ }
56
+ /** A labelled text field; with an error it is aria-invalid, names its message and takes focus. */
57
+ export function textField(field) {
58
+ const hint = field.hint === undefined ? null : `${field.id}-hint`;
59
+ const described = [hint, field.error ? errorId(field.id) : null].filter(Boolean).join(" ");
60
+ return html `<div class="kit-field"><label for="${field.id}">${field.label}</label>\
61
+ ${field.hint === undefined ? "" : html `<p class="kit-hint" id="${hint}">${field.hint}</p>`}\
62
+ <input${attributes({ id: field.id, name: field.name, type: field.type ?? "text", value: field.value ?? null, ...(field.attrs ?? {}),
63
+ "aria-invalid": field.error ? "true" : null, "aria-describedby": described === "" ? null : described, autofocus: field.error ? true : null })}>\
64
+ ${field.error ? fieldError(field.id, field.error) : ""}</div>`;
65
+ }
66
+ /** A secret that is not the person's own password (an API key, a bot token, a client secret): masked, never given a
67
+ * value, kept out of password managers. The field name is the handler's own wire name. */
68
+ export function secretInput(field) {
69
+ const described = [field.hint === undefined ? null : `${field.id}-hint`, field.error ? errorId(field.id) : null].filter(Boolean).join(" ");
70
+ return html `<div class="kit-field"><label for="${field.id}">${field.label}</label>\
71
+ ${field.hint === undefined ? "" : html `<p class="kit-hint" id="${field.id}-hint">${field.hint}</p>`}\
72
+ <input${attributes({ id: field.id, name: field.name, type: "password", autocomplete: "off", spellcheck: "false", autocapitalize: "none", "data-so-secret": true, ...SECRET_OPT_OUTS,
73
+ required: field.required === true, "aria-invalid": field.error ? "true" : null, "aria-describedby": described === "" ? null : described, autofocus: field.error ? true : null })}>\
74
+ ${field.error ? fieldError(field.id, field.error) : ""}</div>`;
75
+ }
76
+ /** A choice group: a fieldset with its legend, each option's detail tied to it. Nothing saves until Save. */
77
+ export function choiceGroup(group) {
78
+ return html `<fieldset class="kit-choices"${group.error ? html ` aria-describedby="${errorId(group.id)}"` : ""}><legend>${group.legend}</legend>\
79
+ ${group.options.map((one, at) => {
80
+ const id = `${group.id}-${at}`;
81
+ return html `<label class="kit-choice" for="${id}"><input${attributes({ type: "radio", id, name: group.name, value: one.value, checked: one.value === group.checked, "aria-describedby": one.detail === undefined ? null : `${id}-detail` })}>\
82
+ <span>${one.label}</span>${one.detail === undefined ? "" : html `<span class="kit-choice-detail" id="${id}-detail">${one.detail}</span>`}</label>`;
83
+ })}${group.error ? fieldError(group.id, group.error) : ""}</fieldset>`;
84
+ }
85
+ // ---- proof ---------------------------------------------------------------------------------------------------------
86
+ /** Proofs that only a password typed now satisfies: no Passwordless session and no identity-provider re-check. */
87
+ export const typedOnly = (row) => row.proof === "typed-password" || row.proof === "typed-now-operator";
88
+ /** The proof field a row's handler reads, as today's forms write it; empty for a CSRF-only row. A step-up field goes
89
+ * through `reauth` (Passwordless leaves it out); a credential or typed field always shows. A typed-only field carries
90
+ * `data-so-typed`, so the identity-provider swap leaves it a real password field (keepTypedFields). Never a value. */
91
+ export function proofField(row, options = {}) {
92
+ if (row.field === null || row.proof === "none")
93
+ return html ``;
94
+ if (!contractReads(row, row.field))
95
+ throw new Error(`${row.id}: its form doesn't read ${row.field}`);
96
+ const label = options.label ?? "Your Toolroll password";
97
+ if (typedOnly(row))
98
+ return html `<label>${label}<input type="password" name="${row.field}" data-so-typed autocomplete="current-password" required></label>`;
99
+ const field = html `<label>${label}<input type="password" name="${row.field}" autocomplete="current-password" required></label>`;
100
+ return row.proof === "step-up" ? reauth(field) : field;
101
+ }
102
+ const TYPED_FIELD = /<input type="password" name="([a-z-]+)" data-so-typed autocomplete="current-password" required>/g;
103
+ const HELD_FIELD = /<input type="password" name="([a-z-]+)" data-so-typed data-so-typed-held required>/g;
104
+ /**
105
+ * The identity-provider swap (render-people.ts ssoStepUps) with typed-only proof fields kept: those handlers take only
106
+ * a password typed now, so the field must stay a real password input. The field is held out of the swap by its
107
+ * marker and put back as it was. Every other step-up field is swapped exactly as before.
108
+ */
109
+ export function keepTypedFields(page, swap, ...rest) {
110
+ const held = replaceMarkup(page, TYPED_FIELD, (_whole, name) => html `<input type="password" name="${name}" data-so-typed data-so-typed-held required>`);
111
+ return replaceMarkup(swap(held, ...rest), HELD_FIELD, (_whole, name) => html `<input type="password" name="${name}" data-so-typed autocomplete="current-password" required>`);
112
+ }
113
+ /** The rows that share this row's route: one handler's variants. */
114
+ const variantsOf = (row) => PROOF_INVENTORY.filter(one => one !== row && one.routes.some(route => row.routes.includes(route)));
115
+ /** How the dialog explains a proof that Passwordless doesn't cover, in words; null where nothing needs saying. */
116
+ export function proofNote(row) {
117
+ if (row.proof === "credential")
118
+ return "This changes a way in, so it always takes your password.";
119
+ if (row.proof === "typed-password")
120
+ return "This always takes your password, typed.";
121
+ if (row.proof === "typed-now-operator")
122
+ return "Widening this takes an instance operator's password, typed now.";
123
+ return null;
124
+ }
125
+ /**
126
+ * A section's form, posted to its existing route with its existing fields, and its sticky Save. When the row needs a
127
+ * proof, the proof field sits in the form's own HTML — inline (as today, which is how it works without JavaScript) and
128
+ * moved into a `<dialog>` inside the same form by settings-kit-script.ts, which opens it on submit, keeps the pressed
129
+ * button, and posts nothing on Cancel. The server still proves everything.
130
+ */
131
+ export function stepUpForm(form) {
132
+ const row = proofRow(form.proof);
133
+ if (!row.routes.includes(form.action))
134
+ throw new Error(`${row.id} doesn't post to ${form.action}`);
135
+ const hidden = form.hidden ?? {};
136
+ for (const name of Object.keys(hidden))
137
+ if (row.field === name || row.secrets.includes(name))
138
+ throw new Error(`${row.id}: ${name} is never a hidden value`);
139
+ // The exact variant: its own selector must be in the hidden terms, and no other variant's may be.
140
+ if (row.select !== undefined && String(hidden[row.select.field] ?? "") !== row.select.value)
141
+ throw new Error(`${row.id} posts ${row.select.field}=${row.select.value}`);
142
+ for (const other of variantsOf(row))
143
+ if (other.select !== undefined && String(hidden[other.select.field] ?? "") === other.select.value)
144
+ throw new Error(`${form.action} with ${other.select.field}=${other.select.value} is ${other.id}, not ${row.id}`);
145
+ const proof = proofField(row);
146
+ const asks = htmlString(proof) !== "";
147
+ const note = proofNote(row);
148
+ const save = form.save ?? {};
149
+ const submit = html `<button${attributes({ type: "submit", class: "kit-save", name: save.name ?? null, value: save.value ?? null })}>${save.label ?? "Save"}</button>`;
150
+ const dialog = asks ? html `<dialog class="kit-dialog" id="${form.id}-dialog" aria-labelledby="${form.id}-dialog-title" data-kit-dialog>\
151
+ <h2 id="${form.id}-dialog-title">Confirm it's you</h2><p class="kit-dialog-terms">${form.confirm ?? row.action}${row.when === undefined ? "" : html ` <span class="kit-hint">(${row.when})</span>`}</p>\
152
+ ${note === null ? "" : html `<p class="kit-hint">${note}</p>`}<div class="kit-dialog-slot" data-kit-slot></div>\
153
+ <div class="kit-dialog-actions"><button type="button" class="kit-cancel" data-kit-cancel>Cancel</button><button type="button" class="kit-confirm" data-kit-confirm>${save.label ?? "Save"}</button></div></dialog>` : "";
154
+ return postForm(form.action, joinHtml([
155
+ form.body,
156
+ asks ? html `<div class="kit-proof" data-kit-proof>${proof}</div>` : "",
157
+ html `<div class="kit-save-bar" data-kit-save-bar><span class="kit-save-state" aria-live="polite" data-kit-state data-idle="${save.idle ?? "No changes"}"></span>${submit}</div>`,
158
+ dialog,
159
+ ]), { hidden, attrs: { id: form.id, class: "kit-form", "data-kit-form": row.id, "data-kit-step-up": asks ? "" : null } });
160
+ }
161
+ /** A section: a heading, a one-line state, a problem the handler sent back (role=alert), then its form. */
162
+ export function kitSection(section) {
163
+ return html `<section class="kit-section" id="${section.id}" aria-labelledby="${section.id}-title"><h2 id="${section.id}-title">${section.title}</h2>\
164
+ ${section.state === undefined ? "" : html `<p class="kit-state">${section.state}</p>`}\
165
+ ${section.problem ? html `<p class="kit-problem" role="alert">${section.problem}</p>` : ""}\
166
+ ${section.said ? html `<p class="kit-said" role="status">${section.said}</p>` : ""}${section.body}</section>`;
167
+ }
168
+ /** The kit's look: one 760px column, sections with no nested wells, the sticky Save, the dialog. The needs-you colour
169
+ * falls back to the warning token until S5 lands `--so-needs-you`. */
170
+ export const SETTINGS_KIT_CSS = ":root{--so-settings-width:760px}" +
171
+ ".kit-crumbs ol{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0;padding:0;list-style:none;font-size:14px;color:var(--so-muted)}" +
172
+ ".kit-crumbs li+li::before{content:'/';margin-right:6px;color:var(--so-muted)}.kit-crumbs a{color:var(--so-muted);text-decoration:none}.kit-crumbs a:hover{color:var(--so-ink);text-decoration:underline}" +
173
+ ".kit-crumbs [aria-current]{color:var(--so-ink);font-weight:600}" +
174
+ ".kit-section{margin:0 0 32px}.kit-section>h2{margin:0 0 4px;font-size:16px}.kit-state{margin:0 0 12px;color:var(--so-muted)}" +
175
+ ".kit-problem{margin:0 0 12px;padding:10px 12px;border:1px solid var(--so-danger);border-radius:8px;color:var(--so-danger)}.kit-said{margin:0 0 12px;color:var(--so-muted)}" +
176
+ ".kit-field{margin:0 0 14px}.kit-hint{margin:2px 0 0;font-size:13px;color:var(--so-muted)}" +
177
+ ".kit-field-error{margin:4px 0 0;font-size:13px;color:var(--so-danger)}.kit-field [aria-invalid=true]{border-color:var(--so-danger)}" +
178
+ ".kit-choices{margin:0 0 14px;padding:0;border:0}.kit-choices legend{padding:0;margin:0 0 6px;font-weight:600}" +
179
+ ".kit-choice{display:grid;grid-template-columns:auto 1fr;column-gap:10px;align-items:start;min-height:44px;padding:8px 0;margin:0}.kit-choice input{margin:3px 0 0}.kit-choice-detail{grid-column:2;font-size:13px;color:var(--so-muted)}" +
180
+ ".kit-save-bar{position:sticky;bottom:0;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:12px;margin:16px 0 0;padding:10px 0;border-top:1px solid var(--so-line);background:var(--so-paper)}" +
181
+ ".kit-save-state{color:var(--so-muted);font-size:14px}.kit-save-bar[data-changed] .kit-save-state{color:var(--so-ink)}" +
182
+ ".kit-js .kit-form[data-kit-step-up]>.kit-proof{display:none}" +
183
+ ".kit-dialog{max-width:min(440px,calc(100vw - 32px));padding:20px;border:1px solid var(--so-line);border-radius:12px;background:var(--so-paper);color:var(--so-ink)}" +
184
+ ".kit-dialog::backdrop{background:rgb(0 0 0 / .35)}.kit-dialog h2{margin:0 0 6px;font-size:17px}.kit-dialog-slot .kit-proof{display:block}" +
185
+ ".kit-dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0}" +
186
+ "@media (max-width:900px){.kit-save-bar button,.kit-dialog-actions button,.kit-crumbs a{min-height:44px}.kit-crumbs a{display:inline-flex;align-items:center}}";