From a93c3ccda75bd553449262be4a9a02fbd9350733 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Wed, 26 Aug 2026 16:47:41 +0930 Subject: [PATCH] feat: light mode variations for the loading screen --- src/interface/pages/settings/general.svelte | 12 +++ src/plugins/monofile.ts | 4 + src/seqta/ui/Loading.ts | 61 +++++++++----- src/seqta/ui/loadingCanvas.ts | 37 ++++++--- src/seqta/ui/loadingHold.test.ts | 30 +++++++ src/seqta/ui/loadingHold.ts | 10 +++ src/seqta/ui/loadingVariants.test.ts | 24 ++++++ src/seqta/ui/loadingVariants.ts | 80 ++++++++++++++++--- src/seqta/utils/cloudSettingsSync.ts | 1 + .../utils/ensureSyncableStorageDefaults.ts | 1 + src/seqta/utils/listeners/SettingsState.ts | 1 + src/types/storage.ts | 2 + 12 files changed, 223 insertions(+), 40 deletions(-) create mode 100644 src/seqta/ui/loadingHold.test.ts create mode 100644 src/seqta/ui/loadingHold.ts create mode 100644 src/seqta/ui/loadingVariants.test.ts diff --git a/src/interface/pages/settings/general.svelte b/src/interface/pages/settings/general.svelte index a7ec2710..4cd27343 100644 --- a/src/interface/pages/settings/general.svelte +++ b/src/interface/pages/settings/general.svelte @@ -619,6 +619,18 @@ /> +
+
+

Delay loading screen

+

Keep the loading overlay visible for 5 extra seconds so you can preview canvas variants.

+
+
+ settingsState.devDelayLoadingScreen = isOn} + /> +
+

Sensitive Hider

diff --git a/src/plugins/monofile.ts b/src/plugins/monofile.ts index 144d6505..441505c9 100644 --- a/src/plugins/monofile.ts +++ b/src/plugins/monofile.ts @@ -20,6 +20,7 @@ import { updateAllColors } from "@/seqta/ui/colors/Manager"; import { applySelectedFont } from "@/seqta/ui/fonts/Manager"; import { verboseInfo, verboseLog } from "@/utils/verboseLog"; import loading, { stopLoadingAnimation } from "@/seqta/ui/Loading"; +import { loadingScreenHoldMs } from "@/seqta/ui/loadingHold"; import { SendNewsPage } from "@/seqta/utils/SendNewsPage"; import { getEngageRoutePage } from "@/seqta/utils/engageRoute"; import { @@ -92,6 +93,9 @@ export async function finishLoad() { document.querySelector(".legacy-root")?.classList.remove("hidden"); + const holdMs = loadingScreenHoldMs(settingsState); + if (holdMs > 0) await delay(holdMs); + const loadingbk = document.getElementById("loading"); loadingbk?.classList.add("closeLoading"); stopLoadingAnimation(); diff --git a/src/seqta/ui/Loading.ts b/src/seqta/ui/Loading.ts index 1cbd9c16..7e4757f8 100644 --- a/src/seqta/ui/Loading.ts +++ b/src/seqta/ui/Loading.ts @@ -6,6 +6,7 @@ import loadingOverlay from "./loading-overlay.html?raw"; import { startLoadingCanvas } from "./loadingCanvas"; import { pickLoadingVariant, + resolveLoadingTheme, type LoadingVariant, } from "./loadingVariants"; @@ -25,26 +26,42 @@ const THEME_GUARD_KEYS = [ "selectedFont", ] as const; -const LOADING_PALETTE = { +const LOADING_PALETTE_DARK = { line: "rgba(255, 255, 255, 0.08)", lineAccent: "rgba(255, 255, 255, 0.15)", lineBlue: "rgba(96, 165, 250, 0.42)", grid: "rgba(255, 255, 255, 0.028)", text: "#f4f4f5", + version: "rgba(255, 255, 255, 0.35)", } as const; +const LOADING_PALETTE_LIGHT = { + line: "rgba(24, 24, 27, 0.1)", + lineAccent: "rgba(24, 24, 27, 0.18)", + lineBlue: "rgba(37, 99, 235, 0.5)", + grid: "rgba(24, 24, 27, 0.055)", + text: "#18181b", + version: "rgba(24, 24, 27, 0.42)", +} as const; + +function loadingPalette(darkMode: boolean) { + return darkMode ? LOADING_PALETTE_DARK : LOADING_PALETTE_LIGHT; +} + const loadingStyles = /* css */ ` :host { - --bk-line-color: ${LOADING_PALETTE.line}; - --bk-line-accent: ${LOADING_PALETTE.lineAccent}; - --bk-line-blue: ${LOADING_PALETTE.lineBlue}; - --bk-grid-color: ${LOADING_PALETTE.grid}; + --bk-line-color: ${LOADING_PALETTE_DARK.line}; + --bk-line-accent: ${LOADING_PALETTE_DARK.lineAccent}; + --bk-line-blue: ${LOADING_PALETTE_DARK.lineBlue}; + --bk-grid-color: ${LOADING_PALETTE_DARK.grid}; --bk-spin-outer: 1s; --bk-spin-inner: 3s; --bk-spin-small: 3s; --bk-stage-name: bkloading-stage-in; --bk-stage-duration: 0.85s; - color: ${LOADING_PALETTE.text}; + --bk-vignette-fill: radial-gradient(ellipse at center, transparent 32%, rgba(0, 0, 0, 0.75) 100%); + --bk-version-color: ${LOADING_PALETTE_DARK.version}; + color: ${LOADING_PALETTE_DARK.text}; opacity: 1; transition: opacity 0.85s cubic-bezier(0.4, 0, 0.2, 1); } @@ -66,7 +83,7 @@ const loadingStyles = /* css */ ` position: absolute; inset: 0; pointer-events: none; - background: radial-gradient(ellipse at center, transparent 32%, rgba(0, 0, 0, 0.75) 100%); + background: var(--bk-vignette-fill); opacity: 0.88; } @@ -153,7 +170,7 @@ const loadingStyles = /* css */ ` bottom: 10px; font-size: 0.95rem; letter-spacing: 0.02em; - color: rgba(255, 255, 255, 0.35); + color: var(--bk-version-color); pointer-events: none; } @@ -199,7 +216,8 @@ function overlayWithSpinner(): string { ); } -function applyHostShell(host: HTMLElement) { +function applyHostShell(host: HTMLElement, darkMode: boolean) { + const palette = loadingPalette(darkMode); const shell = [ ["position", "fixed"], ["inset", "0"], @@ -209,11 +227,12 @@ function applyHostShell(host: HTMLElement) { ["contain", "strict"], ["visibility", "visible"], ["pointer-events", "auto"], - ["color", LOADING_PALETTE.text], - ["--bk-line-color", LOADING_PALETTE.line], - ["--bk-line-accent", LOADING_PALETTE.lineAccent], - ["--bk-line-blue", LOADING_PALETTE.lineBlue], - ["--bk-grid-color", LOADING_PALETTE.grid], + ["color", palette.text], + ["--bk-line-color", palette.line], + ["--bk-line-accent", palette.lineAccent], + ["--bk-line-blue", palette.lineBlue], + ["--bk-grid-color", palette.grid], + ["--bk-version-color", palette.version], ] as const; for (const [prop, value] of shell) { @@ -226,18 +245,24 @@ function applyVariantTheme( shadow: ShadowRoot, variant: LoadingVariant, ) { - const { theme } = variant; + const darkMode = !!settingsState.DarkMode; + const theme = resolveLoadingTheme(variant, darkMode); host.dataset.variant = variant.id; - applyHostShell(host); + host.dataset.scheme = darkMode ? "dark" : "light"; + applyHostShell(host, darkMode); host.style.setProperty("background", theme.background, "important"); host.style.setProperty("--bk-spin-outer", theme.spinOuter, "important"); host.style.setProperty("--bk-spin-inner", theme.spinInner, "important"); - host.style.setProperty("--bk-spin-small", theme.spinSmall, "important"); host.style.setProperty("--bk-stage-name", theme.stageName, "important"); + host.style.setProperty("--bk-spin-small", theme.spinSmall, "important"); host.style.setProperty("--bk-stage-duration", theme.stageDuration, "important"); + host.style.setProperty("--bk-vignette-fill", theme.vignetteFill, "important"); const vignette = shadow.querySelector(".bkloading__vignette") as HTMLElement | null; - if (vignette) vignette.style.opacity = String(theme.vignetteOpacity); + if (vignette) { + vignette.style.opacity = String(theme.vignetteOpacity); + vignette.style.background = theme.vignetteFill; + } } function refreshActiveLoadingTheme() { diff --git a/src/seqta/ui/loadingCanvas.ts b/src/seqta/ui/loadingCanvas.ts index 6b42f39b..74238cac 100644 --- a/src/seqta/ui/loadingCanvas.ts +++ b/src/seqta/ui/loadingCanvas.ts @@ -78,8 +78,15 @@ function cycle( return { t: fadeT, fade: true, ambient: smooth((len - tick) / revealMs) }; } -function cssVar(_root: HTMLElement, _name: string, fallback: string): string { - return fallback; +function cssVar(root: HTMLElement, name: string, fallback: string): string { + const inline = root.style.getPropertyValue(name).trim(); + if (inline) return inline; + const computed = getComputedStyle(root).getPropertyValue(name).trim(); + return computed || fallback; +} + +function isLightScheme(root: HTMLElement): boolean { + return root.dataset.scheme === "light"; } function drawGrid( @@ -122,9 +129,11 @@ function drawSoftBlobs( blur: number, fadeMs: number, tide: boolean, + light: boolean, ) { const time = reduced ? 0 : elapsed * 0.001; const fadeIn = reduced ? 1 : smooth(Math.min(1, elapsed / fadeMs)); + const alphaBoost = light ? 1.18 : 1; ctx.save(); ctx.globalAlpha = fadeIn; @@ -145,8 +154,8 @@ function drawSoftBlobs( y = b.cy * h + Math.sin(t * 0.48 + b.phase) * h * 0.025; rx = b.rx * w; ry = b.ry * h * breathe; - a0 = 0.38; - a1 = 0.14; + a0 = 0.38 * alphaBoost; + a1 = 0.14 * alphaBoost; } else { const amp = b.amp ?? 0.2; const scale = 1 + Math.sin(t * 1.35) * amp; @@ -155,8 +164,8 @@ function drawSoftBlobs( y = b.cy * h + Math.sin(t * 0.41) * h * 0.01; rx = b.rx * w * scale; ry = b.ry * h * stretch; - a0 = 0.5; - a1 = 0.18; + a0 = 0.5 * alphaBoost; + a1 = 0.18 * alphaBoost; ctx.save(); ctx.translate(x, y); ctx.rotate(t * (b.rot ?? 0)); @@ -187,6 +196,7 @@ function drawDotGlobe( phaseElapsed: number, fade: boolean, ambient: number, + light: boolean, ) { const time = reduced ? 0 : elapsed * 0.001; const cx = w * 0.5; @@ -206,8 +216,8 @@ function drawDotGlobe( ctx.save(); ctx.globalAlpha = ambient; const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius * 1.15); - glow.addColorStop(0, "rgba(37, 99, 235, 0.1)"); - glow.addColorStop(0.55, "rgba(37, 99, 235, 0.03)"); + glow.addColorStop(0, light ? "rgba(37, 99, 235, 0.16)" : "rgba(37, 99, 235, 0.1)"); + glow.addColorStop(0.55, light ? "rgba(37, 99, 235, 0.05)" : "rgba(37, 99, 235, 0.03)"); glow.addColorStop(1, "rgba(37, 99, 235, 0)"); ctx.fillStyle = glow; ctx.fillRect(cx - radius * 1.2, cy - radius * 1.2, radius * 2.4, radius * 2.4); @@ -256,8 +266,11 @@ function drawDotGlobe( const size = (1.2 + depth * 2.2) * dot.p; const alpha = (0.12 + brightness * 0.75) * dot.p; - ctx.fillStyle = - brightness > 0.52 + ctx.fillStyle = light + ? brightness > 0.52 + ? `rgba(29, 78, 216, ${alpha})` + : `rgba(24, 24, 27, ${0.1 + brightness * 0.28 * dot.p})` + : brightness > 0.52 ? `rgba(96, 165, 250, ${alpha})` : `rgba(255, 255, 255, ${0.08 + brightness * 0.22 * dot.p})`; ctx.beginPath(); @@ -303,10 +316,11 @@ export function startLoadingCanvas( const elapsed = now - start; ctx.clearRect(0, 0, w, h); + const light = isLightScheme(root); if (visual === "globe") { const { t, fade, ambient } = cycle(elapsed, revealMs, holdMs); - drawDotGlobe(ctx, w, h, elapsed, reduced, t, fade, reduced ? 1 : ambient); + drawDotGlobe(ctx, w, h, elapsed, reduced, t, fade, reduced ? 1 : ambient, light); } else if (visual === "blobs") { const tide = variant.blobStyle === "tide"; drawSoftBlobs( @@ -319,6 +333,7 @@ export function startLoadingCanvas( tide ? 58 : 52, tide ? 1800 : 1600, tide, + light, ); } else { const { t, fade } = cycle(elapsed, revealMs, holdMs); diff --git a/src/seqta/ui/loadingHold.test.ts b/src/seqta/ui/loadingHold.test.ts new file mode 100644 index 00000000..b93cbeba --- /dev/null +++ b/src/seqta/ui/loadingHold.test.ts @@ -0,0 +1,30 @@ +import { loadingScreenHoldMs } from "./loadingHold"; + +describe("loadingScreenHoldMs", () => { + it("holds 5 seconds when developer mode and the delay option are both on", () => { + expect( + loadingScreenHoldMs({ + devMode: true, + devDelayLoadingScreen: true, + }), + ).toBe(5000); + }); + + it("does not hold when developer mode is off", () => { + expect( + loadingScreenHoldMs({ + devMode: false, + devDelayLoadingScreen: true, + }), + ).toBe(0); + }); + + it("does not hold when the delay option is off", () => { + expect( + loadingScreenHoldMs({ + devMode: true, + devDelayLoadingScreen: false, + }), + ).toBe(0); + }); +}); diff --git a/src/seqta/ui/loadingHold.ts b/src/seqta/ui/loadingHold.ts new file mode 100644 index 00000000..c3d1ea05 --- /dev/null +++ b/src/seqta/ui/loadingHold.ts @@ -0,0 +1,10 @@ +export const LOADING_SCREEN_DEBUG_DELAY_MS = 5000; + +export function loadingScreenHoldMs(settings: { + devMode?: boolean; + devDelayLoadingScreen?: boolean; +}): number { + return settings.devMode && settings.devDelayLoadingScreen + ? LOADING_SCREEN_DEBUG_DELAY_MS + : 0; +} diff --git a/src/seqta/ui/loadingVariants.test.ts b/src/seqta/ui/loadingVariants.test.ts new file mode 100644 index 00000000..f7ea8ee0 --- /dev/null +++ b/src/seqta/ui/loadingVariants.test.ts @@ -0,0 +1,24 @@ +import { + listLoadingVariants, + resolveLoadingTheme, +} from "./loadingVariants"; + +describe("loading variant light themes", () => { + it("defines a distinct light theme for every canvas variant", () => { + const variants = listLoadingVariants(); + expect(variants.length).toBeGreaterThan(0); + + for (const variant of variants) { + expect(variant.lightTheme.background).not.toBe(variant.theme.background); + expect(variant.lightTheme.background).not.toMatch(/#0[148]0[148]0[148]/); + expect(variant.lightTheme.vignetteFill).not.toBe(variant.theme.vignetteFill); + } + }); + + it("resolves dark theme when dark mode is on and light theme when it is off", () => { + const sweep = listLoadingVariants().find((variant) => variant.id === "sweep"); + expect(sweep).toBeDefined(); + expect(resolveLoadingTheme(sweep!, true)).toEqual(sweep!.theme); + expect(resolveLoadingTheme(sweep!, false)).toEqual(sweep!.lightTheme); + }); +}); diff --git a/src/seqta/ui/loadingVariants.ts b/src/seqta/ui/loadingVariants.ts index 49cc3ad8..5694c99b 100644 --- a/src/seqta/ui/loadingVariants.ts +++ b/src/seqta/ui/loadingVariants.ts @@ -11,6 +11,17 @@ export type Line = { ) => void; }; +export type LoadingTheme = { + background: string; + vignetteOpacity: number; + vignetteFill: string; + spinOuter: string; + spinInner: string; + spinSmall: string; + stageName: string; + stageDuration: string; +}; + export type LoadingVariant = { id: string; visual?: "lines" | "blobs" | "globe"; @@ -18,15 +29,8 @@ export type LoadingVariant = { holdMs: number; grid: { cols: number; rows: number }; lines: Line[]; - theme: { - background: string; - vignetteOpacity: number; - spinOuter: string; - spinInner: string; - spinSmall: string; - stageName: string; - stageDuration: string; - }; + theme: LoadingTheme; + lightTheme: LoadingTheme; }; const curve = ( @@ -60,7 +64,13 @@ const segment = (x1: number, y1: number, x2: number, y2: number): Line["stroke"] }; const BASE = "linear-gradient(180deg, #010101 0%, #040404 50%, #080808 100%)"; +const BASE_LIGHT = + "linear-gradient(180deg, #fafafa 0%, #f4f4f5 50%, #ececef 100%)"; const STAGE = "bkloading-stage-in"; +const DARK_VIGNETTE = + "radial-gradient(ellipse at center, transparent 32%, rgba(0, 0, 0, 0.75) 100%)"; +const LIGHT_VIGNETTE = + "radial-gradient(ellipse at center, transparent 32%, rgba(250, 250, 250, 0.88) 100%)"; function theme( background: string, @@ -69,10 +79,12 @@ function theme( spinInner: string, spinSmall: string, stageDuration: string, -) { + light = false, +): LoadingTheme { return { background, vignetteOpacity, + vignetteFill: light ? LIGHT_VIGNETTE : DARK_VIGNETTE, spinOuter, spinInner, spinSmall, @@ -85,7 +97,8 @@ function canvasVariant( id: string, visual: "blobs" | "globe", holdMs: number, - t: ReturnType, + t: LoadingTheme, + lightTheme: LoadingTheme, blobStyle?: "diffuse" | "tide", ): LoadingVariant { return { @@ -96,9 +109,17 @@ function canvasVariant( grid: { cols: 0, rows: 0 }, lines: [], theme: t, + lightTheme, }; } +export function resolveLoadingTheme( + variant: LoadingVariant, + darkMode: boolean, +): LoadingTheme { + return darkMode ? variant.theme : variant.lightTheme; +} + const VARIANTS: LoadingVariant[] = [ { id: "sweep", @@ -112,6 +133,15 @@ const VARIANTS: LoadingVariant[] = [ "3s", "0.85s", ), + lightTheme: theme( + `radial-gradient(ellipse 70% 45% at 50% 95%, rgba(37, 99, 235, 0.16), transparent 62%), ${BASE_LIGHT}`, + 0.72, + "1s", + "3s", + "3s", + "0.85s", + true, + ), lines: [ { delay: 0, @@ -177,6 +207,15 @@ const VARIANTS: LoadingVariant[] = [ "2.6s", "0.75s", ), + lightTheme: theme( + `radial-gradient(ellipse 40% 30% at 80% 20%, rgba(59, 130, 246, 0.12), transparent 70%), ${BASE_LIGHT}`, + 0.74, + "0.85s", + "2.6s", + "2.6s", + "0.75s", + true, + ), lines: [ { delay: 0, dur: 1200, stroke: segment(0.5, 0.06, 0.5, 0.94) }, { delay: 80, dur: 1200, stroke: segment(0.04, 0.5, 0.96, 0.5) }, @@ -202,6 +241,7 @@ const VARIANTS: LoadingVariant[] = [ "blobs", 0, theme(BASE, 0.78, "1.3s", "3.8s", "3.8s", "0.9s"), + theme(BASE_LIGHT, 0.62, "1.3s", "3.8s", "3.8s", "0.9s", true), "diffuse", ), canvasVariant( @@ -216,6 +256,15 @@ const VARIANTS: LoadingVariant[] = [ "4.2s", "1s", ), + theme( + `radial-gradient(ellipse 85% 45% at 50% 100%, rgba(37, 99, 235, 0.14), transparent 72%), ${BASE_LIGHT}`, + 0.64, + "1.5s", + "4.2s", + "4.2s", + "1s", + true, + ), "tide", ), canvasVariant( @@ -230,6 +279,15 @@ const VARIANTS: LoadingVariant[] = [ "3.5s", "0.9s", ), + theme( + `radial-gradient(ellipse 55% 50% at 50% 48%, rgba(37, 99, 235, 0.14), transparent 70%), ${BASE_LIGHT}`, + 0.7, + "1.2s", + "3.5s", + "3.5s", + "0.9s", + true, + ), ), ]; diff --git a/src/seqta/utils/cloudSettingsSync.ts b/src/seqta/utils/cloudSettingsSync.ts index 965df51d..bf42ecb6 100644 --- a/src/seqta/utils/cloudSettingsSync.ts +++ b/src/seqta/utils/cloudSettingsSync.ts @@ -84,6 +84,7 @@ const OMIT_FROM_UPLOAD_EXACT = new Set([ ...CLIENT_ONLY_CLOUD_KEYS_EXACT, "devMode", "devGhReleaseVersionOverride", + "devDelayLoadingScreen", ]); const UNSAFE_STORAGE_KEYS = new Set(["__proto__", "constructor", "prototype"]); diff --git a/src/seqta/utils/ensureSyncableStorageDefaults.ts b/src/seqta/utils/ensureSyncableStorageDefaults.ts index cfb969c8..b3d5c0da 100644 --- a/src/seqta/utils/ensureSyncableStorageDefaults.ts +++ b/src/seqta/utils/ensureSyncableStorageDefaults.ts @@ -30,6 +30,7 @@ const OPTIONAL_UNSET_MEANS_DEFAULT_KEYS = [ "justupdated", "devMode", "verboseLogging", + "devDelayLoadingScreen", "hideSensitiveContent", "mockNotices", "homeUpcomingAssessmentsPerSubjectMax", diff --git a/src/seqta/utils/listeners/SettingsState.ts b/src/seqta/utils/listeners/SettingsState.ts index 8c93083f..f3f2cb00 100644 --- a/src/seqta/utils/listeners/SettingsState.ts +++ b/src/seqta/utils/listeners/SettingsState.ts @@ -28,6 +28,7 @@ const SETTINGS_STORAGE_KEYS = [ "justupdated", "devMode", "verboseLogging", + "devDelayLoadingScreen", "devGhReleaseVersionOverride", "lastSeenNightlyPublishedAt", "originalDarkMode", diff --git a/src/types/storage.ts b/src/types/storage.ts index 4fb3b329..8edd8f05 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -57,6 +57,8 @@ export interface SettingsState { devMode?: boolean; /** Dev-only: emit verboseDebug / verboseInfo / verboseLog output. */ verboseLogging?: boolean; + /** Dev-only: keep the loading overlay visible for 5 extra seconds. */ + devDelayLoadingScreen?: boolean; /** Dev-only: pretend this is the latest GitHub release version for update badge testing. */ devGhReleaseVersionOverride?: string; /** ISO timestamp of the last acknowledged nightly release publish time. */