toolroll 0.9.87 → 0.9.89

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.
@@ -17,10 +17,14 @@
17
17
  * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
18
18
  */
19
19
  import { resolve } from "node:path";
20
+ import { insideFolder } from "./folder-gate.js";
20
21
  import { lookIdentities } from "./folder-look.js";
21
22
  import { canonicalProject } from "./project.js";
22
23
  /** How long an answer stands before the background pass resolves the path again. */
23
24
  const STALE_MS = 60_000;
25
+ /** How long an answer vouches that its folder answers, for `answering`: past it (the background pass every 30 s
26
+ * renews it each minute), a synchronous read waits for the next look. */
27
+ const ANSWERING_MS = 3 * STALE_MS;
24
28
  /** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
25
29
  export const IDENTITY_LOOKS = 4;
26
30
  export function projectIdentities(look = {}, now = Date.now) {
@@ -137,6 +141,37 @@ export function projectIdentities(look = {}, now = Date.now) {
137
141
  passes = pass.then(() => undefined, () => undefined);
138
142
  return pass;
139
143
  },
144
+ answering: path => {
145
+ const when = now();
146
+ const recent = (at) => when - at < ANSWERING_MS;
147
+ // Decided on real paths only. First the spelling: a path under an answered spelling (the longest) is read where
148
+ // that answer says it leads, and only while that answer is recent, since a link can be pointed elsewhere.
149
+ let real = resolve(path);
150
+ let spelled = null;
151
+ for (const [key, answer] of answers) {
152
+ if (answer.real !== null && insideFolder(real, key) && (spelled === null || key.length > spelled.key.length))
153
+ spelled = { key, real: answer.real, at: answer.at };
154
+ }
155
+ if (spelled !== null) {
156
+ if (!recent(spelled.at))
157
+ return false;
158
+ real = resolve(spelled.real, `.${real.slice(spelled.key.length)}`);
159
+ }
160
+ // A folder whose last look didn't answer holds back every read inside where it leads, whatever older answer stands.
161
+ for (const key of suspect)
162
+ if (insideFolder(real, answers.get(key)?.real ?? key))
163
+ return false;
164
+ // Otherwise the nearest real folder an answer names decides, by its newest answer; "/" vouches for nothing below it.
165
+ let nearest = null;
166
+ for (const answer of answers.values()) {
167
+ const folder = answer.real;
168
+ if (folder === null || (folder === "/" && real !== "/") || !insideFolder(real, folder))
169
+ continue;
170
+ if (nearest === null || folder.length > nearest.folder.length || (folder.length === nearest.folder.length && answer.at > nearest.at))
171
+ nearest = { folder, at: answer.at };
172
+ }
173
+ return nearest !== null && recent(nearest.at);
174
+ },
140
175
  };
141
176
  }
142
177
  /** The synchronous reads, as before: for embedders and tests that pass no identities. */
@@ -149,5 +184,13 @@ export function syncProjectIdentities() {
149
184
  resolveNow: async (path) => canonicalProject(path),
150
185
  generation: () => 0,
151
186
  refresh: async () => true,
187
+ answering: () => true,
152
188
  };
153
189
  }
190
+ /** The console's folder gate (folder-gate.ts): its own data folders (the database's, evidence, build checkouts) are read
191
+ * freely when the console names them; a path read from a file a build can write (`pointer`), and anything else, only
192
+ * while `answering` vouches for where it leads. */
193
+ export function consoleFolderGate(identities, own) {
194
+ const folders = [...new Set(own.filter((one) => typeof one === "string" && one !== "").map(one => resolve(one)))];
195
+ return (path, pointer) => (!pointer && folders.some(folder => insideFolder(path, folder))) || identities.answering(path);
196
+ }
package/dist/project.d.ts CHANGED
@@ -16,6 +16,7 @@ export type ProjectExec = (file: string, args: readonly string[], options?: RunO
16
16
  * One canonicalization for every path that becomes project identity:
17
17
  * startup config, a session open, task placement. Comparisons elsewhere
18
18
  * fall back to raw equality for legacy rows written before this existed.
19
+ * Throws FolderUnavailable when the console's folder gate holds the read back (folder-gate.ts).
19
20
  */
20
21
  export declare function canonicalProject(path: string): string | null;
21
22
  /** Two repo identities, canonical when possible, raw-equal as the legacy fallback. */
package/dist/project.js CHANGED
@@ -13,12 +13,18 @@
13
13
  import { realpathSync, statSync } from "node:fs";
14
14
  import { basename, resolve, sep } from "node:path";
15
15
  import { run } from "./exec.js";
16
+ import { folderAnswers, FolderUnavailable } from "./folder-gate.js";
16
17
  /**
17
18
  * One canonicalization for every path that becomes project identity:
18
19
  * startup config, a session open, task placement. Comparisons elsewhere
19
20
  * fall back to raw equality for legacy rows written before this existed.
21
+ * Throws FolderUnavailable when the console's folder gate holds the read back (folder-gate.ts).
20
22
  */
21
23
  export function canonicalProject(path) {
24
+ // In the console, a folder that hasn't answered a look lately is not read on its thread (folder-gate.ts). That is
25
+ // "unavailable", never "not a folder": it throws, so no caller falls back to the path's spelling because of it.
26
+ if (!folderAnswers(path))
27
+ throw new FolderUnavailable(resolve(path));
22
28
  try {
23
29
  const real = realpathSync(resolve(path));
24
30
  return statSync(real).isDirectory() ? real : null;
package/dist/proposal.js CHANGED
@@ -30,6 +30,7 @@ export { validateTaskText } from "./task-text.js";
30
30
  import { resolve } from "node:path";
31
31
  import { canonicalProject } from "./project.js";
32
32
  import { heuristicSizing, planningSignals, refineFiledSizing } from "./task-sizing.js";
33
+ import { FolderUnavailable } from "./folder-gate.js";
33
34
  /** Provenance tokens are part of the audit surface: lowercase, bounded,
34
35
  * nothing that could render as anything but itself. */
35
36
  const FILED_VIA = /^[a-z][a-z0-9:-]{0,63}$/;
@@ -75,7 +76,16 @@ function checkRepo(given, admitted) {
75
76
  // directory resolves through symlinks; a path that does not exist yet
76
77
  // still normalizes, because filing must not depend on this machine
77
78
  // seeing every repo the plane knows about.
78
- const canonical = canonicalProject(given) ?? resolve(given);
79
+ let canonical;
80
+ // A folder the console's gate holds back (folder-gate.ts) is unavailable: refused, never filed by its spelling.
81
+ try {
82
+ canonical = canonicalProject(given) ?? resolve(given);
83
+ }
84
+ catch (error) {
85
+ if (error instanceof FolderUnavailable)
86
+ return refuse("bad-repo", "that repository's folder didn't answer — it may be on a drive that's disconnected; try again in a moment");
87
+ throw error;
88
+ }
79
89
  if (admitted !== undefined && !admitted.includes(canonical)) {
80
90
  return refuse("outside-ceiling", "that repository is outside what this surface was configured to show");
81
91
  }
package/dist/recipes.js CHANGED
@@ -12,6 +12,7 @@ import { applyModeToNewFiling } from "./plan-auto.js";
12
12
  import { canonicalProject } from "./project.js";
13
13
  import { RECIPE_FORMAT, RECIPE_LIMITS, readRecipeDocument } from "./contracts/recipes.js";
14
14
  import { resolve } from "node:path";
15
+ import { FolderUnavailable } from "./folder-gate.js";
15
16
  export const RECIPE_SCHEMA = `
16
17
  CREATE TABLE IF NOT EXISTS workflow_recipe (
17
18
  id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision > 0),
@@ -47,7 +48,17 @@ export class RecipeError extends Error {
47
48
  }
48
49
  }
49
50
  const honest = (text) => !hasForbiddenControls(text) && !hasDisguisedText(text);
50
- const recipeProject = (repo) => canonicalProject(repo) ?? resolve(repo);
51
+ const recipeProject = (repo) => {
52
+ // A project folder the console's gate holds back (folder-gate.ts) is unavailable, never matched by its spelling.
53
+ try {
54
+ return canonicalProject(repo) ?? resolve(repo);
55
+ }
56
+ catch (error) {
57
+ if (error instanceof FolderUnavailable)
58
+ throw new RecipeError("This project's folder didn't answer. Try again in a moment.", 503);
59
+ throw error;
60
+ }
61
+ };
51
62
  /** What a refusal at each top-level field tells the person, after the path-named line. */
52
63
  const FIELD_ADVICE = {
53
64
  name: "Name, goal, exclusions, and allowed paths must be readable text.", description: "Name, goal, exclusions, and allowed paths must be readable text.",
package/dist/release.d.ts CHANGED
@@ -51,8 +51,10 @@ export declare const PROTECTION_MINIMUM_CHECKS: readonly string[];
51
51
  /**
52
52
  * Pull-request checks a release never waits for or fails on, by exact name and the workflow file that defines them:
53
53
  * youiyoux's UI drift check (src/design/KIT.md), which fails a pull request that adds design drift but says nothing
54
- * about whether a build is safe to release. Only a GitHub Actions run of that name from that workflow is left out; the
55
- * same name from anywhere else is judged like any other check. Never a required check.
54
+ * about whether a build is safe to release, and the scripted journeys on a pull request (journeys.yml,
55
+ * docs/plans/ci-speed.md item 4), advisory while they settle in CI: the release check runs every journey itself. Only a
56
+ * GitHub Actions run of that name from that workflow is left out; the same name from anywhere else is judged like any
57
+ * other check. Never a required check.
56
58
  */
57
59
  export declare const ADVISORY_PR_CHECKS: Readonly<Record<string, string>>;
58
60
  /**
package/dist/release.js CHANGED
@@ -84,10 +84,16 @@ export const PROTECTION_MINIMUM_CHECKS = REQUIRED_PR_CHECKS.filter(name => !PROT
84
84
  /**
85
85
  * Pull-request checks a release never waits for or fails on, by exact name and the workflow file that defines them:
86
86
  * youiyoux's UI drift check (src/design/KIT.md), which fails a pull request that adds design drift but says nothing
87
- * about whether a build is safe to release. Only a GitHub Actions run of that name from that workflow is left out; the
88
- * same name from anywhere else is judged like any other check. Never a required check.
87
+ * about whether a build is safe to release, and the scripted journeys on a pull request (journeys.yml,
88
+ * docs/plans/ci-speed.md item 4), advisory while they settle in CI: the release check runs every journey itself. Only a
89
+ * GitHub Actions run of that name from that workflow is left out; the same name from anywhere else is judged like any
90
+ * other check. Never a required check.
89
91
  */
90
- export const ADVISORY_PR_CHECKS = { "youiyoux · UI drift": ".github/workflows/youiyoux.yml" };
92
+ export const ADVISORY_PR_CHECKS = {
93
+ "youiyoux · UI drift": ".github/workflows/youiyoux.yml",
94
+ "journeys · app (scripted)": ".github/workflows/journeys.yml",
95
+ "journeys · flows (scripted)": ".github/workflows/journeys.yml",
96
+ };
91
97
  /** The checks a release's publication waits for on its tag (.github/workflows/publish.yml, docs/plans/test-matrix.md):
92
98
  * the release proof (the tagged tree passed every REQUIRED_PR_CHECKS check on its pull request, read from GitHub) and
93
99
  * the publish job that needs it. The tag runs no tests again. Nothing else reported on the merge commit (main-push.yml)
package/dist/serve.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { secretFolder } from "./tool-secret-store.js";
2
2
  import { acceptedIdentities, recoverLearning } from "./project-learning.js";
3
- import { syncProjectIdentities } from "./project-identity.js";
3
+ import { consoleFolderGate, syncProjectIdentities } from "./project-identity.js";
4
+ import { inFolderScope } from "./folder-gate.js";
4
5
  import { firstTasksOffThread, recoverLearningOffThread } from "./learning-look.js";
5
6
  import { loadOrCreateVapidKeys } from "./push.js";
6
7
  import { checkoutPlan } from "./checkout-cleanup.js";
@@ -670,7 +671,7 @@ export function createDecisionServer(options) {
670
671
  instrumentRequest(store.telemetry, request, response);
671
672
  // Every password checked while answering counts against where this request came from (sign-in-guard.ts).
672
673
  // A GET or HEAD only reads (docs/plans/get-side-effects.md): store reads that would tidy up as they go don't.
673
- const answer = () => handle(request, response);
674
+ const answer = () => consoleFolders(options, identities, () => handle(request, response));
674
675
  void withPasswordSource(joinSourceOf(request), () => request.method === "GET" || request.method === "HEAD" ? asReadOnlyRequest(answer) : answer()).catch(error => {
675
676
  // Every unhandled error is logged (v99): the path and the message, never the request's body or query. The reference
676
677
  // is what a person's error page shows, so the log line can be found from it.
@@ -1986,7 +1987,9 @@ export function createDecisionServer(options) {
1986
1987
  // deterministic scan is idle without new meaningful work; shutdown drains it.
1987
1988
  let leadMaintenance = null;
1988
1989
  let leadClosing = false;
1989
- server.once('listening', () => {
1990
+ // Started in the console's folder scope (folder-gate.ts), so their own timers and the personal queue's kick (which
1991
+ // restores the context it was started in, never a request's) read folders as the console's work does.
1992
+ server.once('listening', () => consoleFolders(options, identities, () => {
1990
1993
  team.start();
1991
1994
  personalQueue.start();
1992
1995
  leadMaintenance = startMaintenance({ intervalMs: 5_000, shouldStop: () => leadClosing,
@@ -1996,7 +1999,7 @@ export function createDecisionServer(options) {
1996
1999
  // Pending delivery is durable and is read again on the next pass; a failure is said where follow status is read.
1997
2000
  onError: error => recordLeadFollowFailure(store, error, clock()),
1998
2001
  });
1999
- });
2002
+ }));
2000
2003
  // Background checks (docs/plans/get-side-effects.md): what pages show about this computer's agent sign-ins and the
2001
2004
  // services Toolroll uses is asked here, on a timer, and a page reads the last answer. A page visit never starts a
2002
2005
  // process or a network call, and never acts on what a check finds: lifting a sign-in pause and the lead-by-default
@@ -2011,7 +2014,7 @@ export function createDecisionServer(options) {
2011
2014
  }
2012
2015
  catch { /* a page says secrets can't be read; the next action reports why */ }
2013
2016
  backgroundChecks = startMaintenance({ intervalMs: options.backgroundChecksMs ?? BACKGROUND_CHECKS_MS, shouldStop: () => leadClosing,
2014
- run: async () => {
2017
+ run: () => consoleFolders(options, identities, async () => {
2015
2018
  const now = Date.now();
2016
2019
  const waits = [];
2017
2020
  if (now - agentsCheckedAt >= (options.backgroundChecksMs ?? LOCAL_AGENTS_EVERY_MS)) {
@@ -2109,7 +2112,7 @@ export function createDecisionServer(options) {
2109
2112
  catch { /* shown as last saved */ }
2110
2113
  }
2111
2114
  await Promise.all(waits);
2112
- },
2115
+ }),
2113
2116
  onError: () => { }, });
2114
2117
  void backgroundChecks.runNow();
2115
2118
  });
@@ -2415,3 +2418,22 @@ function startupCeiling(repos, roots, identities, canon) {
2415
2418
  configuredPaths,
2416
2419
  };
2417
2420
  }
2421
+ /** Each console's folder gate (folder-gate.ts), made once for its options. */
2422
+ const consoleGates = new WeakMap();
2423
+ /**
2424
+ * Runs the console's own work (a request, a background pass) in its folder scope: with identities resolved off its
2425
+ * thread (up and serve), it reads a person's folder synchronously only while that folder answered a look lately; its
2426
+ * own data (the database's folder, evidence, build checkouts) is read freely. Without identities (embedders, tests):
2427
+ * as before. Work that isn't the console's (a worker in the same process) never runs in it.
2428
+ */
2429
+ function consoleFolders(options, identities, work) {
2430
+ if (options.identities === undefined)
2431
+ return work();
2432
+ let gate = consoleGates.get(options);
2433
+ if (gate === undefined) {
2434
+ const databaseFile = options.store.databaseFile();
2435
+ gate = consoleFolderGate(identities, [options.configDir, options.evidenceRoot, options.poolRoot, databaseFile === null ? undefined : dirname(databaseFile)]);
2436
+ consoleGates.set(options, gate);
2437
+ }
2438
+ return inFolderScope(gate, work);
2439
+ }
@@ -265,16 +265,26 @@
265
265
  .project-card .project-name:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: .375rem; }
266
266
 
267
267
  label { display: block; font-size: 0.8125rem; font-weight: 500; margin: .75rem 0 0; color: var(--foreground); }
268
- :where(input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], textarea, select) {
269
- width: 100%; margin: .35rem 0 0; padding: .45rem .7rem;
270
- color: var(--foreground); background: var(--card); min-height: 2.125rem;
271
- border: 1px solid var(--input); border-radius: calc(var(--radius) - 3px);
272
- transition: border-color .15s, box-shadow .15s;
273
- }
268
+ /* The kit's field family (src/design/KIT.md "Fields"): one filled surface, no outline, 12px radius; the label above
269
+ names the field and focus draws the ink ring. A transparent border keeps the edge in forced colours. Textareas
270
+ grow with their words (no resize grip); selects draw the kit's chevron. */
271
+ :where(input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], input:not([type]), textarea, select) {
272
+ width: 100%; margin: .35rem 0 0; padding: .5rem .75rem;
273
+ color: var(--foreground); background-color: var(--so-field); min-height: 2.25rem;
274
+ border: 1px solid transparent; border-radius: var(--so-radius-sheet); box-shadow: none;
275
+ transition: background-color .12s;
276
+ }
277
+ :where(textarea) { resize: none; field-sizing: content; min-height: 4.75rem; max-height: 22rem; overflow-y: auto; line-height: 1.5; }
278
+ :where(select:not([multiple])) {
279
+ appearance: none; -webkit-appearance: none; padding-right: 2.1rem; cursor: pointer;
280
+ background-image: linear-gradient(45deg, transparent 50%, var(--so-muted) 50%), linear-gradient(135deg, var(--so-muted) 50%, transparent 50%);
281
+ background-position: calc(100% - 17px) 52%, calc(100% - 12px) 52%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat;
282
+ }
283
+ :where(input, textarea, select)::placeholder { color: var(--muted-foreground); opacity: 1; }
274
284
  /* The box above is in :where() so a surface's own rules (the workspace's 44px phone targets) win; the type keeps
275
285
  its old weight, so the phone's 16px rule still loses to it as before. */
276
286
  input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], textarea, select { font: 400 0.875rem/1.4 var(--font-sans); }
277
- @media (hover: hover) and (pointer: fine) { input:hover, textarea:hover, select:hover { border-color: var(--muted-foreground); } }
287
+ @media (hover: hover) and (pointer: fine) { :where(input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], input[type=search], input[type=time], input:not([type]), textarea, select):hover { background-color: color-mix(in srgb, var(--so-field) 94%, var(--so-ink)); } }
278
288
  input[type=number] { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
279
289
  input[type=radio], input[type=checkbox] { accent-color: var(--ring); }
280
290
  .permission-field { border: 0; padding: 0; margin: 1rem 0 0; min-width: 0; }
@@ -312,7 +322,8 @@
312
322
  min-height: 8.5rem; max-height: 18rem; margin: 0; padding: .9rem 1rem; resize: vertical;
313
323
  border: 0; background: transparent; box-shadow: none; font-size: 1.05rem; line-height: 1.55;
314
324
  }
315
- .task-prompt textarea:focus-visible { border: 0; box-shadow: none; }
325
+ .task-prompt textarea:focus-visible { border: 0; box-shadow: none; outline: none; }
326
+ .task-composer:focus-within { outline: 2px solid var(--so-ring); outline-offset: 2px; }
316
327
  @media (hover: hover) and (pointer: fine) { .task-prompt textarea:hover { border: 0; box-shadow: none; } }
317
328
  .task-repo { margin: .25rem .45rem .7rem; }
318
329
  .task-composer-footer { display: flex; align-items: center; gap: .5rem; padding: .25rem; }
@@ -361,7 +372,7 @@
361
372
  }
362
373
  input[type=password] { font-family: var(--font-mono); }
363
374
  input:focus-visible, textarea:focus-visible, select:focus-visible {
364
- outline: none; border-color: var(--ring); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 25%, transparent);
375
+ outline: 2px solid var(--so-ring); outline-offset: 1px; box-shadow: none;
365
376
  }
366
377
  button:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
367
378
 
@@ -2198,7 +2209,8 @@
2198
2209
  width: 100%; min-height: 3.5rem; max-height: 13rem; margin: 0; padding: .75rem .85rem;
2199
2210
  resize: vertical; border: 0; background: transparent; box-shadow: none; font-size: 1rem;
2200
2211
  }
2201
- .composer textarea:focus-visible { border: 0; box-shadow: none; }
2212
+ .composer textarea:focus-visible { border: 0; box-shadow: none; outline: none; }
2213
+ .composer:focus-within { outline: 2px solid var(--so-ring); outline-offset: 2px; }
2202
2214
  @media (hover: hover) and (pointer: fine) { .composer textarea:hover { border: 0; box-shadow: none; } }
2203
2215
  .composer button {
2204
2216
  flex: 0 0 2.75rem; width: 2.75rem; min-height: 2.75rem; padding: 0; border-radius: 999px;
@@ -17,6 +17,6 @@
17
17
  * HTML without it. It never rides a sensitive page (one that asks for a password or shows a secret), where the page
18
18
  * carries no chrome additions.
19
19
  */
20
- export declare const KIT_STANDALONE_SCRIPT = "(function(){\nif(window.__kitStandalone)return;window.__kitStandalone=1;var d=document;\nvar PHONE=\"(max-width: 760px)\";var FOCUSABLE='a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\nfunction phone(){return window.matchMedia(PHONE).matches;}\nfunction sync(t){var box=t.closest(\".k-composer\");if(!box)return;var st=getComputedStyle(t);var line=parseFloat(st.lineHeight)||21;\nvar pad=(parseFloat(st.paddingTop)||0)+(parseFloat(st.paddingBottom)||0);var max=line*(Number(t.getAttribute(\"data-max\"))||8)+pad;var min=line*(Number(t.getAttribute(\"data-min\"))||1)+pad;\nt.style.height=\"auto\";t.style.height=Math.max(min,Math.min(t.scrollHeight,max))+\"px\";t.style.overflowY=t.scrollHeight>max+1?\"auto\":\"hidden\";\nvar on=t.value.trim()!==\"\";box.classList.toggle(\"has-text\",on);var sends=box.querySelectorAll(\".k-send[data-gate]:not(:disabled)\");for(var i=0;i<sends.length;i++)sends[i].setAttribute(\"aria-disabled\",String(!on));}\nfunction syncAll(){var all=d.querySelectorAll(\".k-composer-ta\");for(var i=0;i<all.length;i++)sync(all[i]);}\nvar stack=[];\nfunction visible(el){return !!el&&el.isConnected&&el.getClientRects().length>0;}\nfunction openPanel(panel,trigger,onClose){if(stack.length)closeTop(\"close\");var floating=panel.classList.contains(\"k-pop\");var ph=phone();\nif(!panel.isConnected)d.body.appendChild(panel);var scrim=d.createElement(\"div\");scrim.className=\"k-scrim\"+(!floating||ph?\" dim\":\"\");panel.parentNode.insertBefore(scrim,panel);\npanel.hidden=false;panel.setAttribute(\"aria-modal\",\"true\");if(!panel.hasAttribute(\"tabindex\"))panel.tabIndex=-1;panel.classList.toggle(\"as-sheet\",floating&&ph);\nif(floating&&!ph&&trigger){var at=trigger.getBoundingClientRect();var w=panel.offsetWidth||336,h=panel.offsetHeight||200;var left=Math.min(Math.max(8,at.right-w),window.innerWidth-w-8);var below=at.bottom+6;var top=below+h>window.innerHeight-8?Math.max(8,at.top-h-6):below;panel.style.left=Math.round(left)+\"px\";panel.style.top=Math.round(top)+\"px\";}\nvar inerted=[];for(var node=panel;node!==d.body&&node.parentElement;node=node.parentElement){var kids=node.parentElement.children;for(var i=0;i<kids.length;i++){var s=kids[i];if(s===node||s===scrim||s.inert)continue;s.inert=true;inerted.push(s);}}\nvar entry={panel:panel,scrim:scrim,trigger:trigger,inerted:inerted,onClose:onClose};stack.push(entry);scrim.addEventListener(\"click\",function(){closeEntry(entry);});\nvar first=panel.querySelector(\"[autofocus]\")||panel.querySelector('input:not([type=\"hidden\"]), textarea, select')||panel;first.focus();}\nfunction closeEntry(entry){var at=stack.indexOf(entry);if(at<0)return;stack.splice(at,1);for(var i=0;i<entry.inerted.length;i++)entry.inerted[i].inert=false;\nentry.scrim.remove();entry.panel.hidden=true;entry.panel.classList.remove(\"as-sheet\");if(visible(entry.trigger))entry.trigger.focus();if(entry.onClose)entry.onClose();}\nfunction closeTop(){var top=stack[stack.length-1];if(top)closeEntry(top);}\nfunction menu(trigger,title,items){var ph=phone();var panel=d.createElement(\"div\");panel.className=ph?\"k-sheet\":\"k-pop k-pop-menu\";panel.setAttribute(\"role\",\"dialog\");\nvar id=\"k-menu-\"+Math.random().toString(36).slice(2,8);panel.setAttribute(\"aria-labelledby\",id);var head=d.createElement(\"div\");head.className=ph?\"k-sheet-h\":\"k-pop-h\";\nvar h=d.createElement(ph?\"h2\":\"h3\");h.id=id;h.textContent=title;var x=d.createElement(\"button\");x.type=\"button\";x.className=\"k-btn k-btn-ghost k-btn-sm k-btn-icon\";x.setAttribute(\"aria-label\",\"Close\");x.setAttribute(\"data-k-close\",\"\");x.textContent=\"\\u00d7\";\nhead.appendChild(h);head.appendChild(x);var list=d.createElement(\"div\");list.className=\"k-menu\";var entry;\nitems.forEach(function(item){var b=d.createElement(\"button\");b.type=\"button\";b.textContent=item.current?item.label+\" \\u00b7 current\":item.label;if(item.current)b.setAttribute(\"aria-current\",\"true\");b.addEventListener(\"click\",function(){closeTop();item.act();});list.appendChild(b);});\npanel.appendChild(head);panel.appendChild(list);openPanel(panel,trigger,function(){panel.remove();});}\nfunction composerMenu(target){var chip=target.closest(\"[data-k-composer-project]\");var plus=target.closest(\"[data-k-composer-plus]\");var id=chip?chip.getAttribute(\"data-k-composer-project\"):plus?plus.getAttribute(\"data-k-composer-plus\"):null;if(id===null)return false;\nvar hidden=d.getElementById(id+\"-project\");function choose(trigger){if(!hidden)return;var items=[];try{var parsed=JSON.parse(hidden.getAttribute(\"data-items\")||\"[]\");if(Array.isArray(parsed))items=parsed.filter(function(one){return typeof one===\"string\";});}catch(e){items=[];}\nmenu(trigger,\"Project\",items.map(function(name){return{label:name,current:name===hidden.value,act:function(){hidden.value=name;var c=d.querySelector('[data-k-composer-project=\"'+CSS.escape(id)+'\"]');if(c){var l=c.querySelector(\"[data-k-project-name]\");if(l)l.textContent=name;c.setAttribute(\"aria-label\",\"Project: \"+name+\". Change\");}hidden.dispatchEvent(new Event(\"change\",{bubbles:true}));}};}));}\nif(chip){choose(chip);return true;}var attach=d.querySelector('input[type=\"file\"][data-k-attach=\"'+CSS.escape(id)+'\"]');var items=[];if(attach)items.push({label:\"Attach a file\",act:function(){attach.click();}});\nif(hidden)items.push({label:\"Choose project\",act:function(){choose(d.querySelector('[data-k-composer-project=\"'+CSS.escape(id)+'\"]')||plus);}});if(items.length)menu(plus,\"Add to your message\",items);return true;}\nd.addEventListener(\"input\",function(e){var t=e.target;if(t&&t.classList&&t.classList.contains(\"k-composer-ta\"))sync(t);});\nd.addEventListener(\"click\",function(e){var t=e.target;if(!t||!t.closest)return;\nif(t.closest('.k-send[data-gate][aria-disabled=\"true\"]')){e.preventDefault();return;}\nvar draft=t.closest(\"[data-k-draft]\");if(draft){var box=d.getElementById((draft.getAttribute(\"data-k-into\")||\"\")+\"-text\");if(box&&!box.disabled){e.preventDefault();var words=draft.getAttribute(\"data-k-draft\")||\"\";var typed=box.value;\nbox.value=typed.trim()===\"\"?words:typed.indexOf(words)>=0?typed:typed.replace(/\\s+$/,\"\")+\"\\n\"+words;sync(box);box.focus();}return;}\nif((t.closest(\"[data-k-composer-project]\")||t.closest(\"[data-k-composer-plus]\"))&&composerMenu(t)){e.preventDefault();return;}\nvar opener=t.closest(\"[data-k-open]\");if(opener){var panel=d.getElementById(opener.getAttribute(\"data-k-open\")||\"\");if(panel){e.preventDefault();openPanel(panel,opener);}return;}\nif(t.closest(\"[data-k-close]\")){var top=stack[stack.length-1];if(top&&top.panel.contains(t)){e.preventDefault();closeEntry(top);}return;}\nvar sw=t.closest(\"button.k-switch[role=switch][data-k-switch]\");if(sw){var on=sw.getAttribute(\"aria-checked\")!==\"true\";sw.setAttribute(\"aria-checked\",String(on));var w=(sw.getAttribute(\"data-k-switch\")||\"On|Off\").split(\"|\");var s=sw.querySelector(\"[data-k-words]\");if(s)s.textContent=(on?w[0]:w[1])||\"\";sw.dispatchEvent(new CustomEvent(\"k-switch\",{bubbles:true,detail:{on:on}}));}});\nd.addEventListener(\"keydown\",function(e){var top=stack[stack.length-1];if(!top)return;if(e.key===\"Escape\"){e.preventDefault();closeEntry(top);return;}if(e.key!==\"Tab\")return;\nvar items=Array.prototype.filter.call(top.panel.querySelectorAll(FOCUSABLE),visible);if(!items.length){e.preventDefault();top.panel.focus();return;}var first=items[0],last=items[items.length-1];\nif(e.shiftKey&&(d.activeElement===first||d.activeElement===top.panel)){e.preventDefault();last.focus();}else if(!e.shiftKey&&d.activeElement===last){e.preventDefault();first.focus();}},true);\nfunction kb(){var v=window.visualViewport;if(!v)return;var covered=Math.max(0,Math.round(window.innerHeight-v.height-v.offsetTop));d.documentElement.style.setProperty(\"--kb\",covered+\"px\");d.documentElement.classList.toggle(\"kb-open\",covered>80);}\nif(window.visualViewport){window.visualViewport.addEventListener(\"resize\",kb);window.visualViewport.addEventListener(\"scroll\",kb);}\nif(d.readyState===\"loading\")d.addEventListener(\"DOMContentLoaded\",syncAll);else syncAll();\n})();";
20
+ export declare const KIT_STANDALONE_SCRIPT = "(function(){\nif(window.__kitStandalone)return;window.__kitStandalone=1;var d=document;\nvar PHONE=\"(max-width: 760px)\";var FOCUSABLE='a[href], button:not([disabled]), input:not([disabled]):not([type=\"hidden\"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';\nfunction phone(){return window.matchMedia(PHONE).matches;}\nfunction sync(t){var box=t.closest(\".k-composer\");if(!box)return;var st=getComputedStyle(t);var line=parseFloat(st.lineHeight)||21;\nvar pad=(parseFloat(st.paddingTop)||0)+(parseFloat(st.paddingBottom)||0);var max=line*(Number(t.getAttribute(\"data-max\"))||8)+pad;var min=line*(Number(t.getAttribute(\"data-min\"))||1)+pad;\nt.style.height=\"auto\";t.style.height=Math.max(min,Math.min(t.scrollHeight,max))+\"px\";t.style.overflowY=t.scrollHeight>max+1?\"auto\":\"hidden\";\nvar on=t.value.trim()!==\"\";box.classList.toggle(\"has-text\",on);var sends=box.querySelectorAll(\".k-send[data-gate]:not(:disabled)\");for(var i=0;i<sends.length;i++)sends[i].setAttribute(\"aria-disabled\",String(!on));}\nfunction syncAll(){var all=d.querySelectorAll(\".k-composer-ta\");for(var i=0;i<all.length;i++)sync(all[i]);}\nvar stack=[];\nfunction visible(el){return !!el&&el.isConnected&&el.getClientRects().length>0;}\nfunction openPanel(panel,trigger,onClose){if(stack.length)closeTop(\"close\");var floating=panel.classList.contains(\"k-pop\");var ph=phone();\nif(!panel.isConnected)d.body.appendChild(panel);var scrim=d.createElement(\"div\");scrim.className=\"k-scrim\"+(!floating||ph?\" dim\":\"\");panel.parentNode.insertBefore(scrim,panel);\npanel.hidden=false;panel.setAttribute(\"aria-modal\",\"true\");if(!panel.hasAttribute(\"tabindex\"))panel.tabIndex=-1;panel.classList.toggle(\"as-sheet\",floating&&ph);\nif(floating&&!ph&&trigger){var at=trigger.getBoundingClientRect();var w=panel.offsetWidth||336,h=panel.offsetHeight||200;var left=Math.min(Math.max(8,at.right-w),window.innerWidth-w-8);var below=at.bottom+6;var top=below+h>window.innerHeight-8?Math.max(8,at.top-h-6):below;panel.style.left=Math.round(left)+\"px\";panel.style.top=Math.round(top)+\"px\";}\nvar inerted=[];for(var node=panel;node!==d.body&&node.parentElement;node=node.parentElement){var kids=node.parentElement.children;for(var i=0;i<kids.length;i++){var s=kids[i];if(s===node||s===scrim||s.inert)continue;s.inert=true;inerted.push(s);}}\nvar entry={panel:panel,scrim:scrim,trigger:trigger,inerted:inerted,onClose:onClose};stack.push(entry);scrim.addEventListener(\"click\",function(){closeEntry(entry);});\nvar first=panel.querySelector(\"[autofocus]\")||panel.querySelector('input:not([type=\"hidden\"]), textarea, select')||panel;first.focus();}\nfunction closeEntry(entry){var at=stack.indexOf(entry);if(at<0)return;stack.splice(at,1);for(var i=0;i<entry.inerted.length;i++)entry.inerted[i].inert=false;\nentry.scrim.remove();entry.panel.hidden=true;entry.panel.classList.remove(\"as-sheet\");if(visible(entry.trigger))entry.trigger.focus();if(entry.onClose)entry.onClose();}\nfunction closeTop(){var top=stack[stack.length-1];if(top)closeEntry(top);}\nfunction menu(trigger,title,items){var ph=phone();var panel=d.createElement(\"div\");panel.className=ph?\"k-sheet\":\"k-pop k-pop-menu\";panel.setAttribute(\"role\",\"dialog\");\nvar id=\"k-menu-\"+Math.random().toString(36).slice(2,8);panel.setAttribute(\"aria-labelledby\",id);var head=d.createElement(\"div\");head.className=ph?\"k-sheet-h\":\"k-pop-h\";\nvar h=d.createElement(ph?\"h2\":\"h3\");h.id=id;h.textContent=title;var x=d.createElement(\"button\");x.type=\"button\";x.className=\"k-btn k-btn-ghost k-btn-sm k-btn-icon\";x.setAttribute(\"aria-label\",\"Close\");x.setAttribute(\"data-k-close\",\"\");x.textContent=\"\\u00d7\";\nhead.appendChild(h);head.appendChild(x);var list=d.createElement(\"div\");list.className=\"k-menu\";var entry;\nitems.forEach(function(item){var b=d.createElement(\"button\");b.type=\"button\";b.textContent=item.current?item.label+\" \\u00b7 current\":item.label;if(item.current)b.setAttribute(\"aria-current\",\"true\");b.addEventListener(\"click\",function(){closeTop();item.act();});list.appendChild(b);});\npanel.appendChild(head);panel.appendChild(list);openPanel(panel,trigger,function(){panel.remove();});}\nfunction composerMenu(target){var chip=target.closest(\"[data-k-composer-project]\");var plus=target.closest(\"[data-k-composer-plus]\");var id=chip?chip.getAttribute(\"data-k-composer-project\"):plus?plus.getAttribute(\"data-k-composer-plus\"):null;if(id===null)return false;\nvar hidden=d.getElementById(id+\"-project\");function choose(trigger){if(!hidden)return;var items=[];try{var parsed=JSON.parse(hidden.getAttribute(\"data-items\")||\"[]\");if(Array.isArray(parsed))items=parsed.filter(function(one){return typeof one===\"string\";});}catch(e){items=[];}\nmenu(trigger,\"Project\",items.map(function(name){return{label:name,current:name===hidden.value,act:function(){hidden.value=name;var c=d.querySelector('[data-k-composer-project=\"'+CSS.escape(id)+'\"]');if(c){var l=c.querySelector(\"[data-k-project-name]\");if(l)l.textContent=name;c.setAttribute(\"aria-label\",\"Project: \"+name+\". Change\");}hidden.dispatchEvent(new Event(\"change\",{bubbles:true}));}};}));}\nif(chip){choose(chip);return true;}var attach=d.querySelector('input[type=\"file\"][data-k-attach=\"'+CSS.escape(id)+'\"]');var items=[];if(attach)items.push({label:\"Attach a file\",act:function(){attach.click();}});\nif(hidden)items.push({label:\"Choose project\",act:function(){choose(d.querySelector('[data-k-composer-project=\"'+CSS.escape(id)+'\"]')||plus);}});if(items.length)menu(plus,\"Add to your message\",items);return true;}\nd.addEventListener(\"input\",function(e){var t=e.target;if(t&&t.classList&&t.classList.contains(\"k-composer-ta\"))sync(t);});\nd.addEventListener(\"click\",function(e){var t=e.target;if(!t||!t.closest)return;\nif(t.closest('.k-send[data-gate][aria-disabled=\"true\"]')){e.preventDefault();return;}\nvar draft=t.closest(\"[data-k-draft]\");if(draft){var box=d.getElementById((draft.getAttribute(\"data-k-into\")||\"\")+\"-text\");if(box&&!box.disabled){e.preventDefault();var words=draft.getAttribute(\"data-k-draft\")||\"\";var typed=box.value;\nbox.value=typed.trim()===\"\"?words:typed.indexOf(words)>=0?typed:typed.replace(/\\s+$/,\"\")+\"\\n\"+words;sync(box);box.focus();}return;}\nif((t.closest(\"[data-k-composer-project]\")||t.closest(\"[data-k-composer-plus]\"))&&composerMenu(t)){e.preventDefault();return;}\nvar opener=t.closest(\"[data-k-open]\");if(opener){var panel=d.getElementById(opener.getAttribute(\"data-k-open\")||\"\");if(panel){e.preventDefault();openPanel(panel,opener);}return;}\nif(t.closest(\"[data-k-close]\")){var top=stack[stack.length-1];if(top&&top.panel.contains(t)){e.preventDefault();closeEntry(top);}return;}\nvar sw=t.closest(\"button.k-switch[role=switch][data-k-switch]\");if(sw){var on=sw.getAttribute(\"aria-checked\")!==\"true\";sw.setAttribute(\"aria-checked\",String(on));var w=(sw.getAttribute(\"data-k-switch\")||\"On|Off\").split(\"|\");var s=sw.querySelector(\"[data-k-words]\");if(s)s.textContent=(on?w[0]:w[1])||\"\";sw.dispatchEvent(new CustomEvent(\"k-switch\",{bubbles:true,detail:{on:on}}));}});\nd.addEventListener(\"keydown\",function(e){var top=stack[stack.length-1];if(!top)return;if(e.key===\"Escape\"){e.preventDefault();closeEntry(top);return;}if(e.key!==\"Tab\")return;\nvar items=Array.prototype.filter.call(top.panel.querySelectorAll(FOCUSABLE),visible);if(!items.length){e.preventDefault();top.panel.focus();return;}var first=items[0],last=items[items.length-1];\nif(e.shiftKey&&(d.activeElement===first||d.activeElement===top.panel)){e.preventDefault();last.focus();}else if(!e.shiftKey&&d.activeElement===last){e.preventDefault();first.focus();}},true);\nfunction kb(){var v=window.visualViewport;if(!v)return;var covered=Math.max(0,Math.round(window.innerHeight-v.height-v.offsetTop));d.documentElement.style.setProperty(\"--kb\",covered+\"px\");d.documentElement.classList.toggle(\"kb-open\",covered>80);}\nif(window.visualViewport){window.visualViewport.addEventListener(\"resize\",kb);window.visualViewport.addEventListener(\"scroll\",kb);}\nvar root=d.documentElement;root.classList.add(\"k-js\");d.addEventListener(\"keydown\",function(e){if(e.key===\"Tab\"||e.key===\"F6\")root.classList.add(\"k-kbd\");},true);d.addEventListener(\"pointerdown\",function(){root.classList.remove(\"k-kbd\");},true);\nif(d.readyState===\"loading\")d.addEventListener(\"DOMContentLoaded\",syncAll);else syncAll();\n})();";
21
21
  /** Whether a page's markup has kit pieces the standalone behaviour wires (a composer, an overlay, a button switch, an idea). */
22
22
  export declare const KIT_BEHAVIOR_MARKUP: RegExp;
@@ -63,6 +63,7 @@ var items=Array.prototype.filter.call(top.panel.querySelectorAll(FOCUSABLE),visi
63
63
  if(e.shiftKey&&(d.activeElement===first||d.activeElement===top.panel)){e.preventDefault();last.focus();}else if(!e.shiftKey&&d.activeElement===last){e.preventDefault();first.focus();}},true);
64
64
  function kb(){var v=window.visualViewport;if(!v)return;var covered=Math.max(0,Math.round(window.innerHeight-v.height-v.offsetTop));d.documentElement.style.setProperty("--kb",covered+"px");d.documentElement.classList.toggle("kb-open",covered>80);}
65
65
  if(window.visualViewport){window.visualViewport.addEventListener("resize",kb);window.visualViewport.addEventListener("scroll",kb);}
66
+ var root=d.documentElement;root.classList.add("k-js");d.addEventListener("keydown",function(e){if(e.key==="Tab"||e.key==="F6")root.classList.add("k-kbd");},true);d.addEventListener("pointerdown",function(){root.classList.remove("k-kbd");},true);
66
67
  if(d.readyState==="loading")d.addEventListener("DOMContentLoaded",syncAll);else syncAll();
67
68
  })();`;
68
69
  /** Whether a page's markup has kit pieces the standalone behaviour wires (a composer, an overlay, a button switch, an idea). */
@@ -90,7 +90,9 @@ export declare function textarea(options: {
90
90
  error?: boolean;
91
91
  attrs?: Attrs;
92
92
  }): Html;
93
- /** A native select with the kit's chevron: 32px, `sm` 28px in a toolbar, 44px and 16px text on a phone. */
93
+ /** A native select in the field family, with the chevron: 36px, `sm` 28px in a toolbar, 44px and 16px text on a phone.
94
+ * `chip` draws it as a composer's filled pill (a project inside the message box): still a native select, so it works
95
+ * without script. */
94
96
  export declare function select(options: {
95
97
  id?: string;
96
98
  name?: string;
@@ -99,6 +101,7 @@ export declare function select(options: {
99
101
  current?: string;
100
102
  sm?: boolean;
101
103
  wide?: boolean;
104
+ chip?: boolean;
102
105
  attrs?: Attrs;
103
106
  }): Html;
104
107
  /** An on/off setting in a form: a checkbox with role=switch and a visible label (posts `name=on` when on). */
@@ -109,6 +112,15 @@ export declare function switchInput(options: {
109
112
  on: boolean;
110
113
  attrs?: Attrs;
111
114
  }): Html;
115
+ /** An on/off choice inside a composer, drawn as a filled pill chip whose mark fills when on: a checkbox with
116
+ * role=switch (posts `name=value` when on), so it works without script. Its longer words go under the box (`note`). */
117
+ export declare function toggleChip(options: {
118
+ id: string;
119
+ name: string;
120
+ label: string;
121
+ on: boolean;
122
+ attrs?: Attrs;
123
+ }): Html;
112
124
  /** Two columns: label and one muted line | the control. `inline` keeps the control beside the label on a phone. */
113
125
  export declare function formRow(label: string, hint: string | null, control: Html, variant?: "" | "inline" | "top" | "wide"): Html;
114
126
  /** Radio cards: one choice from a few, each with a bold label and one muted line. */
@@ -273,8 +285,9 @@ export declare function sendButton(options?: {
273
285
  attrs?: Attrs;
274
286
  }): Html;
275
287
  /**
276
- * The message box: one soft rounded container, the textarea grows from `rows` to `maxRows` lines then scrolls,
277
- * one bottom row inside (+ menu, the project as a quiet chip, small controls, the round send). Textarea
288
+ * The message box: one filled rounded object, the textarea grows from `rows` to `maxRows` lines then scrolls,
289
+ * one bottom row inside (+ menu, the project as a filled pill chip, toggle chips, the round send); its note and its
290
+ * refusal sit under the box (all inside `.k-composer-field`). Textarea
278
291
  * `<id>-text` (named `name`), refusal slot `<id>-err`, chosen project a hidden input `<id>-project` (named
279
292
  * `projectName`). A refused send keeps the words in the box with the reason in the error slot.
280
293
  */
@@ -291,6 +304,7 @@ export declare function composer(options: {
291
304
  current: string;
292
305
  items: readonly string[];
293
306
  name?: string;
307
+ sub?: string;
294
308
  };
295
309
  before?: Html;
296
310
  send: Html;
@@ -71,18 +71,25 @@ export function input(options) {
71
71
  export function textarea(options) {
72
72
  return html `<textarea class="k-textarea" id="${options.id}" name="${options.name}" rows="${options.rows ?? 3}"${attributes({ "aria-invalid": options.error ? "true" : undefined, "aria-describedby": `${options.id}-err`, ...options.attrs })}>${options.value ?? ""}</textarea>`;
73
73
  }
74
- /** A native select with the kit's chevron: 32px, `sm` 28px in a toolbar, 44px and 16px text on a phone. */
74
+ /** A native select in the field family, with the chevron: 36px, `sm` 28px in a toolbar, 44px and 16px text on a phone.
75
+ * `chip` draws it as a composer's filled pill (a project inside the message box): still a native select, so it works
76
+ * without script. */
75
77
  export function select(options) {
76
78
  const opts = options.items.map(item => {
77
79
  const [value, label] = typeof item === "string" ? [item, item] : item;
78
80
  return html `<option value="${value}"${value === options.current ? html ` selected` : ""}>${label}</option>`;
79
81
  });
80
- return html `<span class="${cls("k-selw", options.sm && "sm", options.wide && "wide")}"><select class="k-sel"${attributes({ id: options.id, name: options.name, "aria-label": options.label, ...options.attrs })}>${opts}</select></span>`;
82
+ return html `<span class="${cls("k-selw", options.sm && "sm", options.wide && "wide", options.chip && "k-selw-chip")}"><select class="k-sel"${attributes({ id: options.id, name: options.name, "aria-label": options.label, ...options.attrs })}>${opts}</select></span>`;
81
83
  }
82
84
  /** An on/off setting in a form: a checkbox with role=switch and a visible label (posts `name=on` when on). */
83
85
  export function switchInput(options) {
84
86
  return html `<label class="k-switch"><input class="k-switch-input" type="checkbox" role="switch" id="${options.id}" name="${options.name}"${options.on ? html ` checked` : ""}${attributes(options.attrs ?? {})}><span class="k-trk" aria-hidden="true"></span><span>${options.label}</span></label>`;
85
87
  }
88
+ /** An on/off choice inside a composer, drawn as a filled pill chip whose mark fills when on: a checkbox with
89
+ * role=switch (posts `name=value` when on), so it works without script. Its longer words go under the box (`note`). */
90
+ export function toggleChip(options) {
91
+ return html `<label class="k-tchip"><input class="k-switch-input" type="checkbox" role="switch" id="${options.id}" name="${options.name}"${options.on ? html ` checked` : ""}${attributes(options.attrs ?? {})}><span class="k-tchip-mark" aria-hidden="true"></span><span>${options.label}</span></label>`;
92
+ }
86
93
  /** Two columns: label and one muted line | the control. `inline` keeps the control beside the label on a phone. */
87
94
  export function formRow(label, hint, control, variant = "") {
88
95
  return html `<div class="${cls("k-frow", variant)}"><div class="k-frow-l"><span class="k-frow-t">${label}</span>${hint ? html `<span class="k-frow-h">${hint}</span>` : ""}</div><div class="k-frow-c">${control}</div></div>`;
@@ -243,8 +250,9 @@ export function sendButton(options = {}) {
243
250
  ${options.stop ? html `<button class="k-send k-stop" type="button" aria-label="${options.stop.label ?? "Stop"}" title="${options.stop.label ?? "Stop"}"${attributes(options.stop.attrs ?? {})}>${icon("stop")}</button>` : ""}</span>`;
244
251
  }
245
252
  /**
246
- * The message box: one soft rounded container, the textarea grows from `rows` to `maxRows` lines then scrolls,
247
- * one bottom row inside (+ menu, the project as a quiet chip, small controls, the round send). Textarea
253
+ * The message box: one filled rounded object, the textarea grows from `rows` to `maxRows` lines then scrolls,
254
+ * one bottom row inside (+ menu, the project as a filled pill chip, toggle chips, the round send); its note and its
255
+ * refusal sit under the box (all inside `.k-composer-field`). Textarea
248
256
  * `<id>-text` (named `name`), refusal slot `<id>-err`, chosen project a hidden input `<id>-project` (named
249
257
  * `projectName`). A refused send keeps the words in the box with the reason in the error slot.
250
258
  */
@@ -252,16 +260,16 @@ export function composer(options) {
252
260
  const value = options.value ?? "";
253
261
  const textId = `${options.id}-text`;
254
262
  const project = options.project
255
- ? html `<button class="k-composer-chip" type="button" data-k-composer-project="${options.id}" aria-haspopup="dialog" aria-label="Project: ${options.project.current}. Change"${options.disabled ? html ` disabled` : ""}><span data-k-project-name>${options.project.current}</span>${icon("chevron", true)}</button>\
263
+ ? html `<button class="k-composer-chip" type="button" data-k-composer-project="${options.id}" aria-haspopup="dialog" aria-label="Project: ${options.project.current}. Change"${options.disabled ? html ` disabled` : ""}><span data-k-project-name>${options.project.current}</span>${options.project.sub ? html `<span class="k-chip-sub">${options.project.sub}</span>` : ""}</button>\
256
264
  <input type="hidden" id="${options.id}-project" name="${options.project.name ?? "project"}" value="${options.project.current}" data-items="${JSON.stringify(options.project.items)}">`
257
265
  : html ``;
258
266
  const plus = options.plus
259
267
  ? html `<button class="k-composer-plus" type="button" data-k-composer-plus="${options.id}" aria-haspopup="dialog" aria-label="Add to your message"${options.disabled ? html ` disabled` : ""}>${icon("plus")}</button>`
260
268
  : html ``;
261
- return html `<div class="${cls("k-composer", options.line && "line", options.disabled && "off", value.trim() && "has-text", options.working && "working")}" data-k-composer="${options.id}">\
269
+ return html `<div class="k-composer-field"><div class="${cls("k-composer", options.line && "line", options.disabled && "off", value.trim() && "has-text", options.working && "working")}" data-k-composer="${options.id}">\
262
270
  <label class="k-sr" for="${textId}">${options.label}</label><div class="k-composer-files" data-k-files="${options.id}"></div>\
263
271
  <textarea class="k-composer-ta" id="${textId}" name="${options.name}" rows="${options.rows ?? 1}" data-min="${options.rows ?? 1}" data-max="${options.maxRows ?? 8}"${attributes({ placeholder: options.placeholder, disabled: options.disabled === true, "aria-invalid": options.error ? "true" : undefined, "aria-describedby": `${options.id}-err`, ...options.textAttrs })}>${value}</textarea>\
264
- <div class="k-composer-bar">${plus}${project}${options.before ?? ""}<span class="k-grow"></span>${options.send}</div>\
272
+ <div class="k-composer-bar">${plus}${project}${options.before ?? ""}<span class="k-grow"></span>${options.send}</div></div>\
265
273
  ${options.note ? html `<p class="k-note k-composer-note">${options.note}</p>` : ""}<div class="k-composer-err k-err" id="${options.id}-err" role="alert">${options.error ?? ""}</div></div>`;
266
274
  }
267
275
  /** A floating panel, hidden until its trigger (`overlayTrigger`) opens it: role=dialog, a labelled Close, Escape
@@ -703,7 +703,7 @@ export function createPagesHandlers(runtime) {
703
703
  // connects its builder; there is no separate restart or binding step.
704
704
  // The admission asks again, inside its registry lock and its transaction, about the repo it proves.
705
705
  const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
706
- const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed });
706
+ const admitted = await admitProject(store, { registryFile: options.registryPath ?? null, path: canonical, actor: { label: who.name, origin: "console" }, now, stillAllowed, resolveFolder: identities.resolveNow });
707
707
  if (!admitted.ok) {
708
708
  return void projectsScreen(response, who, admitted.reason === "not-git" ? "that path is not a git repository" : `that project is valid, but it could not be added — ${admitted.message}`, admitted.reason === "moved" ? 409 : 400, safeReturn(body.get("return")), "open");
709
709
  }
@@ -891,7 +891,7 @@ export function createPagesHandlers(runtime) {
891
891
  return projectsScreen(response, who, `the clone landed at ${cloned.target} but did not prove under the ceiling — it was left in place; enroll it by hand`, 400, "/", "clone");
892
892
  }
893
893
  const stillAllowed = (repo) => identities.generation() === decidedOn && visible(repo);
894
- const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed });
894
+ const joined = await admitProject(store, { registryFile: options.registryPath ?? null, path: admitted, actor: { label: who.name, origin: "console" }, now, stillAllowed, resolveFolder: identities.resolveNow });
895
895
  if (!joined.ok) {
896
896
  return projectsScreen(response, who, `${cloned.target} is cloned but not added — ${joined.message}`, joined.reason === "moved" ? 409 : 400, "/", "clone");
897
897
  }
@@ -7,7 +7,7 @@
7
7
  import { html, joinHtml, postForm } from "../html.js";
8
8
  import { projectName } from "../project.js";
9
9
  import { screen } from "./chrome.js";
10
- import { btn, composer, disclose, doc, docHeader, dock, field, ideas, input, note, phoneHeader, radios, select, sendButton, switchInput, textarea } from "./kit.js";
10
+ import { btn, composer, disclose, doc, docHeader, dock, field, ideas, input, note, phoneHeader, radios, select, sendButton, textarea, toggleChip } from "./kit.js";
11
11
  const FORM_ID = "nt-form";
12
12
  const PLAN_ON = "The agent reads the code and asks you anything unclear, then writes the plan.";
13
13
  const PLAN_OFF = "The agent writes the plan straight from your words. Faster for small, obvious changes.";
@@ -51,8 +51,8 @@ suggestions = []) {
51
51
  const folder = !typed ? "" : field({ id: "nt-repo", label: "Project folder", hint: "No projects yet: name the folder of a git repository.",
52
52
  control: input({ id: "nt-repo", name: "repo", value: chosen, hint: true, attrs: { required: true, placeholder: "/path/to/repository", autocapitalize: "none", spellcheck: "false" } }) });
53
53
  const bar = joinHtml([
54
- typed ? "" : select({ id: "nt-project", name: "repo", label: "Project", items, current: chosen, sm: true }),
55
- switchInput({ id: "nt-plan", name: "plan-first", label: "Read code before planning", on: planOn, attrs: { value: "1" } }),
54
+ typed ? "" : select({ id: "nt-project", name: "repo", label: "Project", items, current: chosen, chip: true }),
55
+ toggleChip({ id: "nt-plan", name: "plan-first", label: "Read code before planning", on: planOn, attrs: { value: "1" } }),
56
56
  ]);
57
57
  const box = composer({
58
58
  id: "nt", name: "title", label: "What should change?", placeholder: "What should change? One or two sentences is enough.",
@@ -1083,8 +1083,10 @@ export function decisionAnswerScript() {
1083
1083
  `var f=e.target;if(!f||!f.classList||!f.classList.contains("decide-inline"))return;` +
1084
1084
  `e.preventDefault();` +
1085
1085
  `var card=f.closest("[data-decision-id]");` +
1086
- `var page=f.action.replace(/\\/answer$/,"");` +
1087
- `fetch(f.action,{method:"POST",credentials:"same-origin",` +
1086
+ // The action attribute, not f.action: a field named "action" would shadow that property (DOM clobbering).
1087
+ `var to=new URL(HTMLFormElement.prototype.getAttribute.call(f,"action")||"",location.href).href;` +
1088
+ `var page=to.replace(/\\/answer$/,"");` +
1089
+ `fetch(to,{method:"POST",credentials:"same-origin",` +
1088
1090
  `headers:{"content-type":"application/x-www-form-urlencoded"},` +
1089
1091
  `body:new URLSearchParams(new FormData(f)).toString()})` +
1090
1092
  `.then(function(r){return r.text().then(function(t){return{r:r,t:t}})})` +
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { validModelId } from "./provider.js";
8
8
  import { planInstall, applyInstall, skillContent, SKILL_DIR, SKILL_FILE } from "./skills.js";
9
+ import { folderAnswers } from "./folder-gate.js";
9
10
  export const ASSISTANTS = withAcp({
10
11
  claude: { name: "Claude", description: "Use Claude Code for your tasks." },
11
12
  codex: { name: "Codex", description: "Use your OpenAI coding assistant." },
@@ -37,6 +38,9 @@ export function modelChoices(provider, configured, home = homedir()) {
37
38
  return choices;
38
39
  }
39
40
  export function detectPreparation(repo) {
41
+ // In the console, a folder that hasn't answered a look lately is not read on its thread (folder-gate.ts).
42
+ if (!folderAnswers(repo))
43
+ return null;
40
44
  const has = (file) => existsSync(join(repo, file));
41
45
  if (has("package.json")) {
42
46
  if (has("pnpm-lock.yaml"))
@@ -59,6 +63,8 @@ export function detectPreparation(repo) {
59
63
  }
60
64
  /** Browser installation may write only this known file inside its project. */
61
65
  export function previewProjectInstructions(repo) {
66
+ if (!folderAnswers(repo))
67
+ return { ok: false, message: "This project folder didn't answer. Try again in a moment." };
62
68
  try {
63
69
  if (!lstatSync(repo).isDirectory())
64
70
  return { ok: false, message: "This project folder is unavailable. Reconnect it before adding instructions." };