feat: add ephemeral webcam faces and adaptive screens
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user