oxedyne/daimond/dev/shot_termstage.mjs
9.2 KiB, 1 run
created by r2519314175:197, 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 | // shot_termstage.mjs — look at the Terminal in its new zone. |
| 2 | // |
| 3 | // The panel moved from the dock to the stage, and the two are laid out by |
| 4 | // different rules: a dock panel fills a 300px column of a tiled grid, a stage |
| 5 | // panel takes a seat beside the conversation and is sized inline by the engine. |
| 6 | // What that change is worth cannot be read off a passing assertion — the screen |
| 7 | // is the artefact — so this opens a live session against the same two doubles |
| 8 | // dev/verify_termpanel.mjs uses, writes something into it, and takes the picture |
| 9 | // at the widths that matter. The narrow one is the point of the whole move: below |
| 10 | // 1900px the dock's automatic grid is ONE column of four, so a fifth panel there |
| 11 | // could only arrive by closing one of the four already in it. |
| 12 | // |
| 13 | // It also measures, because an eye is not a ruler: the terminal's box against the |
| 14 | // panel it lives in, the grid the kernel was last told, and how many panels the |
| 15 | // dock is actually seating. |
| 16 | // |
| 17 | // node dev/shot_termstage.mjs |
| 18 | // |
| 19 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 20 | // (DAIMOND_MOCK_PORT, default 9099). |
| 21 | |
| 22 | import { open, shot } from './harness.mjs'; |
| 23 | |
| 24 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 25 | |
| 26 | const s = await open({ name: 'termstage' }); |
| 27 | const p = s.page; |
| 28 | |
| 29 | /// The link double: what hand.js is asked to provide, and nothing more. |
| 30 | async function installLink() { |
| 31 | await p.evaluate(() => { |
| 32 | window.__link = { sent: [], subs: {}, opens: 0 }; |
| 33 | const fire = (id, msg) => (window.__link.subs[id] || []).slice().forEach((f) => f(msg)); |
| 34 | window.__emit = fire; |
| 35 | window.DaimondHand = { |
| 36 | send(m) { |
| 37 | window.__link.sent.push(m); |
| 38 | if (m.t === 'open') { |
| 39 | window.__link.opens++; |
| 40 | setTimeout(() => fire(m.id, { t: 'opened', pid: 4242 }), 0); |
| 41 | } |
| 42 | if (m.t === 'signal') setTimeout(() => fire(m.id, { t: 'closed', exit: 0 }), 0); |
| 43 | return Promise.resolve(); |
| 44 | }, |
| 45 | subscribe(id, fn) { |
| 46 | (window.__link.subs[id] = window.__link.subs[id] || []).push(fn); |
| 47 | return () => {}; |
| 48 | }, |
| 49 | status() { |
| 50 | return Promise.resolve(JSON.stringify({ |
| 51 | paired: true, transport: 'machine', machine: 'double', os: 'linux', |
| 52 | root: '/nowhere/ws', caps: ['fence:linux', 'landlock:abi-8'], |
| 53 | })); |
| 54 | }, |
| 55 | hasHand: () => true, |
| 56 | }; |
| 57 | }); |
| 58 | } |
| 59 | |
| 60 | /// The Rust answer double. The fence is a fiction and nothing here reads it. |
| 61 | async function installComposer() { |
| 62 | await p.evaluate(() => { |
| 63 | window.DaimondTerm._setRequestForTest(async (json) => { |
| 64 | const ask = JSON.parse(json); |
| 65 | return JSON.stringify({ |
| 66 | t: 'open', id: 'ts-' + (++window.__seq || (window.__seq = 1)), |
| 67 | argv: ['bash', '-i'], cwd: '/nowhere/ws', env: [], |
| 68 | size: { cols: ask.cols, rows: ask.rows }, |
| 69 | fence: { rw: ['/nowhere/ws'], ro: [], deny: [], net: false }, |
| 70 | }); |
| 71 | }); |
| 72 | }); |
| 73 | } |
| 74 | |
| 75 | /// Push bytes at the live session the way the hand would. |
| 76 | async function write(text, seq) { |
| 77 | const id = await p.evaluate(() => window.DaimondTerm.session()); |
| 78 | if (!id) return; |
| 79 | await p.evaluate(({ id, text, seq }) => { |
| 80 | const u8 = new TextEncoder().encode(text); |
| 81 | let raw = ''; |
| 82 | for (const b of u8) raw += String.fromCharCode(b); |
| 83 | window.__emit(id, { t: 'output', seq, data: btoa(raw) }); |
| 84 | }, { id, text, seq }); |
| 85 | await sleep(400); |
| 86 | } |
| 87 | |
| 88 | /// The panel, the terminal inside it, and what the dock is holding. |
| 89 | const measure = () => p.evaluate(() => { |
| 90 | const panel = document.getElementById('panel-term'); |
| 91 | const card = panel && panel.querySelector('.termp-card'); |
| 92 | const host = document.getElementById('termp-host'); |
| 93 | const term = document.querySelector('#termp-host .term'); |
| 94 | const box = (e) => { const r = e.getBoundingClientRect(); return { w: Math.round(r.width), h: Math.round(r.height) }; }; |
| 95 | return { |
| 96 | zone: panel && panel.dataset.zone, |
| 97 | parent: panel && panel.parentElement && (panel.parentElement.id || panel.parentElement.className), |
| 98 | panel: panel && box(panel), |
| 99 | card: card && box(card), |
| 100 | host: host && box(host), |
| 101 | term: term && box(term), |
| 102 | grid: term && { cols: +term.dataset.cols, rows: +term.dataset.rows }, |
| 103 | stage: window.DaimondPanels.model().panels.filter((x) => x.zone === 'stage' && x.open).map((x) => x.id), |
| 104 | dockOpen: window.DaimondPanels.model().panels.filter((x) => x.zone === 'dock' && x.open).map((x) => x.id), |
| 105 | dockSeats: [...document.querySelectorAll('#dock .pcol > .panel')] |
| 106 | .filter((e) => getComputedStyle(e).display !== 'none').map((e) => e.dataset.panel), |
| 107 | dockMax: window.DaimondPanels.model().dockMax, |
| 108 | lastSize: (window.__link.sent.filter((m) => m.t === 'resize').pop() || {}).size || null, |
| 109 | }; |
| 110 | }); |
| 111 | |
| 112 | await installLink(); |
| 113 | await installComposer(); |
| 114 | await p.evaluate(() => window.DaimondPanels.show('term')); |
| 115 | await sleep(1200); |
| 116 | await write('$ ls -la\r\ntotal 24\r\ndrwxr-xr-x 5 you you 4096 Aug 3 00:14 .\r\n' |
| 117 | + 'drwxr-xr-x 18 you you 4096 Aug 3 00:12 ..\r\n-rw-r--r-- 1 you you 221 Aug 3 00:13 notes.txt\r\n$ ', 0); |
| 118 | |
| 119 | for (const [w, h] of [[1500, 950], [1440, 900], [1280, 820]]) { |
| 120 | await p.setViewportSize({ width: w, height: h }); |
| 121 | await p.evaluate(() => window.DaimondPanels.reflow()); |
| 122 | await sleep(900); |
| 123 | for (const theme of ['dark', 'light']) { |
| 124 | await p.evaluate((t) => window.DaimondTheme.set(t), theme); |
| 125 | await sleep(500); |
| 126 | await shot(s, `termstage-${w}-${theme}`); |
| 127 | } |
| 128 | await p.evaluate(() => window.DaimondTheme.set('dark')); |
| 129 | await sleep(400); |
| 130 | console.log(`\n${w}x${h}: ` + JSON.stringify(await measure(), null, 1)); |
| 131 | } |
| 132 | |
| 133 | // And a resize that has to reach the kernel: drag the stage's own divider. |
| 134 | // |
| 135 | // At 1900 rather than at the laptop width, because MIN_W.stage is 380 for each |
| 136 | // seat and a 1440px window leaves the two of them about twenty pixels of play |
| 137 | // between them -- a correct clamp, and a useless demonstration. |
| 138 | await p.setViewportSize({ width: 1900, height: 950 }); |
| 139 | await p.evaluate(() => window.DaimondPanels.reflow()); |
| 140 | await sleep(900); |
| 141 | const before = await measure(); |
| 142 | // A real drag, with the mouse, because the handle captures the pointer and a |
| 143 | // synthesised PointerEvent with no capture never reaches the engine's move. |
| 144 | { |
| 145 | const box = await p.$eval('#handle-stage', (e) => { |
| 146 | const r = e.getBoundingClientRect(); |
| 147 | return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; |
| 148 | }); |
| 149 | await p.mouse.move(box.x, box.y); |
| 150 | await p.mouse.down(); |
| 151 | await p.mouse.move(box.x - 90, box.y, { steps: 8 }); |
| 152 | await p.mouse.move(box.x - 180, box.y, { steps: 8 }); |
| 153 | await p.mouse.up(); |
| 154 | } |
| 155 | await sleep(1200); |
| 156 | const after = await measure(); |
| 157 | console.log('\nafter dragging the stage divider 180px left:'); |
| 158 | console.log(' grid ' + JSON.stringify(before.grid) + ' -> ' + JSON.stringify(after.grid)); |
| 159 | console.log(' told ' + JSON.stringify(before.lastSize) + ' -> ' + JSON.stringify(after.lastSize)); |
| 160 | console.log(' term ' + JSON.stringify(before.term) + ' -> ' + JSON.stringify(after.term)); |
| 161 | await shot(s, 'termstage-1900-dragged'); |
| 162 | |
| 163 | // ── The phone ─────────────────────────────────────────────────────── |
| 164 | // |
| 165 | // A stage panel on a phone is not a destination on the bottom bar: it RISES as a |
| 166 | // sheet over the conversation, so the daimon stays under the thing being worked |
| 167 | // in. The Terminal already did that as a dock panel with no seat; what changes |
| 168 | // with the zone is only that it is now doing it for the reason the others do. |
| 169 | // |
| 170 | // The sheet is also where the fit has to catch up: the panel is measured while |
| 171 | // it is still hidden behind the phone stylesheet, so the first grid is the 20x4 |
| 172 | // minimum, and the terminal's own ResizeObserver is what turns it into the |
| 173 | // sheet's real size a moment later. That is the transition worth watching. |
| 174 | await p.setViewportSize({ width: 390, height: 844 }); |
| 175 | await p.evaluate(() => window.DaimondPanels.hide('term')); |
| 176 | await sleep(600); |
| 177 | await p.evaluate(() => window.DaimondPanels.show('term')); |
| 178 | await sleep(1800); |
| 179 | const phone = await p.evaluate(() => { |
| 180 | const panel = document.getElementById('panel-term'); |
| 181 | const term = document.querySelector('#termp-host .term'); |
| 182 | const sheet = document.getElementById('msheet'); |
| 183 | const r = (e) => { const b = e.getBoundingClientRect(); return { w: Math.round(b.width), h: Math.round(b.height) }; }; |
| 184 | return { |
| 185 | inSheet: !!(panel && panel.closest('#msheet')), |
| 186 | sheetOpen: !!(sheet && sheet.classList.contains('open')), |
| 187 | sheetTitle: (document.querySelector('.msheet-title') || {}).textContent, |
| 188 | floor: document.body.dataset.mpanel, |
| 189 | barSeats: [...document.querySelectorAll('#mnav button')].map((b) => b.dataset.mp), |
| 190 | panel: panel && r(panel), |
| 191 | term: term && r(term), |
| 192 | grid: term && { cols: +term.dataset.cols, rows: +term.dataset.rows }, |
| 193 | told: (window.__link.sent.filter((m) => m.t === 'resize').pop() || {}).size || null, |
| 194 | // The panel's own name must not be said twice, once by the sheet and once |
| 195 | // by the head inside it. |
| 196 | nameShown: !!(panel && panel.querySelector('.chead .ctitle') |
| 197 | && getComputedStyle(panel.querySelector('.chead .ctitle')).display !== 'none'), |
| 198 | startVisible: !!(panel && panel.querySelector('[data-act="term-start"]') |
| 199 | && getComputedStyle(panel.querySelector('[data-act="term-start"]')).display !== 'none'), |
| 200 | }; |
| 201 | }); |
| 202 | console.log('\nphone 390x844: ' + JSON.stringify(phone, null, 1)); |
| 203 | await shot(s, 'termstage-phone'); |
| 204 | |
| 205 | const noise = s.errs.filter((e) => !/favicon|ERR_ABORTED|502|Bad Gateway|net::ERR/i.test(e)); |
| 206 | console.log('\nconsole: ' + (noise.length ? noise.slice(0, 4).join(' | ') : 'clean')); |
| 207 | await s.close(); |