diff --git a/spec/ui-hover-defaults-spec.mjs b/spec/ui-hover-defaults-spec.mjs new file mode 100644 index 0000000000..2d3cafb886 --- /dev/null +++ b/spec/ui-hover-defaults-spec.mjs @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +describe("UI and cursor interaction defaults", () => { + const ui = readFileSync("system/public/aesthetic.computer/lib/ui.mjs", "utf8"); + const bios = readFileSync("system/public/aesthetic.computer/bios.mjs", "utf8"); + it("gives text buttons theme-aware rollover defaults", () => { + expect(ui).toContain("defaultRolloverScheme($"); + expect(ui).toContain("mode?.buttonHover"); + }); + it("restores SVG cursors outside native mode", () => { + expect(bios).toContain("cursors/precise.svg"); + expect(bios).toContain("classList.remove(\"native-cursor\")"); + }); + it("preserves continuity-frame aspect", () => { + expect(bios).toContain('freezeFrameCan.style.objectFit = "contain"'); + }); +}); diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs index ae5faa0d56..c1ae02fbb3 100644 --- a/system/public/aesthetic.computer/bios.mjs +++ b/system/public/aesthetic.computer/bios.mjs @@ -1200,7 +1200,11 @@ async function boot(parsed, bpm = 60, resolution, debug) { freezeFrameCan.style.willChange = "opacity"; freezeFrameCan.style.position = "absolute"; freezeFrameCan.style.zIndex = "10"; // Above all other canvases during reframe - freezeFrameCan.style.pointerEvents = "none"; + freezeFrameCan.style.pointerEvents = "none"; + // During a live window resize the old framebuffer must keep its aspect + // ratio. The wrapper may change shape before the worker returns new pixels; + // object-fit prevents that continuity frame from rubber-sheet stretching. + freezeFrameCan.style.objectFit = "contain"; // A buffer for corner label overlays. const overlayCan = document.createElement("canvas"); @@ -12250,9 +12254,18 @@ async function boot(parsed, bpm = 60, resolution, debug) { // hidden. pen.render() owns the on-canvas reticles + the native-cursor // class; here we only drive the OS cursor. Valid CSS values (none, // crosshair, …) pass straight through. - const CURSOR_CSS = { native: "auto", precise: "", tiny: "none", dot: "none" }; - document.body.style.cursor = code in CURSOR_CSS ? CURSOR_CSS[code] : code; - return; + const CURSOR_CSS = { + native: "auto", + precise: "url('/aesthetic.computer/cursors/precise.svg') 12 12, auto", + active: "url('/aesthetic.computer/cursors/active.svg') 12 12, pointer", + viewpoint: "url('/aesthetic.computer/cursors/viewpoint.svg') 12 12, auto", + tiny: "none", + dot: "none", + }; + if (code === "native") document.body.classList.add("native-cursor"); + else document.body.classList.remove("native-cursor"); + document.body.style.cursor = code in CURSOR_CSS ? CURSOR_CSS[code] : code; + return; } // Authenticate / signup or login a user. diff --git a/system/public/aesthetic.computer/lib/ui.mjs b/system/public/aesthetic.computer/lib/ui.mjs index e4b6b9bc07..80098afe35 100644 --- a/system/public/aesthetic.computer/lib/ui.mjs +++ b/system/public/aesthetic.computer/lib/ui.mjs @@ -15,9 +15,17 @@ function stripColorCodes(text) { return text.replace(/\\[^\\]*\\/g, ''); } -function spinnerReset() { +function spinnerReset() { spinnerDelay = 0; -} +} + +function defaultRolloverScheme($, small = false) { + const mode = $?.dark ? $?.theme?.dark : $?.theme?.light; + const bg = mode?.buttonHover || mode?.buttonBg || ($?.dark ? [24, 32, 42] : [220, 245, 255]); + const outline = mode?.buttonHoverOutline || mode?.buttonOutline || [255, 220, 0]; + const text = mode?.buttonHoverText || mode?.buttonText || ($?.dark ? 255 : 0); + return small ? [bg, outline, text, bg] : [bg, outline, text, 255]; +} // Loading icon. function spinner(ctx, timePassed) { @@ -992,8 +1000,9 @@ class TextButton { scheme = [0, 255, 255, 0], hoverScheme = [255, 0, 0, 255], disabledScheme = [64, 127, 127, 64], - rolloverScheme = null, // Optional: used when mouse is over but not pressed - ) { + rolloverScheme = undefined, // Defaults to the active light/dark theme. + ) { + if (rolloverScheme === undefined) rolloverScheme = defaultRolloverScheme($); // Choose scheme based on state: disabled, pressed (down), rollover (hover), default let s; if (this.btn.disabled) { @@ -1128,8 +1137,9 @@ class TextButtonSmall { scheme = [[0, 64, 0], [0, 140, 0], 255, [0, 64, 0]], // [fill, outline, text, fill] hoverScheme = [[0, 100, 0], [0, 180, 0], 255, [0, 100, 0]], // hover/down disabledScheme = [[32, 32, 32], [64, 64, 64], 80, [32, 32, 32]], - rolloverScheme = null, // Optional: used when mouse is over but not pressed - ) { + rolloverScheme = undefined, // Defaults to the active light/dark theme. + ) { + if (rolloverScheme === undefined) rolloverScheme = defaultRolloverScheme($, true); let s; if (this.btn.disabled) { s = disabledScheme; diff --git a/tests/browser/resize-continuity.test.mjs b/tests/browser/resize-continuity.test.mjs new file mode 100644 index 0000000000..e149d9784b --- /dev/null +++ b/tests/browser/resize-continuity.test.mjs @@ -0,0 +1,22 @@ +import { ACSession, scenario, report } from "./ac-harness.mjs"; + +const ac = await ACSession.open(); +try { + await scenario("resize continuity preserves framebuffer aspect", async (expect) => { + await ac.boot("@sol"); + const sizes = [{ width: 420, height: 900 }, { width: 1000, height: 520 }, { width: 720, height: 720 }]; + const samples = []; + for (const size of sizes) { + await ac.page.setViewport(size); + await ac.wait(40); + samples.push(await ac.page.evaluate(() => { + const freeze = document.querySelector('canvas[data-type="freeze"]'); + return { present: Boolean(freeze), objectFit: freeze ? getComputedStyle(freeze).objectFit : null }; + })); + } + const frozen = samples.filter((sample) => sample.present); + expect(frozen.length > 0, "observed a continuity frame during resize"); + expect(frozen.every((sample) => sample.objectFit === "contain"), "continuity frames preserve aspect"); + }); +} finally { await ac.close(); } +process.exit(report());