1
0

feat: add ephemeral webcam faces and adaptive screens

This commit is contained in:
2026-08-11 22:38:56 -07:00
parent 3326d2e6d0
commit d4859b33c6
17 changed files with 1107 additions and 11 deletions
+190
View File
@@ -0,0 +1,190 @@
/** 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<void>;
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<WebcamFacePanelStatus, string> = {
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();
},
};
}