diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 52283eaf3..8c707b21d 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -854,3 +854,22 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)} #bc-card{display:flex;align-items:center;min-width:0} #bc-card>span{min-width:0;overflow:hidden;text-overflow:ellipsis} .help.ask-cur{color:var(--ember)} + +/* scaling-21 (the project lead, 7 October 2026: "the miner needs some scaling so more is visible in the initial window"): the + Overview's vertical rhythm tightened so the rate band, the big button, the ladder strip, the chain card and the node + card's first row sit above the fold at 1280 by 800 (fold.test.mjs measures it on build-2 at three viewports). Same + proportions, no type below 13 px, the live scene and the GPU marks keep their look; only paddings, gaps and the + scene's height move. The other pages already fit their first object at 1280 by 800 and stay as they are. */ +#page-overview{gap:var(--s-3)} +#page-overview .hero-row{gap:var(--s-2)} +#page-overview .totals{padding:16px 22px 14px} +#page-overview .tot .k{margin-top:2px} +#page-overview .toggle-big{min-height:54px;padding:9px 20px} +#page-overview .ladder-strip{padding:10px 16px} +#page-overview .ls-rungs{margin-bottom:6px} +#page-overview .dag-card{padding:16px 18px 14px} +#page-overview .dag-card .card-head{margin-bottom:8px} +#page-overview #dag-live{height:184px} +#page-overview .legend{margin-top:8px} +#page-overview .wait-card{padding:12px 20px} +#page-overview .node-card{padding-top:18px} diff --git a/app/igneum-app/ui/fold.test.mjs b/app/igneum-app/ui/fold.test.mjs new file mode 100644 index 000000000..a250b6b36 --- /dev/null +++ b/app/igneum-app/ui/fold.test.mjs @@ -0,0 +1,52 @@ +// node --test app/igneum-app/ui/fold.test.mjs (scaling-21, the project lead, 7 October 2026: "the miner needs some scaling so +// more is visible in the initial window"). Measures the key elements of each page inside the viewport on the mock with +// Playwright's Chromium, at 1280 by 800, 1440 by 900 and 1920 by 1080: the Overview's rate band, big button, ladder +// strip and chain card must end above the fold, and the node card's first row must start above it; Cards shows its +// first card row, Earnings its headline card, Prove its switch. Runs where the Playwright env is set (build-2: +// `. /srv/builds/_bin/overlap/env.sh`), skips elsewhere: the Mac runs no Playwright suite (CLAUDE.md, 7 October 2026). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +const here = dirname(fileURLToPath(import.meta.url)), root = join(here, '../../..'); +const require = createRequire(import.meta.url); +let chromium = null; +try { if (process.env.IGNEUM_PLAYWRIGHT_DIR) chromium = require(join(process.env.IGNEUM_PLAYWRIGHT_DIR, 'node_modules/playwright')).chromium; } catch (e) { chromium = null; } +const SIZES = [[1280, 800], [1440, 900], [1920, 1080]]; +const ROW = 48; // the first row of a card: its head line with the padding above it + +test('the first screen: the Overview above the fold at 1280 by 800, 1440 by 900 and 1920 by 1080; Cards, Earnings and Prove show their first object', { skip: chromium ? false : 'no Playwright env (IGNEUM_PLAYWRIGHT_DIR); runs on build-2' }, async () => { + const port = 4480 + Math.floor(Math.random() * 400); + const mock = spawn(process.execPath, [join(root, 'tools/ui-mock/server.mjs'), String(port)], { stdio: 'ignore' }); + try { + for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${port}/t/mock/api/state`); if (r.ok) break; } catch (e) { } await new Promise((r) => setTimeout(r, 250)); } + const browser = await chromium.launch(); + const bad = []; + const rect = (page, sel) => page.evaluate((s) => { const el = document.querySelector(s); if (!el) return null; const r = el.getBoundingClientRect(); return { top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight }; }, sel); + for (const [w, h] of SIZES) { + const page = await browser.newPage({ viewport: { width: w, height: h }, reducedMotion: 'reduce', colorScheme: 'dark' }); + const open = async (scenario, p) => { await page.goto(`http://127.0.0.1:${port}/t/mock/?scenario=${scenario}&page=${p}&theme=dark`, { waitUntil: 'load' }); await page.waitForTimeout(1200); }; + const fits = async (sel, label, rowOnly) => { const r = await rect(page, sel); if (!r) { bad.push(`${w}x${h} ${label}: not found`); return; } const end = rowOnly ? r.top + ROW : r.bottom; if (!(r.top >= 0 && end <= r.inner)) bad.push(`${w}x${h} ${label}: ${rowOnly ? 'row ends at ' + end : 'ends at ' + r.bottom} of ${r.inner}`); }; + await open('live', 'overview'); + await fits('#totals', 'Overview rate band'); await fits('#btn-toggle', 'Overview big button'); await fits('#ladder-strip', 'Overview ladder strip'); await fits('#dag-card', 'Overview chain card'); await fits('#node-card', 'Overview node card first row', true); + if (h >= 1080) await fits('#node-card', 'Overview node card whole at 1080'); + await open('rig', 'cards'); await fits('#tune-card', 'Cards tune strip'); await fits('#d-cards .gpu-line:first-child', 'Cards first card row'); + await open('ladder', 'earnings'); await fits('.earn', 'Earnings headline card'); + await open('ladder', 'prove'); await fits('#s-prove', 'Prove switch'); + await page.close(); + } + await browser.close(); + assert.deepEqual(bad, [], 'below the fold:\n' + bad.join('\n')); + } finally { mock.kill(); } +}); + +test('the type never drops below 13 px on the Overview density pass: no font-size under 13px inside the #page-overview rules', async () => { + const { readFileSync } = await import('node:fs'); + const css = readFileSync(join(here, 'app.css'), 'utf8'); + const at = css.indexOf('/* scaling-21'); + assert.ok(at >= 0, 'the density block exists'); + const block = css.slice(at); + for (const m of block.matchAll(/font-size:\s*(\d+(?:\.\d+)?)px/g)) assert.ok(parseFloat(m[1]) >= 13, 'font-size ' + m[1] + 'px in the density block'); +}); diff --git a/app/mac/IgneumMiner.swift b/app/mac/IgneumMiner.swift index 747253618..e0263f973 100644 --- a/app/mac/IgneumMiner.swift +++ b/app/mac/IgneumMiner.swift @@ -109,7 +109,13 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele } func buildWindow() { - let size = snapshotPath == nil ? NSSize(width: 1120, height: 780) : snapshotSize + // scaling-21 (7 October 2026): the window opens at about 80 percent of the screen's work area, capped at 1440 by 900 + // on a display up to 1920 wide and scaled up with the display beyond that; once the user resizes, the frame is + // remembered per machine (the frame autosave name) and the computed size is only the first-run default + let work = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let scale = max(1.0, work.width / 1920.0) + let opening = NSSize(width: max(900, min(work.width * 0.8, 1440 * scale)), height: max(600, min(work.height * 0.8, 900 * scale))) + let size = snapshotPath == nil ? opening : snapshotSize window = NSWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) window.title = "Igneum Miner" window.titlebarAppearsTransparent = true @@ -117,7 +123,10 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele window.isMovableByWindowBackground = true window.backgroundColor = obsidian window.minSize = NSSize(width: 900, height: 600) - window.center() + if snapshotPath == nil { + if !window.setFrameUsingName("IgneumMinerMain") { window.center() } + window.setFrameAutosaveName("IgneumMinerMain") + } else { window.center() } window.delegate = self window.isReleasedWhenClosed = false window.appearance = NSAppearance(named: .darkAqua) diff --git a/app/windows/host.cpp b/app/windows/host.cpp index 43881b672..16aafed4a 100644 --- a/app/windows/host.cpp +++ b/app/windows/host.cpp @@ -341,11 +341,49 @@ static void beginQuit() { } } +// scaling-21 (7 October 2026, the project lead: "the miner needs some scaling so more is visible in the initial window"): the window +// opens at about 80 percent of the work area, capped at 1440 by 900 on a display up to 1920 wide and scaled up with the +// display beyond that; once the user resizes, the size is remembered per machine under HKCU\Software\Igneum\Miner +// (WindowW, WindowH) and used on the next start while it still fits the work area. The Mac window does the same +// through its frame autosave name (app/mac/IgneumMiner.swift). +static const wchar_t* kSizeKey = L"Software\\Igneum\\Miner"; +static void saveWindowSize(HWND hwnd) { + if (IsIconic(hwnd) || IsZoomed(hwnd)) return; + RECT r; if (!GetWindowRect(hwnd, &r)) return; + DWORD w = (DWORD)(r.right - r.left), h = (DWORD)(r.bottom - r.top); + if (w < 900 || h < 600) return; + HKEY k; if (RegCreateKeyExW(HKEY_CURRENT_USER, kSizeKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &k, nullptr) != ERROR_SUCCESS) return; + RegSetValueExW(k, L"WindowW", 0, REG_DWORD, (const BYTE*)&w, sizeof(w)); + RegSetValueExW(k, L"WindowH", 0, REG_DWORD, (const BYTE*)&h, sizeof(h)); + RegCloseKey(k); +} +static void openingSize(int& w, int& h, int& sx, int& sy) { + RECT work = { 0, 0, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN) }; + SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0); + int ww = work.right - work.left, wh = work.bottom - work.top; + double scale = ww > 1920 ? ww / 1920.0 : 1.0; + double cw = 1440.0 * scale, ch = 900.0 * scale, aw = ww * 0.8, ah = wh * 0.8; + w = (int)(aw < cw ? aw : cw); h = (int)(ah < ch ? ah : ch); + if (w < 900) w = ww < 900 ? ww : 900; + if (h < 600) h = wh < 600 ? wh : 600; + HKEY k; DWORD sw = 0, sh = 0, n = sizeof(DWORD); + if (RegOpenKeyExW(HKEY_CURRENT_USER, kSizeKey, 0, KEY_QUERY_VALUE, &k) == ERROR_SUCCESS) { + RegQueryValueExW(k, L"WindowW", nullptr, nullptr, (LPBYTE)&sw, &n); n = sizeof(DWORD); + RegQueryValueExW(k, L"WindowH", nullptr, nullptr, (LPBYTE)&sh, &n); + RegCloseKey(k); + if (sw >= 900 && sh >= 600 && (int)sw <= ww && (int)sh <= wh) { w = (int)sw; h = (int)sh; } + } + sx = work.left + (ww - w) / 2; sy = work.top + (wh - h) / 2; +} + static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_SIZE: if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); } return 0; + case WM_EXITSIZEMOVE: + saveWindowSize(hwnd); + return 0; case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same ((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600; return 0; @@ -512,8 +550,7 @@ int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, PWSTR, int) { wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); wc.hbrBackground = CreateSolidBrush(RGB(12, 12, 14)); RegisterClassW(&wc); - int w = 1120, h = 820; - int sx = (GetSystemMetrics(SM_CXSCREEN) - w) / 2, sy = (GetSystemMetrics(SM_CYSCREEN) - h) / 2; + int w, h, sx, sy; openingSize(w, h, sx, sy); g_hwnd = CreateWindowExW(0, wc.lpszClassName, L"Igneum Miner", WS_OVERLAPPEDWINDOW, sx, sy, w, h, nullptr, nullptr, hInst, nullptr); ShowWindow(g_hwnd, SW_SHOW); diff --git a/docs/plans/scaling-shots.txt b/docs/plans/scaling-shots.txt new file mode 100644 index 000000000..d7982c098 --- /dev/null +++ b/docs/plans/scaling-shots.txt @@ -0,0 +1,3 @@ +01-overview-1280x800-dark.png scenario=live&page=overview&theme=dark 800 +02-overview-1920x1080-dark.png scenario=live&page=overview&theme=dark 1080 +03-cards-1280x800-dark.png scenario=rig&page=cards&theme=dark 800 diff --git a/docs/plans/scaling-shots/01-overview-1280x800-dark.png b/docs/plans/scaling-shots/01-overview-1280x800-dark.png new file mode 100644 index 000000000..143d3056e Binary files /dev/null and b/docs/plans/scaling-shots/01-overview-1280x800-dark.png differ diff --git a/docs/plans/scaling-shots/02-overview-1920x1080-dark.png b/docs/plans/scaling-shots/02-overview-1920x1080-dark.png new file mode 100644 index 000000000..4d5a75ad6 Binary files /dev/null and b/docs/plans/scaling-shots/02-overview-1920x1080-dark.png differ diff --git a/docs/plans/scaling-shots/03-cards-1280x800-dark.png b/docs/plans/scaling-shots/03-cards-1280x800-dark.png new file mode 100644 index 000000000..d831ba22d Binary files /dev/null and b/docs/plans/scaling-shots/03-cards-1280x800-dark.png differ diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index ffc3057e2..291ffd126 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -106,7 +106,7 @@ tree_checks() { run "interface bundle publisher self-test (pack, and sign when the signer is built)" node tools/ui-ota/publish.mjs --self-test run "heat gate reader self-test (a known hold passes, a known drift fails)" node tools/heat-gate.mjs --self-test run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs - run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs app/igneum-app/ui/ui-ota.test.mjs + run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs app/igneum-app/ui/ui-ota.test.mjs app/igneum-app/ui/fold.test.mjs run "the Intel rotate-fold rewrite: Intel gets the shift form, AMD and NVIDIA untouched (proto-opencl/test_intel_rotr.c)" bash -c 'cc -std=c99 -Wall -Wextra -o "${TMPDIR:-/tmp}/test_intel_rotr.$$" proto-opencl/test_intel_rotr.c && "${TMPDIR:-/tmp}/test_intel_rotr.$$"; s=$?; rm -f "${TMPDIR:-/tmp}/test_intel_rotr.$$"; exit $s' run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh' run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs diff --git a/tools/ui-mock/fold-measure.cjs b/tools/ui-mock/fold-measure.cjs new file mode 100644 index 000000000..07a25bd96 --- /dev/null +++ b/tools/ui-mock/fold-measure.cjs @@ -0,0 +1,23 @@ +// Measures where the key elements of a page end inside the viewport, on the mock, with Playwright's Chromium (build-2's +// overlap env: NODE_PATH and IGNEUM_PLAYWRIGHT_DIR from /srv/builds/_bin/overlap/env.sh). Prints one JSON line per +// viewport and page: { w, h, page, items: { id: { top, bottom, fits } } }. +// node tools/ui-mock/fold-measure.cjs [pages...] +const base = process.argv[2]; const pages = process.argv.slice(3).length ? process.argv.slice(3) : ['overview', 'cards', 'earnings', 'prove']; +const { chromium } = require('playwright'); +const SIZES = [[1280, 800], [1440, 900], [1920, 1080]]; +const KEYS = { overview: ['#totals', '#btn-toggle', '#ladder-strip', '#dag-card', '#node-card .card-head, #node-card h3, #node-card'], cards: ['#tune-card, .tune-strip', '#d-cards .gpu-line:first-child'], earnings: ['.earn', '#ladder-card .card-head'], prove: ['#page-prove .card:first-child', '#s-prove'] }; +(async () => { + const dir = process.env.IGNEUM_PLAYWRIGHT_DIR; const exe = process.env.PLAYWRIGHT_CHROMIUM; + const browser = await chromium.launch(exe ? { executablePath: exe } : {}); + for (const [w, h] of SIZES) { + const page = await browser.newPage({ viewport: { width: w, height: h }, reducedMotion: 'reduce', colorScheme: 'dark' }); + for (const p of pages) { + await page.goto(`${base}/t/mock/?scenario=${p === 'cards' ? 'rig' : 'live'}&page=${p}&theme=dark`, { waitUntil: 'load' }); + await page.waitForTimeout(1500); + const items = await page.evaluate((keys) => { const out = {}; for (const k of keys) { const el = document.querySelector(k); if (!el) { out[k] = null; continue; } const r = el.getBoundingClientRect(); out[k] = { top: Math.round(r.top), bottom: Math.round(r.bottom), fits: r.bottom <= window.innerHeight && r.top >= 0 }; } out.scrollHeight = document.querySelector('main').scrollHeight; out.inner = window.innerHeight; return out; }, KEYS[p]); + console.log(JSON.stringify({ w, h, page: p, items })); + } + await page.close(); + } + await browser.close(); +})().catch((e) => { console.error(e); process.exit(1); });