/** Accessible webcam-face controls. This view never acquires or retains media. */ export type WebcamFacePanelStatus = "off" | "requesting" | "active" | "error" | "unsupported"; export interface WebcamFacePanelState { status: WebcamFacePanelStatus; message: string; indicatorVisible: boolean; } export interface WebcamFacePanelOptions { /** Contextual controls, normally mounted inside the character editor. */ container: HTMLElement; /** Persistent chrome that remains visible after the character editor closes. */ indicatorContainer: HTMLElement; supported?: boolean; onStart: () => void | Promise; onStop: () => void; } export interface WebcamFacePanel { root: HTMLElement; indicator: HTMLElement; /** Buttons to include in a containing modal's focus trap. */ focusables: readonly HTMLElement[]; state(): WebcamFacePanelState; update(status: WebcamFacePanelStatus, message?: string): WebcamFacePanelState; /** Restore modal keyboard focus after a browser permission prompt closes. */ focusStop(): void; dispose(): void; } const STYLES = ` .tera-webcam-face { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hairline, rgba(255,255,255,.13)); } .tera-webcam-face__heading { margin: 0 0 4px; color: white; font: 600 13px/1.3 inherit; } .tera-webcam-face__copy { margin: 0 0 10px; color: var(--ink-2, rgba(255,255,255,.62)); } .tera-webcam-face__row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; } .tera-webcam-face__button { min-height: 40px; padding: 8px 14px; color: var(--ink, white); cursor: pointer; background: rgba(255,255,255,.07); border: 1px solid var(--hairline, rgba(255,255,255,.15)); border-radius: var(--r-sm, 5px); font: inherit; } .tera-webcam-face__button:hover:not(:disabled) { background: rgba(255,255,255,.12); } .tera-webcam-face__button:focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; } .tera-webcam-face__button:disabled { cursor: wait; opacity: .55; } .tera-webcam-face__status { margin: 0; color: var(--ink-2, rgba(255,255,255,.62)); } .tera-webcam-face[data-status="active"] .tera-webcam-face__status { color: #8fd7aa; } .tera-webcam-face[data-status="error"] .tera-webcam-face__status, .tera-webcam-face[data-status="unsupported"] .tera-webcam-face__status { color: #ffb2aa; } .tera-webcam-indicator { position: fixed; top: calc(var(--s4, 16px) + env(safe-area-inset-top) + 4.55rem); right: var(--s4, 16px); z-index: 12; display: flex; align-items: center; gap: 9px; padding: 8px 10px; color: #dff8e8; background: rgba(8, 32, 20, .92); border: 1px solid rgba(100, 220, 145, .45); border-radius: var(--r-sm, 5px); box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.35)); font: 11px/1.4 ui-monospace, "SF Mono", Menlo, monospace; } .tera-webcam-indicator__dot { width: 7px; height: 7px; border-radius: 50%; background: #65dc91; box-shadow: 0 0 0 3px rgba(101,220,145,.14); } .tera-webcam-indicator__stop { min-height: 30px; padding: 4px 8px; color: inherit; background: transparent; border: 1px solid rgba(143,215,170,.4); border-radius: 4px; font: inherit; cursor: pointer; } .tera-webcam-indicator__stop:focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; } @media (max-width: 600px) { .tera-webcam-indicator { top: calc(var(--s3, 12px) + env(safe-area-inset-top) + 5rem); right: var(--s3, 12px); } } `; const DEFAULT_MESSAGES: Record = { off: "Camera is off. Nothing is captured or stored.", requesting: "Waiting for camera permission…", active: "Live on your humanoid face. Never recorded or uploaded.", error: "Camera could not start. Check browser permission and try again.", unsupported: "Camera faces are not supported by this browser.", }; export function createWebcamFacePanel(options: WebcamFacePanelOptions): WebcamFacePanel { if (!options.container || typeof options.container.append !== "function" || !options.indicatorContainer || typeof options.indicatorContainer.append !== "function") { throw new RangeError("webcam face panel: valid containers are required"); } if (typeof options.onStart !== "function" || typeof options.onStop !== "function") { throw new RangeError("webcam face panel: start and stop handlers are required"); } const doc = options.container.ownerDocument; const root = doc.createElement("section"); root.className = "tera-webcam-face"; const style = doc.createElement("style"); style.setAttribute("data-tera-webcam-face-style", ""); style.textContent = STYLES; root.append(style); const heading = doc.createElement("h3"); heading.className = "tera-webcam-face__heading"; heading.textContent = "Live camera face"; const copy = doc.createElement("p"); copy.className = "tera-webcam-face__copy"; copy.textContent = "Optionally put your camera on this humanoid for this tab only. It is never saved, recorded, or uploaded."; const row = doc.createElement("div"); row.className = "tera-webcam-face__row"; const start = doc.createElement("button"); start.type = "button"; start.className = "tera-webcam-face__button"; start.setAttribute("data-action", "start-camera"); start.textContent = "Start camera"; const stop = doc.createElement("button"); stop.type = "button"; stop.className = "tera-webcam-face__button"; stop.setAttribute("data-action", "stop-camera"); stop.textContent = "Stop camera"; const status = doc.createElement("p"); status.className = "tera-webcam-face__status"; status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); row.append(start, stop, status); root.append(heading, copy, row); const indicator = doc.createElement("div"); indicator.className = "tera-webcam-indicator"; indicator.setAttribute("role", "region"); indicator.setAttribute("aria-label", "Camera active"); const dot = doc.createElement("span"); dot.className = "tera-webcam-indicator__dot"; dot.setAttribute("aria-hidden", "true"); const indicatorText = doc.createElement("span"); indicatorText.setAttribute("role", "status"); indicatorText.setAttribute("aria-live", "polite"); indicatorText.textContent = "Camera active · local face only"; const indicatorStop = doc.createElement("button"); indicatorStop.type = "button"; indicatorStop.className = "tera-webcam-indicator__stop"; indicatorStop.setAttribute("data-action", "stop-camera-indicator"); indicatorStop.textContent = "Stop"; indicator.append(dot, indicatorText, indicatorStop); options.container.append(root); options.indicatorContainer.append(indicator); let disposed = false; let current: WebcamFacePanelState = { status: options.supported === false ? "unsupported" : "off", message: DEFAULT_MESSAGES[options.supported === false ? "unsupported" : "off"], indicatorVisible: false, }; function snapshot(): WebcamFacePanelState { return { ...current }; } function render(): void { root.setAttribute("data-status", current.status); status.textContent = current.message; const active = current.status === "active"; const requesting = current.status === "requesting"; start.hidden = active; start.disabled = requesting || current.status === "unsupported"; stop.hidden = !active && !requesting; indicator.hidden = !current.indicatorVisible; // Starting capture completes asynchronously. Do not leave keyboard focus on // the Start button at the moment it becomes hidden; move it to the visible // in-dialog Stop control so Escape and the modal focus trap keep working. if (active) stop.focus(); } function stopIntent(): void { if (!disposed && (current.status === "active" || current.status === "requesting")) options.onStop(); } start.addEventListener("click", () => { if (!disposed && current.status !== "requesting" && current.status !== "active" && current.status !== "unsupported") { void options.onStart(); } }); stop.addEventListener("click", stopIntent); indicatorStop.addEventListener("click", stopIntent); render(); return { root, indicator, focusables: [start, stop], state: snapshot, update(next, message = DEFAULT_MESSAGES[next]) { if (disposed) return snapshot(); if (!(next in DEFAULT_MESSAGES) || typeof message !== "string") { throw new RangeError("webcam face panel: invalid state"); } current = { status: next, message, indicatorVisible: next === "active" }; render(); return snapshot(); }, focusStop() { if (!disposed && current.status === "active") stop.focus(); }, dispose() { if (disposed) return; disposed = true; root.remove(); indicator.remove(); }, }; }