Merge remote-tracking branch 'origin/scaling-21' into release-0.3.21

This commit is contained in:
igneum-labs 2026-10-07 17:01:49 +00:00
commit 42787f7fbb
10 changed files with 148 additions and 5 deletions

View file

@ -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}

View file

@ -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');
});

View file

@ -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)

View file

@ -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);

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

View file

@ -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

View file

@ -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 <mock base url> [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); });