toolroll 0.9.83 → 0.9.84

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 (75) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +4 -3
  3. package/dist/assignment-ui.js +5 -1
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +22 -22
  6. package/dist/browser-crew.js +5 -1
  7. package/dist/browser-workspace.d.ts +7 -0
  8. package/dist/contracts/console-api.d.ts +2 -1
  9. package/dist/contracts/console-api.js +5 -1
  10. package/dist/control-setup.d.ts +9 -0
  11. package/dist/control-setup.js +31 -4
  12. package/dist/control-ui.d.ts +7 -1
  13. package/dist/control-ui.js +3 -2
  14. package/dist/desktop-update.d.ts +1 -1
  15. package/dist/dispatch.d.ts +15 -0
  16. package/dist/dispatch.js +26 -4
  17. package/dist/first-run.d.ts +5 -2
  18. package/dist/first-run.js +13 -2
  19. package/dist/folder-look.d.ts +3 -0
  20. package/dist/folder-look.js +8 -4
  21. package/dist/guides.js +4 -3
  22. package/dist/model-catalog.d.ts +5 -0
  23. package/dist/model-catalog.js +11 -2
  24. package/dist/model-choices.d.ts +188 -0
  25. package/dist/model-choices.js +405 -0
  26. package/dist/model-names.d.ts +6 -0
  27. package/dist/model-names.js +34 -0
  28. package/dist/models-ui.d.ts +69 -5
  29. package/dist/models-ui.js +53 -10
  30. package/dist/needs-you.d.ts +8 -0
  31. package/dist/needs-you.js +8 -0
  32. package/dist/operate.d.ts +21 -0
  33. package/dist/operate.js +96 -26
  34. package/dist/project-admission.d.ts +4 -1
  35. package/dist/project-admission.js +23 -2
  36. package/dist/project-identity.d.ts +9 -2
  37. package/dist/project-identity.js +80 -33
  38. package/dist/project-knowledge.d.ts +6 -6
  39. package/dist/serve.js +60 -13
  40. package/dist/server/chat.js +5 -4
  41. package/dist/server/chrome.d.ts +3 -0
  42. package/dist/server/chrome.js +5 -2
  43. package/dist/server/console.css +5 -2
  44. package/dist/server/flows.js +4 -0
  45. package/dist/server/http.d.ts +1 -0
  46. package/dist/server/http.js +6 -0
  47. package/dist/server/pages.js +42 -6
  48. package/dist/server/people-tokens.js +40 -20
  49. package/dist/server/render-pages.d.ts +10 -0
  50. package/dist/server/render-pages.js +29 -14
  51. package/dist/server/render-people.d.ts +14 -2
  52. package/dist/server/render-people.js +35 -12
  53. package/dist/server/render-settings.js +1 -0
  54. package/dist/server/render-tasks.js +7 -14
  55. package/dist/server/request-context.d.ts +2 -0
  56. package/dist/server/route-table.js +8 -1
  57. package/dist/server/settings.d.ts +58 -1
  58. package/dist/server/settings.js +198 -69
  59. package/dist/server/signup-gate.d.ts +9 -0
  60. package/dist/server/signup-gate.js +40 -0
  61. package/dist/server/tasks.js +12 -2
  62. package/dist/settings-kit-script.d.ts +21 -0
  63. package/dist/settings-kit-script.js +67 -0
  64. package/dist/settings-kit.d.ts +137 -0
  65. package/dist/settings-kit.js +186 -0
  66. package/dist/settings-proofs.d.ts +64 -0
  67. package/dist/settings-proofs.js +101 -0
  68. package/dist/setup-guide.js +4 -2
  69. package/dist/spend-report.d.ts +87 -0
  70. package/dist/spend-report.js +265 -0
  71. package/dist/spend-ui.d.ts +29 -8
  72. package/dist/spend-ui.js +74 -39
  73. package/dist/task-sizing.d.ts +5 -3
  74. package/dist/task-sizing.js +4 -4
  75. package/package.json +1 -1
@@ -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}}";
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The settings proof inventory (docs/plans/settings-revamp.md §4): for every settings action, the proof its handler
3
+ * already asks for, as data. Only rendering code reads it (settings-kit.ts draws the step-up dialog from it); no
4
+ * handler does, and nothing here changes what a handler proves. `src/settings-kit.test.ts` pins every row against the
5
+ * form contracts (console-api.ts), the route table, and — for one row of each kind — the handler itself over HTTP.
6
+ *
7
+ * Proof kinds, as the handlers implement them (scope.ts, server/guards.ts):
8
+ * - `none`: the session and the form token (CSRF) only.
9
+ * - `step-up`: `authenticateApprover` — a password typed now; an empty field stands in under Passwordless (the
10
+ * person's own live browser session) and for a one-use identity-provider re-check.
11
+ * - `credential`: `authenticateCredential` — a password typed now, or a one-use identity-provider re-check. Passwordless
12
+ * never covers it.
13
+ * - `typed-password`: the handler checks the typed password itself (`authenticateAccount`, or an empty field refused
14
+ * before the check). Neither Passwordless nor an identity-provider re-check stands in.
15
+ * - `typed-now-operator`: a password typed now by an instance operator (widening check access); not even a re-check.
16
+ */
17
+ import { type ConsoleFormName } from "./contracts/console-api.js";
18
+ export type ProofKind = "none" | "step-up" | "credential" | "typed-password" | "typed-now-operator";
19
+ /** Who the route table admits (route-table.ts `role`); `handler` notes a narrower check the handler makes itself. */
20
+ export type ProofRole = "any" | "approver" | "operator";
21
+ export type ProofRow = {
22
+ /** Stable id the kit is called with. */
23
+ readonly id: string;
24
+ /** What the person is doing, in words. */
25
+ readonly action: string;
26
+ /** The exact POST paths. */
27
+ readonly routes: readonly string[];
28
+ /** The form contract its handler reads through. */
29
+ readonly form: ConsoleFormName;
30
+ /** The proof field's name, or null when none is read. */
31
+ readonly field: "password" | "token" | null;
32
+ readonly proof: ProofKind;
33
+ /** Only some submissions need the proof (the rest are `none`), in words. */
34
+ readonly when?: string;
35
+ /** Rows that share a route are variants of one handler, each with its own exact proof. A variant with `select`
36
+ * applies exactly when the form carries that hidden value (the kit refuses a form whose hidden terms pick another
37
+ * variant); a variant with `explicit` is named by the caller (a choice the server decides, like widening). */
38
+ readonly select?: {
39
+ readonly field: string;
40
+ readonly value: string;
41
+ };
42
+ readonly explicit?: true;
43
+ /** Under Passwordless, does an empty proof field pass? (`accepted` only for step-up.) */
44
+ readonly passwordless: "accepted" | "refused" | "n/a";
45
+ /** Does a one-use identity-provider re-check (empty field after "Confirm with …") pass? */
46
+ readonly sso: "accepted" | "refused" | "n/a";
47
+ readonly role: ProofRole;
48
+ /** A narrower standing the handler checks itself, in words. */
49
+ readonly handlerRole?: string;
50
+ /** Fields that hold a secret that is not the person's proof (never echoed, password managers kept out). */
51
+ readonly secrets: readonly string[];
52
+ /** Fields whose name reads like a credential but hold something else (an id, a bot token…). */
53
+ readonly notProof?: readonly string[];
54
+ /** Every term the handler requires besides the proof: hidden fields carried back unchanged (nonce, digest,
55
+ * fingerprint, the shown project, what a preview showed) and the fields that pick the target (repo, act). */
56
+ readonly terms: readonly string[];
57
+ /** Anything else the handler requires, in words. */
58
+ readonly note?: string;
59
+ };
60
+ export declare const PROOF_INVENTORY: readonly ProofRow[];
61
+ /** One row by id; throws on an unknown id (a rendering bug, never a request's input). */
62
+ export declare function proofRow(id: string): ProofRow;
63
+ /** Whether the field a row names is one its form contract reads (used by the kit's own guard and the tests). */
64
+ export declare function contractReads(row: ProofRow, field: string): boolean;