Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_mobile_ux.mjs

8.8 KiB, 1 run

created by r2519314175:537, 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_mobile_ux.mjs — walk the real mobile UX pathways, not just geometry.
2//
3// This exists because a headless "it renders" pass shipped bugs a human hit in
4// seconds: iOS-zoom on 14px inputs, a modal titled "Settings" for Credits with no
5// corner close, a dead "?" button, emoji toolbar icons. Each check below is one
6// pathway a thumb would take.
7//
8// iOS auto-zoom itself is a Safari behaviour Chromium cannot reproduce, so we test
9// its CAUSE instead: no focusable control may be under 16px on a phone.
10import { open, signInAs, connectMock } from './harness.mjs';
11
12const ok = [], bad = [];
13const check = (name, pass, detail) => {
14 (pass ? ok : bad).push(name);
15 console.log((pass ? ' ok ' : ' FAIL ') + name + (!pass && detail ? ' — ' + detail : ''));
16};
17const sleep = ms => new Promise(r => setTimeout(r, ms));
18
19// Sign-in deferred so we can inspect the account-creation screen first.
20const s = await open({ signIn: false, connect: false, name: 'muxtester' });
21const { page } = s;
22await page.setViewportSize({ width: 390, height: 844 });
23await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' });
24await sleep(400);
25
26// Every input's computed font-size, for the currently-focusable ones.
27const smallInputs = async () => page.evaluate(() => {
28 const bad = [];
29 document.querySelectorAll('input, textarea, select').forEach(el => {
30 if (el.offsetParent === null && el.type !== 'hidden') return; // not visible
31 const fs = parseFloat(getComputedStyle(el).fontSize);
32 if (fs < 16) bad.push((el.id || el.className || el.tagName) + '=' + fs + 'px');
33 });
34 return bad;
35});
36
37// ── 1. Account creation screen: no sub-16px field (would zoom on focus) ──
38check('create-account: identity fields ≥16px', (await smallInputs()).length === 0, JSON.stringify(await smallInputs()));
39
40// Now create the account and connect the mock model.
41await signInAs(s, 'muxtester');
42await connectMock(s);
43await page.evaluate(() => { try { window.DaimondAdmin.closeModal(); } catch (e) {} });
44await sleep(300);
45
46// ── 2. Whole app: no sub-16px focusable field anywhere reachable ──
47check('app: all visible fields ≥16px', (await smallInputs()).length === 0, JSON.stringify(await smallInputs()));
48
49// ── 3. The drawer opens opaque ──
50await page.evaluate(() => document.getElementById('drawer-btn').click());
51await sleep(300);
52check('drawer: opaque background', await page.evaluate(() => {
53 const bg = getComputedStyle(document.getElementById('panel-rail')).backgroundColor;
54 return bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
55}), await page.evaluate(() => getComputedStyle(document.getElementById('panel-rail')).backgroundColor));
56
57// ── 4. Credits: correct title, fits the screen, top-right close that works ──
58await page.evaluate(() => document.getElementById('astat-account').click());
59await sleep(400);
60check('credits: modal is open', await page.evaluate(() => getComputedStyle(document.getElementById('settings-modal')).display !== 'none'));
61// The title belongs to the modal's own head, built into #settings-slot when a
62// view is hosted there -- the view no longer carries one of its own (the rail
63// split took the per-view heads away on 2026-07-18).
64check('credits: titled "Credits", not "Settings"', await page.evaluate(() => {
65 const t = document.querySelector('#settings-slot .admin-view-head .admin-title');
66 return t && /credit/i.test(t.textContent) && t.offsetParent !== null;
67}), await page.evaluate(() => {
68 const t = document.querySelector('#settings-slot .admin-view-head .admin-title');
69 return t ? t.textContent : 'none';
70}));
71check('credits: no stray "Settings" heading', await page.evaluate(() => {
72 const h = document.querySelector('#settings-modal > .modal-card > h2');
73 return !h || getComputedStyle(h).display === 'none';
74}));
75check('credits: card fits within the viewport', await page.evaluate(() => {
76 const r = document.querySelector('#settings-modal .modal-card').getBoundingClientRect();
77 return r.top >= -1 && r.bottom <= window.innerHeight + 1 && r.height <= window.innerHeight;
78}), await page.evaluate(() => { const r = document.querySelector('#settings-modal .modal-card').getBoundingClientRect(); return `h=${Math.round(r.height)} vh=${window.innerHeight} bottom=${Math.round(r.bottom)}`; }));
79check('credits: has a top-right × close', await page.evaluate(() => {
80 const x = document.querySelector('#settings-slot .admin-view-head .admin-back');
81 if (!x || x.offsetParent === null) return false;
82 const r = x.getBoundingClientRect(), card = document.querySelector('#settings-modal .modal-card').getBoundingClientRect();
83 return r.right > card.right - 80 && r.top < card.top + 80; // upper-right region
84}));
85await page.evaluate(() => document.querySelector('#settings-slot .admin-view-head .admin-back').click());
86await sleep(300);
87check('credits: × closes the modal', await page.evaluate(() => getComputedStyle(document.getElementById('settings-modal')).display === 'none'));
88
89// ── 5. Models: correct title ──
90await page.evaluate(() => { document.getElementById('drawer-btn').click(); }); // reopen drawer
91await sleep(200);
92await page.evaluate(() => document.getElementById('astat-model').click());
93await sleep(400);
94check('models: titled "Models"', await page.evaluate(() => {
95 const t = document.querySelector('#settings-slot .admin-view-head .admin-title');
96 return t && /model/i.test(t.textContent) && t.offsetParent !== null;
97}), await page.evaluate(() => {
98 const t = document.querySelector('#settings-slot .admin-view-head .admin-title');
99 return t ? t.textContent : 'none';
100}));
101await page.evaluate(() => { const b = document.getElementById('settings-done'); if (b) b.click(); });
102await sleep(300);
103
104// ── 6. The guide "?" actually opens the guide ──
105await page.evaluate(() => document.getElementById('guide-btn').click());
106await sleep(600);
107check('guide: "?" raises the web sheet', await page.evaluate(() =>
108 window.DaimondSheet.isOpen() && window.DaimondSheet.guest() === 'web'));
109await page.evaluate(() => { try { window.DaimondSheet.close(); } catch (e) {} });
110await sleep(300);
111
112// ── 7. The update chip answers a click ──
113check('update-chip: visible', await page.evaluate(() => {
114 const c = document.getElementById('update-chip'); return c && !c.hidden;
115}));
116await page.evaluate(() => document.getElementById('update-chip').click());
117await sleep(500);
118check('update-chip: click gives feedback (not inert)', await page.evaluate(() => {
119 const c = document.getElementById('update-chip');
120 return /latest|checking|update/i.test(c.title || '');
121}), await page.evaluate(() => document.getElementById('update-chip').title));
122
123// ── 8. Files toolbar: real icons, and a new-file dialog with a ≥16px field ──
124// The footer is the chip row since 2026-08-28; a destination is its chip.
125await page.evaluate(() => { const b = document.querySelector('#mnav .ptag[data-panel="work"]'); if (b) b.click(); });
126await sleep(400);
127check('files: toolbar icons are SVG, not emoji', await page.evaluate(() => {
128 // NO `up`. The parent-folder button is gone, at the owner's own request -- "I hate how
129 // the parent folder button in the workspace panel is right up the top away from the
130 // actual directory listing, and is such a button even needed when you could add a '..'
131 // entry?" (dev/HATES.md, 2026-08-24). There is a `..` row at the top of the listing
132 // instead, which is the universal convention, and not both. This list is the toolbar's
133 // icons, so a control that is no longer in the toolbar does not belong in it.
134 const btns = ['new-file', 'new-dir', 'upload', 'refresh'];
135 return btns.every(a => {
136 const b = document.querySelector(`[data-act="${a}"]`);
137 return b && b.querySelector('svg') && !b.textContent.trim();
138 });
139}));
140check('files: new-file and new-folder icons differ', await page.evaluate(() => {
141 const a = document.querySelector('[data-act="new-file"] svg').innerHTML;
142 const b = document.querySelector('[data-act="new-dir"] svg').innerHTML;
143 return a !== b;
144}));
145await page.evaluate(() => { const b = document.querySelector('[data-act="new-file"]'); if (b) b.click(); });
146await sleep(500);
147const dlgSmall = await page.evaluate(() => {
148 const inp = [...document.querySelectorAll('input')].find(el => el.offsetParent !== null && /dlg|dialog/.test(el.className + ' ' + (el.closest('[class]') || {}).className));
149 if (!inp) return { found: false };
150 return { found: true, fs: parseFloat(getComputedStyle(inp).fontSize) };
151});
152check('files: new-file dialog field is ≥16px', dlgSmall.found ? dlgSmall.fs >= 16 : true, JSON.stringify(dlgSmall));
153
154// ── 9. Nothing threw across all of it ──
155const real = s.errs.filter(e => !/Failed to load resource|status of 4\d\d/.test(e));
156check('no console errors across the walk', real.length === 0, real.slice(0, 3).join(' | '));
157
158console.log(`\n${ok.length} passed, ${bad.length} failed`);
159if (bad.length) console.log('FAILED: ' + bad.join(', '));
160await s.close();
161process.exit(bad.length ? 1 : 0);