1
0

feat: add character studio and aircraft foundation

This commit is contained in:
2026-08-11 19:32:32 -07:00
parent 1c37ef8f3e
commit c0c7fcf974
16 changed files with 2250 additions and 25 deletions
+427
View File
@@ -0,0 +1,427 @@
/**
* Accessible, dependency-free profile editor mounted into a caller-owned node.
*
* It emits JSON profile data for a separate preview renderer. It creates no
* canvas, Three.js object, webcam, storage, URL, or network request. User text
* is assigned only through input `.value` and `textContent`; never `innerHTML`.
*/
import {
ACCENTS,
BODY_SHAPES,
HAIR_COLORS,
OUTFITS,
SKIN_TONES,
createDefaultLocalProfile,
isLocalProfile,
type HumanoidAppearanceChoices,
type LocalProfile,
} from "./model.ts";
export interface ProfileEditorOptions {
container: HTMLElement;
profile: LocalProfile;
/** Used only to regenerate deterministic appearance defaults; never rendered or emitted. */
identityId: string;
onPreview?: (profile: LocalProfile) => void;
onSave?: (profile: LocalProfile) => void;
onCancel?: () => void;
}
export interface ProfileEditorState {
open: boolean;
dirty: boolean;
valid: boolean;
/** Last saved/caller-supplied profile. */
profile: LocalProfile;
/** Current form values. May have an invalid display name while `valid` is false. */
draft: LocalProfile;
}
export interface ProfileEditor {
/** Stable dialog node for host layout or integration tests. */
root: HTMLElement;
state(): ProfileEditorState;
/** Replace both committed and draft data through strict profile validation. */
update(profile: LocalProfile): ProfileEditorState;
open(): ProfileEditorState;
/** Hide without committing or discarding the draft. */
close(): ProfileEditorState;
dispose(): void;
}
type AppearanceField = keyof HumanoidAppearanceChoices;
const PROFILE_EDITOR_STYLES = `
.tera-profile-editor {
width: min(420px, calc(100vw - 32px));
max-height: min(720px, calc(100dvh - 32px));
overflow: auto;
color: var(--ink, rgba(255,255,255,.82));
background: var(--glass-strong, rgba(9,13,18,.9));
border: 1px solid var(--hairline, rgba(255,255,255,.13));
border-radius: var(--r, 8px);
box-shadow: var(--shadow, 0 8px 28px rgba(0,0,0,.45));
backdrop-filter: var(--blur, blur(14px));
padding: var(--s5, 24px);
font: 12px/1.5 ui-monospace, "SF Mono", Menlo, monospace;
}
.tera-profile-editor__title { margin: 0 0 4px; color: white; font: 600 16px/1.3 inherit; }
.tera-profile-editor__intro { margin: 0 0 20px; color: var(--ink-2, rgba(255,255,255,.62)); }
.tera-profile-editor__grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.tera-profile-editor__field { display: grid; gap: 6px; min-width: 0; }
.tera-profile-editor__field--wide { grid-column: 1 / -1; }
.tera-profile-editor__label { color: var(--ink, rgba(255,255,255,.82)); }
.tera-profile-editor__input {
width: 100%; min-height: 40px; padding: 8px 10px; color: white;
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-profile-editor__input:hover { border-color: rgba(255,255,255,.3); }
.tera-profile-editor__input:focus-visible, .tera-profile-editor__button:focus-visible {
outline: 2px solid var(--amber, #f2b134); outline-offset: 2px;
}
.tera-profile-editor__input[aria-invalid="true"] { border-color: #ff8b80; }
.tera-profile-editor__status { min-height: 18px; margin: 12px 0 0; color: #ffb2aa; }
.tera-profile-editor__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.tera-profile-editor__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-profile-editor__button:hover:not(:disabled) { background: rgba(255,255,255,.12); }
.tera-profile-editor__button--primary { color: #171109; background: var(--amber, #f2b134); border-color: transparent; font-weight: 700; }
.tera-profile-editor__button--primary:hover:not(:disabled) { background: var(--amber-lit, #ffc555); }
.tera-profile-editor__button:disabled { cursor: not-allowed; opacity: .42; }
@media (max-width: 460px) { .tera-profile-editor__grid { grid-template-columns: 1fr; } .tera-profile-editor__field--wide { grid-column: auto; } }
@media (prefers-reduced-motion: reduce) { .tera-profile-editor * { scroll-behavior: auto !important; } }
`;
const FIELD_OPTIONS: Readonly<Record<AppearanceField, readonly string[]>> = {
skinTone: SKIN_TONES,
outfit: OUTFITS,
accent: ACCENTS,
hair: HAIR_COLORS,
bodyShape: BODY_SHAPES,
};
const FIELD_LABELS: Readonly<Record<AppearanceField, string>> = {
skinTone: "Skin tone",
outfit: "Outfit",
accent: "Accent",
hair: "Hair",
bodyShape: "Body shape",
};
let editorSequence = 0;
function clone(profile: LocalProfile): LocalProfile {
return { ...profile, appearance: { ...profile.appearance } };
}
function presentChoice(value: string): string {
return value.replace(/(^|-)([a-z])/g, (_match, separator: string, letter: string) => `${separator}${letter.toUpperCase()}`);
}
function validDisplayName(value: string): boolean {
return (
value === value.trim() &&
value.length >= 1 &&
value.length <= 64 &&
!/[\u0000-\u001f\u007f]/u.test(value)
);
}
function sameProfile(a: LocalProfile, b: LocalProfile): boolean {
return (
a.version === b.version &&
a.displayName === b.displayName &&
a.appearance.skinTone === b.appearance.skinTone &&
a.appearance.outfit === b.appearance.outfit &&
a.appearance.accent === b.appearance.accent &&
a.appearance.hair === b.appearance.hair &&
a.appearance.bodyShape === b.appearance.bodyShape
);
}
function focusable(value: Element | null): value is HTMLElement {
return value !== null && typeof (value as HTMLElement).focus === "function";
}
export function createProfileEditor(options: ProfileEditorOptions): ProfileEditor {
if (!options.container || typeof options.container.append !== "function") {
throw new RangeError("profile editor: container must be an HTMLElement");
}
if (!isLocalProfile(options.profile)) throw new RangeError("profile editor: invalid profile");
if (typeof options.identityId !== "string" || options.identityId.length === 0) {
throw new RangeError("profile editor: identity id is required");
}
const doc = options.container.ownerDocument;
const number = ++editorSequence;
const titleId = `tera-profile-title-${number}`;
const descriptionId = `tera-profile-description-${number}`;
const statusId = `tera-profile-status-${number}`;
const root = doc.createElement("section");
root.className = "tera-profile-editor";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-labelledby", titleId);
root.setAttribute("aria-describedby", `${descriptionId} ${statusId}`);
root.setAttribute("aria-hidden", "true");
root.hidden = true;
const style = doc.createElement("style");
style.setAttribute("data-tera-profile-editor-style", "");
style.textContent = PROFILE_EDITOR_STYLES;
root.append(style);
const title = doc.createElement("h2");
title.id = titleId;
title.className = "tera-profile-editor__title";
title.textContent = "Your Tera character";
root.append(title);
const intro = doc.createElement("p");
intro.id = descriptionId;
intro.className = "tera-profile-editor__intro";
intro.textContent = "Choose how your procedural humanoid appears. Preview stays local to this page.";
root.append(intro);
const form = doc.createElement("form");
form.setAttribute("novalidate", "");
const grid = doc.createElement("div");
grid.className = "tera-profile-editor__grid";
form.append(grid);
const nameField = doc.createElement("div");
nameField.className = "tera-profile-editor__field tera-profile-editor__field--wide";
const nameLabel = doc.createElement("label");
const nameId = `tera-profile-name-${number}`;
nameLabel.className = "tera-profile-editor__label";
nameLabel.setAttribute("for", nameId);
nameLabel.textContent = "Display name";
const nameInput = doc.createElement("input");
nameInput.id = nameId;
nameInput.className = "tera-profile-editor__input";
nameInput.setAttribute("data-field", "displayName");
nameInput.setAttribute("aria-describedby", statusId);
nameInput.type = "text";
nameInput.maxLength = 64;
nameInput.autocomplete = "name";
nameField.append(nameLabel, nameInput);
grid.append(nameField);
const selects = {} as Record<AppearanceField, HTMLSelectElement>;
const appearanceFields = Object.keys(FIELD_OPTIONS) as AppearanceField[];
for (const field of appearanceFields) {
const wrapper = doc.createElement("div");
wrapper.className = "tera-profile-editor__field";
const label = doc.createElement("label");
const id = `tera-profile-${field}-${number}`;
label.className = "tera-profile-editor__label";
label.setAttribute("for", id);
label.textContent = FIELD_LABELS[field];
const select = doc.createElement("select");
select.id = id;
select.className = "tera-profile-editor__input";
select.setAttribute("data-field", field);
for (const choice of FIELD_OPTIONS[field]) {
const option = doc.createElement("option");
option.value = choice;
option.textContent = presentChoice(choice);
select.append(option);
}
selects[field] = select;
wrapper.append(label, select);
grid.append(wrapper);
}
const status = doc.createElement("p");
status.id = statusId;
status.className = "tera-profile-editor__status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
form.append(status);
const actions = doc.createElement("div");
actions.className = "tera-profile-editor__actions";
const resetButton = button(doc, "Reset appearance", "reset");
const cancelButton = button(doc, "Cancel", "cancel");
const saveButton = button(doc, "Save profile", "save", true);
actions.append(resetButton, cancelButton, saveButton);
form.append(actions);
root.append(form);
options.container.append(root);
const focusOrder: HTMLElement[] = [nameInput, ...appearanceFields.map((field) => selects[field]), resetButton, cancelButton, saveButton];
let committed = clone(options.profile);
let draft = clone(options.profile);
let isOpen = false;
let disposed = false;
let returnFocus: HTMLElement | null = null;
function valid(): boolean {
return validDisplayName(draft.displayName) && isLocalProfile(draft);
}
function snapshot(): ProfileEditorState {
return {
open: isOpen,
dirty: !sameProfile(committed, draft),
valid: valid(),
profile: clone(committed),
draft: clone(draft),
};
}
function render(): void {
nameInput.value = draft.displayName;
for (const field of appearanceFields) selects[field].value = draft.appearance[field];
const isValid = valid();
nameInput.setAttribute("aria-invalid", isValid ? "false" : "true");
saveButton.disabled = !isValid;
status.textContent = isValid ? "" : "Enter a name from 1 to 64 characters without leading or trailing spaces.";
}
function emitPreview(): void {
if (valid()) options.onPreview?.(clone(draft));
}
function assignField(field: AppearanceField, value: string): void {
if (!(FIELD_OPTIONS[field] as readonly string[]).includes(value)) return;
draft = { ...draft, appearance: { ...draft.appearance, [field]: value } } as LocalProfile;
render();
emitPreview();
}
function save(): void {
if (!valid()) {
render();
nameInput.focus();
return;
}
committed = clone(draft);
options.onSave?.(clone(committed));
close();
}
function cancel(): void {
draft = clone(committed);
render();
emitPreview();
options.onCancel?.();
close();
}
function close(): ProfileEditorState {
if (!isOpen || disposed) return snapshot();
isOpen = false;
root.hidden = true;
root.setAttribute("aria-hidden", "true");
const target = returnFocus;
returnFocus = null;
target?.focus();
return snapshot();
}
nameInput.addEventListener("input", () => {
if (disposed) return;
draft = { ...draft, displayName: nameInput.value };
render();
emitPreview();
});
for (const field of appearanceFields) {
selects[field].addEventListener("change", () => {
if (!disposed) assignField(field, selects[field].value);
});
}
resetButton.addEventListener("click", () => {
if (disposed) return;
const defaults = createDefaultLocalProfile(options.identityId, committed.displayName);
draft = { ...defaults, displayName: draft.displayName };
render();
emitPreview();
});
cancelButton.addEventListener("click", () => {
if (!disposed) cancel();
});
saveButton.addEventListener("click", () => {
if (!disposed) save();
});
form.addEventListener("submit", (event) => {
event.preventDefault();
if (!disposed) save();
});
root.addEventListener("keydown", (event) => {
if (disposed || !isOpen) return;
if (event.key === "Escape") {
event.preventDefault();
cancel();
return;
}
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
save();
return;
}
if (event.key !== "Tab") return;
const enabled = focusOrder.filter((element) => !((element as HTMLButtonElement).disabled));
const first = enabled[0];
const last = enabled[enabled.length - 1];
if (!first || !last) return;
if (event.shiftKey && doc.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && doc.activeElement === last) {
event.preventDefault();
first.focus();
}
});
render();
return {
root,
state: snapshot,
update(profile) {
if (disposed) return snapshot();
if (!isLocalProfile(profile)) throw new RangeError("profile editor: invalid profile");
committed = clone(profile);
draft = clone(profile);
render();
emitPreview();
return snapshot();
},
open() {
if (disposed || isOpen) return snapshot();
isOpen = true;
root.hidden = false;
root.setAttribute("aria-hidden", "false");
returnFocus = focusable(doc.activeElement) ? doc.activeElement : null;
nameInput.focus();
emitPreview();
return snapshot();
},
close,
dispose() {
if (disposed) return;
if (isOpen) close();
disposed = true;
root.remove();
},
};
}
function button(
doc: Document,
label: string,
action: "reset" | "cancel" | "save",
primary = false,
): HTMLButtonElement {
const element = doc.createElement("button");
element.type = "button";
element.className = `tera-profile-editor__button${primary ? " tera-profile-editor__button--primary" : ""}`;
element.setAttribute("data-action", action);
element.textContent = label;
return element;
}
+16
View File
@@ -49,3 +49,19 @@ export {
type WebcamFaceConsentStatus,
type WebcamFaceConsentTransition,
} from "./webcamConsent.ts";
export {
createProfileEditor,
type ProfileEditor,
type ProfileEditorOptions,
type ProfileEditorState,
} from "./editor.ts";
export {
createWebcamFaceTexture,
type WebcamFaceTextureAdapter,
type WebcamFaceTextureBinding,
type WebcamFaceTextureOptions,
type WebcamFaceTextureState,
type WebcamFaceTextureStatus,
} from "./webcamFaceTexture.ts";
+153
View File
@@ -0,0 +1,153 @@
/**
* Ephemeral webcam-face texture binding behind explicit consent.
*
* This adapter has no acquisition path: no `getUserMedia`, URL, fetch, canvas,
* recorder, upload, or storage API. A caller that already owns a stream and a
* video element may bind them only while the existing consent controller says
* active. The adapter owns the resulting Three.js texture and nothing else.
*/
import * as THREE from "three";
import type { WebcamFaceConsentController } from "./webcamConsent.ts";
export type WebcamFaceTextureStatus = "off" | "active" | "disposed";
export interface WebcamFaceTextureState {
status: WebcamFaceTextureStatus;
active: boolean;
/** UI must show this whenever live webcam pixels are available to the scene. */
indicatorVisible: boolean;
consentRevision: number;
readonly ephemeral: true;
readonly persistence: "none";
readonly recording: false;
readonly uploading: false;
}
export interface WebcamFaceTextureBinding {
/** Caller-owned and never stopped. */
stream: MediaStream;
/** Caller-owned and never removed, played, paused, or disposed. */
video: HTMLVideoElement;
}
export interface WebcamFaceTextureAdapter {
state(): WebcamFaceTextureState;
/**
* Creates one adapter-owned `VideoTexture`. The caller must already have
* attached the stream to the video and started playback after user consent.
*/
bind(binding: WebcamFaceTextureBinding): THREE.VideoTexture;
/** Returns null and clears immediately when consent is no longer active. */
texture(): THREE.VideoTexture | null;
/** Explicitly reconcile a consent transition and return the new state. */
sync(): WebcamFaceTextureState;
clear(): WebcamFaceTextureState;
dispose(): void;
}
export interface WebcamFaceTextureOptions {
consent: WebcamFaceConsentController;
/** Active-indicator hook; fires only when the visible state changes. */
onIndicatorChange?: (state: WebcamFaceTextureState) => void;
}
export function createWebcamFaceTexture(options: WebcamFaceTextureOptions): WebcamFaceTextureAdapter {
if (!options.consent || typeof options.consent.state !== "function") {
throw new RangeError("webcam face texture: consent controller is required");
}
let texture: THREE.VideoTexture | null = null;
let disposed = false;
let lastIndicator = false;
function snapshot(): WebcamFaceTextureState {
const consent = options.consent.state();
const active = !disposed && texture !== null && consent.status === "active" && consent.consentGranted;
return {
status: disposed ? "disposed" : active ? "active" : "off",
active,
indicatorVisible: active,
consentRevision: consent.revision,
ephemeral: true,
persistence: "none",
recording: false,
uploading: false,
};
}
function announce(): WebcamFaceTextureState {
const state = snapshot();
if (state.indicatorVisible !== lastIndicator) {
lastIndicator = state.indicatorVisible;
options.onIndicatorChange?.({ ...state });
}
return state;
}
function release(): void {
if (!texture) return;
// `VideoTexture.dispose()` releases only renderer resources. It does not
// stop tracks or dispose/mutate the caller's video element.
texture.dispose();
texture = null;
}
function reconcile(): WebcamFaceTextureState {
const consent = options.consent.state();
if (texture && (consent.status !== "active" || !consent.consentGranted)) release();
return announce();
}
return {
state: reconcile,
bind(binding) {
if (disposed) throw new Error("webcam face texture: adapter is disposed");
const consent = options.consent.state();
if (consent.status !== "active" || !consent.consentGranted) {
throw new Error("webcam face texture: explicit active consent is required");
}
validateBinding(binding);
release();
texture = new THREE.VideoTexture(binding.video);
texture.name = "ephemeral-webcam-face";
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
announce();
return texture;
},
texture() {
reconcile();
return texture;
},
sync: reconcile,
clear() {
if (!disposed) release();
return announce();
},
dispose() {
if (disposed) return;
release();
disposed = true;
announce();
},
};
}
function validateBinding(binding: WebcamFaceTextureBinding): void {
if (!binding || typeof binding !== "object") throw new TypeError("webcam face texture: binding is required");
const stream = binding.stream as MediaStream | undefined;
const video = binding.video as HTMLVideoElement | undefined;
if (!stream || typeof stream.getVideoTracks !== "function") {
throw new TypeError("webcam face texture: caller-owned MediaStream is required");
}
const tracks = stream.getVideoTracks();
if (tracks.length === 0) throw new TypeError("webcam face texture: stream has no video track");
if (!video || typeof video !== "object" || !("srcObject" in video)) {
throw new TypeError("webcam face texture: caller-owned HTMLVideoElement is required");
}
if (video.srcObject !== stream) {
throw new TypeError("webcam face texture: caller must attach the supplied stream to the video first");
}
}