feat: handlers for night city theme's features

This commit is contained in:
2026-05-29 00:44:02 +09:30
parent 65cd0a1c4f
commit 3d13202779
3 changed files with 798 additions and 2 deletions
+72 -2
View File
@@ -17,6 +17,15 @@ import {
clearCustomThemeAdaptiveCssVariables,
setCustomThemeAdaptiveCssVariables,
} from "@/seqta/ui/colors/customThemeAdaptiveBindings";
import {
clearThemeRuntime,
injectThemeDom,
runThemeScript,
validateThemeDom,
validateThemeScript,
type ThemeDomSpec,
type ThemeScriptSpec,
} from "./theme-runtime";
type ThemeContent = {
id: string;
@@ -31,6 +40,8 @@ type ThemeContent = {
forceDark?: boolean;
adaptiveCssVariables?: string[];
images?: { id: string; variableName: string; data: string }[]; // data: base64
themeScript?: ThemeScriptSpec;
themeDom?: ThemeDomSpec;
};
export type InstallThemeMeta = {
@@ -53,6 +64,7 @@ export class ThemeManager {
private imageUrlCache: Map<string, string> = new Map();
private lastTransitionPoint: { x: number; y: number } = { x: 0, y: 0 };
private storeUpdateCheckRunning = false;
private headObserver: MutationObserver | null = null;
private constructor() {
console.debug("[ThemeManager] Initializing...");
@@ -307,6 +319,13 @@ export class ThemeManager {
private async applyTheme(theme: CustomTheme): Promise<void> {
console.debug("[ThemeManager] Applying theme:", theme.name);
try {
// Run the theme script BEFORE injecting CustomCSS so any state the
// script publishes (e.g. `data-city-state` and `--city-sky-color` for
// Noir City) is already on <html> when the new CSS rules paint.
// Otherwise the CSS lands with var() unresolved and the page flashes
// its previous state before snapping to the right colour.
runThemeScript(theme.themeScript);
// Apply custom CSS
if (theme.CustomCSS) {
console.debug("[ThemeManager] Applying custom CSS");
@@ -348,6 +367,8 @@ export class ThemeManager {
}
setCustomThemeAdaptiveCssVariables(theme.adaptiveCssVariables ?? []);
injectThemeDom(theme.themeDom);
} catch (error) {
console.error("[ThemeManager] Error applying theme:", error);
}
@@ -379,6 +400,13 @@ export class ThemeManager {
): Promise<void> {
console.debug("[ThemeManager] Removing theme:", theme.name);
try {
clearThemeRuntime();
// Disconnect the head observer BEFORE removing the style element,
// otherwise the removal fires the observer and it would no-op only
// because the style is already gone — wasted work, but harmless.
this.disconnectStyleObserver();
// Remove custom CSS
if (this.styleElement) {
console.debug("[ThemeManager] Removing custom CSS");
@@ -448,7 +476,13 @@ export class ThemeManager {
}
/**
* Apply custom CSS to the document
* Apply custom CSS to the document. The `<style>` element is always
* re-appended to the end of `<head>` so it wins specificity ties
* against any styles SEQTA's late-loading injected.scss adds in dev
* mode (where `import("@/css/injected.scss")` is fire-and-forget and
* can resolve after the theme has already been applied). The head
* observer below keeps us at the end if anything else gets appended
* later (Vite HMR, another script-injected stylesheet, etc.).
*/
private applyCustomCSS(css: string): void {
console.debug("[ThemeManager] Applying custom CSS");
@@ -456,14 +490,39 @@ export class ThemeManager {
if (!this.styleElement) {
this.styleElement = document.createElement("style");
this.styleElement.id = "custom-theme";
document.head.appendChild(this.styleElement);
}
this.styleElement.textContent = css;
document.head.appendChild(this.styleElement);
this.ensureStyleStaysLast();
} catch (error) {
console.error("[ThemeManager] Error applying custom CSS:", error);
}
}
/**
* Watch `<head>` for any child-list changes and re-append the theme
* style element if anything has been added after it. Idempotent: the
* observer's own re-append fires the callback again, but the early
* `lastElementChild === style` check short-circuits the second pass.
*/
private ensureStyleStaysLast(): void {
if (this.headObserver) return;
this.headObserver = new MutationObserver(() => {
const style = this.styleElement;
if (!style || !document.head.contains(style)) return;
if (document.head.lastElementChild === style) return;
document.head.appendChild(style);
});
this.headObserver.observe(document.head, { childList: true });
}
private disconnectStyleObserver(): void {
if (this.headObserver) {
this.headObserver.disconnect();
this.headObserver = null;
}
}
/**
* Get list of available themes
*/
@@ -637,6 +696,15 @@ export class ThemeManager {
throw new Error("Theme is missing required fields (id or name)");
}
// Validate optional runtime hooks. Reject the install if either is
// malformed so a tampered theme cannot smuggle in unsafe values.
if (!validateThemeScript(themeData.themeScript)) {
throw new Error("Theme has invalid themeScript");
}
if (!validateThemeDom(themeData.themeDom)) {
throw new Error("Theme has invalid themeDom");
}
const fromStore = meta?.fromStore ?? false;
const serverUpdatedAtSec = meta?.serverUpdatedAtSec;
@@ -701,6 +769,8 @@ export class ThemeManager {
? true
: undefined,
adaptiveCssVariables: themeData.adaptiveCssVariables,
themeScript: themeData.themeScript,
themeDom: themeData.themeDom,
};
await this.saveTheme(theme);
@@ -0,0 +1,714 @@
/**
* Theme runtime: minimal, locked-down hooks for themes that need a clock or
* a few decorative DOM elements (e.g. Noir City's day/night cycle and
* animated cars).
*
* Hard constraints:
* - No `eval` and no execution of theme-author JS. Themes reference built-in
* functions by name from a fixed allowlist.
* - No network, storage, cookies, or SEQTA DOM access.
* - Only mutations allowed are: CSS custom properties on <html>, a single
* `data-city-state` attribute on <html>, and appending a fixed set of
* decorative elements into `#bsplus-theme-runtime-root`.
*/
export type ThemeScriptSpec = {
onLoad?: string;
interval?: number;
onInterval?: string;
};
export type ThemeDomSpec = {
roadStrip?: boolean;
cars?: number;
/** @deprecated Use cityLayers flicker overlays instead. */
flickers?: number;
cityLayers?: boolean;
};
const ROOT_ID = "bsplus-theme-runtime-root";
const WALLPAPER_ID = "bsplus-theme-wallpaper";
const MAX_CARS = 10;
const MAX_FLICKERS = 8;
const MIN_INTERVAL_MS = 60_000;
const DEFAULT_INTERVAL_MS = 600_000;
/**
* IDs of decorative city layers injected when `themeDom.cityLayers` is on.
* Order matters: earlier entries paint behind later ones. Buildings sit
* behind the lit batches (so windows draw over the silhouettes); flicker
* frames sit over the lit batches so blinking windows hide steady ones.
* Sun and moon are last so they paint over everything else in the
* wallpaper stack (still behind #main via z-index 0).
*/
/**
* `city-buildings` always paints the night panorama; `city-day` paints
* the day panorama on top with opacity controlled by `--city-day-
* opacity`. The two stack so we can CSS-transition opacity between
* them at the day boundary, instead of snapping background-image (which
* doesn't animate). */
const CITY_LAYER_IDS = [
"city-buildings",
"city-day",
"city-lights-1",
"city-lights-2",
"city-lights-3",
"city-flicker-a",
"city-flicker-b",
"city-sun",
"city-moon",
] as const;
// Built-in functions themes may reference by exact string match.
const BUILTINS: Record<string, () => void> = {
"setTimeState()": setTimeState,
"setCityTime()": setCityTime,
};
let intervalHandle: ReturnType<typeof setInterval> | null = null;
let lastTimeState: TimeState | null = null;
let lastDomSpec: ThemeDomSpec | null = null;
let bodyObserver: MutationObserver | null = null;
type TimeState = "night" | "dawn" | "day" | "dusk" | "evening";
/**
* Sky colour anchors. Exactly one of these is published as
* `--city-sky-color` at any given moment, based on the discrete
* `timeStateForMinutes()` bucket. CSS handles the cross-fade between
* anchors via its own `transition: background-color` rule, so the sky
* is a flat colour during the whole duration of each phase and only
* animates on the boundary crossing itself.
*
* night near-black with a hint of blue
* dawn peachy sunrise — brighter than the previous dusty rose so
* the lerp from night -> dawn -> day doesn't pass through
* desaturated grey-purple in the middle.
* day soft pastel "skyblue" (#87CEEB, the CSS named colour). Was
* previously a desaturated steel-blue that read as grey at
* mid-lightness.
* dusk warm orange sunset, matching dawn's brightness so the
* afternoon transition stays colourful.
* evening deep indigo, low chroma, settling toward night.
*/
const SKY_COLOR: Record<TimeState, string> = {
night: "#0d0d0f",
dawn: "#cc6a5a",
day: "#87ceeb",
dusk: "#d56a3a",
evening: "#111118",
};
/* ============================================================ *
* Dev-only time picker
*
* Flip this to `false` to remove the floating slider entirely
* (it's also automatically off in production builds via the
* `IS_DEV` check below).
* ============================================================ */
const DEV_TIME_PICKER_ENABLED = true;
const DEV_OVERRIDE_KEY = "bsplus-city-time-override";
const IS_DEV = import.meta.env.MODE === "development";
const DEV_PICKER_ID = "bsplus-city-dev-picker";
function readDevOverride(): number | null {
if (!IS_DEV || !DEV_TIME_PICKER_ENABLED) return null;
try {
const raw = localStorage.getItem(DEV_OVERRIDE_KEY);
if (raw === null) return null;
const n = Number(raw);
if (Number.isFinite(n) && n >= 0 && n < 24 * 60) return n;
} catch {
// localStorage may be unavailable; ignore.
}
return null;
}
/**
* "Balanced 1h wedges" schedule:
*
* 00:00 .. 05:30 night (dark)
* 05:30 .. 06:30 dawn (peachy sunrise, ramping up)
* 06:30 .. 18:00 day (pastel blue, the big stable block)
* 18:00 .. 19:00 dusk (warm sunset)
* 19:00 .. 21:00 evening (deep indigo, fading toward night)
* 21:00 .. 24:00 night
*
* The discrete bucket here drives `data-city-state` (used by the car
* sprite swap and the day panorama). The continuous sky-colour lerp in
* `TIME_BOUNDARIES` MUST use the same minute markers so the boundary
* the user sees in the dev slider matches the visible colour change.
*/
function timeStateForMinutes(minutes: number): TimeState {
if (minutes < 5 * 60 + 30) return "night";
if (minutes < 6 * 60 + 30) return "dawn";
if (minutes < 18 * 60) return "day";
if (minutes < 19 * 60) return "dusk";
if (minutes < 21 * 60) return "evening";
return "night";
}
function getMinutesOfDay(now: Date = new Date()): number {
const override = readDevOverride();
if (override !== null) return override;
return now.getHours() * 60 + now.getMinutes();
}
function getTimeState(now: Date = new Date()): TimeState {
return timeStateForMinutes(getMinutesOfDay(now));
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
/**
* Sky colour publishing is intentionally NOT time-interpolated. We
* publish exactly one of the five `SKY_COLOR` anchors based on the
* discrete state from `timeStateForMinutes()`, and let CSS handle the
* cross-fade via its own `transition: background-color` on #content.
*
* That way the sky is a flat colour for the entire duration of each
* phase, and only animates between two anchors AT THE MOMENT the state
* boundary is crossed. The animation duration is decoupled from how
* long the phase lasts.
*
* Removed: the previous `lerpColor` + `TIME_BOUNDARIES` minute table.
*/
function skyColorForState(state: TimeState): string {
return SKY_COLOR[state];
}
function darknessForMinutes(minutes: number): number {
const noon = 12 * 60;
let distance = Math.abs(minutes - noon);
if (distance > 12 * 60) distance = 24 * 60 - distance;
return clamp01(distance / (12 * 60));
}
/**
* Per-phase lit-window opacities. Like `SKY_COLOR`, this is a flat lookup
* by discrete state — NOT a continuous function of minute-of-day. The
* existing 10s CSS `transition: opacity` on #city-lights-1/2/3 handles
* the cross-fade between phases when the discrete state changes.
*
* night everything lit — peak window activity
* dawn batch 1 only — a few early risers, lobby lights
* day all off
* dusk batch 1 fully on, batch 2 fading in
* evening batches 1 + 2 fully on, batch 3 partially — peak evening
*
* Tweak these freely; they're purely cosmetic and don't have to add up
* to anything.
*/
const LIT_OPACITIES: Record<TimeState, [number, number, number]> = {
night: [1.0, 1.0, 1.0],
dawn: [1.0, 0.0, 0.0],
day: [0.0, 0.0, 0.0],
dusk: [1.0, 0.6, 0.0],
evening: [1.0, 1.0, 0.5],
};
function litOpacitiesForState(state: TimeState): [number, number, number] {
return LIT_OPACITIES[state];
}
function sunForMinutes(minutes: number): { t: number; opacity: number } {
const sunrise = 6 * 60;
const sunset = 19 * 60;
if (minutes < sunrise || minutes >= sunset) return { t: 0, opacity: 0 };
return {
t: (minutes - sunrise) / (sunset - sunrise),
opacity: 1,
};
}
function moonForMinutes(minutes: number): { t: number; opacity: number } {
const sun = sunForMinutes(minutes);
if (sun.opacity > 0) return { t: 0, opacity: 0 };
const dusk = 19 * 60 + 30;
const dawn = 5 * 60;
if (minutes >= dusk) {
return { t: clamp01((minutes - dusk) / (24 * 60 - dusk)), opacity: 1 };
}
if (minutes < dawn) {
return { t: clamp01((dawn - minutes) / dawn), opacity: 1 };
}
return { t: 0.5, opacity: 1 };
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
/**
* Read the clock and update `data-city-state` + sky colour on <html>. Only
* writes when the state actually changes, so CSS transitions are driven by
* real state changes rather than every tick.
*/
export function setTimeState(): void {
setCityTime();
}
/**
* Read the clock and publish continuous city variables plus the discrete
* `data-city-state` bucket used by CSS that still needs hard cuts (e.g. day
* panorama swap, night car sprites).
*/
export function setCityTime(): void {
const minutes = getMinutesOfDay();
const state = timeStateForMinutes(minutes);
const html = document.documentElement;
if (state !== lastTimeState) {
lastTimeState = state;
html.setAttribute("data-city-state", state);
html.style.setProperty("--city-time-state", state);
}
const darkness = darknessForMinutes(minutes);
const [lit1, lit2, lit3] = litOpacitiesForState(state);
const sun = sunForMinutes(minutes);
const moon = moonForMinutes(minutes);
html.style.setProperty("--city-sky-color", skyColorForState(state));
html.style.setProperty("--city-darkness", String(darkness));
// 1 only during the `day` phase; 0 in every other phase. CSS cross-
// fades the day panorama over the night panorama using this on a 10s
// opacity transition, which avoids the visual snap that
// `background-image` swaps cause (background-image doesn't animate).
html.style.setProperty("--city-day-opacity", state === "day" ? "1" : "0");
html.style.setProperty("--city-lit-1-opacity", String(lit1));
html.style.setProperty("--city-lit-2-opacity", String(lit2));
html.style.setProperty("--city-lit-3-opacity", String(lit3));
html.style.setProperty("--city-sun-t", String(sun.t));
html.style.setProperty("--city-sun-opacity", String(sun.opacity));
html.style.setProperty("--city-moon-t", String(moon.t));
html.style.setProperty("--city-moon-opacity", String(moon.opacity));
}
/**
* Validate the script spec strictly. Returns true if the spec is safe to run.
* Themes that include unknown calls or non-builtins are rejected wholesale.
*/
export function validateThemeScript(script: unknown): script is ThemeScriptSpec {
if (script == null) return true; // optional field
if (typeof script !== "object") return false;
const s = script as Record<string, unknown>;
for (const key of Object.keys(s)) {
if (key !== "onLoad" && key !== "interval" && key !== "onInterval") {
return false;
}
}
if (s.onLoad !== undefined && !isAllowedCall(s.onLoad)) return false;
if (s.onInterval !== undefined && !isAllowedCall(s.onInterval)) return false;
if (
s.interval !== undefined &&
(typeof s.interval !== "number" ||
!Number.isFinite(s.interval) ||
s.interval < MIN_INTERVAL_MS)
) {
return false;
}
return true;
}
function isAllowedCall(value: unknown): boolean {
return typeof value === "string" && value in BUILTINS;
}
/**
* Validate the DOM spec. Caps counts and rejects unknown keys.
*/
export function validateThemeDom(dom: unknown): dom is ThemeDomSpec {
if (dom == null) return true;
if (typeof dom !== "object") return false;
const d = dom as Record<string, unknown>;
for (const key of Object.keys(d)) {
if (key !== "roadStrip" && key !== "cars" && key !== "flickers" && key !== "cityLayers") {
return false;
}
}
if (d.cityLayers !== undefined && typeof d.cityLayers !== "boolean") return false;
if (d.roadStrip !== undefined && typeof d.roadStrip !== "boolean") return false;
if (
d.cars !== undefined &&
(typeof d.cars !== "number" ||
!Number.isInteger(d.cars) ||
d.cars < 0 ||
d.cars > MAX_CARS)
) {
return false;
}
if (
d.flickers !== undefined &&
(typeof d.flickers !== "number" ||
!Number.isInteger(d.flickers) ||
d.flickers < 0 ||
d.flickers > MAX_FLICKERS)
) {
return false;
}
return true;
}
function callBuiltin(name: string): void {
const fn = BUILTINS[name];
if (!fn) return;
try {
fn();
} catch (e) {
console.error("[ThemeRuntime] Built-in failed:", name, e);
}
}
/**
* Run a validated themeScript: invoke `onLoad` once and start an interval
* timer for `onInterval` if configured. Idempotent — clears any prior
* interval before scheduling a new one so repeated calls (e.g. during
* theme re-apply races) don't leak timers.
*/
export function runThemeScript(script: ThemeScriptSpec | undefined): void {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
if (!script || !validateThemeScript(script)) return;
if (script.onLoad) callBuiltin(script.onLoad);
if (script.onInterval) {
const period = Math.max(MIN_INTERVAL_MS, script.interval ?? DEFAULT_INTERVAL_MS);
intervalHandle = setInterval(() => callBuiltin(script.onInterval!), period);
}
}
function getOrCreateRoot(): HTMLElement {
let root = document.getElementById(ROOT_ID);
if (!root) {
root = document.createElement("div");
root.id = ROOT_ID;
root.setAttribute("aria-hidden", "true");
}
// Always parent under <body>: the runtime root uses `position: fixed`
// and needs to escape any stacking context SEQTA puts on `#content` so
// the sidebar can layer over it cleanly via z-index.
if (root.parentElement !== document.body) {
document.body.appendChild(root);
}
return root;
}
/**
* The wallpaper container is a *separate* sibling of the runtime root,
* never a child. We keep it independent so its existence is determined
* solely by `themeDom.cityLayers`, and so the road strip's tight 60px
* bottom geometry can't accidentally constrain the full-area city
* layers. All CSS positioning of cityscape/sun/moon happens via
* `position: fixed` on the layer divs themselves; this container only
* groups them for cheap teardown.
*/
function getOrCreateWallpaper(): HTMLElement {
let wallpaper = document.getElementById(WALLPAPER_ID);
if (!wallpaper) {
wallpaper = document.createElement("div");
wallpaper.id = WALLPAPER_ID;
wallpaper.setAttribute("aria-hidden", "true");
}
if (wallpaper.parentElement !== document.body) {
document.body.appendChild(wallpaper);
}
return wallpaper;
}
function populateWallpaper(wallpaper: HTMLElement): void {
while (wallpaper.firstChild) wallpaper.removeChild(wallpaper.firstChild);
for (const id of CITY_LAYER_IDS) {
const layer = document.createElement("div");
layer.id = id;
// `class` lets the theme target all cityscape overlays with a single
// rule for the shared fixed-positioning + background-attachment block.
if (
id.startsWith("city-buildings") ||
id === "city-day" ||
id.startsWith("city-lights") ||
id.startsWith("city-flicker")
) {
layer.className = "city-cityscape-layer";
}
wallpaper.appendChild(layer);
}
}
const CAR_VARIANTS = ["car-sedan", "car-hatchback", "car-boxy"] as const;
/**
* Populate the runtime root with the decorative DOM described by `dom`.
* Always wipes the root's existing children first so repeated calls leave
* exactly one road / N cars, no duplicates. The runtime root contains
* ONLY the road strip and cars — full-area city layers (buildings, lit
* batches, flicker, sun, moon) live in a separate `#bsplus-theme-wallpaper`
* container so the root's tight 60px bottom strip never grows.
*/
function populateRoot(root: HTMLElement, dom: ThemeDomSpec): void {
while (root.firstChild) root.removeChild(root.firstChild);
if (dom.roadStrip) {
const road = document.createElement("div");
road.id = "city-road";
root.appendChild(road);
}
const carCount = Math.min(dom.cars ?? 0, MAX_CARS);
for (let i = 1; i <= carCount; i++) {
const car = document.createElement("div");
const variant = CAR_VARIANTS[(i - 1) % CAR_VARIANTS.length];
car.className = `city-car city-car-${i} ${variant}`;
root.appendChild(car);
}
// Legacy scattered flicker dots. Themes that opt into `cityLayers` use
// the overlay-cross-fade flicker instead, so we skip these when the new
// system is active to avoid double flickering.
if (!dom.cityLayers) {
const flickerCount = Math.min(dom.flickers ?? 0, MAX_FLICKERS);
for (let i = 1; i <= flickerCount; i++) {
const flicker = document.createElement("div");
flicker.className = `city-win-flicker city-win-flicker-${i}`;
root.appendChild(flicker);
}
}
}
/**
* Watch `<body>` for the runtime root or wallpaper being removed (SPA
* re-renders, external code, etc.) and re-inject from the cached spec.
* Without this, intermittent reloads could leave the page with no road/
* cars or no cityscape layers even though the runtime believed they were
* mounted.
*/
function ensureBodyObserver(): void {
if (bodyObserver) return;
bodyObserver = new MutationObserver(() => {
if (!lastDomSpec) return;
if (!document.getElementById(ROOT_ID)) {
const root = getOrCreateRoot();
populateRoot(root, lastDomSpec);
}
if (lastDomSpec.cityLayers && !document.getElementById(WALLPAPER_ID)) {
const wallpaper = getOrCreateWallpaper();
populateWallpaper(wallpaper);
}
});
bodyObserver.observe(document.body, { childList: true });
}
/**
* Inject the decorative DOM scaffolding required by themes that opt in via
* `themeDom`. Idempotent: safe to call multiple times in a row without
* an intervening `clearThemeRuntime`. Everything is positioned/styled by
* theme CSS.
*/
export function injectThemeDom(dom: ThemeDomSpec | undefined): void {
if (!dom || !validateThemeDom(dom)) return;
lastDomSpec = dom;
const root = getOrCreateRoot();
populateRoot(root, dom);
if (dom.cityLayers) {
const wallpaper = getOrCreateWallpaper();
populateWallpaper(wallpaper);
} else {
// Theme switched from a cityLayers theme to one without — tear down
// any leftover wallpaper so we don't paint stale buildings/sun.
document.getElementById(WALLPAPER_ID)?.remove();
}
ensureBodyObserver();
// Suppress the slow `transition: background-color` for the very first
// frame after the theme CSS lands. Without this, the browser
// interpolates from SEQTA's pre-theme `background: unset` (light) to
// var(--city-sky-color) over 30s on every page load. Double rAF: the
// first runs after the next layout, the second after that frame has
// actually been painted with the attribute set, so we can safely
// remove it and let real state changes (night -> dawn etc.) animate.
const html = document.documentElement;
html.setAttribute("data-city-just-applied", "");
requestAnimationFrame(() => {
requestAnimationFrame(() => {
html.removeAttribute("data-city-just-applied");
});
});
mountDevTimePicker();
}
/**
* Dev-only floating time picker. Renders a slider (0..1440 minutes) plus a
* checkbox to toggle "use real clock". The override is persisted in
* `localStorage` so it survives reloads during a debugging session. The
* whole block is dead code in production builds (the `IS_DEV` check is
* static and Vite tree-shakes it out).
*/
function mountDevTimePicker(): void {
if (!IS_DEV || !DEV_TIME_PICKER_ENABLED) return;
if (document.getElementById(DEV_PICKER_ID)) return;
const initialOverride = readDevOverride();
const startMinutes =
initialOverride ?? new Date().getHours() * 60 + new Date().getMinutes();
const useReal = initialOverride === null;
const panel = document.createElement("div");
panel.id = DEV_PICKER_ID;
panel.setAttribute("aria-label", "City time override (dev mode)");
// Inline styles so the panel can't be styled away by the active theme.
Object.assign(panel.style, {
position: "fixed",
bottom: "12px",
right: "12px",
zIndex: "2147483647",
padding: "10px 12px",
background: "rgba(20, 20, 24, 0.92)",
color: "#e8e8f0",
font: "12px/1.4 system-ui, sans-serif",
borderRadius: "8px",
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.4)",
border: "1px solid rgba(255, 255, 255, 0.08)",
pointerEvents: "auto",
userSelect: "none",
minWidth: "220px",
} satisfies Partial<CSSStyleDeclaration>);
const header = document.createElement("div");
header.textContent = "City time (dev)";
Object.assign(header.style, {
fontWeight: "600",
marginBottom: "6px",
opacity: "0.85",
} satisfies Partial<CSSStyleDeclaration>);
panel.appendChild(header);
const realRow = document.createElement("label");
Object.assign(realRow.style, {
display: "flex",
alignItems: "center",
gap: "6px",
marginBottom: "8px",
cursor: "pointer",
} satisfies Partial<CSSStyleDeclaration>);
const realCheck = document.createElement("input");
realCheck.type = "checkbox";
realCheck.checked = useReal;
realRow.appendChild(realCheck);
const realLabel = document.createElement("span");
realLabel.textContent = "Use real clock";
realRow.appendChild(realLabel);
panel.appendChild(realRow);
const slider = document.createElement("input");
slider.type = "range";
slider.min = "0";
slider.max = String(24 * 60 - 1);
slider.step = "5";
slider.value = String(startMinutes);
slider.disabled = useReal;
Object.assign(slider.style, {
width: "100%",
accentColor: "#c8b97a",
} satisfies Partial<CSSStyleDeclaration>);
panel.appendChild(slider);
const readout = document.createElement("div");
Object.assign(readout.style, {
marginTop: "4px",
opacity: "0.8",
fontVariantNumeric: "tabular-nums",
} satisfies Partial<CSSStyleDeclaration>);
panel.appendChild(readout);
function paintReadout(): void {
const m = Number(slider.value);
readout.textContent = useReal
? "Real clock"
: `${formatMinutes(m)} · ${timeStateForMinutes(m)}`;
}
function applyOverride(): void {
try {
if (realCheck.checked) {
localStorage.removeItem(DEV_OVERRIDE_KEY);
} else {
localStorage.setItem(DEV_OVERRIDE_KEY, slider.value);
}
} catch (e) {
console.warn("[ThemeRuntime] Could not persist time override:", e);
}
// Force a full re-publish next tick by clearing the cached bucket;
// setCityTime() short-circuits the discrete state when unchanged but
// always re-writes the continuous vars.
lastTimeState = null;
setCityTime();
paintReadout();
}
realCheck.addEventListener("change", () => {
slider.disabled = realCheck.checked;
applyOverride();
});
slider.addEventListener("input", () => {
if (realCheck.checked) {
realCheck.checked = false;
slider.disabled = false;
}
applyOverride();
});
paintReadout();
document.body.appendChild(panel);
}
function unmountDevTimePicker(): void {
document.getElementById(DEV_PICKER_ID)?.remove();
}
/**
* Remove everything the runtime added: injected DOM, interval timer, custom
* properties, and the `data-city-state` attribute.
*/
export function clearThemeRuntime(): void {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
// Disconnect the body observer BEFORE removing the root so the observer
// doesn't see its own teardown removal as "root went missing" and try to
// re-inject what we're trying to clear.
if (bodyObserver) {
bodyObserver.disconnect();
bodyObserver = null;
}
lastDomSpec = null;
lastTimeState = null;
const root = document.getElementById(ROOT_ID);
if (root) root.remove();
document.getElementById(WALLPAPER_ID)?.remove();
unmountDevTimePicker();
const html = document.documentElement;
html.removeAttribute("data-city-state");
html.removeAttribute("data-city-just-applied");
html.style.removeProperty("--city-time-state");
html.style.removeProperty("--city-sky-color");
html.style.removeProperty("--city-darkness");
html.style.removeProperty("--city-day-opacity");
html.style.removeProperty("--city-lit-1-opacity");
html.style.removeProperty("--city-lit-2-opacity");
html.style.removeProperty("--city-lit-3-opacity");
html.style.removeProperty("--city-sun-t");
html.style.removeProperty("--city-sun-opacity");
html.style.removeProperty("--city-moon-t");
html.style.removeProperty("--city-moon-opacity");
}
+12
View File
@@ -26,6 +26,18 @@ export type CustomTheme = {
userEdited?: boolean;
/** CSS custom property names (e.g. `--my-accent`) that receive the same value as `--better-main` when adaptive colours apply. */
adaptiveCssVariables?: string[];
/** Optional runtime hook for themes that need clock-driven CSS variables. See `theme-runtime.ts`. */
themeScript?: {
onLoad?: string;
interval?: number;
onInterval?: string;
};
/** Optional decorative DOM injection (e.g. animated cars). See `theme-runtime.ts`. */
themeDom?: {
roadStrip?: boolean;
cars?: number;
flickers?: number;
};
};
export type LoadedCustomTheme = CustomTheme & {