Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_railface.mjs

16.3 KiB, 1 run

created by r2519314175:623, 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_railface.mjs — the rail's second divider, and the crystal's own face.
2//
3// Two small things the user asked for, both of them about a boundary being
4// visible.
5//
6// The divider: Diamonds accumulate, and the list grew with them until the chat
7// tiles were off the bottom of the rail — with the only handle in the rail
8// belonging to the Admin pane, which no longer splits anything. So the boundary
9// between the two lists moves now, and A HEIGHT IN PIXELS is saved with the rest
10// of the layout. It was a share until 2026-08-28, and a share is spent against a
11// room that the Status strip below changes on its own: see section 4.
12//
13// The face: the AI panel shows either a conversation or a Diamond's crystal, and
14// nothing said which. The crystal takes the mark beside its name and squares its
15// corners — square against rounded everywhere else, which has to hold in three
16// themes and both skins, since the warm skin's whole idea is a larger radius.
17//
18// node dev/verify_railface.mjs
19//
20// Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway.
21
22import { open, signInAs, shot, errors } from './harness.mjs';
23
24const ok = [], bad = [];
25const check = (name, pass, detail) => {
26 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
27 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
28};
29const sleep = ms => new Promise(r => setTimeout(r, ms));
30
31const s = await open({ name: 'railface' });
32const p = s.page;
33
34const rail = () => p.evaluate(() => {
35 const top = document.getElementById('rail-top');
36 const list = document.getElementById('diamond-list');
37 const sess = document.getElementById('session-list');
38 const h = document.getElementById('handle-rail-split');
39 const kids = [...(top ? top.children : [])];
40 return {
41 handle: !!h,
42 shown: !!h && getComputedStyle(h).display !== 'none',
43 // Between the Diamonds list and the Chats head, or it divides the wrong pair.
44 between: !!h && kids.indexOf(h) === kids.indexOf(list) + 1
45 && (kids[kids.indexOf(h) + 1] || {}).className === 'railhead',
46 list: list ? Math.round(list.getBoundingClientRect().height) : 0,
47 sess: sess ? Math.round(sess.getBoundingClientRect().height) : 0,
48 // What is on disk, both formats, so the check can say the old one has gone
49 // rather than only that the new one is there.
50 saved: (() => { try { return JSON.parse(localStorage.getItem('daimond-layout') || '{}').railH; } catch (e) { return null; } })(),
51 share: (() => { try { return JSON.parse(localStorage.getItem('daimond-layout') || '{}').railSplit; } catch (e) { return null; } })(),
52 };
53});
54/// The Admin drawer is an overlay that rises over the rail, and unlocking opens
55/// it — so it sits on top of the divider until it is dismissed. Not a fault in
56/// the divider, but it does have to be got out of the way before one can be
57/// dragged, exactly as a user would.
58async function closeAdmin() {
59 const x = await p.$('#admin-close');
60 if (x && await x.isVisible()) { await x.click({ force: true }); await sleep(400); }
61}
62/// Drag the divider by `dy` pixels. Returns false when there is nothing to drag.
63async function drag(dy) {
64 const el = await p.$('#handle-rail-split');
65 if (!el) return false;
66 const b = await el.boundingBox();
67 if (!b) return false;
68 await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
69 await p.mouse.down();
70 await p.mouse.move(b.x + b.width / 2, b.y + b.height / 2 + dy, { steps: 12 });
71 await p.mouse.up();
72 await sleep(400);
73 return true;
74}
75
76// A few Diamonds and a chat, so both lists have something in them.
77for (const n of ['One', 'Two', 'Three']) {
78 await p.click('#new-diamond-btn', { force: true });
79 await p.waitForSelector('.dlg-input', { timeout: 10000 });
80 await p.fill('.dlg-input', n);
81 await p.click('.dlg-ok', { force: true });
82 await sleep(700);
83}
84await p.click('#new-session-btn', { force: true });
85await sleep(600);
86
87// ── 1. There is a divider, and it divides the right pair ─────────────
88const r0 = await rail();
89check('the rail has a divider between Diamonds and Chats', r0.handle && r0.shown);
90check('and it sits between the Diamonds list and the Chats head', r0.between === true);
91
92// ── 2. It moves the boundary ─────────────────────────────────────────
93check('the divider can be dragged', await drag(150));
94const r1 = await rail();
95check('dragging down gives the Diamonds list the room',
96 r1.list > r0.list + 60, `${r0.list}px → ${r1.list}px`);
97check('and takes it from the Chats list, which keeps a usable height',
98 r1.sess < r0.sess && r1.sess > 40, `chats ${r0.sess}px → ${r1.sess}px`);
99await shot(s, 'railface-dragged');
100
101// ── 3. Neither list can be crushed out of existence ──────────────────
102await drag(-900);
103const r2 = await rail();
104check('dragged hard up, the Diamonds list keeps a height it can be seen at',
105 r2.list >= 60, `${r2.list}px`);
106await drag(900);
107const r3 = await rail();
108check('dragged hard down, the Chats list keeps one too',
109 r3.sess >= 60, `${r3.sess}px`);
110
111// ── 4. The height is remembered, in pixels ───────────────────────────
112// A HEIGHT, not a share, since 2026-08-28. A share is spent against the room the
113// two lists have, and that room changes for reasons that have nothing to do with
114// the divider: the Status strip below them is a stack of rows that come and go,
115// and one row appearing took 28px out of the pane and carried the Chats head 14
116// further. `applyRailSplit` in js/daimond.js holds the reasoning, and
117// dev/verify_frame.mjs holds the check that the class is fixed rather than the
118// instance.
119// Back up the pane rather than hard against its floor: a divider at 72px comes
120// back at 72px however it is stored, so the reload below would prove nothing.
121await drag(-150);
122const r4 = await rail();
123check('the drag is saved with the rest of the layout, as a height in pixels',
124 typeof r4.saved === 'number' && r4.saved > 60 && Math.abs(r4.saved - r4.list) <= 2,
125 `stored ${r4.saved}, drawn ${r4.list}px`);
126check('and no share is written any more, so there is one format on disk',
127 r4.share === undefined, JSON.stringify(r4.share));
128await p.reload({ waitUntil: 'domcontentloaded' });
129await signInAs(s, 'railface');
130await sleep(1800);
131await closeAdmin();
132const r5 = await rail();
133// THE PIXELS, and this check used to compare shares. The furniture below the two
134// lists is a different height on an unlocked session than on a freshly created
135// one, so the same share came back as a different number of pixels -- which is
136// exactly the movement the height was introduced to stop.
137check('and the divider comes back at the height it was left at',
138 Math.abs(r5.list - r4.list) <= 2, `${r4.list}px → ${r5.list}px`);
139
140// ── 5. A height made on a tall window survives a short one ───────────
141// He drags the divider low on a large screen and then opens Daimond on a laptop.
142// A raw pixel figure could push the Chats list off the bottom of the rail, so
143// what is APPLIED is held inside the room in force -- and what is STORED is his
144// gesture, left alone, so the large screen gives it back exactly. The clamp is on
145// read; nothing rewrites the figure to fit a window he is only passing through.
146await p.setViewportSize({ width: 1440, height: 1100 });
147await sleep(700);
148await drag(220);
149const tall = await rail();
150await p.setViewportSize({ width: 1440, height: 820 });
151await sleep(700);
152const short = await rail();
153check('a divider dragged low on a tall window leaves the Chats list usable on a short one',
154 short.sess >= 60 && short.list >= 60, `diamonds ${short.list}px, chats ${short.sess}px`);
155check('and the short window does not rewrite what he chose',
156 typeof tall.saved === 'number' && short.saved === tall.saved,
157 `${tall.saved} → ${short.saved}`);
158await p.setViewportSize({ width: 1440, height: 1100 });
159await sleep(700);
160const again = await rail();
161check('so the tall window gives the divider back where he put it',
162 Math.abs(again.list - tall.list) <= 1, `${tall.list}px → ${short.list}px → ${again.list}px`);
163
164// ── 6. A layout saved before the divider was a height ────────────────
165// Every layout ever saved holds a `railSplit`, because it was written whether or
166// not anybody had touched the handle. One holding a share somebody chose is spent
167// once, at the size in force, and written back as a height. Not left readable for
168// ever, and not silently discarded either.
169await p.evaluate(() => {
170 const j = JSON.parse(localStorage.getItem('daimond-layout') || '{}');
171 delete j.railH;
172 j.railSplit = 0.78;
173 localStorage.setItem('daimond-layout', JSON.stringify(j));
174});
175await p.reload({ waitUntil: 'domcontentloaded' });
176await signInAs(s, 'railface');
177await sleep(1800);
178await closeAdmin();
179const mig = await rail();
180const share = x => x.list / (x.list + x.sess);
181// TO THE PIXEL, and it took `railPin` to get there. The first layout runs before
182// the Status strip's rows exist, in a pane some 130px taller than the one that
183// settles, so converting the share THERE pinned the divider well below where it
184// was left -- measured at 0.869 for a stored 0.78, and the clamp then made that
185// permanent. The share goes on cutting instead, and what is written down is the
186// cut, taken from a room that had settled.
187check('a share saved by an older build is honoured, at the size in force',
188 Math.abs(share(mig) - 0.78) <= 0.02, share(mig).toFixed(3));
189check('and is written back as a height, the share gone from the layout',
190 typeof mig.saved === 'number' && mig.saved >= mig.list && mig.share === undefined,
191 `railH ${mig.saved}, drawn ${mig.list}px, railSplit ${JSON.stringify(mig.share)}`);
192
193// ── 7. Double-click puts it back to even, and unpins it ──────────────
194// Guarded, so this file can be RUN against the code before the divider existed:
195// a hard dblclick on a missing element aborts the pass instead of failing the
196// check that is meant to catch its absence.
197if (await p.$('#handle-rail-split')) await p.dblclick('#handle-rail-split', { force: true });
198await sleep(500);
199const r6 = await rail();
200check('a double-click resets the divider to an even split',
201 Math.abs(r6.list - r6.sess) <= 24, `${r6.list}px vs ${r6.sess}px`);
202// An even split is not a choice, so it goes on following the window -- which is
203// what keeps every user who has never touched the handle on the behaviour they
204// have always had. The reset undoes the pinning as well as the position.
205await p.setViewportSize({ width: 1440, height: 820 });
206await sleep(700);
207const r7 = await rail();
208check('and a reset divider follows the window again, rather than staying pinned',
209 Math.abs(r7.list - r7.sess) <= 24 && r7.list < r6.list - 40,
210 `${r6.list}px at 1100 → ${r7.list}px vs ${r7.sess}px at 820`);
211await p.setViewportSize({ width: 1440, height: 900 });
212await sleep(600);
213
214// ── 8. The crystal face, in every theme and both skins ───────────────
215const face = () => p.evaluate(() => {
216 const ai = document.getElementById('panel-ai');
217 const m = document.getElementById('chead-mark');
218 const cs = getComputedStyle(ai);
219 return {
220 cls: ai.classList.contains('crystal-face'),
221 radius: parseFloat(cs.borderTopLeftRadius),
222 mark: !!m && getComputedStyle(m).display !== 'none',
223 markInHead: !!(m && m.closest('.chead')),
224 // The mark is decoration beside a name that is already there, so it must be
225 // invisible to a screen reader rather than read out as a second title.
226 hidden: !!m && m.getAttribute('aria-hidden') === 'true' && m.getAttribute('alt') === '',
227 nameFirst: (() => {
228 const head = document.querySelector('.panel.ai .chead');
229 if (!head || !m) return null;
230 const kids = [...head.children];
231 return kids.indexOf(m) < kids.indexOf(head.querySelector('.ctitle'));
232 })(),
233 };
234});
235async function selectDiamond(name) {
236 await p.evaluate((n) => {
237 const box = [...document.querySelectorAll('.diamond-box')]
238 .find(b => (b.querySelector('.session-box-name') || {}).textContent === n);
239 if (box) box.click();
240 }, name);
241 await sleep(900);
242}
243async function selectChat() {
244 await p.evaluate(() => {
245 const box = document.querySelector('.session-box:not(.diamond-box)');
246 if (box) box.click();
247 });
248 await sleep(700);
249}
250
251await selectChat();
252const chatFace = await face();
253check('a chat wears no mark', chatFace.mark === false && chatFace.cls === false);
254check('and keeps the rounded corners everything else in the app has',
255 chatFace.radius > 4, `${chatFace.radius}px`);
256
257for (const skin of ['sharp', 'warm']) {
258 await p.evaluate(k => window.DaimondSkin.set(k), skin);
259 await sleep(500);
260 for (const theme of ['dark', 'light', 'lollypop']) {
261 await p.evaluate(t => window.DaimondTheme.set(t), theme);
262 await sleep(400);
263 await selectDiamond('Two');
264 const f = await face();
265 check(`the crystal squares its corners (${skin}/${theme})`,
266 f.cls === true && f.radius === 0, `radius ${f.radius}px`);
267 check(`and shows the mark beside the crystal name (${skin}/${theme})`,
268 f.mark && f.markInHead && f.nameFirst === true,
269 `mark=${f.mark} inHead=${f.markInHead} beforeName=${f.nameFirst}`);
270 if (theme === 'dark') await shot(s, `railface-crystal-${skin}`);
271 await selectChat();
272 const c = await face();
273 check(`and the chat gets its corners back (${skin}/${theme})`,
274 c.cls === false && c.radius > 4, `radius ${c.radius}px`);
275 }
276 await p.evaluate(() => window.DaimondTheme.set('dark'));
277}
278await p.evaluate(() => window.DaimondSkin.set('sharp'));
279await sleep(400);
280await selectDiamond('Two');
281const acc = await face();
282check('the mark is decoration, not a second heading, to a screen reader', acc.hidden === true);
283
284// ── 9. The phone drawer is not divided ───────────────────────────────
285await p.setViewportSize({ width: 390, height: 844 });
286await sleep(700);
287const phone = await rail();
288check('on a phone the drawer scrolls as one column, with no divider in it',
289 phone.shown === false, `handle shown=${phone.shown}`);
290const inline = await p.$eval('#diamond-list', e => e.style.height);
291check('and no height is imposed on either list there', inline === '', JSON.stringify(inline));
292await shot(s, 'railface-phone');
293
294// ── 10. A phone must not eat an old share on its way past ────────────
295// The layout is one of the keys pairing copies between devices, and the phone
296// draws no divider -- so there is no room to convert a share against and nothing
297// to write down. It is carried instead of dropped, and the desktop spends it.
298await p.evaluate(() => {
299 const j = JSON.parse(localStorage.getItem('daimond-layout') || '{}');
300 delete j.railH;
301 j.railSplit = 0.34;
302 localStorage.setItem('daimond-layout', JSON.stringify(j));
303});
304await p.reload({ waitUntil: 'domcontentloaded' });
305await signInAs(s, 'railface');
306await sleep(1800);
307const onPhone = await rail();
308check('a phone-only session leaves an old share where it found it',
309 onPhone.share === 0.34 && (onPhone.saved === null || onPhone.saved === undefined),
310 `railSplit ${onPhone.share}, railH ${JSON.stringify(onPhone.saved)}`);
311// RELOADED ON THE PHONE, and the order matters. The share is still in memory
312// after the step above, so a desktop viewport set first would cut a divider from
313// there whether or not anything survived the write -- and the check would pass on
314// code that had just eaten it. Reloading before the window widens makes the disk
315// the only source there is.
316await p.reload({ waitUntil: 'domcontentloaded' });
317await signInAs(s, 'railface');
318await sleep(1800);
319await p.setViewportSize({ width: 1440, height: 1000 });
320await sleep(1400);
321await closeAdmin();
322const offPhone = await rail();
323check('and the next desktop spends it, as a height, the share gone',
324 typeof offPhone.saved === 'number' && offPhone.share === undefined
325 && Math.abs(share(offPhone) - 0.34) <= 0.03,
326 `railH ${offPhone.saved}, share ${share(offPhone).toFixed(3)}`);
327
328// 402 as well as 502: the unlock path asks the account service what this account
329// is entitled to, and with no gateway running that question is answered by
330// nothing at all.
331const errs = errors(s).filter(e => !/favicon|404|401|402|502|Bad Gateway|net::ERR/.test(e));
332console.log('\nconsole errors:', errs.slice(0, 5));
333check('nothing throws while all this happens', errs.length === 0, errs[0] || '');
334
335await s.close();
336console.log(`\n${ok.length} passed, ${bad.length} failed`);
337if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
338process.exit(bad.length ? 1 : 0);