feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* The mic and the monitor speaker, drawn as instruments rather than as a table
|
||||
* of key/value pairs.
|
||||
*
|
||||
* There was no device concept anywhere in this product a week ago — no type, no
|
||||
* route, no state — so this panel is not a rewrite of anything and has no legacy
|
||||
* to preserve. What it does have is a rule it must not break, and it is the same
|
||||
* rule the media-surface panel and `RobotOperationsDefinition` already live
|
||||
* under: **a reading that was invented has to say so, on the same surface as the
|
||||
* reading.** A level meter bouncing next to a photograph of a desk is a claim
|
||||
* about a real room. `DeviceDeclaration.disclosure` is the sentence that makes it
|
||||
* an honest one, `validateDeviceDeclaration` refuses a `simulated` device whose
|
||||
* disclosure does not contain the word, and this panel prints it under every
|
||||
* instrument rather than once at the top — because a panel that scrolls is a
|
||||
* panel whose header you have already scrolled past.
|
||||
*
|
||||
* ### One control per declared capability, and never a `switch` on kind
|
||||
*
|
||||
* The controls are built by walking `declaration.capabilities` and looking each
|
||||
* one up in `CAPABILITY_READING`. Nothing here asks whether a device is a mic.
|
||||
* That is the whole design of `src/devices/types.ts` and it is what makes a
|
||||
* third kind — a smart light, a thermostat — data rather than a code change: the
|
||||
* capability is declared, the reading is named by the table, and the control
|
||||
* falls out. A `if (kind === "mic") renderGain()` ladder would have to be found
|
||||
* and edited in four consumers the first time somebody added one.
|
||||
*
|
||||
* `level` is the odd one and is deliberately not a control: it is a *reading*.
|
||||
* You can ask a microphone for its programme level; you cannot set it.
|
||||
* `DeviceCommandOp` is `Exclude<DeviceCapability, "level">` for exactly this
|
||||
* reason, so the type system agrees with the panel about which rows are knobs.
|
||||
*
|
||||
* ### Anonymous visitors get the panel
|
||||
*
|
||||
* Declarations are authored into the office pack, which is bundled into the
|
||||
* static build, which means they are public by construction — the hardware in
|
||||
* the room is a description of the room. What an account buys is the live
|
||||
* readings from the authenticated route. So this mounts for everyone and shows
|
||||
* the at-rest state from `initialDeviceState` until something better arrives,
|
||||
* and the disclosure line is what tells you which of the two you are looking at.
|
||||
*/
|
||||
|
||||
import {
|
||||
CAPABILITY_READING,
|
||||
DEVICE_RANGES,
|
||||
initialDeviceState,
|
||||
normalizeDeviceCommand,
|
||||
} from "../devices/types.ts";
|
||||
import type {
|
||||
DeviceCapability,
|
||||
DeviceCommand,
|
||||
DeviceDeclaration,
|
||||
DeviceState,
|
||||
} from "../devices/types.ts";
|
||||
import { TOUCH_TARGET_PX } from "./tokens.ts";
|
||||
|
||||
// ---- Options and handle ---------------------------------------------------
|
||||
|
||||
export interface DevicePanelOptions {
|
||||
/**
|
||||
* The authored hardware in this room, from the resolved `Plan`.
|
||||
*
|
||||
* Required, and this is the one addition to the signature the build spec
|
||||
* fixed: the panel's structure is a function of the declarations and there is
|
||||
* no honest way to render a control without one — the capability list, the
|
||||
* label, the ranges and the disclosure all come from here. States alone would
|
||||
* leave the panel guessing which readings a device is *supposed* to have,
|
||||
* which is precisely the distinction `undefined` is reserved for.
|
||||
*/
|
||||
declarations: readonly DeviceDeclaration[];
|
||||
/**
|
||||
* Send one instruction. Already validated and clamped by
|
||||
* `normalizeDeviceCommand` before it arrives; a refusal is a silent no-op here
|
||||
* and never reaches this callback.
|
||||
*/
|
||||
onCommand(command: DeviceCommand): void;
|
||||
/**
|
||||
* False while nothing is connected — an anonymous visitor, or a build with no
|
||||
* server. The controls stay visible and stay pressable, because a disabled
|
||||
* mixing desk teaches nothing; what changes is the line under them.
|
||||
*/
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
export interface DevicePanelHandle {
|
||||
root: HTMLElement;
|
||||
apply(states: readonly DeviceState[]): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
// ---- Style ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Exported so the stylesheet test can assert the two properties that are easy to
|
||||
* lose and impossible to see in a Node test: that every interactive row clears
|
||||
* the 44px touch target, and that nothing in here writes a raw `z-index`.
|
||||
*/
|
||||
export const DEVICE_PANEL_CSS = `
|
||||
.tera-devices { display: flex; flex-direction: column; gap: var(--s2, 8px); }
|
||||
.tera-device {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2, 8px);
|
||||
padding: var(--s3, 12px);
|
||||
border: 1px solid var(--hairline, rgba(255,255,255,.11));
|
||||
border-radius: var(--r-sm, 5px);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.tera-device__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s2, 8px); }
|
||||
.tera-device__name { font-size: 11px; letter-spacing: .04em; color: var(--ink, rgba(255,255,255,.78)); }
|
||||
.tera-device__kind { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); }
|
||||
.tera-device__row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr) 3.25rem;
|
||||
align-items: center;
|
||||
gap: var(--s2, 8px);
|
||||
min-height: ${TOUCH_TARGET_PX}px;
|
||||
}
|
||||
.tera-device__row[data-unavailable="true"] { display: none; }
|
||||
.tera-device__label { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); }
|
||||
.tera-device__value { font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2, rgba(255,255,255,.56)); }
|
||||
.tera-device__switch {
|
||||
min-height: ${TOUCH_TARGET_PX}px;
|
||||
min-width: ${TOUCH_TARGET_PX}px;
|
||||
padding: 0 var(--s3, 12px);
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
color: var(--ink-2, rgba(255,255,255,.56));
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid var(--hairline, rgba(255,255,255,.11));
|
||||
border-radius: var(--r-pill, 999px);
|
||||
}
|
||||
.tera-device__switch[aria-pressed="true"] {
|
||||
color: var(--amber-ink, #ffd68a);
|
||||
background: rgba(242, 177, 52, 0.2);
|
||||
border-color: rgba(242, 177, 52, 0.5);
|
||||
}
|
||||
.tera-device__slider { width: 100%; min-height: ${TOUCH_TARGET_PX}px; accent-color: var(--amber, #f2b134); }
|
||||
.tera-device__meter {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.tera-device__meter i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg, #6fd58f 0%, #f2b134 78%, #ff8f6b 100%);
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
.tera-device__disclosure {
|
||||
margin: 0;
|
||||
font-size: 9px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-3, rgba(255,255,255,.4));
|
||||
border-left: 2px solid rgba(242, 177, 52, 0.4);
|
||||
padding-left: var(--s2, 8px);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .tera-device__meter i { transition: none; } }
|
||||
`;
|
||||
|
||||
const STYLE_ID = "tera-device-panel-style";
|
||||
|
||||
// ---- Rows ----------------------------------------------------------------
|
||||
|
||||
/** How each capability presents itself. `level` is a meter because it is a reading. */
|
||||
type RowShape = "switch" | "slider" | "meter";
|
||||
|
||||
const ROW_SHAPE: Readonly<Record<DeviceCapability, RowShape>> = {
|
||||
power: "switch",
|
||||
mute: "switch",
|
||||
playback: "switch",
|
||||
gain: "slider",
|
||||
volume: "slider",
|
||||
level: "meter",
|
||||
};
|
||||
|
||||
const ROW_LABEL: Readonly<Record<DeviceCapability, string>> = {
|
||||
power: "Power",
|
||||
mute: "Mute",
|
||||
playback: "Play",
|
||||
gain: "Gain",
|
||||
volume: "Volume",
|
||||
level: "Level",
|
||||
};
|
||||
|
||||
/** The step a slider moves in, in the reading's own unit. */
|
||||
const SLIDER_STEP: Readonly<Record<"gain" | "volume", number>> = { gain: 1, volume: 0.01 };
|
||||
|
||||
interface Row {
|
||||
capability: DeviceCapability;
|
||||
/**
|
||||
* How this row presents itself, carried rather than re-derived.
|
||||
*
|
||||
* Deliberately not an `instanceof HTMLButtonElement` check at apply time:
|
||||
* `HTMLButtonElement` is not a global in Node, so an `instanceof` against it
|
||||
* throws a `ReferenceError` rather than returning false — which would make
|
||||
* this module untestable outside a browser for no benefit at all.
|
||||
*/
|
||||
shape: RowShape;
|
||||
root: HTMLElement;
|
||||
control: HTMLElement;
|
||||
/** The slider itself, for the two rows that have one. */
|
||||
input: HTMLInputElement | null;
|
||||
value: HTMLElement;
|
||||
meterFill: HTMLElement | null;
|
||||
}
|
||||
|
||||
interface Instrument {
|
||||
declaration: DeviceDeclaration;
|
||||
rows: readonly Row[];
|
||||
}
|
||||
|
||||
function formatReading(capability: DeviceCapability, reading: unknown): string {
|
||||
if (reading === undefined || reading === null) return "—";
|
||||
if (typeof reading === "boolean") {
|
||||
if (capability === "power") return reading ? "On" : "Off";
|
||||
if (capability === "mute") return reading ? "Muted" : "Open";
|
||||
return reading ? "Playing" : "Stopped";
|
||||
}
|
||||
if (typeof reading !== "number" || !Number.isFinite(reading)) return "—";
|
||||
if (capability === "gain") return `${reading >= 0 ? "+" : ""}${reading.toFixed(0)} dB`;
|
||||
if (capability === "level") return `${reading.toFixed(0)} dBFS`;
|
||||
if (capability === "volume") return `${Math.round(reading * 100)}%`;
|
||||
return String(reading);
|
||||
}
|
||||
|
||||
/** A level in dBFS as a fraction of the meter's travel. `-60` is empty, `0` is full. */
|
||||
export function meterFraction(levelDb: number | undefined): number {
|
||||
if (levelDb === undefined || !Number.isFinite(levelDb)) return 0;
|
||||
const { min, max } = DEVICE_RANGES.level;
|
||||
return Math.min(1, Math.max(0, (levelDb - min) / (max - min)));
|
||||
}
|
||||
|
||||
// ---- Mount ----------------------------------------------------------------
|
||||
|
||||
export function mountDevicePanel(
|
||||
host: HTMLElement,
|
||||
options: DevicePanelOptions,
|
||||
): DevicePanelHandle {
|
||||
const doc = host.ownerDocument;
|
||||
const root = doc.createElement("section");
|
||||
root.className = "tera-devices";
|
||||
root.setAttribute("aria-label", "Studio hardware");
|
||||
|
||||
// One stylesheet per document, not per panel: this panel is torn down and
|
||||
// rebuilt on every office switch, and a `<style>` per mount would accumulate
|
||||
// one copy of the same rules per building the visitor walked through.
|
||||
const head = doc.head ?? null;
|
||||
let styleAdded = false;
|
||||
if (head !== null && head.querySelector(`#${STYLE_ID}`) === null) {
|
||||
const style = doc.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = DEVICE_PANEL_CSS;
|
||||
head.append(style);
|
||||
styleAdded = true;
|
||||
}
|
||||
|
||||
const instruments: Instrument[] = [];
|
||||
let disposed = false;
|
||||
/** Last known state per device, so a control can compute its own toggle. */
|
||||
const latest = new Map<string, DeviceState>();
|
||||
|
||||
function send(declaration: DeviceDeclaration, command: DeviceCommand): void {
|
||||
if (disposed) return;
|
||||
// Validated here as well as on the route, and that is not belt-and-braces
|
||||
// duplication: this is the copy that stops the UI *sending* something the
|
||||
// route would refuse, so a slider that overshoots its range by a float
|
||||
// rounding error is clamped rather than round-tripped into a 400.
|
||||
const normalized = normalizeDeviceCommand(declaration, command);
|
||||
if (normalized === null) return;
|
||||
options.onCommand(normalized);
|
||||
}
|
||||
|
||||
for (const declaration of options.declarations) {
|
||||
const card = doc.createElement("article");
|
||||
card.className = "tera-device";
|
||||
card.setAttribute("data-device", declaration.id);
|
||||
card.setAttribute("data-kind", declaration.kind);
|
||||
|
||||
const head2 = doc.createElement("div");
|
||||
head2.className = "tera-device__head";
|
||||
const name = doc.createElement("span");
|
||||
name.className = "tera-device__name";
|
||||
name.textContent = declaration.label;
|
||||
const kind = doc.createElement("span");
|
||||
kind.className = "tera-device__kind";
|
||||
kind.textContent = declaration.kind;
|
||||
head2.append(name, kind);
|
||||
card.append(head2);
|
||||
|
||||
const rows: Row[] = [];
|
||||
for (const capability of declaration.capabilities) {
|
||||
const shape = ROW_SHAPE[capability];
|
||||
if (shape === undefined) continue;
|
||||
|
||||
const row = doc.createElement("div");
|
||||
row.className = "tera-device__row";
|
||||
row.setAttribute("data-capability", capability);
|
||||
|
||||
const label = doc.createElement("span");
|
||||
label.className = "tera-device__label";
|
||||
label.textContent = ROW_LABEL[capability];
|
||||
|
||||
const value = doc.createElement("span");
|
||||
value.className = "tera-device__value";
|
||||
value.textContent = "—";
|
||||
|
||||
let control: HTMLElement;
|
||||
let input: HTMLInputElement | null = null;
|
||||
let meterFill: HTMLElement | null = null;
|
||||
|
||||
if (shape === "switch") {
|
||||
const button = doc.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "tera-device__switch";
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.textContent = ROW_LABEL[capability];
|
||||
button.addEventListener("click", () => {
|
||||
const current = latest.get(declaration.id);
|
||||
const reading = current?.[CAPABILITY_READING[capability]];
|
||||
// The command is the *negation of what is on screen*, not a blind
|
||||
// `true`: a toggle that always sends `true` is a button that works
|
||||
// once. `undefined` — no reading yet — is treated as off, which is
|
||||
// what `initialDeviceState` says a device at rest is.
|
||||
send(declaration, {
|
||||
deviceId: declaration.id,
|
||||
op: capability === "power" ? "power" : capability === "mute" ? "mute" : "playback",
|
||||
value: !(reading === true),
|
||||
});
|
||||
});
|
||||
control = button;
|
||||
} else if (shape === "slider") {
|
||||
const range = capability === "gain" ? DEVICE_RANGES.gain : DEVICE_RANGES.volume;
|
||||
const step = capability === "gain" ? SLIDER_STEP.gain : SLIDER_STEP.volume;
|
||||
const slider = doc.createElement("input");
|
||||
slider.type = "range";
|
||||
slider.className = "tera-device__slider";
|
||||
slider.min = String(range.min);
|
||||
slider.max = String(range.max);
|
||||
slider.step = String(step);
|
||||
slider.value = String(range.initial);
|
||||
slider.setAttribute("aria-label", `${declaration.label} ${ROW_LABEL[capability]}`);
|
||||
slider.addEventListener("input", () => {
|
||||
const parsed = Number(slider.value);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
send(declaration, {
|
||||
deviceId: declaration.id,
|
||||
op: capability === "gain" ? "gain" : "volume",
|
||||
value: parsed,
|
||||
});
|
||||
});
|
||||
control = slider;
|
||||
input = slider;
|
||||
} else {
|
||||
const meter = doc.createElement("div");
|
||||
meter.className = "tera-device__meter";
|
||||
meter.setAttribute("role", "meter");
|
||||
meter.setAttribute("aria-valuemin", String(DEVICE_RANGES.level.min));
|
||||
meter.setAttribute("aria-valuemax", String(DEVICE_RANGES.level.max));
|
||||
const fill = doc.createElement("i");
|
||||
fill.style.width = "0%";
|
||||
meter.append(fill);
|
||||
meterFill = fill;
|
||||
control = meter;
|
||||
}
|
||||
|
||||
row.append(label, control, value);
|
||||
card.append(row);
|
||||
rows.push({ capability, shape, root: row, control, input, value, meterFill });
|
||||
}
|
||||
|
||||
// Per instrument, not once per panel. A disclosure at the top of a scrolling
|
||||
// list is a disclosure you have already scrolled past by the time you are
|
||||
// looking at the meter that needed it.
|
||||
const disclosure = doc.createElement("p");
|
||||
disclosure.className = "tera-device__disclosure";
|
||||
disclosure.textContent = declaration.disclosure;
|
||||
card.append(disclosure);
|
||||
|
||||
root.append(card);
|
||||
instruments.push({ declaration, rows });
|
||||
}
|
||||
|
||||
host.append(root);
|
||||
|
||||
function apply(states: readonly DeviceState[]): void {
|
||||
if (disposed) return;
|
||||
const byId = new Map(states.map((state) => [state.id, state]));
|
||||
latest.clear();
|
||||
for (const state of states) latest.set(state.id, state);
|
||||
|
||||
for (const instrument of instruments) {
|
||||
const state = byId.get(instrument.declaration.id) ?? null;
|
||||
for (const row of instrument.rows) {
|
||||
const reading = state === null ? undefined : state[CAPABILITY_READING[row.capability]];
|
||||
// `undefined` is "this device has no such reading", which is a different
|
||||
// statement from zero and gets a different treatment: the row goes away
|
||||
// rather than sitting there reporting a confident 0 dB.
|
||||
row.root.setAttribute("data-unavailable", String(reading === undefined));
|
||||
row.value.textContent = formatReading(row.capability, reading);
|
||||
|
||||
if (row.shape === "switch") {
|
||||
row.control.setAttribute("aria-pressed", String(reading === true));
|
||||
} else if (row.shape === "slider") {
|
||||
if (row.input !== null && typeof reading === "number" && Number.isFinite(reading)) {
|
||||
row.input.value = String(reading);
|
||||
}
|
||||
} else if (row.meterFill !== null) {
|
||||
const fraction = meterFraction(typeof reading === "number" ? reading : undefined);
|
||||
row.meterFill.style.width = `${(fraction * 100).toFixed(1)}%`;
|
||||
row.control.setAttribute(
|
||||
"aria-valuenow",
|
||||
typeof reading === "number" ? reading.toFixed(1) : String(DEVICE_RANGES.level.min),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything starts at rest rather than blank.
|
||||
*
|
||||
* `initialDeviceState` is a pure function of the declaration and is the same
|
||||
* one the simulator and the route start from, so the pre-connection panel is
|
||||
* not an invented placeholder — it is the honest answer to "what does this
|
||||
* device report when nobody has observed it": powered off, gain at its resting
|
||||
* value, meter at the floor, and `synthetic: true` whatever the declaration's
|
||||
* provenance claims. A panel of dashes would teach nothing about the
|
||||
* instrument; this teaches the whole of it before a single reading arrives.
|
||||
*/
|
||||
apply(options.declarations.map((declaration) => initialDeviceState(declaration, 0)));
|
||||
|
||||
return {
|
||||
root,
|
||||
apply,
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
root.remove();
|
||||
latest.clear();
|
||||
if (styleAdded) doc.querySelector(`#${STYLE_ID}`)?.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user