Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_frame.mjs

22.1 KiB, 1 run

created by r2519314175:425, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_frame.mjs — A TRANSIENT ELEMENT MUST NEVER SIT IN THE FLOW OF CLICKABLE
2// TARGETS. Either it lives outside that flow, or the space it occupies is
3// reserved whether or not it is showing.
4//
5// The top bar is where that rule is hardest to keep, because five things in it
6// come and go on their own timing: a sync round starting and stopping, a held
7// cloud sweep, the pairing button arriving with a session, the update chip going
8// quiet, and an unseen count landing on a chip. Until 2026-08-28 the bar's
9// right-hand group shrank to its contents inside a `space-between` row, so every
10// one of those moved the whole chip row sideways -- 86px for a sync round, 98 for
11// a held sweep, 122 for the pairing button, 21 for a mail badge -- and the owner
12// reported it as what it is: "it makes it impossible to click moving targets
13// during the process."
14//
15// So this measures rather than inspects. Each transient is made to appear
16// through the app's own path, and the x of every chip and every icon button in
17// the bar is compared across the change. Nothing may move by a pixel.
18//
19// EVERY CHECK BELOW WAS SHOWN GOING RED, 2026-08-28, by putting each mechanism
20// back the way it was: the badge in the chip's flow (4 chips moved 22px), the
21// update chip's `[hidden]` rule taken out (visible while hidden), the pairing
22// button hidden with `display` (its arrival caught), the sync chip mounted back
23// in `.top-actions` (18 targets moved, worst 77px, and 9 rail rows with it), and
24// `.top-actions` back to `flex: 0 1 auto` (a longer label moved all 12 chips to
25// its left). That last one caught a real mistake in the very change this file
26// was written for: the property had been reverted while the comment above it
27// still described the fix, and the four transient checks all passed anyway --
28// their space is reserved, so they never needed the origin to be nailed down.
29//
30// WHAT THIS CANNOT SEE. It measures one window width and one language: a rule
31// that held at 1440px in English and broke at 900px in German would pass here.
32// The widths are `verify_workspace`'s subject and the languages are
33// `verify_i18n`'s; this one owns the question of whether anything MOVES.
34// And the pairing section is the weakest of the five, because the button's
35// hidden form has to be posed by this file rather than read off the app: it
36// catches a change to how the button is revealed, not every way its slot could
37// stop being reserved.
38import { open, errors } from './harness.mjs';
39
40const ok = [], bad = [];
41const check = (name, pass, detail) => {
42 (pass ? ok : bad).push(name);
43 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
44};
45const sleep = ms => new Promise(r => setTimeout(r, ms));
46
47const s = await open({ signIn: true, connect: true, name: 'frame' });
48const { page } = s;
49await page.route('**/*.{woff,woff2,ttf,otf}', r => r.abort());
50await page.evaluate(() => { try { window.DaimondAdmin.closeModal(); } catch (e) {} const m = document.getElementById('settings-modal'); if (m) m.style.display = 'none'; });
51await page.setViewportSize({ width: 1440, height: 900 });
52await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' });
53await sleep(700);
54
55/// Every target in the top bar, by its left edge.
56const bar = () => page.evaluate(() => {
57 const o = {};
58 document.querySelectorAll('#panel-tags .ptag').forEach(c => {
59 o['chip:' + (c.dataset.panel || c.id)] = Math.round(c.getBoundingClientRect().x);
60 });
61 document.querySelectorAll('.top-actions > button').forEach(b => {
62 o['btn:' + (b.id || b.className)] = Math.round(b.getBoundingClientRect().x);
63 });
64 return o;
65});
66
67/// Compare two readings and report the worst movement.
68const still = async (what, before) => {
69 const after = await bar();
70 const shared = Object.keys(before).filter(k => after[k] !== undefined);
71 const moved = shared.filter(k => after[k] !== before[k]);
72 const worst = moved.length ? Math.max(...moved.map(k => Math.abs(after[k] - before[k]))) : 0;
73 check(what, moved.length === 0 && shared.length > 6,
74 `${shared.length} targets` + (moved.length
75 ? `, ${moved.length} moved, worst ${worst}px: ` + moved.slice(0, 4).join(' ')
76 : ', none moved'));
77};
78
79// ── 1. A sync round ────────────────────────────────────────────────
80// Through sync.js's own `pullOnce`, with the gateway stubbed so the request
81// hangs: that is the state being complained about, a round in flight while a
82// thumb is on its way to a chip.
83{
84 const before = await bar();
85 check('the bar holds chips and icon buttons to measure',
86 Object.keys(before).filter(k => k.startsWith('chip:')).length > 3
87 && Object.keys(before).filter(k => k.startsWith('btn:')).length > 2,
88 Object.keys(before).length + ' targets');
89 await page.evaluate(() => {
90 window.DaimondGateway.state = () => ({ authed: true });
91 window.DaimondGateway.clientApi = () => 1;
92 window.DaimondGateway.gwFetch = () => new Promise(r => setTimeout(
93 () => r({ status: 200, json: async () => ({ present: false }) }), 5000));
94 });
95 page.evaluate(() => window.DaimondSync.pull()).catch(() => {});
96 await sleep(800);
97 const chip = await page.evaluate(() => {
98 const c = document.getElementById('sync-chip');
99 return c ? { shown: getComputedStyle(c).display !== 'none', text: (c.textContent || '').trim(), where: c.parentNode.id } : null;
100 });
101 check('a round in flight says so somewhere the user can see it',
102 !!(chip && chip.shown && /sync/i.test(chip.text)), JSON.stringify(chip));
103 // AND NOT IN THE ROW OF TARGETS. The chip is the reason this file exists; a
104 // later change that moved it back into `.top-actions` would pass every
105 // measurement below only until the bar ran short of room.
106 check('and it says so OUT of the bar, in the rail\'s status strip',
107 !!(chip && chip.where === 'astat-sync'), chip && chip.where);
108 await still('a sync round moves nothing in the bar', before);
109 await sleep(5000);
110}
111
112// ── 2. The pairing button, revealed by a session ───────────────────
113{
114 await page.evaluate(() => { const b = document.getElementById('pair-link-btn'); if (b) b.style.visibility = 'hidden'; });
115 await sleep(250);
116 const before = await bar();
117 await page.evaluate(() => window.dispatchEvent(new Event('daimond:authed')));
118 await sleep(350);
119 check('the pairing button is showing once a session exists', await page.evaluate(() => {
120 const b = document.getElementById('pair-link-btn');
121 return !!b && getComputedStyle(b).visibility === 'visible';
122 }));
123 await still('and its slot was already there, so nothing moved when it arrived', before);
124}
125
126// ── 3. The update chip going quiet ─────────────────────────────────
127// `hidden` used to do nothing at all here: an author rule setting `display:
128// flex` beats the browser's `[hidden] { display: none }`, so the one state that
129// asks for silence -- no version stamp deployed -- drew a faint mark anyway.
130{
131 await page.evaluate(() => { document.getElementById('update-chip').hidden = true; });
132 await sleep(250);
133 const quiet = await page.evaluate(() => {
134 const c = getComputedStyle(document.getElementById('update-chip'));
135 return { display: c.display, visibility: c.visibility, w: Math.round(document.getElementById('update-chip').getBoundingClientRect().width) };
136 });
137 check('a hidden update chip is actually invisible', quiet.visibility === 'hidden', JSON.stringify(quiet));
138 check('and still holds its place in the row', quiet.w > 20, quiet.w + 'px');
139 const before = await bar();
140 await page.evaluate(() => { document.getElementById('update-chip').hidden = false; });
141 await sleep(250);
142 await still('the update chip coming back moves nothing', before);
143}
144
145// ── 4. An unseen count landing on a chip ───────────────────────────
146// Through the event mail.js dispatches, so what is measured is the badge the
147// app really draws.
148{
149 const before = await bar();
150 await page.evaluate(() => window.dispatchEvent(new CustomEvent('daimond:mail-arrived', { detail: { count: 3 } })));
151 await sleep(450);
152 check('three messages arriving draws a count on the Email chip', await page.evaluate(() => {
153 const b = document.querySelector('#panel-tags .ptag[data-panel="mail"] .dock-count');
154 return !!b && (b.textContent || '').trim() === '3';
155 }));
156 await still('a count arriving moves nothing — not even the chip it is on', before);
157}
158
159// ── 5. A chip's own label getting longer ───────────────────────────
160// The row's LEFT EDGE has to be fixed too, not merely undisturbed by what is to
161// its right. A language change, a renamed panel, or the `... N` chip going from
162// one digit to two all change how much room the row wants; if the row shrank to
163// its contents inside a right-anchored group, every one of those would slide the
164// whole row sideways. This is the check that says the origin is nailed down --
165// and it is here because the rule was written into the comment above
166// `.top-actions` before it was written into the property, and nothing else
167// noticed.
168{
169 // Through `relabel`, which is what a language change calls: writing a longer
170 // word into the chip by hand does not survive, because the row's own
171 // ResizeObserver rebuilds it from the registry within the frame.
172 const before = await bar();
173 await page.evaluate(() => {
174 const el = document.getElementById('panel-social');
175 el.dataset.was = el.dataset.label;
176 el.dataset.label = el.dataset.label + ' and then some';
177 window.DaimondPanels.relabel();
178 });
179 await sleep(400);
180 const after = await bar();
181 const grew = await page.evaluate(() => {
182 const c = document.querySelector('#panel-tags .ptag[data-panel="social"]');
183 return c ? Math.round(c.getBoundingClientRect().width) : 0;
184 });
185 const left = Object.keys(before).filter(k => k.startsWith('chip:') && k !== 'chip:social'
186 && after[k] !== undefined && before[k] < before['chip:social']);
187 const movedLeft = left.filter(k => after[k] !== before[k]);
188 check('a longer label moves no chip to its left',
189 left.length > 3 && movedLeft.length === 0 && grew > 90,
190 `the chip grew to ${grew}px; ${left.length} chips before it, ${movedLeft.length} moved`);
191 await page.evaluate(() => {
192 const el = document.getElementById('panel-social');
193 if (el.dataset.was) { el.dataset.label = el.dataset.was; delete el.dataset.was; }
194 window.DaimondPanels.relabel();
195 });
196 await sleep(300);
197}
198
199// ── 6. A held-back cloud sweep ─────────────────────────────────────
200{
201 const before = await bar();
202 const drawn = await page.evaluate(() => {
203 const c = document.getElementById('chunk-chip');
204 if (!c) return null;
205 c.querySelector('.ctext').textContent = 'Cleanup paused';
206 c.style.display = 'flex';
207 return c.parentNode.id;
208 });
209 await sleep(300);
210 // IN THE SYNC ROW, sharing it. Its own row at the foot of the strip was the
211 // first answer and it was the same fault one surface over -- the strip grew
212 // and the rail's controls moved with it (`dev/verify_sweep_seen.mjs`, ANCHOR).
213 check('a held sweep says so in the rail rather than in the bar',
214 drawn === 'astat-sync', String(drawn));
215 await still('a held sweep moves nothing in the bar', before);
216}
217
218// ── 7. And the strip it all moved into does not move either ────────
219// A status that appears among the rail's rows would be the same fault one panel
220// over: those rows are buttons, and the two above the sync row open Credits and
221// the Pro offer.
222{
223 const rows = () => page.evaluate(() => {
224 const o = {};
225 document.querySelectorAll('#admin-status > *').forEach(r => { o[r.id || r.className] = Math.round(r.getBoundingClientRect().y); });
226 return o;
227 });
228 const quiet = await rows();
229 page.evaluate(() => window.DaimondSync.pull()).catch(() => {});
230 await sleep(800);
231 const busy = await rows();
232 const moved = Object.keys(quiet).filter(k => busy[k] !== undefined && busy[k] !== quiet[k]);
233 check('a sync round moves no row in the rail\'s status strip either',
234 moved.length === 0 && Object.keys(quiet).length > 6,
235 Object.keys(quiet).length + ' rows, ' + moved.length + ' moved' + (moved.length ? ': ' + moved.join(', ') : ''));
236 // The row cannot fade the way the old pill did -- it would take its
237 // neighbours up the strip with it -- so what it does instead is say the one
238 // thing that is always true and was never on screen anywhere before: a phone
239 // has no hover, and this sentence lived only in the pill's `title`.
240 //
241 // THE TIER HAS TO BE HELD FOR THE RESTING STATE TO EXIST AT ALL. Without it
242 // "Sync off" stands permanently and correctly, and there is nothing to rest
243 // to; `recheck` is the app's own door to the state a Pro purchase leaves
244 // behind. Then a round that lands leaves "Synced" for 1.8s and rests.
245 await sleep(5500);
246 await page.evaluate(async () => {
247 window.DaimondGateway.gwFetch = () => Promise.resolve({ status: 200, json: async () => ({ present: false }) });
248 window.DaimondSync.recheck();
249 await window.DaimondSync.pull();
250 });
251 await sleep(2400);
252 const rest = await page.evaluate(() => {
253 const c = document.getElementById('sync-chip'), r = document.getElementById('sync-rest');
254 return { chipShown: c ? c.style.display !== 'none' : null,
255 restShown: getComputedStyle(r).display !== 'none', rest: (r.textContent || '').trim() };
256 });
257 check('with nothing in flight the row says when a sync last worked',
258 rest.restShown && /sync/i.test(rest.rest), JSON.stringify(rest));
259 // SIX VERIFIERS READ `#sync-chip`'s `style.display` to mean "is it saying
260 // anything", verify_sessionrenew's `state === 'synced' || !shown` among them.
261 // The resting line is a SIBLING for exactly that reason.
262 check('and the chip itself is still hidden when it has nothing to say',
263 rest.chipShown === false, JSON.stringify(rest));
264}
265
266// A world with no gateway of its own answers `/api/*` with a 502, deliberately
267// (dev/world.sh), and nothing in this file is about the gateway.
268// ── 8. The Admin drawer, bounded by the rail ───────────────────────
269// The strip's own height is only safe to change because the drawer gives the
270// height back instead of rising into the rail's head. HIT-TESTED, not measured:
271// the failure here is a control that is present, positioned and covered, which
272// is the one case a geometric check passes and a user fails.
273{
274 const seen = (sel) => page.evaluate((q) => {
275 const e = document.querySelector(q);
276 if (!e) return 'absent';
277 const r = e.getBoundingClientRect();
278 if (r.width === 0 || r.height === 0) return 'not drawn';
279 const own = (x, y) => { const el = document.elementFromPoint(Math.round(x), Math.round(y)); return !!(el && (el === e || e.contains(el))); };
280 const c = own(r.x + r.width / 2, r.y + r.height / 2);
281 const t = own(r.x + r.width / 2, r.y + 2);
282 const b = own(r.x + r.width / 2, r.bottom - 2);
283 return c && t && b ? 'whole' : (c ? 'part' : (t || b ? 'half-live' : 'covered'));
284 }, sel);
285 const spill = () => page.evaluate(() => {
286 const b = document.querySelector('.admin-body').getBoundingClientRect();
287 const r = document.querySelector('.panel.rail').getBoundingClientRect();
288 return Math.round(r.y - b.y);
289 });
290 // Three window heights, the middle one being where the old 540px cap put the
291 // drawer's top edge inside `#new-diamond-btn`.
292 const bad = [];
293 for (const h of [700, 940, 1100]) {
294 await page.setViewportSize({ width: 1440, height: h });
295 await sleep(500);
296 await page.evaluate(() => { const b = document.getElementById('settings-btn'); if (b && !document.getElementById('admin').classList.contains('admin-open')) b.click(); });
297 await sleep(500);
298 const said = { h, spill: await spill(), close: await seen('#admin-close'), plus: await seen('#new-diamond-btn') };
299 if (said.spill > 0 || said.close !== 'whole' || said.plus !== 'whole') bad.push(JSON.stringify(said));
300 await page.evaluate(() => { try { DaimondAdmin.close(); } catch (e) {} });
301 await sleep(250);
302 }
303 check('the open Admin drawer never leaves the rail, and its own × is always pressable',
304 bad.length === 0, bad.join(' | '));
305 check('and the rail\'s own head is whole above it, at every window height',
306 bad.length === 0, bad.length ? bad.join(' | ') : '700, 940 and 1100px');
307 await page.setViewportSize({ width: 1440, height: 900 });
308 await sleep(400);
309}
310
311// ── 9. The strip may change height by ANY amount ───────────────────
312// Section 7 says no row inside the strip moves when a row's contents change.
313// This says the thing underneath it: the strip may change HEIGHT, by any amount
314// and for any reason, and the divider the user set in the rail above it does not
315// move. A different claim, and until 2026-08-28 it was false. The Diamonds list
316// held a SHARE of what the furniture left over, so a taller strip shrank
317// `#rail-top`, `applyRailSplit` recomputed the list, and the Chats head went half
318// as far again as the strip's own rows did. Measured on this file's world before
319// the change, at 1440x900: a strip 37px taller moved the Chats head 16px, one
320// 113px taller moved it 54.
321//
322// GROWN WITH PADDING rather than by showing one of the rows that happen to exist
323// today. `astat-byok`, `astat-pro` and `astat-store-native` are all still
324// transient, by decision or by circumstance, and more rows will be written after
325// this file is: a check that names one of them proves nothing about the next.
326// Padding is any reason at all, which is the whole claim.
327//
328// THE DIVIDER IS DRAGGED FIRST, and it has to be. A divider nobody has touched is
329// an even split and is still cut from the room every time, deliberately -- see
330// `applyRailSplit`. What is fixed here is that a position somebody CHOSE is never
331// recomputed out from under them, whatever the strip below does.
332{
333 const MIN_LIST = 72; // MIN_H.list in js/daimond.js -- neither list is crushed
334 const rail = () => page.evaluate(() => {
335 const heads = [...document.querySelectorAll('#rail-top .railhead')];
336 const chats = heads[heads.length - 1];
337 const list = document.getElementById('diamond-list');
338 const sess = document.getElementById('session-list');
339 return {
340 chats: chats ? Math.round(chats.getBoundingClientRect().y) : null,
341 list: Math.round(list.getBoundingClientRect().height),
342 sess: Math.round(sess.getBoundingClientRect().height),
343 strip: Math.round(document.getElementById('admin-status').getBoundingClientRect().height),
344 };
345 });
346 // Its BOTTOM padding, which is nought in the stylesheet, so the strip grows by
347 // exactly the number asked for. The top padding is 6px, and setting that grew
348 // the strip by px-6 -- caught by the premise check below, which is the argument
349 // for having one.
350 const grow = (px) => page.evaluate((px) => {
351 document.getElementById('admin-status').style.paddingBottom = px ? px + 'px' : '';
352 }, px);
353 const drag = async (dy) => {
354 const b = await (await page.$('#handle-rail-split')).boundingBox();
355 await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
356 await page.mouse.down();
357 await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2 + dy, { steps: 12 });
358 await page.mouse.up();
359 await sleep(400);
360 };
361 await page.evaluate(() => { try { DaimondAdmin.close(); } catch (e) {} });
362 await page.setViewportSize({ width: 1440, height: 900 });
363 await sleep(500);
364 const even = await rail();
365 await drag(-60);
366 const set = await rail();
367 check('the rail\'s divider can be put somewhere the user chose',
368 set.list < even.list - 40 && set.sess > even.sess + 40,
369 `diamonds ${even.list}->${set.list}px, chats ${even.sess}->${set.sess}px`);
370
371 const missed = [], moved9 = [];
372 for (const h of [900, 1100]) {
373 await page.setViewportSize({ width: 1440, height: h });
374 await sleep(500);
375 for (const px of [37, 54, 97]) {
376 const a = await rail();
377 await grow(px);
378 await sleep(350);
379 const b = await rail();
380 await grow(0);
381 await sleep(300);
382 const c = await rail();
383 const said = `${h}/+${px}: chats ${a.chats}->${b.chats}, diamonds ${a.list}->${b.list}->${c.list}, chats list ${a.sess}->${b.sess}, strip ${a.strip}->${b.strip}`;
384 // The growth has to have reached the rail and left the Chats list room
385 // to give, or the clamp is entitled to move the divider and this is not
386 // the case being made. Reported apart from a failure, because a check
387 // whose premise did not hold has proved nothing rather than passed.
388 if (b.strip - a.strip < px - 1 || b.sess >= a.sess || b.sess < MIN_LIST) { missed.push(said); continue; }
389 if (b.chats !== a.chats || b.list !== a.list || c.list !== a.list) moved9.push(said);
390 }
391 }
392 check('the strip really grew under the rail, six times over', missed.length === 0,
393 missed.length ? missed.join(' | ') : '37, 54 and 97px at 900 and 1100');
394 check('a strip taller by any amount leaves a divider the user set exactly where it was',
395 moved9.length === 0 && missed.length === 0, moved9.join(' | '));
396
397 // And when the room really does run out, the Chats list keeps its floor and
398 // the STORED height is not quietly rewritten to fit: the clamp is on read.
399 await page.setViewportSize({ width: 1440, height: 900 });
400 await sleep(500);
401 await drag(160); // the divider low, with little left to give
402 const low = await rail();
403 await grow(60);
404 await sleep(400);
405 const squeezed = await rail();
406 await grow(0);
407 await sleep(400);
408 const back = await rail();
409 check('squeezed past what it can give, the Chats list keeps a usable height',
410 squeezed.sess >= MIN_LIST - 1 && squeezed.list < low.list,
411 `diamonds ${low.list}->${squeezed.list}, chats ${low.sess}->${squeezed.sess}`);
412 check('and the divider returns to the pixel it was left at, nothing having been rewritten',
413 back.list === low.list && back.chats === low.chats,
414 `diamonds ${low.list}->${squeezed.list}->${back.list}`);
415 await page.setViewportSize({ width: 1440, height: 900 });
416 await sleep(400);
417}
418
419const errs = errors(s).filter(e => !/Failed to load resource|status of \d\d\d|favicon|net::ERR_ABORTED/.test(e));
420check('no console errors while the bar was measured', errs.length === 0, errs.slice(0, 3).join(' | '));
421
422await s.close();
423console.log('');
424console.log(bad.length ? `${ok.length} passed, ${bad.length} failed` : `ALL ${ok.length} CHECKS PASSED`);
425process.exit(bad.length ? 1 : 0);