staysfixed 0.12.0 → 0.14.0
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.
- package/CHANGELOG.md +85 -2
- package/docs/guards.md +18 -0
- package/docs/how-v2-works.md +10 -0
- package/package.json +1 -1
- package/src/cli/approve.js +4 -1
- package/src/cli/flake.js +4 -1
- package/src/cli/mark.js +5 -1
- package/src/cli/status.js +53 -1
- package/src/cli/trace.js +27 -2
- package/src/core/config.js +136 -25
- package/src/core/stop-tree.js +109 -0
- package/src/drive/browser.js +20 -31
- package/src/drive/page.js +74 -2
- package/src/guard/api.js +14 -9
- package/src/types.js +1 -1
- package/src/v2/adapters/child.js +15 -17
- package/src/v2/adapters/contract.js +122 -1
- package/src/v2/adapters/http.js +152 -30
- package/src/v2/adapters/isolate.js +169 -14
- package/src/v2/adapters/process.js +72 -8
- package/src/v2/adapters/source.js +254 -7
- package/src/v2/adapters/web.js +69 -19
- package/src/v2/browsers.js +136 -24
- package/src/v2/cause.js +46 -5
- package/src/v2/check.js +372 -35
- package/src/v2/cli.js +19 -1
- package/src/v2/coverage.js +555 -18
- package/src/v2/detect.js +737 -40
- package/src/v2/doctor.js +3 -3
- package/src/v2/escalate.js +91 -12
- package/src/v2/init.js +577 -21
- package/src/v2/journeys/answers-probe.js +376 -0
- package/src/v2/journeys/from-exports.js +456 -0
- package/src/v2/journeys/from-suite.js +9 -1
- package/src/v2/mcp/tools.js +185 -12
- package/src/v2/observation.js +145 -0
- package/src/v2/run.js +133 -9
- package/src/v2/selfcheck.js +297 -11
- package/src/v2/store.js +16 -1
- package/src/v2/watch/dialogs.js +410 -0
- package/src/v2/watch/panel.js +27 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The system dialogs this tool causes, and what to do about them.
|
|
3
|
+
*
|
|
4
|
+
* ## What went wrong, 2026-09-01
|
|
5
|
+
*
|
|
6
|
+
* A real check on a real product ran for four minutes and spent the last two of them frozen.
|
|
7
|
+
* The owner recorded it. On screen, over everything, was a macOS alert:
|
|
8
|
+
*
|
|
9
|
+
* Keychain Not Found
|
|
10
|
+
* A keychain cannot be found to store "Terminal Deck Key."
|
|
11
|
+
* [?] [Cancel] [Reset To Defaults]
|
|
12
|
+
*
|
|
13
|
+
* The panel sat on "Walking ask the private channel voice:status to answer" at 23 of 27
|
|
14
|
+
* journeys and never moved. When the run finally gave up it said only that one journey had no
|
|
15
|
+
* answer. It never said WHY, and the why was a modal box waiting for a person who was not
|
|
16
|
+
* going to be there — on a machine where nobody had been asked to sit and watch.
|
|
17
|
+
*
|
|
18
|
+
* ## Why this is the tool's problem and not the product's
|
|
19
|
+
*
|
|
20
|
+
* Every adapter here starts the thing under test in a THROWAWAY settings folder, on its own
|
|
21
|
+
* ports, under its own name, so that a run can never touch the real install. That isolation is
|
|
22
|
+
* the whole design, and a fresh settings folder is exactly the condition that makes an
|
|
23
|
+
* application ask the operating system for something it has never been granted: a keychain, a
|
|
24
|
+
* microphone, the Documents folder, permission to control another app. So these dialogs are a
|
|
25
|
+
* NORMAL consequence of how this tool works. A tool that causes them and then hangs behind
|
|
26
|
+
* them, silently, is broken in its own right.
|
|
27
|
+
*
|
|
28
|
+
* ## The three rules, and why each one is a rule
|
|
29
|
+
*
|
|
30
|
+
* ONLY OURS. A dialog is touched only when it belongs to an application this run started, and
|
|
31
|
+
* the caller supplies that list — the same `ours` bookkeeping the screen guard keeps. His own
|
|
32
|
+
* apps ask him things all day and none of it is any of this tool's business. Getting this
|
|
33
|
+
* wrong would mean a background check silently answering a prompt in his email client.
|
|
34
|
+
*
|
|
35
|
+
* ONLY HARMLESS BUTTONS. The button pressed comes from a fixed list of ones that decline,
|
|
36
|
+
* dismiss or close, and nothing else is ever pressed. On the very dialog that started this,
|
|
37
|
+
* the other button was "Reset To Defaults" — on a keychain — which is a change to the machine
|
|
38
|
+
* that no automated run may make on somebody's behalf. When there is no harmless button, the
|
|
39
|
+
* dialog is left exactly as it is and reported instead. A tool that cannot act safely says so;
|
|
40
|
+
* it does not guess.
|
|
41
|
+
*
|
|
42
|
+
* SAY IT HAPPENED. Every dialog seen is recorded with its words, whether it could be dismissed
|
|
43
|
+
* or not, and the check reports it. "No answer for one journey" is a fact with no cause in it.
|
|
44
|
+
* "No answer, because your app put up a box saying a keychain cannot be found, and I closed it"
|
|
45
|
+
* is the same fact with the thing a person needs in order to act.
|
|
46
|
+
*
|
|
47
|
+
* Everything here is best effort and every failure is swallowed: no window server, no Apple
|
|
48
|
+
* Events permission, a locked screen or a machine that is not a Mac all mean there are no
|
|
49
|
+
* dialogs to clear, which is never a reason to fail a check.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { execFile } from 'node:child_process';
|
|
53
|
+
import { promisify } from 'node:util';
|
|
54
|
+
|
|
55
|
+
import { detail } from '../../core/log.js';
|
|
56
|
+
|
|
57
|
+
const run = promisify(execFile);
|
|
58
|
+
|
|
59
|
+
/** How often to look. Dialogs are not urgent — a person would take a second to notice one too. */
|
|
60
|
+
const LOOK_EVERY_MS = 1200;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The only buttons this file will ever press.
|
|
64
|
+
*
|
|
65
|
+
* Every one of them declines, dismisses or closes. Nothing here grants anything, changes a
|
|
66
|
+
* setting, deletes anything or agrees to anything. The list is deliberately short and
|
|
67
|
+
* deliberately literal: a fuzzy match would eventually press "Allow" because it contained
|
|
68
|
+
* "low", and the whole safety of this file is that it cannot.
|
|
69
|
+
*/
|
|
70
|
+
const SAFE_BUTTONS = [
|
|
71
|
+
'Cancel',
|
|
72
|
+
"Don't Allow",
|
|
73
|
+
'Don’t Allow',
|
|
74
|
+
'Deny',
|
|
75
|
+
'Not Now',
|
|
76
|
+
'Later',
|
|
77
|
+
'Close',
|
|
78
|
+
'Dismiss',
|
|
79
|
+
'No',
|
|
80
|
+
'Quit',
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Buttons that must never be pressed even if some future edit adds them above.
|
|
85
|
+
*
|
|
86
|
+
* A second, independent gate. `SAFE_BUTTONS` is the allow-list and this is the veto, and a
|
|
87
|
+
* button has to get past both. It exists because the failure this file guards against is not
|
|
88
|
+
* "the wrong button was pressed once" — it is "somebody widened the allow-list a year from now
|
|
89
|
+
* and nobody noticed what it now includes".
|
|
90
|
+
*/
|
|
91
|
+
const NEVER_PRESS = [
|
|
92
|
+
'reset',
|
|
93
|
+
'allow',
|
|
94
|
+
'delete',
|
|
95
|
+
'erase',
|
|
96
|
+
'remove',
|
|
97
|
+
'ok',
|
|
98
|
+
'yes',
|
|
99
|
+
'continue',
|
|
100
|
+
'agree',
|
|
101
|
+
'accept',
|
|
102
|
+
'grant',
|
|
103
|
+
'always',
|
|
104
|
+
'trust',
|
|
105
|
+
'update',
|
|
106
|
+
'install',
|
|
107
|
+
'send',
|
|
108
|
+
'share',
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {object} SeenDialog
|
|
113
|
+
* @property {string} app The application it belongs to.
|
|
114
|
+
* @property {string} title Its window title, when it has one.
|
|
115
|
+
* @property {string} says The text on it, joined into one line.
|
|
116
|
+
* @property {string|null} closed The button pressed, or null when it was left alone.
|
|
117
|
+
* @property {string|null} why Why it was left alone, when it was.
|
|
118
|
+
* @property {number} at Milliseconds into the run.
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Is this a button this file is allowed to press?
|
|
123
|
+
*
|
|
124
|
+
* @param {string} label
|
|
125
|
+
* @returns {boolean}
|
|
126
|
+
*/
|
|
127
|
+
export function mayPress(label) {
|
|
128
|
+
const name = String(label ?? '').trim();
|
|
129
|
+
if (name.length === 0) return false;
|
|
130
|
+
const lower = name.toLowerCase();
|
|
131
|
+
// The veto runs first and on the whole label, so "Reset To Defaults" is refused before
|
|
132
|
+
// anything else gets a chance to like the look of it.
|
|
133
|
+
for (const banned of NEVER_PRESS) {
|
|
134
|
+
if (lower.split(/[^a-z’']+/).includes(banned)) return false;
|
|
135
|
+
}
|
|
136
|
+
return SAFE_BUTTONS.some((safe) => safe.toLowerCase() === lower);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Pick the button to press out of what a dialog offers.
|
|
141
|
+
*
|
|
142
|
+
* @param {string[]} labels
|
|
143
|
+
* @returns {string|null}
|
|
144
|
+
*/
|
|
145
|
+
export function safeButton(labels) {
|
|
146
|
+
const offered = (labels ?? []).map((l) => String(l ?? '').trim()).filter(Boolean);
|
|
147
|
+
// In SAFE_BUTTONS order rather than in the dialog's order, so the answer does not depend on
|
|
148
|
+
// how somebody laid their buttons out.
|
|
149
|
+
for (const wanted of SAFE_BUTTONS) {
|
|
150
|
+
const hit = offered.find((l) => l.toLowerCase() === wanted.toLowerCase() && mayPress(l));
|
|
151
|
+
if (hit) return hit;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every modal box currently up, for the applications named.
|
|
158
|
+
*
|
|
159
|
+
* WHAT COUNTS AS A DIALOG, and this took measuring rather than guessing. The obvious test is
|
|
160
|
+
* the window's subrole, and it is wrong: on this Mac an ordinary Terminal window, Activity
|
|
161
|
+
* Monitor and System Settings all report `AXDialog`, the same value the real keychain alert
|
|
162
|
+
* reports. A tool matching on that would go hunting for buttons to press on somebody's actual
|
|
163
|
+
* work. The attribute that does separate them is **AXModal** — measured true on the alert and
|
|
164
|
+
* false on all four of his open Terminal windows — which is also the honest definition of the
|
|
165
|
+
* thing being fixed here: a box that blocks. Sheets are included because a sheet is modal to
|
|
166
|
+
* the window it hangs off whatever it says about itself.
|
|
167
|
+
*
|
|
168
|
+
* One `osascript` call for the whole sweep. The output is one record per line, tab separated,
|
|
169
|
+
* because parsing AppleScript's own list syntax is a worse idea than choosing a separator.
|
|
170
|
+
*
|
|
171
|
+
* @param {string[]} apps
|
|
172
|
+
* @returns {Promise<{app: string, title: string, says: string, buttons: string[]}[]>}
|
|
173
|
+
*/
|
|
174
|
+
export async function dialogsUp(apps) {
|
|
175
|
+
if (process.platform !== 'darwin' || !apps || apps.length === 0) return [];
|
|
176
|
+
const list = '{' + apps.map((a) => JSON.stringify(String(a))).join(', ') + '}';
|
|
177
|
+
const script = `
|
|
178
|
+
set out to ""
|
|
179
|
+
tell application "System Events"
|
|
180
|
+
repeat with wanted in ${list}
|
|
181
|
+
repeat with proc in (every application process whose name is (wanted as string))
|
|
182
|
+
try
|
|
183
|
+
repeat with win in (every window of proc)
|
|
184
|
+
set isDialog to false
|
|
185
|
+
try
|
|
186
|
+
if (value of attribute "AXModal" of win) is true then set isDialog to true
|
|
187
|
+
end try
|
|
188
|
+
try
|
|
189
|
+
if subrole of win is "AXSystemDialog" then set isDialog to true
|
|
190
|
+
end try
|
|
191
|
+
-- NOT "sheets". Inside a System Events tell block that word is an element name,
|
|
192
|
+
-- so "set sheets to {}" is read as "set every sheet to {}" and throws -10006.
|
|
193
|
+
-- The error was swallowed by the try around this loop and the whole sweep came
|
|
194
|
+
-- back empty, which looked exactly like a machine with no dialogs on it.
|
|
195
|
+
set sheetCount to 0
|
|
196
|
+
set firstSheet to missing value
|
|
197
|
+
try
|
|
198
|
+
set theSheets to every sheet of win
|
|
199
|
+
set sheetCount to (count of theSheets)
|
|
200
|
+
if sheetCount > 0 then set firstSheet to item 1 of theSheets
|
|
201
|
+
end try
|
|
202
|
+
if (isDialog) or (sheetCount > 0) then
|
|
203
|
+
set target to win
|
|
204
|
+
if sheetCount > 0 then set target to firstSheet
|
|
205
|
+
set theTitle to ""
|
|
206
|
+
try
|
|
207
|
+
set theTitle to name of target as string
|
|
208
|
+
end try
|
|
209
|
+
set theText to ""
|
|
210
|
+
try
|
|
211
|
+
repeat with t in (every static text of target)
|
|
212
|
+
set theText to theText & (value of t as string) & " "
|
|
213
|
+
end repeat
|
|
214
|
+
end try
|
|
215
|
+
set theButtons to ""
|
|
216
|
+
try
|
|
217
|
+
repeat with b in (every button of target)
|
|
218
|
+
set theButtons to theButtons & (name of b as string) & "|"
|
|
219
|
+
end repeat
|
|
220
|
+
end try
|
|
221
|
+
set out to out & (name of proc as string) & tab & theTitle & tab & theText & tab & theButtons & linefeed
|
|
222
|
+
end if
|
|
223
|
+
end repeat
|
|
224
|
+
end try
|
|
225
|
+
end repeat
|
|
226
|
+
end repeat
|
|
227
|
+
end tell
|
|
228
|
+
return out
|
|
229
|
+
`;
|
|
230
|
+
try {
|
|
231
|
+
const { stdout } = await run('osascript', ['-e', script], { timeout: 6000 });
|
|
232
|
+
return stdout
|
|
233
|
+
.split('\n')
|
|
234
|
+
.map((line) => line.replace(/\r$/, ''))
|
|
235
|
+
.filter((line) => line.trim().length > 0)
|
|
236
|
+
.map((line) => {
|
|
237
|
+
const [app = '', title = '', says = '', buttons = ''] = line.split('\t');
|
|
238
|
+
return {
|
|
239
|
+
app: app.trim(),
|
|
240
|
+
title: title.trim(),
|
|
241
|
+
says: says.replace(/\s+/g, ' ').trim(),
|
|
242
|
+
buttons: buttons.split('|').map((b) => b.trim()).filter(Boolean),
|
|
243
|
+
};
|
|
244
|
+
})
|
|
245
|
+
.filter((d) => d.app.length > 0);
|
|
246
|
+
} catch {
|
|
247
|
+
// No window server, no permission, a locked screen. There is nothing to clear.
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Press one button on one application's frontmost dialog.
|
|
254
|
+
*
|
|
255
|
+
* @param {string} app
|
|
256
|
+
* @param {string} button
|
|
257
|
+
* @returns {Promise<boolean>}
|
|
258
|
+
*/
|
|
259
|
+
export async function pressButton(app, button) {
|
|
260
|
+
if (process.platform !== 'darwin') return false;
|
|
261
|
+
if (!mayPress(button)) return false;
|
|
262
|
+
const script = `
|
|
263
|
+
tell application "System Events"
|
|
264
|
+
tell (first application process whose name is ${JSON.stringify(app)})
|
|
265
|
+
repeat with win in (every window of it)
|
|
266
|
+
try
|
|
267
|
+
set target to win
|
|
268
|
+
try
|
|
269
|
+
if (count of (every sheet of win)) > 0 then set target to item 1 of (every sheet of win)
|
|
270
|
+
end try
|
|
271
|
+
click (first button of target whose name is ${JSON.stringify(button)})
|
|
272
|
+
return "pressed"
|
|
273
|
+
end try
|
|
274
|
+
end repeat
|
|
275
|
+
end tell
|
|
276
|
+
end tell
|
|
277
|
+
return "no"
|
|
278
|
+
`;
|
|
279
|
+
try {
|
|
280
|
+
const { stdout } = await run('osascript', ['-e', script], { timeout: 6000 });
|
|
281
|
+
return stdout.trim() === 'pressed';
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @typedef {object} DialogWatcher
|
|
289
|
+
* @property {(name: string) => void} claim Name an application this run started.
|
|
290
|
+
* @property {() => Promise<void>} stop Stop watching. Safe to call twice.
|
|
291
|
+
* @property {() => SeenDialog[]} report Every dialog seen, in the order they appeared.
|
|
292
|
+
* @property {() => Promise<void>} sweepNow Look once, right now. For tests and for the end of a run.
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Watch for modal boxes belonging to what this run started, close the harmless ones, and
|
|
297
|
+
* remember all of them.
|
|
298
|
+
*
|
|
299
|
+
* `look` and `press` exist for the same reason the screen guard has them: the decisions here —
|
|
300
|
+
* whose dialog it is, which button qualifies, what gets recorded when nothing can be pressed —
|
|
301
|
+
* are the part that has to be right, and they are unreachable behind two `osascript` calls
|
|
302
|
+
* that answer differently on every machine and not at all on most.
|
|
303
|
+
*
|
|
304
|
+
* @param {{claims?: string[], everyMs?: number, elapsed?: () => number, look?: (apps: string[]) => Promise<any[]>, press?: (app: string, button: string) => Promise<boolean>}} [opts]
|
|
305
|
+
* @returns {DialogWatcher}
|
|
306
|
+
*/
|
|
307
|
+
export function watchForDialogs(opts = {}) {
|
|
308
|
+
const everyMs = opts.everyMs ?? LOOK_EVERY_MS;
|
|
309
|
+
const look = opts.look ?? dialogsUp;
|
|
310
|
+
const press = opts.press ?? pressButton;
|
|
311
|
+
const elapsed = opts.elapsed ?? (() => 0);
|
|
312
|
+
|
|
313
|
+
/** @type {Set<string>} */
|
|
314
|
+
const ours = new Set(opts.claims ?? []);
|
|
315
|
+
/** @type {SeenDialog[]} */
|
|
316
|
+
const seen = [];
|
|
317
|
+
/** Dialogs already recorded, so one box sitting there for a minute is one line, not fifty. */
|
|
318
|
+
const already = new Set();
|
|
319
|
+
let stopped = process.platform !== 'darwin' && !opts.look;
|
|
320
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
321
|
+
let timer = null;
|
|
322
|
+
|
|
323
|
+
const sweep = async () => {
|
|
324
|
+
if (stopped || ours.size === 0) return;
|
|
325
|
+
let up = [];
|
|
326
|
+
try {
|
|
327
|
+
up = await look([...ours]);
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
for (const d of up ?? []) {
|
|
332
|
+
const key = `${d.app}${d.title}${d.says}`;
|
|
333
|
+
if (already.has(key)) continue;
|
|
334
|
+
const button = safeButton(d.buttons ?? []);
|
|
335
|
+
let closed = null;
|
|
336
|
+
let why = null;
|
|
337
|
+
if (button) {
|
|
338
|
+
const done = await press(d.app, button).catch(() => false);
|
|
339
|
+
if (done) closed = button;
|
|
340
|
+
else why = `tried to press "${button}" and the click did not land`;
|
|
341
|
+
} else {
|
|
342
|
+
// Recorded, not guessed at. The offered buttons go into the reason so a person reading
|
|
343
|
+
// the run knows exactly what they are being asked and why nothing was pressed.
|
|
344
|
+
why =
|
|
345
|
+
(d.buttons ?? []).length > 0
|
|
346
|
+
? `nothing on it is safe for a machine to press: ${(d.buttons ?? []).join(', ')}`
|
|
347
|
+
: 'it offered no button this tool could find';
|
|
348
|
+
}
|
|
349
|
+
already.add(key);
|
|
350
|
+
seen.push({ app: d.app, title: d.title ?? '', says: d.says ?? '', closed, why, at: elapsed() });
|
|
351
|
+
detail(
|
|
352
|
+
`A dialog from ${d.app} said "${d.says || d.title}" — ` +
|
|
353
|
+
(closed ? `closed it with "${closed}".` : `left it alone: ${why}.`),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const tick = async () => {
|
|
359
|
+
await sweep();
|
|
360
|
+
if (!stopped) timer = setTimeout(tick, everyMs);
|
|
361
|
+
};
|
|
362
|
+
if (!stopped) timer = setTimeout(tick, everyMs);
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
claim: (name) => {
|
|
366
|
+
if (name) ours.add(name);
|
|
367
|
+
},
|
|
368
|
+
stop: async () => {
|
|
369
|
+
stopped = true;
|
|
370
|
+
if (timer) clearTimeout(timer);
|
|
371
|
+
timer = null;
|
|
372
|
+
},
|
|
373
|
+
report: () => seen.slice(),
|
|
374
|
+
sweepNow: sweep,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* One plain sentence about the dialogs a run met, or nothing when it met none.
|
|
380
|
+
*
|
|
381
|
+
* Written to be read after the verdict, by somebody who has just been told a journey had no
|
|
382
|
+
* answer and needs to know why.
|
|
383
|
+
*
|
|
384
|
+
* @param {SeenDialog[]} seen
|
|
385
|
+
* @returns {string|null}
|
|
386
|
+
*/
|
|
387
|
+
export function describeDialogs(seen) {
|
|
388
|
+
const all = seen ?? [];
|
|
389
|
+
if (all.length === 0) return null;
|
|
390
|
+
const closed = all.filter((d) => d.closed);
|
|
391
|
+
const left = all.filter((d) => !d.closed);
|
|
392
|
+
const parts = [];
|
|
393
|
+
if (closed.length > 0) {
|
|
394
|
+
const first = closed[0];
|
|
395
|
+
parts.push(
|
|
396
|
+
`${closed.length === 1 ? 'A box' : `${closed.length} boxes`} came up from the app this run started and ` +
|
|
397
|
+
`${closed.length === 1 ? 'was' : 'were'} closed with "${first.closed}"` +
|
|
398
|
+
`${first.says ? `: "${first.says}"` : ''}. Anything walked while it was up may have had no answer for that reason.`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (left.length > 0) {
|
|
402
|
+
const first = left[0];
|
|
403
|
+
parts.push(
|
|
404
|
+
`${left.length === 1 ? 'A box is' : `${left.length} boxes are`} still up and ${left.length === 1 ? 'was' : 'were'} ` +
|
|
405
|
+
`left alone, because ${first.why}${first.says ? `. It says: "${first.says}"` : ''}. ` +
|
|
406
|
+
'Nothing here presses a button that could change your machine.',
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return parts.join(' ');
|
|
410
|
+
}
|
package/src/v2/watch/panel.js
CHANGED
|
@@ -568,7 +568,10 @@ button { font: inherit; color: inherit; }
|
|
|
568
568
|
}
|
|
569
569
|
|
|
570
570
|
/* --- the scrolling body -------------------------------------------------- */
|
|
571
|
-
|
|
571
|
+
/* A FLOOR, so the report can never be reduced to a slit again. Whatever else is on screen,
|
|
572
|
+
the body keeps enough height to show a heading and a few rows under it — which is the
|
|
573
|
+
difference between "there is more here, scroll" and "the detail is gone". */
|
|
574
|
+
.scroll { flex: 1 1 auto; min-height: 132px; overflow-y: auto; overflow-x: hidden; padding: 0 var(--pad) 18px; }
|
|
572
575
|
.scroll::-webkit-scrollbar { width: 9px; }
|
|
573
576
|
.scroll::-webkit-scrollbar-thumb { background: var(--resting); border-radius: 999px; }
|
|
574
577
|
.scroll::-webkit-scrollbar-track { background: transparent; }
|
|
@@ -778,8 +781,25 @@ button { font: inherit; color: inherit; }
|
|
|
778
781
|
.nothing { padding: 30px 16px; color: var(--faint); font-size: var(--t-body); line-height: 1.8; text-align: center; }
|
|
779
782
|
|
|
780
783
|
/* --- the only thing that ever reaches a person --------------------------- */
|
|
784
|
+
/* THE FOOTER MAY NEVER EAT THE WINDOW.
|
|
785
|
+
It used to be "flex: 0 0 auto" with nothing capping it, so its height was simply however
|
|
786
|
+
many things needed a person. On a real run that found seventeen, the footer grew past the
|
|
787
|
+
height of the whole panel and squeezed the scrolling body — the walk, the wobble, what
|
|
788
|
+
survived, what was not checked, every journey and its address count — down to EIGHTEEN
|
|
789
|
+
PIXELS holding 41,115 of content. Measured, not guessed: clientHeight 18, scrollHeight
|
|
790
|
+
41115. What a person saw at the end of a run was one clipped line of text where the whole
|
|
791
|
+
report had been a second earlier, which reads exactly like the tool throwing its detail
|
|
792
|
+
away at the moment it finishes.
|
|
793
|
+
So it shrinks now, the list inside it scrolls, and the body below keeps a floor. */
|
|
781
794
|
.foot {
|
|
795
|
+
/* Sizes to its content, and stops at half the window. Not "0 1 auto": that let the flex
|
|
796
|
+
row below it take every pixel and shrank the footer to its label, so seventeen things
|
|
797
|
+
needing a person rendered at zero height — the same detail lost, from the other end. */
|
|
782
798
|
flex: 0 0 auto;
|
|
799
|
+
max-height: 52%;
|
|
800
|
+
min-height: 0;
|
|
801
|
+
display: flex;
|
|
802
|
+
flex-direction: column;
|
|
783
803
|
padding: 13px var(--pad) 15px;
|
|
784
804
|
background: var(--glass);
|
|
785
805
|
backdrop-filter: blur(22px) saturate(120%);
|
|
@@ -787,6 +807,12 @@ button { font: inherit; color: inherit; }
|
|
|
787
807
|
border-top: 1px solid var(--line);
|
|
788
808
|
animation: arrive 340ms var(--ease) both;
|
|
789
809
|
}
|
|
810
|
+
/* The label above and the sentence below stay put; only the list moves. A person reading
|
|
811
|
+
"Needs a person" must never have to scroll to find out that is what they are reading. */
|
|
812
|
+
#needs { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; }
|
|
813
|
+
#needs::-webkit-scrollbar { width: 9px; }
|
|
814
|
+
#needs::-webkit-scrollbar-thumb { background: var(--resting); border-radius: 999px; }
|
|
815
|
+
#needs::-webkit-scrollbar-track { background: transparent; }
|
|
790
816
|
.nextlabel {
|
|
791
817
|
font-size: var(--t-label); font-weight: 600; letter-spacing: 0.2em;
|
|
792
818
|
text-transform: uppercase; color: var(--wait); margin-bottom: 8px;
|