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.
@@ -12,11 +12,21 @@ import { canonicalProject } from "./project.js";
12
12
  import { phoneText, projectLabel } from "./telegram-status.js";
13
13
  import { CHAT_CONTROLS, chatControlHref, isChatControl, } from "./chat-controls.js";
14
14
  import { CHAT_TASK_ACTIONS, isChatTaskAction } from "./chat-task-actions.js";
15
+ import { FolderUnavailable } from "./folder-gate.js";
15
16
  export function channelRepos(store, approver, registry) {
16
17
  const seen = new Set();
17
18
  const repos = [];
18
19
  for (const path of registry) {
19
- const canonical = canonicalProject(path) ?? path;
20
+ let canonical;
21
+ // A folder the console's gate holds back (folder-gate.ts) is left out, never matched by its spelling.
22
+ try {
23
+ canonical = canonicalProject(path) ?? path;
24
+ }
25
+ catch (error) {
26
+ if (error instanceof FolderUnavailable)
27
+ continue;
28
+ throw error;
29
+ }
20
30
  if (seen.has(canonical) || !store.accountCanAccess(approver, canonical))
21
31
  continue;
22
32
  seen.add(canonical);
@@ -87,26 +87,32 @@
87
87
  .k-tabs > [aria-selected="true"], .k-tabs > [aria-current] { color: var(--so-ink); border-bottom-color: var(--so-ink); }
88
88
  .k-tabs .k-count { margin-left: 2px; }
89
89
 
90
- /* ---------- Fields ---------- */
90
+ /* ---------- Fields: one family (KIT.md "Fields") ----------
91
+ Every text field, select and secondary text box is one filled surface (--so-field), no outline, radius 12px; the
92
+ label above names it, the hint and the error sit below it, focus draws the 2px ink ring. A transparent 1px border
93
+ keeps the box's edge in forced colours. Textareas grow with their words (field-sizing), no resize grip. */
91
94
  .k-field { display: grid; gap: 6px; margin: 0; min-width: 0; }
92
- .k-label { display: block; margin: 0; font: var(--so-typography-caption); color: var(--so-ink); }
95
+ .k-label { display: block; margin: 0; font: 500 12.5px/1.35 var(--so-font-sans); color: var(--so-muted); }
93
96
  .k-hint { margin: 0; font-size: 12px; color: var(--so-muted); line-height: 1.45; }
94
- .k-input.k-input, .k-textarea.k-textarea { box-sizing: border-box; width: 100%; margin: 0; border: 1px solid var(--so-field-line); border-radius: 8px; background: var(--so-paper); color: var(--so-ink); font: 400 13px/1.4 var(--so-font-sans); box-shadow: none; }
95
- .k-input.k-input { height: 32px; min-height: 0; padding: 0 10px; }
96
- .k-textarea.k-textarea { min-height: 72px; padding: 8px 10px; resize: vertical; max-width: 640px; }
97
- .k-input:focus-visible, .k-textarea:focus-visible { outline: 2px solid var(--so-ring); outline-offset: 1px; box-shadow: none; }
97
+ .k-input.k-input, .k-textarea.k-textarea, .k-sel.k-sel { box-sizing: border-box; width: 100%; margin: 0; border: 1px solid transparent; border-radius: 12px; background: var(--so-field); color: var(--so-ink); font: 400 13.5px/1.4 var(--so-font-sans); box-shadow: none; transition: background-color var(--m-quick) var(--m-ease); }
98
+ .k-input.k-input { height: 36px; min-height: 0; padding: 0 12px; }
99
+ .k-textarea.k-textarea { min-height: 76px; max-height: 360px; padding: 10px 12px; resize: none; field-sizing: content; overflow-y: auto; max-width: 640px; line-height: 1.5; }
100
+ .k-input::placeholder, .k-textarea::placeholder { color: var(--so-muted); opacity: 1; }
101
+ .k-input:focus-visible, .k-textarea:focus-visible, .k-sel:focus-visible { outline: 2px solid var(--so-ring); outline-offset: 1px; box-shadow: none; }
98
102
  .k-input[aria-invalid="true"], .k-textarea[aria-invalid="true"], .k-sel[aria-invalid="true"] { border-color: var(--so-danger); }
103
+ .k-input:disabled, .k-textarea:disabled, .k-sel:disabled { color: var(--so-muted); cursor: default; }
99
104
  .k-err { display: block; margin: 0; font-size: 12px; color: var(--so-fail-ink); line-height: 1.45; }
100
105
  .k-err:empty { display: none; }
101
- /* A native select with the chevron: 32px (sm 28px in a toolbar). */
106
+ /* A native select in the same family, with the chevron: 36px (sm 28px in a toolbar). */
102
107
  .k-selw { position: relative; display: inline-block; min-width: 0; max-width: 100%; }
103
- .k-selw::after { content: ""; position: absolute; right: 12px; top: 50%; margin-top: -5px; width: 6px; height: 6px; border: solid var(--so-muted); border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); pointer-events: none; }
104
- .k-sel.k-sel { appearance: none; -webkit-appearance: none; box-sizing: border-box; width: 100%; height: 32px; min-height: 0; margin: 0; padding: 0 30px 0 10px; border: 1px solid var(--so-field-line); border-radius: 8px; background: var(--so-paper); color: var(--so-ink); font: 400 13px/1 var(--so-font-sans); text-overflow: ellipsis; cursor: pointer; box-shadow: none; }
105
- .k-sel:focus-visible { outline: 2px solid var(--so-ring); outline-offset: 1px; }
106
- .k-sel:disabled { color: var(--so-muted); cursor: default; }
107
- .k-selw.sm .k-sel { height: 28px; font: 500 12.5px/1 var(--so-font-sans); padding: 0 26px 0 9px; }
108
- .k-selw.sm::after { right: 10px; }
108
+ .k-selw::after { content: ""; position: absolute; right: 13px; top: 50%; margin-top: -5px; width: 6px; height: 6px; border: solid var(--so-muted); border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); pointer-events: none; }
109
+ .k-sel.k-sel { appearance: none; -webkit-appearance: none; height: 36px; min-height: 0; padding: 0 32px 0 12px; line-height: 1; text-overflow: ellipsis; cursor: pointer; }
110
+ .k-selw.sm .k-sel { height: 28px; border-radius: 999px; font: 500 12.5px/1 var(--so-font-sans); padding: 0 26px 0 11px; }
111
+ .k-selw.sm::after { right: 11px; }
109
112
  .k-selw.wide { display: block; width: 100%; }
113
+ /* A select drawn as a pill chip (a composer's project): filled, no box, works without script. */
114
+ .k-selw-chip::after { right: 12px; }
115
+ .k-selw-chip .k-sel.k-sel { height: 36px; border-radius: 999px; padding: 0 30px 0 14px; background: var(--so-composer-control); font: 500 13.5px/1 var(--so-font-sans); }
110
116
  /* Switch: a button[role=switch] that says its state, or a checkbox[role=switch] for a form. */
111
117
  .k-switch { position: relative; display: inline-flex; align-items: center; gap: 8px; min-height: 28px; margin: 0; padding: 0; border: 0; background: transparent; font: 500 12.5px/1 var(--so-font-sans); color: var(--so-muted); cursor: pointer; box-shadow: none; }
112
118
  .k-trk { position: relative; width: 32px; height: 18px; border-radius: 9px; border: 1px solid var(--so-field-line); background: var(--so-paper); transition: background-color var(--m-quick) var(--m-ease), border-color var(--m-quick) var(--m-ease); flex: none; box-sizing: border-box; }
@@ -314,25 +320,49 @@
314
320
  .k-swap-in { animation: k-swap var(--m-base) var(--m-ease) both; }
315
321
  @keyframes k-swap { from { opacity: 0; transform: translateY(4px); } }
316
322
 
317
- /* ---------- Composer: one soft rounded box; the textarea grows 1 → 8 lines; one bottom row inside ---------- */
318
- .k-composer { --c-r: 22px; box-sizing: border-box; border: 1px solid var(--so-field-line); border-radius: var(--c-r); background: var(--so-paper); display: grid; padding: 4px 6px 6px; outline: 3px solid transparent; outline-offset: 0; transition: border-color var(--m-quick) var(--m-ease), outline-color var(--m-quick) var(--m-ease); }
319
- .k-composer:focus-within { border-color: var(--so-ink); outline-color: color-mix(in srgb, var(--so-ink) 10%, transparent); }
320
- .k-composer.off { border-color: var(--so-line); background: var(--so-raised); }
321
- .k-composer-ta.k-composer-ta { box-sizing: border-box; width: 100%; min-height: 40px; margin: 0; padding: 10px 10px 6px; border: 0; border-radius: 0; outline: none; resize: none; background: transparent; box-shadow: none; color: var(--so-ink); font: var(--so-typography-body-reading); line-height: 1.5; overflow-y: hidden; scrollbar-width: thin; }
322
- .k-composer-ta::placeholder { color: var(--so-muted); }
323
+ /* ---------- Composer: one filled rounded object (KIT.md "Composer"); the textarea grows 1 → 8 lines; one bottom row
324
+ of filled circles and pills inside; its note and refusal sit under it, outside the box ---------- */
325
+ .k-composer-field { display: grid; gap: 6px; min-width: 0; }
326
+ .k-composer { --c-r: 28px; box-sizing: border-box; border: 1px solid transparent; border-radius: var(--c-r); background: var(--so-composer); display: grid; padding: 8px 10px 10px; transition: border-color var(--m-quick) var(--m-ease), background-color var(--m-quick) var(--m-ease); }
327
+ /* Focus: the fill lifts a step (a tap's cue is that and the caret); keyboard focus also draws the 2px ink ring, 3:1 or
328
+ better in both themes (html.k-kbd from the kit behaviour; without script the ring always shows). The textarea draws
329
+ no ring of its own. */
330
+ .k-composer { outline: 2px solid transparent; outline-offset: 2px; }
331
+ .k-composer:focus-within { background: color-mix(in srgb, var(--so-composer) 97%, var(--so-ink)); }
332
+ html:not(.k-js) .k-composer:focus-within, html.k-kbd .k-composer:focus-within { outline-color: var(--so-ring); }
333
+ .k-composer.off { background: var(--so-raised); color: var(--so-muted); }
334
+ .k-composer-ta.k-composer-ta { box-sizing: border-box; width: 100%; min-height: 44px; margin: 0; padding: 10px 10px 8px; border: 0; border-radius: 0; outline: none; resize: none; background: transparent; box-shadow: none; color: var(--so-ink); font: 400 17px/1.5 var(--so-font-sans); letter-spacing: -0.005em; overflow-y: hidden; scrollbar-width: thin; }
335
+ .k-composer-ta::placeholder { color: var(--so-muted); opacity: 1; }
323
336
  .k-composer-ta:focus, .k-composer-ta:focus-visible { outline: none; box-shadow: none; }
324
- .k-composer:has(.k-composer-ta[aria-invalid="true"]) { border-color: var(--so-danger); outline-color: transparent; }
337
+ .k-composer:has(.k-composer-ta[aria-invalid="true"]) { border-color: var(--so-danger); }
325
338
  .k-composer + .k-err { margin-top: 6px; }
326
- .k-composer-bar { display: flex; align-items: center; gap: 6px; padding: 2px 0 0 2px; flex-wrap: wrap; min-height: 36px; }
327
- .k-composer-plus { box-sizing: border-box; width: 32px; height: 32px; min-height: 0; margin: 0; padding: 0; border-radius: 50%; display: grid; place-items: center; color: var(--so-muted); border: 1px solid var(--so-line); background: transparent; cursor: pointer; box-shadow: none; transition: background-color var(--m-quick) var(--m-ease), color var(--m-quick) var(--m-ease); }
328
- .k-composer-chip { box-sizing: border-box; height: 32px; min-height: 0; margin: 0; display: inline-flex; align-items: center; gap: 4px; padding: 0 8px 0 10px; border: 0; border-radius: 999px; font: 500 12.5px/1 var(--so-font-sans); color: var(--so-muted); background: transparent; cursor: pointer; box-shadow: none; transition: background-color var(--m-quick) var(--m-ease), color var(--m-quick) var(--m-ease); }
339
+ .k-composer-bar { display: flex; align-items: center; gap: 8px; padding: 4px 0 0; flex-wrap: wrap; min-height: 40px; }
340
+ .k-composer-plus { box-sizing: border-box; width: 36px; height: 36px; min-height: 0; margin: 0; padding: 0; border-radius: 50%; display: grid; place-items: center; flex: none; color: var(--so-ink); border: 0; background: var(--so-composer-control); cursor: pointer; box-shadow: none; transition: background-color var(--m-quick) var(--m-ease), color var(--m-quick) var(--m-ease); }
341
+ .k-composer-plus .k-i { width: 18px; height: 18px; }
342
+ /* A pill chip: the primary word, then a dimmer secondary word (a project, then the agent). */
343
+ .k-composer-chip { box-sizing: border-box; height: 36px; min-height: 0; max-width: 100%; margin: 0; display: inline-flex; align-items: center; gap: 6px; padding: 0 14px; border: 0; border-radius: 999px; font: 500 13.5px/1 var(--so-font-sans); color: var(--so-ink); background: var(--so-composer-control); cursor: pointer; box-shadow: none; transition: background-color var(--m-quick) var(--m-ease), color var(--m-quick) var(--m-ease); }
344
+ .k-composer-chip > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
345
+ .k-composer-chip .k-chip-sub { color: var(--so-muted); font-weight: 400; }
329
346
  .k-composer-plus:disabled, .k-composer-chip:disabled { opacity: .5; pointer-events: none; }
330
- /* The round send: ink with text, muted until then; Stop takes its place while the work runs. */
331
- .k-send-slot { position: relative; display: inline-grid; width: 34px; height: 34px; flex: none; }
332
- .k-send-slot .k-send { grid-area: 1 / 1; position: relative; box-sizing: border-box; width: 34px; height: 34px; min-height: 0; margin: 0; padding: 0; border-radius: 50%; display: grid; place-items: center; background: var(--so-ink); color: var(--so-paper); border: 0; cursor: pointer; box-shadow: none; transition: background-color var(--m-quick) var(--m-ease), color var(--m-quick) var(--m-ease), transform var(--m-base) var(--m-ease); }
333
- .k-send::before { content: ""; position: absolute; inset: -5px; }
334
- .k-send .k-i { width: 17px; height: 17px; stroke-width: 2.2; }
335
- .k-send[aria-disabled="true"], .k-composer:not(.has-text) .k-send:not(.k-stop) { background: var(--so-raised); color: var(--so-muted); }
347
+ /* A small act inside the bar (Pause and send) is a filled pill like the chips, never an outlined box in the box. */
348
+ .k-composer-bar .k-btn:not(.k-send) { border-color: transparent; border-radius: 999px; background: var(--so-composer-control); }
349
+ .k-composer-plus:focus-visible, .k-composer-chip:focus-visible { outline: 2px solid var(--so-ring); outline-offset: 2px; }
350
+ /* A toggle chip (an on/off choice inside the box, as Read code before planning): a filled pill whose mark fills when
351
+ on; the checkbox inside is the control (role=switch), so it works without script. */
352
+ .k-tchip { position: relative; box-sizing: border-box; height: 36px; margin: 0; display: inline-flex; align-items: center; gap: 8px; padding: 0 14px 0 12px; border-radius: 999px; background: var(--so-composer-control); color: var(--so-muted); font: 500 13.5px/1 var(--so-font-sans); cursor: pointer; transition: color var(--m-quick) var(--m-ease); }
353
+ .k-tchip > input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 1; }
354
+ .k-tchip-mark { box-sizing: border-box; width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid currentColor; flex: none; display: grid; place-items: center; transition: background-color var(--m-quick) var(--m-ease), border-color var(--m-quick) var(--m-ease); }
355
+ .k-tchip:has(input:checked) { color: var(--so-ink); }
356
+ .k-tchip:has(input:checked) .k-tchip-mark { background: var(--so-ink); border-color: var(--so-ink); }
357
+ .k-tchip:has(input:checked) .k-tchip-mark::after { content: ""; width: 3px; height: 6px; margin-top: -2px; border: solid var(--so-paper); border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); }
358
+ .k-tchip:has(input:focus-visible) { outline: 2px solid var(--so-ring); outline-offset: 2px; }
359
+ /* The round send: solid ink with a paper arrow once there are words, the muted control fill until then; Stop takes
360
+ its place while the work runs. */
361
+ .k-send-slot { position: relative; display: inline-grid; width: 36px; height: 36px; flex: none; }
362
+ .k-send-slot .k-send { grid-area: 1 / 1; position: relative; box-sizing: border-box; width: 36px; height: 36px; min-height: 0; margin: 0; padding: 0; border-radius: 50%; display: grid; place-items: center; background: var(--so-ink); color: var(--so-paper); border: 0; cursor: pointer; box-shadow: none; transition: background-color var(--m-quick) var(--m-ease), color var(--m-quick) var(--m-ease), transform var(--m-base) var(--m-ease); }
363
+ .k-send::before { content: ""; position: absolute; inset: -4px; }
364
+ .k-send .k-i { width: 18px; height: 18px; stroke-width: 2.2; }
365
+ .k-send[aria-disabled="true"], .k-composer:not(.has-text) .k-send:not(.k-stop) { background: var(--so-composer-control); color: var(--so-muted); }
336
366
  .k-send[aria-disabled="true"] { cursor: default; }
337
367
  .k-send:not([aria-disabled="true"]):active { transform: scale(.94); }
338
368
  .k-stop { visibility: hidden; }
@@ -344,17 +374,18 @@
344
374
  .k-send:focus-visible { outline: 2px solid var(--so-ring); outline-offset: 2px; }
345
375
  .k-composer-files { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 8px; }
346
376
  .k-composer-files:empty { display: none; }
347
- .k-composer-file { display: inline-flex; align-items: center; gap: 6px; height: 28px; margin-top: 6px; padding: 0 4px 0 10px; border: 1px solid var(--so-line); border-radius: 999px; font-size: 12px; }
377
+ .k-composer-file { display: inline-flex; align-items: center; gap: 6px; height: 28px; margin-top: 6px; padding: 0 4px 0 12px; border: 0; border-radius: 999px; background: var(--so-composer-control); font-size: 12px; }
348
378
  .k-composer-file button { width: 22px; height: 22px; min-height: 0; margin: 0; padding: 0; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--so-muted); }
349
- .k-composer-note { padding: 2px 10px 6px; }
350
- .k-composer-err { padding: 2px 10px 6px; }
379
+ /* Under the box, in small dim words: its note, then its refusal. */
380
+ .k-composer-note.k-composer-note { margin: 0; padding: 0 16px; font-size: 12px; color: var(--so-muted); line-height: 1.45; }
381
+ .k-composer-err { padding: 0 16px; }
351
382
  .k-composer-err:empty { display: none; }
352
383
  /* One line: the text and the send share a row. */
353
- .k-composer.line { grid-template-columns: minmax(0, 1fr) auto; align-items: end; padding: 4px 6px 4px 8px; }
354
- .k-composer.line .k-composer-ta { padding: 8px 6px; min-height: 36px; }
355
- .k-composer.line .k-composer-bar { padding: 0 0 1px; min-height: 0; }
384
+ .k-composer.line { --c-r: 26px; grid-template-columns: minmax(0, 1fr) auto; align-items: end; padding: 5px 6px 5px 10px; }
385
+ .k-composer.line .k-composer-ta { padding: 9px 6px; min-height: 42px; font-size: 16px; }
386
+ .k-composer.line .k-composer-bar { padding: 0 0 3px; min-height: 0; }
356
387
  .k-composer.line .k-composer-bar .k-grow { display: none; }
357
- .k-composer.line .k-composer-files, .k-composer.line .k-composer-err, .k-composer.line .k-composer-note { grid-column: 1 / -1; }
388
+ .k-composer.line .k-composer-files { grid-column: 1 / -1; }
358
389
  /* A composer docked to the bottom rides above the phone keyboard (kit behaviour sets html.kb-open and --kb). */
359
390
  .k-composer-dock { position: sticky; bottom: 0; z-index: 4; padding: 12px 24px 14px; background: var(--so-paper); border-top: 1px solid var(--so-line); }
360
391
 
@@ -425,8 +456,10 @@
425
456
  .k-card:hover { background: color-mix(in srgb, var(--so-raised) 60%, transparent); }
426
457
  .k-backlink:hover { color: var(--so-ink); }
427
458
  .k-menu > button:hover, .k-menu > a:hover { background: var(--so-soft); }
428
- .k-composer-plus:hover { background: var(--so-raised); color: var(--so-ink); }
429
- .k-composer-chip:hover { background: var(--so-raised); color: var(--so-ink); }
459
+ .k-composer-plus:hover, .k-composer-chip:hover, .k-tchip:hover { background: color-mix(in srgb, var(--so-composer-control) 88%, var(--so-ink)); }
460
+ .k-dock-needs:hover { background: color-mix(in srgb, var(--so-composer-control) 88%, var(--so-ink)); }
461
+ .k-input:hover, .k-textarea:hover, .k-sel:hover { background: color-mix(in srgb, var(--so-field) 94%, var(--so-ink)); }
462
+ .k-selw-chip .k-sel:hover { background: color-mix(in srgb, var(--so-composer-control) 88%, var(--so-ink)); }
430
463
  .k-nav:hover { background: var(--so-nav-hover); color: var(--so-ink); }
431
464
  .k-crumbs a:hover { color: var(--so-ink); }
432
465
  }
@@ -437,7 +470,7 @@
437
470
  /* ---------- Phone dock: the Needs-you button and the screen's one primary ---------- */
438
471
  .k-dock { display: none; }
439
472
  /* The Needs-you button (the app shell's dock): a dot, the words, the ink count, and which projects it covers. */
440
- .k-dock-needs { display: grid; align-content: center; gap: 1px; box-sizing: border-box; width: 136px; min-height: 48px; margin: 0; padding: 0 12px; border-radius: 8px; border: 1px solid var(--so-field-line); background: var(--so-paper); color: var(--so-ink); text-align: left; white-space: nowrap; cursor: pointer; box-shadow: none; }
473
+ .k-dock-needs { display: grid; align-content: center; gap: 1px; box-sizing: border-box; width: 136px; min-height: 48px; margin: 0; padding: 0 14px; border-radius: 14px; border: 1px solid transparent; background: var(--so-composer-control); color: var(--so-ink); text-align: left; white-space: nowrap; cursor: pointer; box-shadow: none; }
441
474
  .k-dock-needs-l { display: inline-flex; align-items: center; gap: 7px; font: 500 14px/1.2 var(--so-font-sans); }
442
475
  .k-dock-needs-s { overflow: hidden; text-overflow: ellipsis; font-size: 11.5px; color: var(--so-muted); padding-left: 14px; }
443
476
  .k-dock-needs:focus-visible { outline: 2px solid var(--so-ring); outline-offset: 2px; }
@@ -509,16 +542,17 @@
509
542
  .k-frow .k-err { text-align: left; }
510
543
  .k-frow-c .k-seg { width: 100%; }
511
544
  .k-frow-c .k-seg > button { flex: 1; justify-content: center; }
512
- .k-composer-ta.k-composer-ta { font-size: 16px; }
545
+ .k-composer-ta.k-composer-ta { font-size: 17px; }
513
546
  .k-composer-bar .k-btn-sm { height: 44px; }
514
- /* Every composer control is a real 44px target; the circles stay drawn at 36px inside it. */
547
+ /* Every composer control is a real, filled 44px target. */
548
+ .k-composer { --c-r: 26px; padding: 6px 8px 8px; }
549
+ .k-composer-ta.k-composer-ta { padding: 10px 10px 6px; }
515
550
  .k-send-slot, .k-send-slot .k-send { width: 44px; height: 44px; }
516
- .k-send-slot .k-send { background: radial-gradient(circle, var(--so-ink) 17.5px, transparent 18px); }
517
- .k-send[aria-disabled="true"], .k-composer:not(.has-text) .k-send:not(.k-stop) { background: radial-gradient(circle, var(--so-raised) 17.5px, transparent 18px); }
518
551
  .k-send::before { content: none; }
519
- .k-composer-plus { width: 44px; height: 44px; border: 0; background: radial-gradient(circle, transparent 15.5px, var(--so-line) 16px, var(--so-line) 16.5px, transparent 17px); }
520
- .k-composer-chip { height: 44px; }
521
- .k-composer-bar { gap: 2px; }
552
+ .k-composer-plus { width: 44px; height: 44px; }
553
+ .k-composer-chip, .k-tchip, .k-selw-chip .k-sel.k-sel { height: 44px; }
554
+ .k-selw-chip .k-sel.k-sel { font-size: 16px; }
555
+ .k-composer-bar { gap: 6px; }
522
556
  .k-composer-file button { width: 32px; height: 32px; }
523
557
  .k-composer-dock { padding: 12px 16px 16px; }
524
558
  html.kb-open .k-composer-dock { position: fixed; left: 0; right: 0; bottom: var(--kb, 0px); z-index: 13; }
@@ -535,6 +569,9 @@
535
569
  .k-dock { display: flex; gap: 8px; align-items: stretch; position: fixed; left: 0; right: 0; bottom: 0; z-index: 12; padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); background: var(--so-paper); border-top: 1px solid var(--so-line); }
536
570
  .k-dock-primary { flex: 1; display: flex; }
537
571
  .k-dock-primary .k-btn { width: 100%; }
572
+ /* The dock is filled controls only: a secondary act there is a filled control, never an outlined box. */
573
+ .k-dock .k-btn-outline { border-color: transparent; background: var(--so-composer-control); }
574
+ .k-dock .k-btn { min-height: 48px; border-radius: 14px; }
538
575
  }
539
576
  @media (min-width: 761px) { .k-phone-only { display: none !important; } }
540
577
 
@@ -563,9 +600,9 @@ main:has(> .k-out) { max-width: none; margin: 0; padding: 0; }
563
600
  .k-refusal .k-empty-t::first-letter, .k-refusal .k-notice > span::first-letter { text-transform: uppercase; }
564
601
 
565
602
  /* A composer's note can follow its switch: k-on-note while it is on, k-off-note while it is off (no script). */
566
- .k-composer .k-off-note { display: none; }
567
- .k-composer:has(.k-switch-input):not(:has(.k-switch-input:checked)) .k-on-note { display: none; }
568
- .k-composer:has(.k-switch-input):not(:has(.k-switch-input:checked)) .k-off-note { display: inline; }
603
+ .k-composer-field .k-off-note { display: none; }
604
+ .k-composer-field:has(.k-switch-input):not(:has(.k-switch-input:checked)) .k-on-note { display: none; }
605
+ .k-composer-field:has(.k-switch-input):not(:has(.k-switch-input:checked)) .k-off-note { display: inline; }
569
606
 
570
607
  /* The composer's focus is the box's (KIT.md): the workspace's own :focus-visible ring never lands on its textarea. */
571
608
  .k-composer .k-composer-ta.k-composer-ta:focus-visible { outline: none; box-shadow: none; }
@@ -614,7 +651,7 @@ main:has(> .k-out) { max-width: none; margin: 0; padding: 0; }
614
651
 
615
652
  /* ---------- Reduced motion changes presentation only ---------- */
616
653
  @media (prefers-reduced-motion: reduce) {
617
- .k-btn, .k-send, .k-composer, .k-composer-plus, .k-composer-chip, .k-trk, .k-trk::after, .k-tabs > *, .k-slots, .k-crow-bar { transition: none; }
654
+ .k-btn, .k-send, .k-composer, .k-composer-plus, .k-composer-chip, .k-tchip, .k-tchip-mark, .k-input, .k-textarea, .k-sel, .k-trk, .k-trk::after, .k-tabs > *, .k-slots, .k-crow-bar { transition: none; }
618
655
  .k-steps-fill { transition: none; }
619
656
  .k-grow-in, .k-msg-in, .k-swap-in, .k-scrim, .k-btn.swap .k-face, .k-check-draw path, .k-composer .k-i { animation: none !important; }
620
657
  .k-check-draw path { stroke-dashoffset: 0; }
@@ -8,10 +8,16 @@
8
8
  --so-attention: var(--so-signal);
9
9
  --so-attention-soft: var(--so-signal-soft);
10
10
  --so-code-bg: #fafafa;
11
+ /* Composer: the filled surface of the message box, one step off the page. */
12
+ --so-composer: #f2f2f2;
13
+ /* Composer Control: the filled circle and pill chips inside the composer (+, project, toggle chips) and the empty send. */
14
+ --so-composer-control: #dcdcdc;
11
15
  /* Vermilion: failure and irreversible acts. */
12
16
  --so-danger: #c4320a;
13
17
  --so-danger-soft: #feebe7;
14
18
  --so-ease-out: cubic-bezier(0.23, 1, 0.32, 1);
19
+ /* Field: the filled surface of a text field, select or secondary text box (the field family: no outline; the label names it, focus draws the ink ring). */
20
+ --so-field: #ececec;
15
21
  /* Field Line: the boundary that identifies a control (fields, outline buttons, switches, radio cards), 3:1 or better on paper. */
16
22
  --so-field-line: #8f8f8f;
17
23
  --so-font-mono: "Geist Mono", ui-monospace, "SF Mono", "SFMono-Regular", "Menlo", "Consolas", monospace;
@@ -162,8 +168,11 @@
162
168
  --so-accent-hover: #ffffff;
163
169
  --so-accent-text: #ededed;
164
170
  --so-code-bg: #111111;
171
+ --so-composer: #242424;
172
+ --so-composer-control: #333333;
165
173
  --so-danger: #ff977d;
166
174
  --so-danger-soft: rgb(255 151 125 / .12);
175
+ --so-field: #232323;
167
176
  --so-field-line: #6b6b6b;
168
177
  --so-ground: #0b0b0b;
169
178
  --so-info: #70b8ff;
@@ -210,8 +219,11 @@
210
219
  --so-accent-hover: #ffffff;
211
220
  --so-accent-text: #ededed;
212
221
  --so-code-bg: #111111;
222
+ --so-composer: #242424;
223
+ --so-composer-control: #333333;
213
224
  --so-danger: #ff977d;
214
225
  --so-danger-soft: rgb(255 151 125 / .12);
226
+ --so-field: #232323;
215
227
  --so-field-line: #6b6b6b;
216
228
  --so-ground: #0b0b0b;
217
229
  --so-info: #70b8ff;
package/dist/egress.js CHANGED
@@ -18,6 +18,7 @@ import { readdirSync } from "node:fs";
18
18
  import { basename, join } from "node:path";
19
19
  import { connectFirst, isPublicAddress } from "./scout-net.js";
20
20
  import { lookup as dnsLookup } from "node:dns/promises";
21
+ import { folderAnswers } from "./folder-gate.js";
21
22
  /** The policy's shape version: a worker enforces the versions it knows and refuses any other. */
22
23
  export const NETWORK_POLICY_VERSION = 1;
23
24
  export const PROJECT_NETWORK_DEFAULT = { mode: "allowlist", hosts: [], loopback: "proxy-only" };
@@ -114,6 +115,10 @@ const WALK_SKIP = new Set(["node_modules", "vendor", "target", "dist", "build",
114
115
  * project's own hosts only.
115
116
  */
116
117
  export function repositoryEcosystems(repo) {
118
+ // In the console, a folder that hasn't answered a look lately is not walked on its thread (folder-gate.ts): it names
119
+ // none, as an unreadable folder does.
120
+ if (!folderAnswers(repo))
121
+ return [];
117
122
  const paths = [];
118
123
  const walk = (folder, depth) => {
119
124
  let entries;
@@ -10,6 +10,7 @@ import { flowDefinitionOf } from "./flow-engine.js";
10
10
  import { addFlowTriggerTo, githubRepoOf } from "./flow-triggers.js";
11
11
  import { flowFromSteps, ISSUE_LABEL } from "./flows.js";
12
12
  import { publishingOf } from "./pull-request-flow.js";
13
+ import { folderAnswers } from "./folder-gate.js";
13
14
  export const STARTER_IDS = ["ci-fix", "issue-task", "overnight", "plane-review"];
14
15
  export const STARTER_FLOWS = [
15
16
  {
@@ -87,6 +88,8 @@ export function planeReviewSteps(own) {
87
88
  }
88
89
  /** Whether a project is Toolroll's own source. */
89
90
  export function isToolrollRepo(repo) {
91
+ if (!folderAnswers(repo))
92
+ return false;
90
93
  try {
91
94
  return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).name === "toolroll";
92
95
  }
@@ -52,6 +52,7 @@ import { envValue } from "./names.js";
52
52
  import { recordBranchCheckRun, unprovenNoticeRuns } from "./tidy.js";
53
53
  import { linearLabelNames, readFormSubmission, readGithubEvent, readGithubIssue, readGithubIssueEventList, readGithubIssueList, readGithubRun, readGithubRunList, readInboundMail, readLinearAnswer, readLinearEvent, readLinearIssue, readWebhookPayload } from "./contracts/trigger-payloads.js";
54
54
  import { isRecord } from "./is-record.js";
55
+ import { folderAnswers } from "./folder-gate.js";
55
56
  export { FLOW_TRIGGER_KINDS };
56
57
  export const CHAT_APP_NAMES = { slack: "Slack", discord: "Discord", teams: "Teams", telegram: "Telegram" };
57
58
  /** What each kind is called on the canvas. */
@@ -124,6 +125,9 @@ export function scheduleFromWords(text) {
124
125
  }
125
126
  /** A GitHub repository as owner/name, read from a project's origin remote when it is on GitHub. */
126
127
  export function githubRepoOf(path) {
128
+ // In the console, a folder that hasn't answered a look lately is not read on its thread (folder-gate.ts).
129
+ if (!folderAnswers(path))
130
+ return null;
127
131
  try {
128
132
  const dot = join(path, ".git");
129
133
  const config = statSync(dot).isDirectory() ? readFileSync(join(dot, "config"), "utf8") : null;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Whether the console may read a folder synchronously right now.
3
+ *
4
+ * The console answers every page on one thread, so one synchronous read of a folder that has stopped answering (a
5
+ * drive that hangs, a folder macOS holds in iCloud) freezes the console with it: it listens and answers nothing, not
6
+ * even /healthz (the 0.9.82 and 0.9.87 deploys). So the console's own work (its request handling and its background
7
+ * pass, serve.ts) runs inside a folder scope: there, a synchronous read of a person's folder goes ahead only when that
8
+ * folder answered an off-thread look lately (project-identity.ts `answering`), or it lies in the console's own data.
9
+ * Otherwise the read isn't made and its caller refuses, says the folder is unavailable, or throws FolderUnavailable.
10
+ *
11
+ * Availability never decides authorization: a held-back canonicalization throws FolderUnavailable (project.ts), never
12
+ * answers null, so no caller falls back to a path's spelling because a folder didn't answer.
13
+ *
14
+ * Work outside a console scope (the worker, even in the same process as the console; `up`; the CLI) reads as it
15
+ * always did.
16
+ */
17
+ /** One console's answer for a resolved path. `pointer`: the path came from a file a build can write (a checkout's
18
+ * `.git` or `commondir`), so the console's own folders (build checkouts among them) don't vouch for it by spelling. */
19
+ export type FolderGate = (path: string, pointer: boolean) => boolean;
20
+ /** What a caller says, and throws, when the gate holds a read back. */
21
+ export declare const FOLDER_DIDNT_ANSWER = "the folder didn't answer";
22
+ /** A synchronous read the console's folder gate held back: the folder is unavailable now, which is never a "no". */
23
+ export declare class FolderUnavailable extends Error {
24
+ readonly path: string;
25
+ constructor(path: string);
26
+ }
27
+ /** Runs `work` (and everything it starts) inside the console's folder scope. */
28
+ export declare function inFolderScope<T>(gate: FolderGate, work: () => T): T;
29
+ /** Whether a synchronous read of `path` may happen now. Outside a console's folder scope: always. */
30
+ export declare function folderAnswers(path: string, pointer?: boolean): boolean;
31
+ /** `path` is `folder` or inside it (both resolved). */
32
+ export declare function insideFolder(path: string, folder: string): boolean;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Whether the console may read a folder synchronously right now.
3
+ *
4
+ * The console answers every page on one thread, so one synchronous read of a folder that has stopped answering (a
5
+ * drive that hangs, a folder macOS holds in iCloud) freezes the console with it: it listens and answers nothing, not
6
+ * even /healthz (the 0.9.82 and 0.9.87 deploys). So the console's own work (its request handling and its background
7
+ * pass, serve.ts) runs inside a folder scope: there, a synchronous read of a person's folder goes ahead only when that
8
+ * folder answered an off-thread look lately (project-identity.ts `answering`), or it lies in the console's own data.
9
+ * Otherwise the read isn't made and its caller refuses, says the folder is unavailable, or throws FolderUnavailable.
10
+ *
11
+ * Availability never decides authorization: a held-back canonicalization throws FolderUnavailable (project.ts), never
12
+ * answers null, so no caller falls back to a path's spelling because a folder didn't answer.
13
+ *
14
+ * Work outside a console scope (the worker, even in the same process as the console; `up`; the CLI) reads as it
15
+ * always did.
16
+ */
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+ import { resolve, sep } from "node:path";
19
+ const scope = new AsyncLocalStorage();
20
+ /** What a caller says, and throws, when the gate holds a read back. */
21
+ export const FOLDER_DIDNT_ANSWER = "the folder didn't answer";
22
+ /** A synchronous read the console's folder gate held back: the folder is unavailable now, which is never a "no". */
23
+ export class FolderUnavailable extends Error {
24
+ path;
25
+ constructor(path) {
26
+ super(FOLDER_DIDNT_ANSWER);
27
+ this.path = path;
28
+ this.name = "FolderUnavailable";
29
+ }
30
+ }
31
+ /** Runs `work` (and everything it starts) inside the console's folder scope. */
32
+ export function inFolderScope(gate, work) {
33
+ return scope.run(gate, work);
34
+ }
35
+ /** Whether a synchronous read of `path` may happen now. Outside a console's folder scope: always. */
36
+ export function folderAnswers(path, pointer = false) {
37
+ const gate = scope.getStore();
38
+ return gate === undefined || gate(resolve(path), pointer);
39
+ }
40
+ /** `path` is `folder` or inside it (both resolved). */
41
+ export function insideFolder(path, folder) {
42
+ return path === folder || path.startsWith(folder.endsWith(sep) ? folder : folder + sep);
43
+ }
@@ -1,6 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { canonicalProject } from "./project.js";
4
+ import { FolderUnavailable } from "./folder-gate.js";
4
5
  export function normalizeProjectAccess(value) {
5
6
  if (value === null)
6
7
  return null;
@@ -26,7 +27,20 @@ export function readProjectAccess(value) {
26
27
  }
27
28
  }
28
29
  export function projectAccessAllows(access, repo) {
29
- return access === null || (repo !== null && access.includes(canonicalProject(repo) ?? resolve(repo)));
30
+ if (access === null)
31
+ return true;
32
+ if (repo === null)
33
+ return false;
34
+ // A folder the console's gate holds back (folder-gate.ts) is unavailable: access to it is denied, never decided by
35
+ // its spelling.
36
+ try {
37
+ return access.includes(canonicalProject(repo) ?? resolve(repo));
38
+ }
39
+ catch (error) {
40
+ if (error instanceof FolderUnavailable)
41
+ return false;
42
+ throw error;
43
+ }
30
44
  }
31
45
  /** SQL: projectAccessAllows for an `approver` row (`alias`) and a project path expression, for the dispatch belts. A
32
46
  * null list reaches every project; an unreadable one reaches none. */
@@ -42,9 +42,11 @@ export type Released = {
42
42
  removed: boolean;
43
43
  };
44
44
  export declare const PROJECT_ADDED = "project added";
45
+ /** A folder's real path (a folder), null (not one), or "no-answer" (it didn't answer in time). */
46
+ export type FolderResolver = (path: string) => Promise<string | null | "no-answer">;
45
47
  export declare const PROJECT_REMOVED = "project removed";
46
48
  /** The checkout root a folder belongs to, canonical; never a shell. */
47
- export declare function proveProjectRoot(path: string, exec?: ProjectExec): Promise<{
49
+ export declare function proveProjectRoot(path: string, exec?: ProjectExec, resolveFolder?: FolderResolver): Promise<{
48
50
  ok: true;
49
51
  repo: string;
50
52
  } | AdmissionFailure>;
@@ -63,6 +65,9 @@ export declare function admitProject(store: Store, input: {
63
65
  /** The caller's authority, asked again about the proved repo inside the locked registry update and inside the
64
66
  * database transaction (the console: nothing it proved before an await moved, and the repo is still visible). */
65
67
  stillAllowed?: (repo: string) => boolean;
68
+ /** How the folder and its checkout root are resolved (the console: off its thread, under a deadline). Absent:
69
+ * canonicalProject. */
70
+ resolveFolder?: FolderResolver;
66
71
  }): Promise<Admitted | AdmissionFailure>;
67
72
  /**
68
73
  * Remove one project from the lists and the builder. Its tasks, results and
@@ -17,6 +17,7 @@ import { run } from "./exec.js";
17
17
  import { canonicalProject, projectName, sameRepo } from "./project.js";
18
18
  import { remoteUrlProblem } from "./remote-main.js";
19
19
  import { addRepos, loadProjectRegistry, removeRepos, updateRepos } from "./repos.js";
20
+ import { FolderUnavailable } from "./folder-gate.js";
20
21
  const ADMISSION_GIT = {
21
22
  envAllowlist: ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"],
22
23
  env: { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1" },
@@ -24,15 +25,23 @@ const ADMISSION_GIT = {
24
25
  export const PROJECT_ADDED = "project added";
25
26
  export const PROJECT_REMOVED = "project removed";
26
27
  /** The checkout root a folder belongs to, canonical; never a shell. */
27
- export async function proveProjectRoot(path, exec = run) {
28
+ export async function proveProjectRoot(path, exec = run, resolveFolder) {
28
29
  if (path.trim() === "" || path.length > 4096 || /[\x00-\x1f]/.test(path)) {
29
30
  return { ok: false, reason: "unavailable", message: "Choose a valid project folder." };
30
31
  }
31
- const canonical = canonicalProject(path);
32
+ // The console resolves both folders off its thread (project-identity.ts resolveNow): its thread never waits on one.
33
+ const canon = async (one) => resolveFolder === undefined ? canonicalProject(one) : resolveFolder(one);
34
+ const didntAnswer = (one) => ({ ok: false, reason: "unavailable", message: `${one} didn't answer. It may be on a drive that's disconnected, or waiting on macOS; try again in a moment.` });
35
+ const canonical = await canon(path);
36
+ if (canonical === "no-answer")
37
+ return didntAnswer(path);
32
38
  if (canonical === null)
33
39
  return { ok: false, reason: "unavailable", message: `${path} isn't a folder on this computer.` };
34
40
  const top = await exec("git", ["rev-parse", "--show-toplevel"], { cwd: canonical, timeoutMs: 5_000, ...ADMISSION_GIT });
35
- const repo = top.code === 0 ? canonicalProject(top.stdout.trim()) : null;
41
+ const topReal = top.code === 0 ? await canon(top.stdout.trim()) : null;
42
+ if (topReal === "no-answer")
43
+ return didntAnswer(top.stdout.trim());
44
+ const repo = topReal;
36
45
  if (repo === null)
37
46
  return { ok: false, reason: "not-git", message: `${path} isn't a Git repository. Run git init there first, or choose a folder inside one.` };
38
47
  return { ok: true, repo };
@@ -48,7 +57,7 @@ async function originOf(repo, exec) {
48
57
  }
49
58
  /** Registry first, then the project and its audit records in one transaction. Re-adds preserve trust. */
50
59
  export async function admitProject(store, input) {
51
- const proved = await proveProjectRoot(input.path, input.exec);
60
+ const proved = await proveProjectRoot(input.path, input.exec, input.resolveFolder);
52
61
  if (!proved.ok)
53
62
  return proved;
54
63
  const { repo } = proved;
@@ -110,12 +119,40 @@ export async function admitProject(store, input) {
110
119
  * saved project changes nothing.
111
120
  */
112
121
  export async function releaseProject(store, input) {
113
- const asked = canonicalProject(input.path) ?? resolve(input.path);
122
+ // Removing only narrows: a folder the console's gate holds back (folder-gate.ts) is removed by its exact saved path,
123
+ // and matches no other spelling.
124
+ // Latched for the whole removal: once its folder was unavailable, a later answer (during an await below) never
125
+ // widens it to other spellings.
126
+ const unavailable = (error) => error instanceof FolderUnavailable;
127
+ let asked, exactOnly = false;
128
+ try {
129
+ asked = canonicalProject(input.path) ?? resolve(input.path);
130
+ }
131
+ catch (error) {
132
+ if (!unavailable(error))
133
+ throw error;
134
+ asked = resolve(input.path);
135
+ exactOnly = true;
136
+ }
137
+ const same = (one) => {
138
+ if (exactOnly)
139
+ return false;
140
+ try {
141
+ return sameRepo(one, asked);
142
+ }
143
+ catch (error) {
144
+ if (unavailable(error)) {
145
+ exactOnly = true;
146
+ return false;
147
+ }
148
+ throw error;
149
+ }
150
+ };
114
151
  const loaded = input.registryFile === null ? { repos: [] } : await loadProjectRegistry(input.registryFile);
115
152
  if ("error" in loaded)
116
153
  return { ok: false, reason: "registry", message: loaded.error };
117
- const entries = loaded.repos.filter(one => one === asked || sameRepo(one, asked));
118
- const rows = store.listProjects().filter(one => one.path === asked || sameRepo(one.path, asked)).map(one => one.path);
154
+ const entries = loaded.repos.filter(one => one === asked || same(one));
155
+ const rows = store.listProjects().filter(one => one.path === asked || same(one.path)).map(one => one.path);
119
156
  const repo = entries[0] ?? rows[0] ?? asked;
120
157
  if (entries.length === 0 && rows.length === 0)
121
158
  return { ok: true, repo, name: projectName(repo), removed: false };
@@ -16,6 +16,7 @@
16
16
  *
17
17
  * An embedder or test that passes no identities gets `syncProjectIdentities`: the old synchronous reads, unchanged.
18
18
  */
19
+ import { type FolderGate } from "./folder-gate.js";
19
20
  import { type FolderLookOptions } from "./folder-look.js";
20
21
  export type ProjectIdentities = {
21
22
  /** For display and grouping only: the last known identity, else the path as saved (resolved lexically). Reads
@@ -36,9 +37,16 @@ export type ProjectIdentities = {
36
37
  /** Moves whenever a known identity changes or goes: a decision made before an await is re-checked against it right
37
38
  * before its write. */
38
39
  generation: () => number;
40
+ /** Whether a synchronous read inside `path` may happen on the console's thread now (folder-gate.ts): the folder it is
41
+ * in answered a look within the last few minutes, and no look at it since went unanswered. Reads nothing. */
42
+ answering: (path: string) => boolean;
39
43
  };
40
44
  /** How many folders a pass looks at side by side when they're looked at one by one (within FOLDER_LOOK_CHILDREN). */
41
45
  export declare const IDENTITY_LOOKS = 4;
42
46
  export declare function projectIdentities(look?: FolderLookOptions, now?: () => number): ProjectIdentities;
43
47
  /** The synchronous reads, as before: for embedders and tests that pass no identities. */
44
48
  export declare function syncProjectIdentities(): ProjectIdentities;
49
+ /** The console's folder gate (folder-gate.ts): its own data folders (the database's, evidence, build checkouts) are read
50
+ * freely when the console names them; a path read from a file a build can write (`pointer`), and anything else, only
51
+ * while `answering` vouches for where it leads. */
52
+ export declare function consoleFolderGate(identities: ProjectIdentities, own: readonly (string | undefined)[]): FolderGate;