/**
* Mounts the overlay Vue app inside a closed shadow root appended to
* document.documentElement — the host page's CSS and CSP stay untouched.
*
* Styles strategy: Vite extracts everything imported by the content entry
* (kit.css, phosphor icons, SFC styles) into assets/style.css. At runtime we
* fetch it from the extension bundle, rewrite the kit's absolute /assets/ font
* URLs to extension URLs, and adopt it as a constructable stylesheet — no
* <style> injection into the page, so strict style-src pages stay happy.
*/
import { createApp, reactive } from "vue";
import browser from "webextension-polyfill";
import App from "./App.vue";
import "./overlay.css";
import "gnexus-ui-kit/dist/css/kit.css";
import "gnexus-ui-kit/dist/assets/fonts/phosphor-icons/src/css/icons.css";
export interface OverlayState {
mode: "idle" | "picker";
recording: boolean;
/** transient message shown in the flash toast (replay results etc.) */
flash: string | null;
}
export interface Overlay {
startPicker: () => void;
setRecorderState: (recording: boolean) => void;
setFlash: (text: string | null) => void;
destroy: () => void;
}
function rewriteKitAssetUrls(css: string): string {
const base = browser.runtime.getURL("");
return css
.replaceAll("url(/assets/", `url(${base}assets/`)
.replaceAll('url("/assets/', `url("${base}assets/`)
.replaceAll("url('/assets/", `url('${base}assets/`);
}
// same palette as the panel (packages/web/src/theme.css); overlay components
// reference these via var(--color-*, fallback) — the fallbacks stay in sync
const OVERLAY_VARS = {
"--color-bg": "#10121c",
"--color-surface": "#1f2335",
"--color-border": "#2f334d",
"--color-text": "#c0caf5",
"--color-text-dim": "#787c99",
"--color-accent": "#7aa2f7",
"--color-success": "#9ece6a",
"--color-warning": "#e0af68",
"--color-danger": "#f7768e",
};
export function mountOverlay(): Overlay {
const host = document.createElement("div");
host.id = "ltt-overlay-host";
host.style.cssText = "all: initial; position: fixed; inset: 0; z-index: 2147483647; pointer-events: none;";
document.documentElement.appendChild(host);
const shadowRoot = host.attachShadow({ mode: "closed" });
const mountPoint = document.createElement("div");
const vars = Object.entries(OVERLAY_VARS)
.map(([name, value]) => `${name}:${value};`)
.join("");
mountPoint.style.cssText = `all: initial; pointer-events: none; width: 100%; height: 100%; ${vars}`;
shadowRoot.appendChild(mountPoint);
const state = reactive<OverlayState>({ mode: "idle", recording: false, flash: null });
const app = createApp(App, { state });
app.mount(mountPoint);
let flashTimer: number | null = null;
// styles load asynchronously; the overlay works unstyled until then
void (async () => {
try {
const url = browser.runtime.getURL("assets/style.css");
const text = await (await fetch(url)).text();
const sheet = new CSSStyleSheet();
sheet.replaceSync(rewriteKitAssetUrls(text));
shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, sheet];
} catch {
// styles are cosmetic — the overlay must keep working without them
}
})();
// constructable stylesheets ignore @font-face (Chromium limitation), so the
// Phosphor font must be registered through the FontFace API instead
void (async () => {
try {
const font = new FontFace(
"Phosphor",
`url(${browser.runtime.getURL("assets/fonts/phosphor-icons/src/fonts/Phosphor.woff2")})`,
{ weight: "400", style: "normal", display: "block" }
);
await font.load();
document.fonts.add(font);
} catch {
// icons degrade to empty boxes; the overlay keeps working
}
})();
return {
startPicker: () => {
state.mode = "picker";
state.recording = false;
},
setRecorderState: (recording: boolean) => {
// only toggles the bar; picking stays opt-in via the bar's Note button
state.recording = recording;
if (!recording && state.mode === "picker") state.mode = "idle";
},
setFlash: (text: string | null) => {
state.flash = text;
if (flashTimer) window.clearTimeout(flashTimer);
if (text) flashTimer = window.setTimeout(() => (state.flash = null), 5000);
},
destroy: () => {
app.unmount();
host.remove();
},
};
}