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. |
| 10 | import { open, signInAs, connectMock } from './harness.mjs'; |
| 11 | |
| 12 | const ok = [], bad = []; |
| 13 | const check = (name, pass, detail) => { |
| 14 | (pass ? ok : bad).push(name); |
| 15 | console.log((pass ? ' ok ' : ' FAIL ') + name + (!pass && detail ? ' — ' + detail : '')); |
| 16 | }; |
| 17 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 18 | |
| 19 | // Sign-in deferred so we can inspect the account-creation screen first. |
| 20 | const s = await open({ signIn: false, connect: false, name: 'muxtester' }); |
| 21 | const { page } = s; |
| 22 | await page.setViewportSize({ width: 390, height: 844 }); |
| 23 | await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' }); |
| 24 | await sleep(400); |
| 25 | |
| 26 | // Every input's computed font-size, for the currently-focusable ones. |
| 27 | const 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) ── |
| 38 | check('create-account: identity fields ≥16px', (await smallInputs()).length === 0, JSON.stringify(await smallInputs())); |
| 39 | |
| 40 | // Now create the account and connect the mock model. |
| 41 | await signInAs(s, 'muxtester'); |
| 42 | await connectMock(s); |
| 43 | await page.evaluate(() => { try { window.DaimondAdmin.closeModal(); } catch (e) {} }); |
| 44 | await sleep(300); |
| 45 | |
| 46 | // ── 2. Whole app: no sub-16px focusable field anywhere reachable ── |
| 47 | check('app: all visible fields ≥16px', (await smallInputs()).length === 0, JSON.stringify(await smallInputs())); |
| 48 | |
| 49 | // ── 3. The drawer opens opaque ── |
| 50 | await page.evaluate(() => document.getElementById('drawer-btn').click()); |
| 51 | await sleep(300); |
| 52 | check('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 ── |
| 58 | await page.evaluate(() => document.getElementById('astat-account').click()); |
| 59 | await sleep(400); |
| 60 | check('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). |
| 64 | check('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 | })); |
| 71 | check('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 | })); |
| 75 | check('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)}`; })); |
| 79 | check('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 | })); |
| 85 | await page.evaluate(() => document.querySelector('#settings-slot .admin-view-head .admin-back').click()); |
| 86 | await sleep(300); |
| 87 | check('credits: × closes the modal', await page.evaluate(() => getComputedStyle(document.getElementById('settings-modal')).display === 'none')); |
| 88 | |
| 89 | // ── 5. Models: correct title ── |
| 90 | await page.evaluate(() => { document.getElementById('drawer-btn').click(); }); // reopen drawer |
| 91 | await sleep(200); |
| 92 | await page.evaluate(() => document.getElementById('astat-model').click()); |
| 93 | await sleep(400); |
| 94 | check('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 | })); |
| 101 | await page.evaluate(() => { const b = document.getElementById('settings-done'); if (b) b.click(); }); |
| 102 | await sleep(300); |
| 103 | |
| 104 | // ── 6. The guide "?" actually opens the guide ── |
| 105 | await page.evaluate(() => document.getElementById('guide-btn').click()); |
| 106 | await sleep(600); |
| 107 | check('guide: "?" raises the web sheet', await page.evaluate(() => |
| 108 | window.DaimondSheet.isOpen() && window.DaimondSheet.guest() === 'web')); |
| 109 | await page.evaluate(() => { try { window.DaimondSheet.close(); } catch (e) {} }); |
| 110 | await sleep(300); |
| 111 | |
| 112 | // ── 7. The update chip answers a click ── |
| 113 | check('update-chip: visible', await page.evaluate(() => { |
| 114 | const c = document.getElementById('update-chip'); return c && !c.hidden; |
| 115 | })); |
| 116 | await page.evaluate(() => document.getElementById('update-chip').click()); |
| 117 | await sleep(500); |
| 118 | check('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. |
| 125 | await page.evaluate(() => { const b = document.querySelector('#mnav .ptag[data-panel="work"]'); if (b) b.click(); }); |
| 126 | await sleep(400); |
| 127 | check('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 | })); |
| 140 | check('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 | })); |
| 145 | await page.evaluate(() => { const b = document.querySelector('[data-act="new-file"]'); if (b) b.click(); }); |
| 146 | await sleep(500); |
| 147 | const 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 | }); |
| 152 | check('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 ── |
| 155 | const real = s.errs.filter(e => !/Failed to load resource|status of 4\d\d/.test(e)); |
| 156 | check('no console errors across the walk', real.length === 0, real.slice(0, 3).join(' | ')); |
| 157 | |
| 158 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 159 | if (bad.length) console.log('FAILED: ' + bad.join(', ')); |
| 160 | await s.close(); |
| 161 | process.exit(bad.length ? 1 : 0); |