diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..84c7d2d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,25 @@ + + + + + + + + Persona.OS — Identity Matrix + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ba92369 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "persona-interaction-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "persona-interaction-frontend", + "version": "0.1.0", + "dependencies": { + "three": "^0.184.0" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..424e975 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,15 @@ +{ + "name": "persona-interaction-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "node scripts/dev-server.mjs", + "test": "node --test tests/*.test.js", + "build": "node scripts/build.mjs", + "smoke": "node scripts/smoke-test.mjs" + }, + "dependencies": { + "three": "^0.184.0" + } +} diff --git a/frontend/public/assets/custom-chrome.png b/frontend/public/assets/custom-chrome.png new file mode 100644 index 0000000..a2c747b Binary files /dev/null and b/frontend/public/assets/custom-chrome.png differ diff --git a/frontend/public/assets/jarvis-core.png b/frontend/public/assets/jarvis-core.png new file mode 100644 index 0000000..57cb2c3 Binary files /dev/null and b/frontend/public/assets/jarvis-core.png differ diff --git a/frontend/public/assets/johnny-silverhand.png b/frontend/public/assets/johnny-silverhand.png new file mode 100644 index 0000000..dac83c9 Binary files /dev/null and b/frontend/public/assets/johnny-silverhand.png differ diff --git a/frontend/public/assets/v2/card-jarvis-optics.png b/frontend/public/assets/v2/card-jarvis-optics.png new file mode 100644 index 0000000..b0d448b Binary files /dev/null and b/frontend/public/assets/v2/card-jarvis-optics.png differ diff --git a/frontend/public/assets/v2/card-johnny-noise.png b/frontend/public/assets/v2/card-johnny-noise.png new file mode 100644 index 0000000..3d5d897 Binary files /dev/null and b/frontend/public/assets/v2/card-johnny-noise.png differ diff --git a/frontend/public/assets/v2/card-self-mercury.png b/frontend/public/assets/v2/card-self-mercury.png new file mode 100644 index 0000000..918f929 Binary files /dev/null and b/frontend/public/assets/v2/card-self-mercury.png differ diff --git a/frontend/public/assets/v2/card-winter-memory.png b/frontend/public/assets/v2/card-winter-memory.png new file mode 100644 index 0000000..2f42727 Binary files /dev/null and b/frontend/public/assets/v2/card-winter-memory.png differ diff --git a/frontend/public/assets/v2/custom-silver-dream-arm.png b/frontend/public/assets/v2/custom-silver-dream-arm.png new file mode 100644 index 0000000..a4be033 Binary files /dev/null and b/frontend/public/assets/v2/custom-silver-dream-arm.png differ diff --git a/frontend/public/assets/v2/jarvis-silver-dream.png b/frontend/public/assets/v2/jarvis-silver-dream.png new file mode 100644 index 0000000..02c7177 Binary files /dev/null and b/frontend/public/assets/v2/jarvis-silver-dream.png differ diff --git a/frontend/public/assets/v2/johnny-silver-dream.png b/frontend/public/assets/v2/johnny-silver-dream.png new file mode 100644 index 0000000..1b5a9eb Binary files /dev/null and b/frontend/public/assets/v2/johnny-silver-dream.png differ diff --git a/frontend/public/assets/v2/winter-silver-dream.png b/frontend/public/assets/v2/winter-silver-dream.png new file mode 100644 index 0000000..2f02919 Binary files /dev/null and b/frontend/public/assets/v2/winter-silver-dream.png differ diff --git a/frontend/public/assets/v3/crystal-chamber.png b/frontend/public/assets/v3/crystal-chamber.png new file mode 100644 index 0000000..dd95e69 Binary files /dev/null and b/frontend/public/assets/v3/crystal-chamber.png differ diff --git a/frontend/public/assets/v3/jarvis-reactor.png b/frontend/public/assets/v3/jarvis-reactor.png new file mode 100644 index 0000000..a262f73 Binary files /dev/null and b/frontend/public/assets/v3/jarvis-reactor.png differ diff --git a/frontend/public/assets/v3/johnny-exact.png b/frontend/public/assets/v3/johnny-exact.png new file mode 100644 index 0000000..1f92208 Binary files /dev/null and b/frontend/public/assets/v3/johnny-exact.png differ diff --git a/frontend/public/assets/winter-soldier.png b/frontend/public/assets/winter-soldier.png new file mode 100644 index 0000000..c8ac980 Binary files /dev/null and b/frontend/public/assets/winter-soldier.png differ diff --git a/frontend/scripts/build.mjs b/frontend/scripts/build.mjs new file mode 100644 index 0000000..24e2d4c --- /dev/null +++ b/frontend/scripts/build.mjs @@ -0,0 +1,29 @@ +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(fileURLToPath(new URL(".", import.meta.url)), ".."); +const dist = join(root, "dist"); +const required = ["index.html", "src/main.js", "src/styles.css"]; +const threeModule = join(root, "node_modules/three/build/three.module.js"); + +for (const path of required) { + if (!existsSync(join(root, path))) { + throw new Error(`Missing required frontend file: ${path}`); + } +} + +rmSync(dist, { recursive: true, force: true }); +mkdirSync(dist, { recursive: true }); +cpSync(join(root, "index.html"), join(dist, "index.html")); +cpSync(join(root, "src"), join(dist, "src"), { recursive: true }); + +if (existsSync(join(root, "public"))) { + cpSync(join(root, "public"), join(dist, "public"), { recursive: true }); +} + +const threeTarget = join(dist, "node_modules/three/build"); +mkdirSync(threeTarget, { recursive: true }); +cpSync(threeModule, join(threeTarget, "three.module.js")); + +console.log("Static build prepared in dist/"); diff --git a/frontend/scripts/dev-server.mjs b/frontend/scripts/dev-server.mjs new file mode 100644 index 0000000..9a1ecae --- /dev/null +++ b/frontend/scripts/dev-server.mjs @@ -0,0 +1,36 @@ +import { createReadStream, existsSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = normalize(join(fileURLToPath(new URL(".", import.meta.url)), "..")); +const port = Number(process.env.PORT || 4173); + +const mime = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", +}; + +createServer((request, response) => { + const rawPath = decodeURIComponent(new URL(request.url, "http://localhost").pathname); + const requested = rawPath === "/" ? "/index.html" : rawPath; + const filePath = normalize(join(root, requested)); + + if (!filePath.startsWith(root) || !existsSync(filePath) || !statSync(filePath).isFile()) { + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found"); + return; + } + + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": mime[extname(filePath)] || "application/octet-stream", + }); + createReadStream(filePath).pipe(response); +}).listen(port, "0.0.0.0", () => { + console.log(`Persona.OS preview: http://0.0.0.0:${port}`); +}); diff --git a/frontend/scripts/smoke-test.mjs b/frontend/scripts/smoke-test.mjs new file mode 100644 index 0000000..165f709 --- /dev/null +++ b/frontend/scripts/smoke-test.mjs @@ -0,0 +1,37 @@ +const baseUrl = process.env.PREVIEW_URL || "http://127.0.0.1:4173"; + +const checks = [ + ["/", "Persona.OS — Identity Matrix"], + ["/src/main.js", "mountChatView"], + ["/src/styles.css", ".crystal-chamber"], + ["/src/components/crystal-field.js", "class CrystalField"], + ["/src/components/persona-select-view.js", "projected-arm-layer"], + ["/src/components/xyz-editor-view.js", "mountXyzEditor"], + ["/src/components/chat-view.js", "mountChatView"], + ["/node_modules/three/build/three.module.js", "WebGLRenderer"], + ["/public/assets/v2/winter-silver-dream.png", null], + ["/public/assets/v2/custom-silver-dream-arm.png", null], + ["/public/assets/v3/johnny-exact.png", null], + ["/public/assets/v3/jarvis-reactor.png", null], + ["/public/assets/v3/crystal-chamber.png", null], + ["/public/assets/v2/card-winter-memory.png", null], + ["/public/assets/v2/card-johnny-noise.png", null], + ["/public/assets/v2/card-jarvis-optics.png", null], + ["/public/assets/v2/card-self-mercury.png", null], +]; + +for (const [path, expected] of checks) { + const response = await fetch(`${baseUrl}${path}`); + if (!response.ok) { + throw new Error(`${path} returned ${response.status}`); + } + + if (expected) { + const content = await response.text(); + if (!content.includes(expected)) { + throw new Error(`${path} did not include ${expected}`); + } + } +} + +console.log(`Smoke test passed against ${baseUrl}`); diff --git a/frontend/src/adapters/mock-agent-adapter.js b/frontend/src/adapters/mock-agent-adapter.js new file mode 100644 index 0000000..235965c --- /dev/null +++ b/frontend/src/adapters/mock-agent-adapter.js @@ -0,0 +1,34 @@ +const RESPONSES = { + winter: [ + "收到。我会先判断风险,再给你最短的行动路线。", + "环境稳定。把目标说清楚,剩下的交给我。", + ], + johnny: [ + "规矩不是答案。你先说想打破哪一条。", + "听见了。别给我漂亮话,给我真正的问题。", + ], + jarvis: [ + "系统已就绪。我会为你整理信息,并保留必要的判断依据。", + "当然。建议先确定优先级,然后逐项处理。", + ], + custom: [ + "我正在按照你设定的人格坐标回应。告诉我,此刻你最想讨论什么?", + "人格参数已同步。我们可以从一个具体问题开始。", + ], +}; + +export function createMockAgentAdapter({ + delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +} = {}) { + let responseIndex = 0; + + return { + async reply({ personaId }) { + await delay(620); + const options = RESPONSES[personaId] ?? RESPONSES.custom; + const response = options[responseIndex % options.length]; + responseIndex += 1; + return response; + }, + }; +} diff --git a/frontend/src/adapters/mock-media-adapter.js b/frontend/src/adapters/mock-media-adapter.js new file mode 100644 index 0000000..a184ed4 --- /dev/null +++ b/frontend/src/adapters/mock-media-adapter.js @@ -0,0 +1,31 @@ +export function createMockMediaAdapter({ + delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +} = {}) { + let state = "idle"; + const listeners = new Set(); + + function update(next) { + state = next; + listeners.forEach((listener) => listener(state)); + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + startRecording() { + if (state !== "idle") return false; + update("recording"); + return true; + }, + async stopRecording() { + if (state !== "recording") return ""; + update("transcribing"); + await delay(760); + update("idle"); + return "你好,我想了解现在的人格状态。"; + }, + }; +} diff --git a/frontend/src/components/chat-view.js b/frontend/src/components/chat-view.js new file mode 100644 index 0000000..66b5a95 --- /dev/null +++ b/frontend/src/components/chat-view.js @@ -0,0 +1,264 @@ +import { createMockAgentAdapter } from "../adapters/mock-agent-adapter.js"; +import { createMockMediaAdapter } from "../adapters/mock-media-adapter.js"; +import { createChatSession } from "../lib/chat-session.js"; +import { describeProfile } from "../lib/persona-profile.js"; +import { getPersona } from "../lib/personas.js"; + +const sessions = new Map(); +const agentAdapter = createMockAgentAdapter(); + +const VISION_STATES = [ + { key: "searching", label: "未检测到用户", detail: "VISION SEARCHING" }, + { key: "detected", label: "已检测到用户", detail: "PRESENCE DETECTED" }, + { key: "greeted", label: "已完成问候", detail: "GREETING COMPLETE" }, +]; + +const PROMPT_DECK = [ + { index: "01", label: "读取当前人格状态", prompt: "读取我当前的人格坐标,并告诉我它会怎样影响你的回应。" }, + { index: "02", label: "开始一次深度对话", prompt: "我们开始一次不回避真实想法的对话。" }, + { index: "03", label: "生成今日意识简报", prompt: "根据当前人格状态,为我生成一份简短的今日意识简报。" }, +]; + +function getSession(persona) { + if (sessions.has(persona.id)) return sessions.get(persona.id); + const session = createChatSession({ + personaId: persona.id, + agentAdapter, + initialMessages: [{ + id: `${persona.id}-welcome`, + role: "assistant", + content: { + winter: "连接稳定。说吧,你需要我处理什么?", + johnny: "信号接通了。别浪费时间,说点真的。", + jarvis: "晚上好。系统在线,我已准备好协助你。", + custom: "人格坐标已经加载。我们从哪里开始?", + }[persona.id], + status: "complete", + createdAt: Date.now(), + }], + }); + sessions.set(persona.id, session); + return session; +} + +function formatTime(timestamp) { + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(timestamp)); +} + +function createMessageElement(message, persona) { + const article = document.createElement("article"); + article.className = `chat-message chat-message--${message.role}`; + article.dataset.messageId = message.id; + + const label = document.createElement("div"); + label.className = "chat-message__meta"; + const name = document.createElement("span"); + name.textContent = message.role === "user" ? "YOU / LOCAL" : persona.english; + const time = document.createElement("time"); + time.dateTime = new Date(message.createdAt).toISOString(); + time.textContent = formatTime(message.createdAt); + label.append(name, time); + + const content = document.createElement("p"); + content.textContent = message.content; + article.append(label, content); + return article; +} + +export function mountChatView({ root, appState }) { + const state = appState.getState(); + const persona = getPersona(state.personaId) ?? getPersona("custom"); + const session = getSession(persona); + const mediaAdapter = createMockMediaAdapter(); + let visionIndex = 0; + + root.innerHTML = ` +
+ + +
+ +
AD·VXCONSCIOUSNESS CHANNEL
+

CHANNEL SECURE 03 / 04

+
+ + + +
+
+
+ LIVE CONVERSATION / ${persona.number} +

意识通道

+
+

RESPONSE MODEL READY

+
+
+
+ ${PROMPT_DECK.map((item) => ` + + `).join("")} +
+ + +
+ + + +
+
+ + + ENTER 发送 · SHIFT + ENTER 换行 +
+ + +
+
+ `; + + const thread = root.querySelector(".chat-thread"); + const generating = root.querySelector(".chat-generating"); + const error = root.querySelector(".chat-error"); + const input = root.querySelector("#chat-input"); + const sendButton = root.querySelector(".send-button"); + const micButton = root.querySelector(".mic-button"); + const visionButton = root.querySelector(".vision-cycle"); + + function renderSession(nextState = session.getState()) { + thread.replaceChildren( + ...nextState.messages.map((message) => createMessageElement(message, persona)), + ); + generating.hidden = !nextState.generating; + sendButton.disabled = nextState.generating; + input.disabled = nextState.generating; + error.hidden = !nextState.error; + error.textContent = nextState.error ?? ""; + thread.scrollTop = thread.scrollHeight; + root.querySelector(".chat-prompt-deck").hidden = + nextState.messages.some((message) => message.role === "user"); + } + + async function sendMessage(value = input.value) { + const content = String(value ?? "").trim(); + if (!content) return; + input.value = ""; + await session.send(content); + input.focus(); + } + + async function handleSubmit(event) { + event.preventDefault(); + await sendMessage(); + } + + function handleInputKeydown(event) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + root.querySelector(".chat-composer").requestSubmit(); + } + } + + async function handleMicrophone() { + const mediaState = mediaAdapter.getState(); + if (mediaState === "idle") { + mediaAdapter.startRecording(); + return; + } + if (mediaState === "recording") { + const transcript = await mediaAdapter.stopRecording(); + if (transcript) await sendMessage(transcript); + } + } + + function renderMediaState(nextState) { + micButton.dataset.state = nextState; + micButton.setAttribute( + "aria-label", + nextState === "recording" ? "停止录音" : "开始录音", + ); + const label = micButton.querySelector("span"); + label.childNodes[0].textContent = { + idle: "语音", + recording: "录音中", + transcribing: "转写中", + }[nextState]; + } + + function cycleVision() { + visionIndex = (visionIndex + 1) % VISION_STATES.length; + const vision = VISION_STATES[visionIndex]; + visionButton.dataset.state = vision.key; + const status = visionButton.querySelector(".side-status span"); + status.childNodes[0].textContent = vision.label; + status.querySelector("b").textContent = vision.detail; + } + + const unsubscribeSession = session.subscribe(renderSession); + const unsubscribeMedia = mediaAdapter.subscribe(renderMediaState); + root.querySelector(".chat-composer").addEventListener("submit", handleSubmit); + input.addEventListener("keydown", handleInputKeydown); + micButton.addEventListener("click", handleMicrophone); + visionButton.addEventListener("click", cycleVision); + root.querySelectorAll("[data-prompt]").forEach((button) => { + button.addEventListener("click", () => sendMessage(button.dataset.prompt)); + }); + root.querySelector(".workspace-back").addEventListener("click", () => { + appState.closeWorkspace(); + }); + + renderSession(); + renderMediaState("idle"); + input.focus(); + + return () => { + unsubscribeSession(); + unsubscribeMedia(); + }; +} diff --git a/frontend/src/components/crystal-field.js b/frontend/src/components/crystal-field.js new file mode 100644 index 0000000..a47216a --- /dev/null +++ b/frontend/src/components/crystal-field.js @@ -0,0 +1,152 @@ +import { createCrystalShards } from "../lib/crystal-shards.js"; + +export class CrystalField { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d", { alpha: true }); + this.pointer = { x: 0, y: 0 }; + this.targetPointer = { x: 0, y: 0 }; + this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(canvas); + this.resize(); + this.animate = this.animate.bind(this); + this.frame = requestAnimationFrame(this.animate); + } + + resize() { + const ratio = Math.min(window.devicePixelRatio || 1, 2); + const bounds = this.canvas.getBoundingClientRect(); + this.width = Math.max(1, bounds.width); + this.height = Math.max(1, bounds.height); + this.canvas.width = Math.round(this.width * ratio); + this.canvas.height = Math.round(this.height * ratio); + this.context.setTransform(ratio, 0, 0, ratio, 0, 0); + this.shards = createCrystalShards({ + count: this.width < 1200 ? 28 : 36, + seed: 20260725, + }); + } + + setPointer(x, y) { + this.targetPointer.x = Math.max(-0.5, Math.min(0.5, x)); + this.targetPointer.y = Math.max(-0.5, Math.min(0.5, y)); + } + + drawHorizon() { + const { context, width, height, pointer } = this; + const x = width * (0.6 + pointer.x * 0.025); + const y = height * (0.73 + pointer.y * 0.012); + const radius = width * 0.36; + const glow = context.createRadialGradient(x, y, 0, x, y, radius); + glow.addColorStop(0, "rgba(205, 236, 255, 0.22)"); + glow.addColorStop(0.18, "rgba(118, 190, 230, 0.09)"); + glow.addColorStop(0.52, "rgba(45, 93, 125, 0.035)"); + glow.addColorStop(1, "rgba(0, 0, 0, 0)"); + context.fillStyle = glow; + context.fillRect(x - radius, y - radius, radius * 2, radius * 2); + } + + drawShard(shard, index, time) { + const { context, width, height, pointer } = this; + const depthScale = 0.62 + shard.depth * 0.24; + const direction = index % 4 === 0 ? -1 : 1; + const travel = time * shard.speed * direction; + const normalizedY = ((shard.y + travel) % 1 + 1) % 1; + const pulse = Math.sin(time * 0.00045 * (1 + shard.depth * 0.18) + shard.phase); + const parallax = (0.45 + shard.depth * 0.28) * 18; + const x = shard.x * width + pointer.x * parallax; + const y = normalizedY * height + pointer.y * parallax * 0.55; + const shardWidth = Math.max(2, width * shard.width * depthScale * (0.32 + Math.abs(pulse) * 0.68)); + const shardHeight = height * shard.height * depthScale; + const opacity = shard.opacity * (0.68 + shard.depth * 0.18); + const gradient = context.createLinearGradient(-shardWidth / 2, 0, shardWidth / 2, 0); + + gradient.addColorStop(0, `rgba(36, 64, 84, ${opacity * 0.3})`); + gradient.addColorStop(0.22, `rgba(168, 216, 242, ${opacity * 0.72})`); + gradient.addColorStop(0.48, `rgba(239, 249, 255, ${opacity})`); + gradient.addColorStop(0.58, `rgba(78, 135, 168, ${opacity * 0.7})`); + gradient.addColorStop(1, `rgba(9, 20, 30, ${opacity * 0.15})`); + + context.save(); + context.translate(x, y); + context.rotate(shard.tilt + pulse * 0.035); + context.filter = `blur(${shard.blur}px)`; + context.fillStyle = gradient; + context.shadowColor = "rgba(151, 220, 255, 0.28)"; + context.shadowBlur = shard.depth === 2 ? 14 : 5; + context.fillRect(-shardWidth / 2, -shardHeight / 2, shardWidth, shardHeight); + + context.filter = "none"; + context.lineWidth = 0.55; + context.strokeStyle = `rgba(225, 246, 255, ${opacity * 1.45})`; + context.strokeRect(-shardWidth / 2, -shardHeight / 2, shardWidth, shardHeight); + + if (shard.depth > 0 && shardWidth > 8) { + context.strokeStyle = `rgba(219, 242, 255, ${opacity * 0.72})`; + context.beginPath(); + context.moveTo(-shardWidth * 0.38, -shardHeight * 0.24); + context.lineTo(shardWidth * 0.08, -shardHeight * 0.02); + context.lineTo(-shardWidth * 0.18, shardHeight * 0.31); + context.moveTo(shardWidth * 0.1, -shardHeight * 0.02); + context.lineTo(shardWidth * 0.36, shardHeight * 0.14); + context.stroke(); + } + + context.restore(); + } + + drawReflections(time) { + const { context, width, height } = this; + const horizon = height * 0.73; + const reflection = context.createLinearGradient(0, horizon, 0, height); + reflection.addColorStop(0, "rgba(135, 211, 252, 0.055)"); + reflection.addColorStop(0.28, "rgba(81, 148, 189, 0.026)"); + reflection.addColorStop(1, "rgba(0, 0, 0, 0)"); + context.fillStyle = reflection; + context.fillRect(0, horizon, width, height - horizon); + + for (let index = 0; index < 11; index += 1) { + const progress = index / 10; + const y = horizon + progress ** 1.65 * (height - horizon); + const shift = Math.sin(time * 0.00034 + index) * width * 0.025; + context.strokeStyle = `rgba(184, 226, 249, ${0.018 + progress * 0.026})`; + context.lineWidth = 0.6; + context.beginPath(); + context.moveTo(width * 0.21 + shift, y); + context.bezierCurveTo( + width * 0.38, + y - 2.5, + width * 0.61, + y + 2.5, + width * 0.87 + shift, + y, + ); + context.stroke(); + } + } + + render(time = 0) { + this.context.clearRect(0, 0, this.width, this.height); + this.context.globalCompositeOperation = "screen"; + this.drawHorizon(); + this.shards.forEach((shard, index) => this.drawShard(shard, index, time)); + this.drawReflections(time); + this.context.globalCompositeOperation = "source-over"; + } + + animate(time) { + this.pointer.x += (this.targetPointer.x - this.pointer.x) * 0.035; + this.pointer.y += (this.targetPointer.y - this.pointer.y) * 0.035; + this.render(time); + + if (!this.reducedMotion) { + this.frame = requestAnimationFrame(this.animate); + } + } + + destroy() { + cancelAnimationFrame(this.frame); + this.resizeObserver.disconnect(); + } +} diff --git a/frontend/src/components/particle-field.js b/frontend/src/components/particle-field.js new file mode 100644 index 0000000..178478f --- /dev/null +++ b/frontend/src/components/particle-field.js @@ -0,0 +1,148 @@ +import { createParticles } from "../lib/particles.js"; +import { createIdleScheduler } from "../lib/idle-scheduler.js"; + +const TAU = Math.PI * 2; + +export class ParticleField { + constructor(canvas) { + this.canvas = canvas; + this.context = canvas.getContext("2d", { alpha: true }); + this.pointer = { x: 0, y: 0 }; + this.targetPointer = { x: 0, y: 0 }; + this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + this.scheduler = createIdleScheduler({ + duration: 820, + render: (time) => this.render(time), + }); + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(canvas); + this.handleVisibility = () => { + if (document.hidden) { + this.scheduler.stop(); + } else { + this.scheduler.renderOnce(performance.now()); + } + }; + document.addEventListener("visibilitychange", this.handleVisibility); + this.resize(); + } + + resize() { + const ratio = Math.min(window.devicePixelRatio || 1, 2); + const bounds = this.canvas.getBoundingClientRect(); + this.width = Math.max(1, bounds.width); + this.height = Math.max(1, bounds.height); + this.canvas.width = Math.round(this.width * ratio); + this.canvas.height = Math.round(this.height * ratio); + this.context.setTransform(ratio, 0, 0, ratio, 0, 0); + this.particles = createParticles({ + count: Math.min(140, Math.round((this.width * this.height) / 14000)), + width: this.width, + height: this.height, + seed: 20260725, + }); + this.scheduler.renderOnce(performance.now()); + } + + setPointer(x, y) { + this.targetPointer.x = x; + this.targetPointer.y = y; + if (this.reducedMotion) { + this.pointer.x = x; + this.pointer.y = y; + this.scheduler.renderOnce(performance.now()); + return; + } + this.scheduler.wake(performance.now()); + } + + drawGrid(time) { + const { context, width, height, pointer } = this; + const horizon = height * 0.69 + pointer.y * 10; + const vanishingX = width * 0.68 + pointer.x * 34; + + context.save(); + context.globalCompositeOperation = "screen"; + context.lineWidth = 0.65; + + for (let index = -12; index <= 12; index += 1) { + const baseX = vanishingX + index * 92; + const gradient = context.createLinearGradient(vanishingX, horizon, baseX, height); + gradient.addColorStop(0, "rgba(225, 230, 235, 0)"); + gradient.addColorStop(0.45, "rgba(225, 230, 235, 0.018)"); + gradient.addColorStop(1, "rgba(225, 230, 235, 0.055)"); + context.strokeStyle = gradient; + context.beginPath(); + context.moveTo(vanishingX, horizon); + context.lineTo(baseX, height); + context.stroke(); + } + + for (let index = 0; index < 12; index += 1) { + const progress = index / 11; + const eased = progress ** 2.15; + const y = horizon + eased * (height - horizon); + context.strokeStyle = `rgba(219, 224, 229, ${0.012 + eased * 0.04})`; + context.beginPath(); + context.moveTo(0, y); + context.lineTo(width, y); + context.stroke(); + } + + const scan = (time * 0.035) % Math.max(height, 1); + const scanGradient = context.createLinearGradient(0, scan - 50, 0, scan + 50); + scanGradient.addColorStop(0, "rgba(255, 255, 255, 0)"); + scanGradient.addColorStop(0.5, "rgba(255, 255, 255, 0.018)"); + scanGradient.addColorStop(1, "rgba(255, 255, 255, 0)"); + context.fillStyle = scanGradient; + context.fillRect(0, scan - 50, width, 100); + context.restore(); + } + + drawParticles(time) { + const { context, width, height, particles, pointer } = this; + + for (const particle of particles) { + const speed = particle.kind === "ember" ? 0.007 : 0.0025; + const wave = Math.sin(time * speed + particle.phase); + const x = + (particle.x + time * particle.drift * particle.depth + pointer.x * particle.depth * 10 + width) % + width; + const y = (particle.y + wave * 7 * particle.depth + height) % height; + const radius = particle.kind === "axis" ? 1.15 : 0.35 + particle.depth * 0.75; + + context.beginPath(); + context.fillStyle = + particle.kind === "ember" + ? `rgba(255, 255, 255, ${0.035 + particle.depth * 0.1})` + : `rgba(205, 212, 219, ${0.035 + particle.depth * 0.14})`; + context.arc(x, y, radius, 0, TAU); + context.fill(); + + if (particle.kind === "axis") { + context.strokeStyle = `rgba(224, 229, 234, ${0.018 + particle.depth * 0.04})`; + context.lineWidth = 0.5; + context.beginPath(); + context.moveTo(x - 24 * particle.depth, y); + context.lineTo(x + 34 * particle.depth, y); + context.moveTo(x, y - 14 * particle.depth); + context.lineTo(x, y + 22 * particle.depth); + context.stroke(); + } + } + } + + render(time) { + this.pointer.x += (this.targetPointer.x - this.pointer.x) * 0.045; + this.pointer.y += (this.targetPointer.y - this.pointer.y) * 0.045; + this.context.clearRect(0, 0, this.width, this.height); + this.drawGrid(time); + this.drawParticles(time); + } + + destroy() { + this.scheduler.stop(); + this.resizeObserver.disconnect(); + document.removeEventListener("visibilitychange", this.handleVisibility); + } +} diff --git a/frontend/src/components/persona-select-view.js b/frontend/src/components/persona-select-view.js new file mode 100644 index 0000000..c350472 --- /dev/null +++ b/frontend/src/components/persona-select-view.js @@ -0,0 +1,317 @@ +import { ParticleField } from "./particle-field.js"; +import { getCardVisualState, normalizePointer } from "../lib/interaction.js"; +import { PERSONAS, getPersona } from "../lib/personas.js"; + +const cardMarkup = PERSONAS + .map( + (persona, index) => ` + + `, + ) + .join(""); + +export function mountPersonaSelect({ root, appState }) { +root.innerHTML = ` +
+ + + + + + + +
+ + + AD·VXPERSONA OPERATING SYSTEM + +
+ CORE ONLINE + LOCAL / 001 + SHANGHAI 02:57:24 +
+ +
+ + + +
+

SELECT CONSCIOUSNESS

+

+ 选择一个
意识载体
+ +

+

人格不是头像,而是一种持续响应你的存在方式。

+
+ 4 SHELLS AVAILABLE + POINTER REACTIVE +
+
+ +
+ ${cardMarkup} +
+ +
+ + + + + + +
+
+ 自我人格的人格形象 + +
+
+ + + +
+

可塑身份体

+

自我人格SELF / LIQUID CHROME

+
+ IDENTITY SHELL READY + MALLEABLE CORE / SPECTRAL ARM +
+

身体保持冷冽黑银,只有伸向屏幕的机械前臂承载流彩反射。

+
+
+ + + + + + + + + + +
+`; + +const shell = root.querySelector(".interface-shell"); +const particleField = new ParticleField(root.querySelector(".particle-field")); +const cards = [...root.querySelectorAll(".persona-card")]; +const heroFigure = root.querySelector(".hero-figure"); +const heroImage = root.querySelector(".hero-image--base"); +const projectedArm = root.querySelector(".projected-arm-layer img"); +const clock = root.querySelector("#system-clock"); +const creationGate = root.querySelector(".creation-gate"); +let personaTimer; + +function updateClock() { + clock.textContent = new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(new Date()); +} + +function commitPersona(persona) { + shell.dataset.activePersona = persona.id; + heroFigure.dataset.heroKind = persona.id; + heroImage.src = persona.heroImage; + heroImage.alt = `${persona.name}的人格形象`; + projectedArm.src = persona.heroImage; + + root.querySelector("#hero-number").textContent = persona.number; + root.querySelector("#hero-role").textContent = persona.role; + root.querySelector("#hero-name").textContent = persona.name; + root.querySelector("#hero-english").textContent = persona.english; + root.querySelector("#hero-status").textContent = persona.status; + root.querySelector("#hero-signature").textContent = persona.signature; + root.querySelector("#hero-note").textContent = persona.note; + appState.selectPersona(persona.id); + + cards.forEach((card) => { + const selected = card.dataset.persona === persona.id; + card.classList.toggle("is-active", selected); + card.setAttribute("aria-pressed", String(selected)); + }); +} + +function selectPersona(id, { immediate = false } = {}) { + const persona = getPersona(id); + if (!persona) return; + + window.clearTimeout(personaTimer); + shell.classList.add("is-developing"); + + if (immediate) { + commitPersona(persona); + shell.classList.remove("is-developing"); + return; + } + + personaTimer = window.setTimeout(() => { + commitPersona(persona); + shell.classList.remove("is-developing"); + }, 180); +} + +cards.forEach((card) => { + const index = Number(card.dataset.index); + + card.addEventListener("pointermove", (event) => { + const pointer = normalizePointer(event.clientX, event.clientY, card.getBoundingClientRect()); + const visual = getCardVisualState(index, pointer); + card.classList.add("is-hovered"); + card.style.setProperty("--card-hue", visual.hue); + card.style.setProperty("--light-x", `${visual.lightX}%`); + card.style.setProperty("--light-y", `${visual.lightY}%`); + card.style.setProperty("--tilt-x", `${visual.tiltX}deg`); + card.style.setProperty("--tilt-y", `${visual.tiltY}deg`); + }); + + card.addEventListener("pointerleave", () => { + card.classList.remove("is-hovered"); + card.style.removeProperty("--light-x"); + card.style.removeProperty("--light-y"); + card.style.removeProperty("--tilt-x"); + card.style.removeProperty("--tilt-y"); + }); + + card.addEventListener("click", () => selectPersona(card.dataset.persona)); +}); + +function handlePointerMove(event) { + const x = event.clientX / window.innerWidth - 0.5; + const y = event.clientY / window.innerHeight - 0.5; + shell.style.setProperty("--pointer-x", x.toFixed(3)); + shell.style.setProperty("--pointer-y", y.toFixed(3)); + shell.style.setProperty("--arm-hue", `${Math.round(205 + event.clientX / window.innerWidth * 190)}deg`); + particleField.setPointer(x, y); +} + +window.addEventListener("pointermove", handlePointerMove, { passive: true }); + +root.querySelector(".sound-toggle").addEventListener("click", (event) => { + const button = event.currentTarget; + const enabled = button.getAttribute("aria-pressed") !== "true"; + button.setAttribute("aria-pressed", String(enabled)); + button.querySelector("small").textContent = enabled ? "AMBIENCE ON" : "AMBIENCE OFF"; +}); + +function setGate(open) { + creationGate.hidden = !open; + document.body.classList.toggle("has-dialog", open); + if (open) { + requestAnimationFrame(() => creationGate.classList.add("is-visible")); + creationGate.querySelector(".gate-close").focus(); + } else { + creationGate.classList.remove("is-visible"); + } +} + +root.querySelector(".enter-button").addEventListener("click", () => appState.openChat()); +root.querySelector(".add-button").addEventListener("click", () => setGate(true)); +root.querySelector(".gate-close").addEventListener("click", () => setGate(false)); +root.querySelector(".gate-backdrop").addEventListener("click", () => setGate(false)); +root.querySelector(".gate-confirm").addEventListener("click", () => { + setGate(false); + appState.openEditor(); +}); + +function handleKeydown(event) { + if (event.key === "Escape" && !creationGate.hidden) { + setGate(false); + } +} + +document.addEventListener("keydown", handleKeydown); + +updateClock(); +const clockTimer = window.setInterval(updateClock, 1000); +selectPersona(appState.getState().personaId, { immediate: true }); + +return () => { + window.clearInterval(clockTimer); + window.clearTimeout(personaTimer); + window.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("keydown", handleKeydown); + particleField.destroy(); + document.body.classList.remove("has-dialog"); +}; +} diff --git a/frontend/src/components/transition-layer.js b/frontend/src/components/transition-layer.js new file mode 100644 index 0000000..5d2a077 --- /dev/null +++ b/frontend/src/components/transition-layer.js @@ -0,0 +1,34 @@ +export function getTransitionTiming(duration = 600) { + const endAt = Math.max(200, Math.round(duration)); + return { + swapAt: Math.round(endAt * 0.45), + endAt, + }; +} + +export function runViewTransition({ + root, + render, + duration = 600, +}) { + const timing = getTransitionTiming(duration); + const layer = document.createElement("div"); + layer.className = "view-transition-layer"; + layer.setAttribute("aria-hidden", "true"); + layer.innerHTML = "PERSONA LINK / TRANSFER"; + document.body.append(layer); + root.classList.add("is-view-transitioning"); + + return new Promise((resolve) => { + window.setTimeout(() => { + render(); + layer.classList.add("is-swapped"); + }, timing.swapAt); + + window.setTimeout(() => { + root.classList.remove("is-view-transitioning"); + layer.remove(); + resolve(); + }, timing.endAt); + }); +} diff --git a/frontend/src/components/xyz-editor-view.js b/frontend/src/components/xyz-editor-view.js new file mode 100644 index 0000000..2a7c2eb --- /dev/null +++ b/frontend/src/components/xyz-editor-view.js @@ -0,0 +1,181 @@ +import { XYZParticleSpace } from "./xyz-particle-space.js"; +import { + DEFAULT_PROFILE, + describeProfile, + loadProfile, + saveProfile, +} from "../lib/persona-profile.js"; + +const AXES = [ + { key: "x", name: "亲和度", negative: "疏离", positive: "温暖" }, + { key: "y", name: "理性度", negative: "感性", positive: "理性" }, + { key: "z", name: "表现力", negative: "克制", positive: "活跃" }, +]; + +const MBTI_TYPES = [ + "INTJ", "INTP", "ENTJ", "ENTP", + "INFJ", "INFP", "ENFJ", "ENFP", + "ISTJ", "ISFJ", "ESTJ", "ESFJ", + "ISTP", "ISFP", "ESTP", "ESFP", +]; + +function sliderMarkup(axis, profile) { + const value = profile[axis.key]; + const state = value <= -34 ? axis.negative : value >= 34 ? axis.positive : "均衡"; + return ` + + `; +} + +export function mountXyzEditor({ root, appState }) { + let draft = { + ...DEFAULT_PROFILE, + ...loadProfile(window.localStorage), + ...appState.getState().profile, + }; + + root.innerHTML = ` +
+ +
+ +
AD·VXPERSONA FABRICATION LAB
+

CALIBRATION ONLINE 02 / 04

+
+ +
+

NEW CONSCIOUSNESS / COORDINATE CALIBRATION

+

塑造你的
意识坐标

+ 拖动光点改变人格倾向,拖动粒子空间旋转视角。 +
+ +
+
+ X · AFFINITY + Y · RATIONALITY + Z · EXPRESSION +
+ + + +
+

ACTIVE PLANE XY←→ X / ↑↓ Y · 空白处旋转

+
+ + + + +
+ `; + + const particleSpace = new XYZParticleSpace( + root.querySelector(".xyz-canvas-host"), + draft, + ); + const summary = root.querySelector(".profile-summary p"); + + function renderDraft() { + for (const axis of AXES) { + const control = root.querySelector(`[data-axis="${axis.key}"]`); + control.querySelector("input").value = String(draft[axis.key]); + control.querySelector("output").value = String(draft[axis.key]); + control.querySelector(".axis-control__reading i").textContent = + draft[axis.key] <= -34 ? axis.negative : draft[axis.key] >= 34 ? axis.positive : "均衡"; + control.dataset.polarity = + draft[axis.key] <= -34 ? "negative" : draft[axis.key] >= 34 ? "positive" : "balanced"; + control.style.setProperty("--axis-value", `${(draft[axis.key] + 100) / 2}%`); + } + summary.textContent = describeProfile(draft); + root.querySelectorAll("[data-mbti]").forEach((button) => { + button.classList.toggle("is-active", button.dataset.mbti === draft.mbti); + }); + particleSpace.setProfile(draft); + } + + particleSpace.setOnProfileChange((profile) => { + draft = { ...draft, ...profile }; + renderDraft(); + }); + + root.querySelectorAll(".axis-control input").forEach((input) => { + input.addEventListener("input", () => { + const axis = input.closest(".axis-control").dataset.axis; + draft = { ...draft, [axis]: Number(input.value) }; + renderDraft(); + }); + }); + + root.querySelectorAll("[data-plane]").forEach((button) => { + button.addEventListener("click", () => { + particleSpace.setActivePlane(button.dataset.plane); + root.querySelectorAll("[data-plane]").forEach((item) => { + item.classList.toggle("is-active", item === button); + }); + root.querySelector(".xyz-readout b").textContent = button.dataset.plane.toUpperCase(); + root.querySelector(".xyz-readout span").textContent = { + xy: "←→ X / ↑↓ Y · 空白处旋转", + xz: "←→ X / ↑↓ Z · 空白处旋转", + yz: "←→ Z / ↑↓ Y · 空白处旋转", + }[button.dataset.plane]; + }); + }); + + root.querySelectorAll("[data-mbti]").forEach((button) => { + button.addEventListener("click", () => { + draft = { ...draft, mbti: button.dataset.mbti }; + renderDraft(); + }); + }); + + root.querySelector(".reset-profile").addEventListener("click", () => { + draft = { ...DEFAULT_PROFILE }; + renderDraft(); + }); + root.querySelector(".save-profile").addEventListener("click", () => { + draft = saveProfile(window.localStorage, draft); + appState.saveProfile(draft); + }); + root.querySelector(".workspace-back").addEventListener("click", () => { + appState.closeWorkspace(); + }); + + renderDraft(); + + return () => particleSpace.dispose(); +} diff --git a/frontend/src/components/xyz-particle-space.js b/frontend/src/components/xyz-particle-space.js new file mode 100644 index 0000000..d901f5f --- /dev/null +++ b/frontend/src/components/xyz-particle-space.js @@ -0,0 +1,322 @@ +import * as THREE from "three"; + +import { + getParticleBudget, + getPerformanceTier, +} from "../lib/performance-tier.js"; +import { normalizeAxis } from "../lib/persona-profile.js"; +import { + applyPlaneDrag, + axisToPosition, + createSpherePoints, + getDepthCompensatedScale, +} from "../lib/xyz-geometry.js"; + +const AXIS_COLORS = { + x: 0xf4f7fb, + y: 0x9ed8ff, + z: 0xd1b3ff, +}; + +function createDigitTexture() { + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const context = canvas.getContext("2d"); + context.clearRect(0, 0, 64, 64); + context.fillStyle = "rgba(255,255,255,.92)"; + context.font = "700 34px ui-monospace, SFMono-Regular, Menlo, monospace"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText("01", 32, 32); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + return texture; +} + +function createAxis(axis, color) { + const vector = { + x: new THREE.Vector3(1.65, 0, 0), + y: new THREE.Vector3(0, 1.65, 0), + z: new THREE.Vector3(0, 0, 1.65), + }[axis]; + const geometry = new THREE.BufferGeometry().setFromPoints([ + vector.clone().multiplyScalar(-1), + vector, + ]); + const material = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.72, + }); + return new THREE.Line(geometry, material); +} + +export class XYZParticleSpace { + constructor(container, profile) { + this.container = container; + this.profile = { ...profile }; + this.activePlane = "xy"; + this.onProfileChange = () => {}; + this.pointer = new THREE.Vector2(); + this.raycaster = new THREE.Raycaster(); + this.dragMode = null; + this.lastPointer = { x: 0, y: 0 }; + this.dragStartPointer = { x: 0, y: 0 }; + this.dragStartProfile = { ...this.profile }; + this.rotationVelocity = { x: 0, y: 0 }; + this.running = false; + this.frame = null; + this.lastRender = 0; + + this.scene = new THREE.Scene(); + this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100); + this.camera.position.set(0, 0.15, 4.65); + + this.renderer = new THREE.WebGLRenderer({ + alpha: true, + antialias: false, + powerPreference: "high-performance", + }); + this.renderer.setClearColor(0x000000, 0); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); + this.renderer.domElement.className = "xyz-webgl"; + this.renderer.domElement.setAttribute("aria-label", "XYZ 粒子人格空间"); + container.append(this.renderer.domElement); + + const tier = getPerformanceTier({ + hardwareConcurrency: navigator.hardwareConcurrency, + dpr: window.devicePixelRatio, + }); + const positions = createSpherePoints({ + count: getParticleBudget(tier), + seed: 20260725, + }); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + this.digitTexture = createDigitTexture(); + const material = new THREE.PointsMaterial({ + color: 0xe9f3fb, + map: this.digitTexture, + alphaMap: this.digitTexture, + size: tier === "high" ? 0.078 : 0.086, + sizeAttenuation: true, + transparent: true, + opacity: 0.94, + alphaTest: 0.08, + depthTest: false, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + + this.spaceGroup = new THREE.Group(); + this.particleSphere = new THREE.Points(geometry, material); + this.spaceGroup.add(this.particleSphere); + this.axes = { + x: createAxis("x", AXIS_COLORS.x), + y: createAxis("y", AXIS_COLORS.y), + z: createAxis("z", AXIS_COLORS.z), + }; + Object.values(this.axes).forEach((line) => this.spaceGroup.add(line)); + + const originGeometry = new THREE.SphereGeometry(0.028, 12, 12); + const originMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.7, + }); + this.origin = new THREE.Mesh(originGeometry, originMaterial); + this.spaceGroup.add(this.origin); + + const pointGeometry = new THREE.SphereGeometry(0.095, 24, 24); + const pointMaterial = new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.95, + }); + this.personaPoint = new THREE.Mesh(pointGeometry, pointMaterial); + const glowMaterial = new THREE.MeshBasicMaterial({ + color: 0xa7ddff, + transparent: true, + opacity: 0.18, + side: THREE.BackSide, + }); + this.pointGlow = new THREE.Mesh( + new THREE.SphereGeometry(0.18, 20, 20), + glowMaterial, + ); + this.depthOrbit = new THREE.Mesh( + new THREE.TorusGeometry(0.16, 0.006, 8, 56), + new THREE.MeshBasicMaterial({ + color: 0xdcecf6, + transparent: true, + opacity: 0.28, + depthTest: false, + }), + ); + this.depthOrbit.rotation.x = Math.PI / 2; + this.personaPoint.add(this.pointGlow); + this.personaPoint.add(this.depthOrbit); + this.spaceGroup.add(this.personaPoint); + this.scene.add(this.spaceGroup); + + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(container); + this.handlePointerDown = (event) => this.pointerDown(event); + this.handlePointerMove = (event) => this.pointerMove(event); + this.handlePointerUp = () => { this.dragMode = null; }; + this.handleVisibility = () => { + if (document.hidden) this.stop(); + else this.start(); + }; + this.renderer.domElement.addEventListener("pointerdown", this.handlePointerDown); + window.addEventListener("pointermove", this.handlePointerMove, { passive: false }); + window.addEventListener("pointerup", this.handlePointerUp); + document.addEventListener("visibilitychange", this.handleVisibility); + + this.setProfile(profile); + this.resize(); + this.start(); + } + + setOnProfileChange(callback) { + this.onProfileChange = callback; + } + + setActivePlane(plane) { + if (["xy", "xz", "yz"].includes(plane)) this.activePlane = plane; + } + + setProfile(profile) { + this.profile = { + ...this.profile, + x: normalizeAxis(profile.x), + y: normalizeAxis(profile.y), + z: normalizeAxis(profile.z), + }; + const position = axisToPosition(this.profile); + this.personaPoint.position.set(position.x, position.y, position.z); + this.personaPoint.scale.setScalar(getDepthCompensatedScale({ + z: position.z, + cameraZ: this.camera.position.z, + })); + this.depthOrbit.material.opacity = 0.16 + ((position.z + 1) / 2) * 0.34; + this.depthOrbit.rotation.z = position.z * Math.PI * 0.5; + this.updateAxisLight(); + } + + updateAxisLight() { + for (const axis of ["x", "y", "z"]) { + const strength = Math.abs(this.profile[axis]) / 100; + this.axes[axis].material.opacity = 0.34 + strength * 0.66; + } + } + + updatePointer(event) { + const bounds = this.renderer.domElement.getBoundingClientRect(); + this.pointer.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; + this.pointer.y = -((event.clientY - bounds.top) / bounds.height) * 2 + 1; + this.raycaster.setFromCamera(this.pointer, this.camera); + } + + pointerDown(event) { + this.updatePointer(event); + const hits = this.raycaster.intersectObject(this.personaPoint, true); + this.lastPointer = { x: event.clientX, y: event.clientY }; + if (hits.length) { + this.dragMode = "point"; + this.dragStartPointer = { x: event.clientX, y: event.clientY }; + this.dragStartProfile = { ...this.profile }; + } else { + this.dragMode = "rotate"; + } + this.renderer.domElement.setPointerCapture?.(event.pointerId); + } + + pointerMove(event) { + if (!this.dragMode) return; + event.preventDefault(); + if (this.dragMode === "rotate") { + const deltaX = event.clientX - this.lastPointer.x; + const deltaY = event.clientY - this.lastPointer.y; + this.spaceGroup.rotation.y += deltaX * 0.006; + this.spaceGroup.rotation.x += deltaY * 0.004; + this.rotationVelocity = { x: deltaY * 0.0009, y: deltaX * 0.0012 }; + this.lastPointer = { x: event.clientX, y: event.clientY }; + return; + } + + const bounds = this.renderer.domElement.getBoundingClientRect(); + const next = applyPlaneDrag(this.dragStartProfile, { + plane: this.activePlane, + deltaX: event.clientX - this.dragStartPointer.x, + deltaY: event.clientY - this.dragStartPointer.y, + width: bounds.width, + height: bounds.height, + }); + this.setProfile(next); + this.onProfileChange({ ...this.profile }); + } + + resize() { + const bounds = this.container.getBoundingClientRect(); + const width = Math.max(1, bounds.width); + const height = Math.max(1, bounds.height); + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + this.render(performance.now()); + } + + render(time) { + const pulse = 1 + Math.sin(time * 0.002) * 0.08; + this.pointGlow.scale.setScalar(pulse); + this.spaceGroup.rotation.x += this.rotationVelocity.x; + this.spaceGroup.rotation.y += this.rotationVelocity.y; + this.rotationVelocity.x *= 0.94; + this.rotationVelocity.y *= 0.94; + this.renderer.render(this.scene, this.camera); + } + + animate = (time) => { + if (!this.running) return; + if (time - this.lastRender >= 32) { + this.lastRender = time; + this.render(time); + } + this.frame = requestAnimationFrame(this.animate); + }; + + start() { + if (this.running) return; + this.running = true; + this.frame = requestAnimationFrame(this.animate); + } + + stop() { + this.running = false; + if (this.frame !== null) cancelAnimationFrame(this.frame); + this.frame = null; + } + + dispose() { + this.stop(); + this.resizeObserver.disconnect(); + this.renderer.domElement.removeEventListener("pointerdown", this.handlePointerDown); + window.removeEventListener("pointermove", this.handlePointerMove); + window.removeEventListener("pointerup", this.handlePointerUp); + document.removeEventListener("visibilitychange", this.handleVisibility); + this.scene.traverse((object) => { + object.geometry?.dispose?.(); + if (Array.isArray(object.material)) { + object.material.forEach((material) => material.dispose?.()); + } else { + object.material?.dispose?.(); + } + }); + this.digitTexture.dispose(); + this.renderer.dispose(); + this.renderer.domElement.remove(); + } +} diff --git a/frontend/src/lib/app-state.js b/frontend/src/lib/app-state.js new file mode 100644 index 0000000..3cc36d2 --- /dev/null +++ b/frontend/src/lib/app-state.js @@ -0,0 +1,50 @@ +export const APP_MODES = Object.freeze({ + PERSONA_SELECT: "PERSONA_SELECT", + XYZ_EDITOR: "XYZ_EDITOR", + CHAT: "CHAT", +}); + +export function createAppState(initial = {}) { + let state = { + mode: APP_MODES.PERSONA_SELECT, + personaId: initial.personaId ?? "custom", + profile: initial.profile ?? null, + }; + const listeners = new Set(); + + function update(patch) { + const changed = Object.entries(patch).some( + ([key, value]) => state[key] !== value, + ); + if (!changed) return; + state = { ...state, ...patch }; + listeners.forEach((listener) => listener({ ...state })); + } + + return { + getState: () => ({ ...state }), + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + selectPersona(personaId) { + update({ personaId }); + }, + openChat() { + update({ mode: APP_MODES.CHAT }); + }, + openEditor() { + update({ mode: APP_MODES.XYZ_EDITOR }); + }, + closeWorkspace() { + update({ mode: APP_MODES.PERSONA_SELECT }); + }, + saveProfile(profile) { + update({ + profile: { ...profile }, + personaId: "custom", + mode: APP_MODES.CHAT, + }); + }, + }; +} diff --git a/frontend/src/lib/chat-session.js b/frontend/src/lib/chat-session.js new file mode 100644 index 0000000..69c6928 --- /dev/null +++ b/frontend/src/lib/chat-session.js @@ -0,0 +1,76 @@ +export function createChatSession({ + personaId, + agentAdapter, + initialMessages = [], +}) { + let sequence = initialMessages.length; + let state = { + messages: initialMessages.map((message) => ({ ...message })), + generating: false, + error: null, + }; + const listeners = new Set(); + + function emit() { + const snapshot = { + ...state, + messages: state.messages.map((message) => ({ ...message })), + }; + listeners.forEach((listener) => listener(snapshot)); + } + + function append(role, content, status = "complete") { + sequence += 1; + const message = { + id: `${personaId}-${sequence}`, + role, + content, + status, + createdAt: Date.now(), + }; + state = { ...state, messages: [...state.messages, message] }; + return message; + } + + return { + getMessages() { + return state.messages.map((message) => ({ ...message })); + }, + getState() { + return { + ...state, + messages: state.messages.map((message) => ({ ...message })), + }; + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async send(value) { + const content = String(value ?? "").trim(); + if (!content || state.generating) return false; + + append("user", content); + state = { ...state, generating: true, error: null }; + emit(); + + try { + const reply = await agentAdapter.reply({ + personaId, + messages: state.messages.map((message) => ({ ...message })), + }); + append("assistant", String(reply)); + state = { ...state, generating: false }; + } catch { + state = { + ...state, + generating: false, + error: "对话通道暂时不可用,请重试。", + }; + } + + emit(); + return !state.error; + }, + }; +} diff --git a/frontend/src/lib/crystal-shards.js b/frontend/src/lib/crystal-shards.js new file mode 100644 index 0000000..ba9449c --- /dev/null +++ b/frontend/src/lib/crystal-shards.js @@ -0,0 +1,32 @@ +function createRandom(seed) { + let state = seed >>> 0; + + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; +} + +export function createCrystalShards({ count = 32, seed = 25 } = {}) { + if (!Number.isFinite(count) || count <= 0) return []; + + const random = createRandom(seed); + + return Array.from({ length: Math.floor(count) }, (_, index) => { + const depth = index % 3; + const depthProgress = depth / 2; + + return { + depth, + x: random(), + y: random(), + width: 0.008 + random() * 0.062, + height: 0.08 + random() * 0.34, + speed: 0.000006 + random() * 0.000018, + tilt: -0.19 + random() * 0.38, + opacity: 0.05 + random() * 0.23, + blur: (1 - depthProgress) * 5 * random(), + phase: random() * Math.PI * 2, + }; + }); +} diff --git a/frontend/src/lib/idle-scheduler.js b/frontend/src/lib/idle-scheduler.js new file mode 100644 index 0000000..a443a6c --- /dev/null +++ b/frontend/src/lib/idle-scheduler.js @@ -0,0 +1,43 @@ +export function createIdleScheduler({ + duration = 800, + render = () => {}, + requestFrame = (callback) => requestAnimationFrame(callback), + cancelFrame = (frame) => cancelAnimationFrame(frame), +} = {}) { + let awake = false; + let deadline = 0; + let frameId = null; + + function tick(time) { + render(time); + + if (time >= deadline) { + awake = false; + frameId = null; + return; + } + + frameId = requestFrame(tick); + } + + return { + wake(time = 0) { + deadline = Math.max(deadline, time + duration); + if (awake) return; + awake = true; + frameId = requestFrame(tick); + }, + renderOnce(time = 0) { + render(time); + }, + stop() { + if (frameId !== null) cancelFrame(frameId); + frameId = null; + awake = false; + deadline = 0; + }, + isAwake() { + return awake; + }, + }; +} diff --git a/frontend/src/lib/interaction.js b/frontend/src/lib/interaction.js new file mode 100644 index 0000000..73cd586 --- /dev/null +++ b/frontend/src/lib/interaction.js @@ -0,0 +1,25 @@ +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +const BASE_HUES = [188, 20, 278, 154]; + +export function normalizePointer(clientX, clientY, rect) { + const x = ((clientX - rect.left) / rect.width) * 2 - 1; + const y = ((clientY - rect.top) / rect.height) * 2 - 1; + + return { + x: clamp(x, -1, 1), + y: clamp(y, -1, 1), + }; +} + +export function getCardVisualState(index, pointer) { + const baseHue = BASE_HUES[index % BASE_HUES.length]; + + return { + hue: Math.round(baseHue + pointer.x * 18 - pointer.y * 8), + lightX: Math.round((pointer.x + 1) * 50), + lightY: Math.round((pointer.y + 1) * 50), + tiltX: Number((-pointer.y * 4).toFixed(2)), + tiltY: Number((pointer.x * 5).toFixed(2)), + }; +} diff --git a/frontend/src/lib/montage.js b/frontend/src/lib/montage.js new file mode 100644 index 0000000..3b21b00 --- /dev/null +++ b/frontend/src/lib/montage.js @@ -0,0 +1,35 @@ +function createRandom(seed) { + let state = seed >>> 0; + + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 4294967296; + }; +} + +export function createMontageSchedule({ count = 20, seed = 25 } = {}) { + if (!Number.isFinite(count) || count <= 0) return []; + + const total = Math.floor(count); + const random = createRandom(seed); + const memoryCount = Math.round(total * 0.3); + const kinds = Array.from({ length: total }, (_, index) => + index < memoryCount ? "memory" : index % 3 === 0 ? "bloom" : "ribbon", + ); + + for (let index = kinds.length - 1; index > 0; index -= 1) { + const target = Math.floor(random() * (index + 1)); + [kinds[index], kinds[target]] = [kinds[target], kinds[index]]; + } + + return kinds.map((kind, index) => ({ + kind, + x: random(), + y: random(), + scale: 0.3 + random() * 1.1, + opacity: 0.03 + random() * 0.19, + start: Math.round(random() * 9000), + duration: Math.round(2800 + random() * 6200), + phase: random() * Math.PI * 2 + index * 0.13, + })); +} diff --git a/frontend/src/lib/particles.js b/frontend/src/lib/particles.js new file mode 100644 index 0000000..f6c2a6f --- /dev/null +++ b/frontend/src/lib/particles.js @@ -0,0 +1,33 @@ +function mulberry32(seed) { + let value = seed >>> 0; + + return () => { + value += 0x6d2b79f5; + let result = value; + result = Math.imul(result ^ (result >>> 15), result | 1); + result ^= result + Math.imul(result ^ (result >>> 7), result | 61); + return ((result ^ (result >>> 14)) >>> 0) / 4294967296; + }; +} + +export function createParticles({ + count, + width, + height, + seed = 721, +}) { + if (count <= 0 || width <= 0 || height <= 0) { + return []; + } + + const random = mulberry32(seed); + + return Array.from({ length: count }, (_, index) => ({ + x: random() * width, + y: random() * height, + depth: 0.2 + random() * 0.8, + drift: (random() - 0.5) * 0.18, + phase: random() * Math.PI * 2, + kind: index % 11 === 0 ? "axis" : index % 5 === 0 ? "ember" : "dust", + })); +} diff --git a/frontend/src/lib/performance-tier.js b/frontend/src/lib/performance-tier.js new file mode 100644 index 0000000..cb38c64 --- /dev/null +++ b/frontend/src/lib/performance-tier.js @@ -0,0 +1,16 @@ +export function getPerformanceTier({ + hardwareConcurrency = 4, + dpr = 1, +} = {}) { + if (hardwareConcurrency <= 4) return "low"; + if (hardwareConcurrency >= 8 && dpr <= 2) return "high"; + return "normal"; +} + +export function getParticleBudget(tier) { + return { + high: 4000, + normal: 2000, + low: 1000, + }[tier] ?? 1000; +} diff --git a/frontend/src/lib/persona-profile.js b/frontend/src/lib/persona-profile.js new file mode 100644 index 0000000..a6712e9 --- /dev/null +++ b/frontend/src/lib/persona-profile.js @@ -0,0 +1,84 @@ +const STORAGE_KEY = "advx.selfPersona.v1"; +const MBTI_TYPES = new Set([ + "INTJ", "INTP", "ENTJ", "ENTP", + "INFJ", "INFP", "ENFJ", "ENFP", + "ISTJ", "ISFJ", "ESTJ", "ESFJ", + "ISTP", "ISFP", "ESTP", "ESFP", +]); + +export const DEFAULT_PROFILE = Object.freeze({ + x: 0, + y: 0, + z: 0, + mbti: "INFJ", +}); + +export function normalizeAxis(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return Math.max(-100, Math.min(100, Math.round(numeric))); +} + +export function toInternalAxis(value) { + return normalizeAxis(value) / 100; +} + +function normalizeProfile(profile = {}) { + return { + x: normalizeAxis(profile.x), + y: normalizeAxis(profile.y), + z: normalizeAxis(profile.z), + mbti: MBTI_TYPES.has(profile.mbti) ? profile.mbti : DEFAULT_PROFILE.mbti, + }; +} + +function describeAxis(value, negative, neutral, positive) { + const magnitude = Math.abs(value); + if (magnitude <= 20) return neutral; + const intensity = magnitude > 80 ? "极强" : magnitude > 50 ? "强烈" : "明显"; + return `${intensity}${value < 0 ? negative : positive}`; +} + +export function describeProfile(profile) { + const normalized = normalizeProfile(profile); + const affinity = describeAxis( + normalized.x, + "疏离,保持谨慎的关系距离", + "自然而有分寸地回应", + "温暖友善,并主动关心对方", + ); + const rationality = describeAxis( + normalized.y, + "依赖感受与直觉组织判断", + "在理性与感性之间保持平衡", + "偏好理性分析和清晰依据", + ); + const expression = describeAxis( + normalized.z, + "克制平静,表达简短", + "采用自然稳定的表达强度", + "活跃外放,表达更有情绪与节奏", + ); + + return `${normalized.mbti} · ${affinity};${rationality};${expression}。`; +} + +export function loadProfile(storage) { + try { + const value = storage?.getItem?.(STORAGE_KEY); + if (!value) return { ...DEFAULT_PROFILE }; + return normalizeProfile(JSON.parse(value)); + } catch { + return { ...DEFAULT_PROFILE }; + } +} + +export function saveProfile(storage, profile) { + const normalized = normalizeProfile(profile); + try { + storage?.setItem?.(STORAGE_KEY, JSON.stringify(normalized)); + } catch { + return normalized; + } + return normalized; +} diff --git a/frontend/src/lib/personas.js b/frontend/src/lib/personas.js new file mode 100644 index 0000000..72faeb1 --- /dev/null +++ b/frontend/src/lib/personas.js @@ -0,0 +1,58 @@ +const items = [ + { + id: "winter", + number: "01", + name: "冬兵", + english: "WINTER SOLDIER", + role: "战术执行体", + signature: "TACTICAL MEMORY / COLD RESOLVE", + status: "COMBAT MEMORY STABLE", + heroImage: "/public/assets/v2/winter-silver-dream.png", + cardImage: "/public/assets/v2/card-winter-memory.png", + note: "沉默、精准、在破碎记忆中维持绝对执行力。", + allowsSpectralArm: false, + }, + { + id: "johnny", + number: "02", + name: "强尼·银手", + english: "JOHNNY SILVERHAND", + role: "反叛意识体", + signature: "VOLATILE EGO / CHROME NOISE", + status: "ENGRAM SIGNAL ACTIVE", + heroImage: "/public/assets/v3/johnny-exact.png", + cardImage: "/public/assets/v2/card-johnny-noise.png", + note: "高噪、锋利、把每次对话都变成一次不服从。", + allowsSpectralArm: false, + }, + { + id: "jarvis", + number: "03", + name: "贾维斯", + english: "J.A.R.V.I.S.", + role: "全域智能体", + signature: "CALM LOGIC / AMBIENT CONTROL", + status: "SYSTEMS FULLY ONLINE", + heroImage: "/public/assets/v3/jarvis-reactor.png", + cardImage: "/public/assets/v2/card-jarvis-optics.png", + note: "克制、可靠、在信息洪流中保持近乎优雅的秩序。", + allowsSpectralArm: false, + }, + { + id: "custom", + number: "04", + name: "自我人格", + english: "SELF / LIQUID CHROME", + role: "可塑身份体", + signature: "MALLEABLE CORE / SPECTRAL ARM", + status: "IDENTITY SHELL READY", + heroImage: "/public/assets/v2/custom-silver-dream-arm.png", + cardImage: "/public/assets/v2/card-self-mercury.png", + note: "身体保持冷冽黑银,只有伸向屏幕的机械前臂承载流彩反射。", + allowsSpectralArm: true, + }, +]; + +export const PERSONAS = Object.freeze(items.map(Object.freeze)); + +export const getPersona = (id) => PERSONAS.find((persona) => persona.id === id); diff --git a/frontend/src/lib/xyz-geometry.js b/frontend/src/lib/xyz-geometry.js new file mode 100644 index 0000000..afa91a9 --- /dev/null +++ b/frontend/src/lib/xyz-geometry.js @@ -0,0 +1,81 @@ +import { normalizeAxis } from "./persona-profile.js"; + +function createRandom(seed) { + let value = seed >>> 0; + return () => { + value += 0x6d2b79f5; + let result = value; + result = Math.imul(result ^ (result >>> 15), result | 1); + result ^= result + Math.imul(result ^ (result >>> 7), result | 61); + return ((result ^ (result >>> 14)) >>> 0) / 4294967296; + }; +} + +export function createSpherePoints({ count = 1000, seed = 25 } = {}) { + const safeCount = Math.max(0, Math.floor(count)); + const positions = new Float32Array(safeCount * 3); + const random = createRandom(seed); + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + + for (let index = 0; index < safeCount; index += 1) { + const progress = (index + 0.5) / safeCount; + const y = 1 - progress * 2; + const horizontalRadius = Math.sqrt(Math.max(0, 1 - y * y)); + const angle = index * goldenAngle; + const radialJitter = 0.88 + random() * 0.12; + const offset = index * 3; + + positions[offset] = Math.cos(angle) * horizontalRadius * radialJitter; + positions[offset + 1] = y * radialJitter; + positions[offset + 2] = Math.sin(angle) * horizontalRadius * radialJitter; + } + + return positions; +} + +export function axisToPosition({ x = 0, y = 0, z = 0 } = {}) { + return { + x: normalizeAxis(x) / 100, + y: normalizeAxis(y) / 100, + z: normalizeAxis(z) / 100, + }; +} + +export function getDepthCompensatedScale({ z = 0, cameraZ = 4.65 } = {}) { + const numericDepth = Number(z); + const numericCamera = Number(cameraZ); + if (!Number.isFinite(numericDepth) || !Number.isFinite(numericCamera) || numericCamera <= 1) { + return 1; + } + + const safeDepth = Math.max(-1, Math.min(1, numericDepth)); + return Number(((numericCamera - safeDepth) / numericCamera).toFixed(3)); +} + +export function applyPlaneDrag( + profile = {}, + { + plane = "xy", + deltaX = 0, + deltaY = 0, + width = 1, + height = 1, + } = {}, +) { + const next = { + x: normalizeAxis(profile.x), + y: normalizeAxis(profile.y), + z: normalizeAxis(profile.z), + }; + const span = Math.max(160, Math.min(Number(width) || 1, Number(height) || 1)); + const unitsPerPixel = 200 / span; + const horizontal = (Number(deltaX) || 0) * unitsPerPixel; + const vertical = -(Number(deltaY) || 0) * unitsPerPixel; + + if (plane === "xy" || plane === "xz") next.x = normalizeAxis(next.x + horizontal); + if (plane === "xy" || plane === "yz") next.y = normalizeAxis(next.y + vertical); + if (plane === "xz") next.z = normalizeAxis(next.z + vertical); + if (plane === "yz") next.z = normalizeAxis(next.z + horizontal); + + return next; +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..a9942e5 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,48 @@ +import { APP_MODES, createAppState } from "./lib/app-state.js"; +import { mountChatView } from "./components/chat-view.js"; +import { mountPersonaSelect } from "./components/persona-select-view.js"; +import { runViewTransition } from "./components/transition-layer.js"; +import { mountXyzEditor } from "./components/xyz-editor-view.js"; +import { loadProfile } from "./lib/persona-profile.js"; + +const root = document.querySelector("#app"); +const appState = createAppState({ + personaId: "custom", + profile: loadProfile(window.localStorage), +}); +let cleanupView = () => {}; +let activeMode = null; + +function mountMode(state) { + if (state.mode === activeMode) return; + cleanupView(); + activeMode = state.mode; + + if (state.mode === APP_MODES.XYZ_EDITOR) { + cleanupView = mountXyzEditor({ root, appState }); + return; + } + if (state.mode === APP_MODES.CHAT) { + cleanupView = mountChatView({ root, appState }); + return; + } + cleanupView = mountPersonaSelect({ root, appState }); +} + +let transition = Promise.resolve(); + +function render(state) { + if (activeMode === null) { + mountMode(state); + return; + } + if (state.mode === activeMode) return; + + transition = transition.then(() => runViewTransition({ + root, + render: () => mountMode(state), + })); +} + +appState.subscribe(render); +mountMode(appState.getState()); diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..18c4f25 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,3916 @@ +:root { + color-scheme: dark; + font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif; + font-synthesis: none; + --pointer-x: 0; + --pointer-y: 0; + --arm-hue: 280deg; + --paper: #eef0f2; + --silver: #b8bec5; + --muted: #777e85; + --hairline: rgba(218, 224, 230, 0.15); + background: #030405; + color: var(--paper); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 960px; + min-height: 100%; + margin: 0; + background: #030405; +} + +body { + min-height: 100vh; + overflow: hidden; +} + +button, +a { + color: inherit; + font: inherit; +} + +button { + border: 0; +} + +button:focus-visible, +a:focus-visible { + outline: 1px solid rgba(255, 255, 255, 0.88); + outline-offset: 4px; +} + +[hidden] { + display: none !important; +} + +.interface-shell { + position: relative; + isolation: isolate; + width: 100vw; + min-height: 100vh; + overflow: hidden; + background: + linear-gradient(90deg, rgba(2, 3, 4, 0.98) 0%, rgba(3, 4, 5, 0.91) 36%, rgba(4, 5, 6, 0.48) 61%, rgba(2, 3, 4, 0.78) 100%), + radial-gradient(ellipse at 74% 42%, rgba(221, 226, 231, 0.09), transparent 31%), + #030405; +} + +.crystal-chamber, +.crystal-field, +.particle-field, +.mirror-array, +.mirror-lake, +.exposure-sweeps, +.scan-noise { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.crystal-chamber { + z-index: -8; + inset: -3%; + background: + linear-gradient(90deg, rgba(2, 5, 10, 0.94) 0%, rgba(4, 9, 16, 0.76) 32%, rgba(4, 12, 21, 0.22) 61%, rgba(1, 5, 10, 0.4) 100%), + linear-gradient(180deg, rgba(1, 4, 8, 0.18) 0%, transparent 52%, rgba(2, 7, 12, 0.28) 100%), + url("/public/assets/v3/crystal-chamber.png") center 54% / cover no-repeat; + filter: saturate(0.74) contrast(1.11) brightness(0.72); + transform: translate3d(calc(var(--pointer-x) * -12px), calc(var(--pointer-y) * -8px), 0) scale(1.035); + transform-origin: 55% 62%; + animation: chamber-breathe 18s ease-in-out infinite alternate; +} + +.crystal-chamber::after { + position: absolute; + inset: 0; + content: ""; + background: + radial-gradient(ellipse at 63% 73%, rgba(164, 224, 255, 0.18), transparent 29%), + linear-gradient(90deg, rgba(0, 0, 0, 0.28), transparent 44%, rgba(0, 0, 0, 0.08)); + mix-blend-mode: screen; +} + +.crystal-field { + z-index: -7; + opacity: 0.88; + filter: contrast(1.08); +} + +.particle-field { + z-index: -3; + opacity: 0.48; +} + +.mirror-array { + z-index: -6; + overflow: hidden; + perspective: 900px; +} + +.mirror-array > i { + position: absolute; + display: block; + width: var(--mirror-width); + height: var(--mirror-height); + left: var(--mirror-left); + top: var(--mirror-top); + border: 1px solid rgba(202, 236, 255, 0.15); + border-radius: 2px; + opacity: var(--mirror-opacity); + background: + linear-gradient(111deg, transparent 0 24%, rgba(235, 249, 255, 0.18) 42%, rgba(77, 138, 174, 0.11) 58%, transparent 78%), + repeating-linear-gradient(148deg, transparent 0 41px, rgba(219, 244, 255, 0.06) 42px, transparent 43px 71px), + rgba(119, 178, 211, 0.035); + box-shadow: + inset 1px 0 rgba(237, 248, 255, 0.22), + inset -1px 0 rgba(73, 132, 166, 0.18), + 0 0 30px rgba(94, 174, 218, 0.055); + backdrop-filter: blur(1px); + transform-style: preserve-3d; + animation: mirror-float var(--mirror-duration) ease-in-out var(--mirror-delay) infinite alternate; +} + +.mirror-array > i::before, +.mirror-array > i::after { + position: absolute; + content: ""; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(235, 248, 255, 0.33), transparent); + transform-origin: left; +} + +.mirror-array > i::before { + width: 72%; + top: 32%; + left: 14%; + transform: rotate(29deg); +} + +.mirror-array > i::after { + width: 48%; + top: 53%; + left: 45%; + transform: rotate(-42deg); +} + +.mirror-array > i:nth-child(1) { + --mirror-width: 7vw; + --mirror-height: 41vh; + --mirror-left: 3%; + --mirror-top: 8%; + --mirror-opacity: 0.36; + --mirror-duration: 13s; + --mirror-delay: -7s; + --mirror-tilt: 34deg; +} + +.mirror-array > i:nth-child(2) { + --mirror-width: 3.5vw; + --mirror-height: 28vh; + --mirror-left: 20%; + --mirror-top: 47%; + --mirror-opacity: 0.22; + --mirror-duration: 9s; + --mirror-delay: -2s; + --mirror-tilt: -31deg; +} + +.mirror-array > i:nth-child(3) { + --mirror-width: 6vw; + --mirror-height: 33vh; + --mirror-left: 36%; + --mirror-top: 3%; + --mirror-opacity: 0.26; + --mirror-duration: 15s; + --mirror-delay: -11s; + --mirror-tilt: 27deg; +} + +.mirror-array > i:nth-child(4) { + --mirror-width: 2.7vw; + --mirror-height: 22vh; + --mirror-left: 48%; + --mirror-top: 32%; + --mirror-opacity: 0.3; + --mirror-duration: 8s; + --mirror-delay: -5s; + --mirror-tilt: -38deg; +} + +.mirror-array > i:nth-child(5) { + --mirror-width: 8vw; + --mirror-height: 44vh; + --mirror-left: 61%; + --mirror-top: -7%; + --mirror-opacity: 0.21; + --mirror-duration: 14s; + --mirror-delay: -8s; + --mirror-tilt: 42deg; +} + +.mirror-array > i:nth-child(6) { + --mirror-width: 4.2vw; + --mirror-height: 29vh; + --mirror-left: 73%; + --mirror-top: 41%; + --mirror-opacity: 0.33; + --mirror-duration: 10s; + --mirror-delay: -3s; + --mirror-tilt: -24deg; +} + +.mirror-array > i:nth-child(7) { + --mirror-width: 5vw; + --mirror-height: 37vh; + --mirror-left: 84%; + --mirror-top: 10%; + --mirror-opacity: 0.28; + --mirror-duration: 12s; + --mirror-delay: -10s; + --mirror-tilt: 33deg; +} + +.mirror-array > i:nth-child(8) { + --mirror-width: 2.6vw; + --mirror-height: 18vh; + --mirror-left: 93%; + --mirror-top: 58%; + --mirror-opacity: 0.34; + --mirror-duration: 8.5s; + --mirror-delay: -6s; + --mirror-tilt: -47deg; +} + +.mirror-lake { + z-index: -5; + top: auto; + height: 43%; + overflow: hidden; + background: + linear-gradient(180deg, rgba(102, 179, 219, 0.07), rgba(18, 52, 73, 0.05) 28%, rgba(0, 0, 0, 0.21)), + repeating-linear-gradient(178deg, transparent 0 15px, rgba(194, 232, 252, 0.034) 16px, transparent 18px 35px); + mask-image: linear-gradient(180deg, transparent, #000 15%, #000); + transform: translate3d(calc(var(--pointer-x) * 6px), calc(var(--pointer-y) * 3px), 0); +} + +.mirror-lake::before { + position: absolute; + left: 7%; + right: -4%; + top: 0; + height: 2px; + content: ""; + background: linear-gradient(90deg, transparent, rgba(186, 230, 252, 0.48) 54%, transparent); + filter: blur(1px); + box-shadow: 0 0 28px rgba(120, 203, 244, 0.3); +} + +.lake-ripple { + position: absolute; + left: calc(62% + var(--pointer-x) * 3%); + top: calc(36% + var(--pointer-y) * 3%); + width: 38vw; + height: 9vw; + border: 1px solid rgba(198, 234, 252, 0.16); + border-radius: 50%; + transform: translate(-50%, -50%) scale(0.22); + opacity: 0; + box-shadow: + inset 0 0 14px rgba(153, 216, 249, 0.08), + 0 0 18px rgba(114, 192, 234, 0.07); + animation: lake-ripple 5.8s ease-out infinite; +} + +.lake-ripple--two { + left: 46%; + top: 62%; + width: 46vw; + height: 11vw; + animation-duration: 8.4s; + animation-delay: -4.1s; +} + +.lake-caustics { + position: absolute; + inset: -35% -10%; + opacity: 0.17; + background: + repeating-radial-gradient(ellipse at 50% 50%, transparent 0 28px, rgba(170, 223, 250, 0.12) 30px, transparent 32px 65px), + repeating-linear-gradient(108deg, transparent 0 62px, rgba(214, 241, 255, 0.06) 65px, transparent 68px 125px); + mix-blend-mode: screen; + filter: blur(2px); + animation: lake-caustics 13s linear infinite; +} + +.exposure-sweeps { + z-index: -4; + overflow: hidden; + mix-blend-mode: screen; +} + +.exposure-sweeps i { + position: absolute; + top: -15%; + bottom: -15%; + width: 19vw; + opacity: 0; + background: linear-gradient(90deg, transparent, rgba(198, 234, 252, 0.09), rgba(255, 255, 255, 0.17), transparent); + filter: blur(6px); + transform: skewX(-8deg) translateX(-28vw); + animation: exposure-pass 7s ease-in-out infinite; +} + +.exposure-sweeps i:nth-child(2) { + width: 11vw; + animation-duration: 11s; + animation-delay: -7s; +} + +.exposure-sweeps i:nth-child(3) { + width: 26vw; + animation-duration: 16s; + animation-delay: -3s; +} + +.scan-noise { + z-index: 40; + opacity: 0.14; + background: + repeating-linear-gradient(180deg, transparent 0, transparent 3px, rgba(255, 255, 255, 0.018) 4px), + linear-gradient(90deg, rgba(255, 255, 255, 0.012), transparent 27%, rgba(255, 255, 255, 0.008) 73%, transparent); + mix-blend-mode: screen; +} + +.topbar { + position: absolute; + z-index: 30; + top: 0; + left: 0; + right: 0; + height: 72px; + padding: 0 32px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + border-bottom: 1px solid rgba(219, 224, 229, 0.12); + background: linear-gradient(180deg, rgba(3, 4, 5, 0.88), rgba(3, 4, 5, 0.18)); + backdrop-filter: blur(18px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 14px; + width: max-content; + color: var(--paper); + text-decoration: none; +} + +.brand-mark { + position: relative; + width: 32px; + height: 32px; + transform: rotate(45deg); + border: 1px solid rgba(255, 255, 255, 0.45); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), transparent 55%); +} + +.brand-mark::after, +.brand-mark i { + position: absolute; + content: ""; + border: 1px solid rgba(255, 255, 255, 0.26); +} + +.brand-mark::after { + inset: 6px; +} + +.brand-mark i:nth-child(1) { + inset: -5px 10px; +} + +.brand-mark i:nth-child(2) { + inset: 10px -5px; +} + +.brand-mark i:nth-child(3) { + width: 5px; + height: 5px; + top: 13px; + left: 13px; + border: 0; + background: #fff; + box-shadow: 0 0 15px rgba(255, 255, 255, 0.8); +} + +.brand > span:last-child { + display: grid; + gap: 2px; +} + +.brand strong { + font-size: 17px; + letter-spacing: 0.28em; +} + +.brand small, +.system-line, +.sound-toggle small, +.left-rail, +.section-kicker, +.intro-meta, +.card-coordinate, +.hero-index, +.hero-status, +.selection-state p, +.action-dock small, +.edge-readout, +.creation-gate > section > small, +.gate-spec, +.checkpoint-toast span { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + text-transform: uppercase; +} + +.brand small { + font-size: 7px; + color: rgba(206, 212, 218, 0.48); + letter-spacing: 0.23em; +} + +.system-line { + display: flex; + align-items: center; + gap: 30px; + font-size: 8px; + letter-spacing: 0.16em; + color: rgba(206, 212, 218, 0.48); +} + +.system-line b { + color: rgba(239, 241, 243, 0.72); + font-weight: 500; +} + +.pulse-dot { + display: inline-block; + width: 5px; + height: 5px; + margin-right: 8px; + border-radius: 50%; + background: #f6f7f8; + box-shadow: 0 0 12px rgba(255, 255, 255, 0.75); + animation: pulse 1.8s ease-in-out infinite; +} + +.sound-toggle { + justify-self: end; + display: inline-flex; + align-items: center; + gap: 3px; + padding: 12px 0 12px 12px; + background: transparent; + cursor: pointer; +} + +.sound-toggle > span { + width: 1px; + height: 9px; + background: rgba(224, 228, 232, 0.4); +} + +.sound-toggle > span:nth-child(2) { + height: 15px; +} + +.sound-toggle > span:nth-child(3) { + height: 5px; +} + +.sound-toggle > span:nth-child(4) { + height: 11px; +} + +.sound-toggle[aria-pressed="true"] > span { + animation: sound-wave 0.8s ease-in-out infinite alternate; +} + +.sound-toggle small { + margin-left: 8px; + font-size: 7px; + letter-spacing: 0.13em; + color: rgba(208, 213, 218, 0.46); +} + +.left-rail { + position: absolute; + z-index: 15; + left: 24px; + top: 50%; + display: flex; + align-items: center; + gap: 17px; + transform: translate(-50%, -50%) rotate(-90deg); + transform-origin: center; + font-size: 7px; + letter-spacing: 0.22em; + color: rgba(200, 207, 213, 0.39); +} + +.left-rail i { + width: 55px; + height: 1px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.36), transparent); +} + +.intro-block { + position: absolute; + z-index: 10; + top: clamp(108px, 13vh, 132px); + left: clamp(90px, 7.6vw, 132px); + width: 42vw; + max-width: 650px; +} + +.section-kicker { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 17px; + font-size: 8px; + letter-spacing: 0.25em; + color: rgba(220, 225, 230, 0.56); +} + +.section-kicker span { + width: 21px; + height: 1px; + background: linear-gradient(90deg, #fff, rgba(255, 255, 255, 0.08)); + box-shadow: 0 0 9px rgba(255, 255, 255, 0.4); +} + +.silver-title { + position: relative; + width: max-content; + min-width: 510px; + min-height: 148px; + margin: 0; + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; + font-size: clamp(50px, 5.05vw, 76px); + font-weight: 700; + line-height: 0.92; + letter-spacing: -0.065em; +} + +.silver-title span { + position: absolute; + inset: 0; + display: block; +} + +.silver-title em { + font-style: normal; + font-weight: 700; +} + +.silver-title__base { + color: transparent; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.72) 0%, transparent 22%, rgba(4, 10, 15, 0.28) 88%), + linear-gradient(106deg, #87939d 2%, #f1f7fb 23%, #ffffff 41%, #9caab5 58%, #eef5f9 76%, #6c7983 97%); + background-size: 250% 100%; + background-position: 0 50%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-stroke: 0.35px rgba(231, 244, 252, 0.45); + filter: + drop-shadow(1px 2px 0 rgba(12, 22, 29, 0.88)) + drop-shadow(-1px -1px 0 rgba(238, 248, 255, 0.16)) + drop-shadow(0 8px 22px rgba(0, 0, 0, 0.34)); + animation: silver-drift 7.5s ease-in-out infinite alternate; +} + +.silver-title__sheen { + color: transparent; + background: + linear-gradient(96deg, transparent 32%, rgba(255, 255, 255, 0.98) 47%, rgba(255, 255, 255, 0.12) 54%, transparent 68%); + background-size: 240% 100%; + background-position: 180% 0; + background-clip: text; + -webkit-background-clip: text; + mix-blend-mode: screen; + filter: blur(0.18px) drop-shadow(0 0 12px rgba(196, 232, 252, 0.38)); + animation: title-sheen 5.8s cubic-bezier(0.45, 0, 0.2, 1) infinite; +} + +.intro-copy { + max-width: 430px; + margin: 17px 0 0; + color: rgba(218, 223, 228, 0.62); + font-size: 13px; + letter-spacing: 0.08em; +} + +.intro-meta { + display: flex; + gap: 28px; + margin-top: 24px; + font-size: 7px; + letter-spacing: 0.14em; + color: rgba(186, 192, 198, 0.34); +} + +.intro-meta span::before { + content: "·"; + margin-right: 9px; + color: rgba(255, 255, 255, 0.76); +} + +.identity-matrix { + position: absolute; + z-index: 19; + left: clamp(90px, 7.6vw, 132px); + bottom: 26px; + width: min(55vw, 870px); + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + perspective: 1200px; +} + +.persona-card { + --light-x: 50%; + --light-y: 50%; + --tilt-x: 0deg; + --tilt-y: 0deg; + --card-hue: 210; + position: relative; + min-width: 0; + height: clamp(160px, 19vh, 196px); + padding: 0; + overflow: hidden; + border: 1px solid rgba(214, 220, 226, 0.16); + border-radius: 14px; + background: rgba(6, 7, 8, 0.68); + text-align: left; + cursor: pointer; + transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y)) translateY(0); + transform-style: preserve-3d; + box-shadow: 0 18px 34px rgba(0, 0, 0, 0.16); + transition: + transform 260ms ease, + border-color 360ms ease, + box-shadow 360ms ease; +} + +.persona-card::after { + position: absolute; + inset: 0; + z-index: 3; + content: ""; + opacity: 0; + background: + radial-gradient(circle at var(--light-x) var(--light-y), hsla(var(--card-hue), 96%, 72%, 0.27), transparent 28%), + linear-gradient(118deg, transparent 32%, hsla(calc(var(--card-hue) + 65), 95%, 77%, 0.1) 47%, transparent 62%); + mix-blend-mode: screen; + transition: opacity 260ms ease; +} + +.persona-card:hover, +.persona-card.is-hovered, +.persona-card.is-active { + transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y)) translateY(-7px); + border-color: rgba(235, 238, 241, 0.44); + box-shadow: 0 24px 52px rgba(0, 0, 0, 0.35), 0 0 24px hsla(var(--card-hue), 82%, 62%, 0.06); +} + +.persona-card:hover::after, +.persona-card.is-hovered::after { + opacity: 1; +} + +.persona-card.is-active { + border-color: rgba(255, 255, 255, 0.6); +} + +.persona-card.is-active::before { + position: absolute; + z-index: 6; + top: 10px; + right: 10px; + width: 5px; + height: 5px; + content: ""; + border-radius: 50%; + background: white; + box-shadow: 0 0 12px white; +} + +.card-art, +.card-art img, +.card-refraction, +.card-grain { + position: absolute; + inset: 0; +} + +.card-art { + z-index: 0; + overflow: hidden; + background: #08090a; +} + +.card-art::after { + position: absolute; + inset: 0; + content: ""; + background: + linear-gradient(180deg, rgba(2, 3, 4, 0.06) 0%, rgba(2, 3, 4, 0.16) 45%, rgba(2, 3, 4, 0.94) 100%), + linear-gradient(90deg, rgba(0, 0, 0, 0.33), transparent 70%); +} + +.card-art img { + width: 100%; + height: 100%; + object-fit: cover; + filter: grayscale(1) contrast(1.1) brightness(0.64); + transform: scale(1.04); + transition: + transform 900ms cubic-bezier(0.22, 1, 0.36, 1), + filter 500ms ease; +} + +.persona-card:hover .card-art img, +.persona-card.is-hovered .card-art img, +.persona-card.is-active .card-art img { + filter: grayscale(0.84) contrast(1.14) brightness(0.82); + transform: scale(1.11) translate3d(calc((var(--light-x) - 50%) * -0.025), calc((var(--light-y) - 50%) * -0.02), 0); +} + +.card-refraction { + opacity: 0; + background: + radial-gradient(circle at var(--light-x) var(--light-y), hsla(var(--card-hue), 100%, 72%, 0.38), transparent 19%), + conic-gradient(from 120deg at var(--light-x) var(--light-y), transparent, rgba(104, 225, 255, 0.18), rgba(244, 125, 255, 0.19), rgba(255, 197, 96, 0.13), transparent 44%); + mix-blend-mode: color; + transition: opacity 240ms ease; +} + +.persona-card:hover .card-refraction, +.persona-card.is-hovered .card-refraction { + opacity: 1; +} + +.card-grain { + opacity: 0.13; + background: repeating-radial-gradient(circle at 35% 44%, rgba(255, 255, 255, 0.14) 0 0.35px, transparent 0.8px 2px); + mix-blend-mode: overlay; +} + +.card-coordinate { + position: absolute; + z-index: 5; + top: 13px; + left: 14px; + font-size: 6px; + letter-spacing: 0.13em; + color: rgba(225, 230, 235, 0.55); +} + +.card-copy { + position: absolute; + z-index: 5; + left: 14px; + right: 12px; + bottom: 12px; + display: flex; + align-items: end; + justify-content: space-between; +} + +.card-copy > span:first-child { + display: grid; + gap: 4px; +} + +.card-copy small { + font-size: 7px; + color: rgba(205, 211, 217, 0.48); + letter-spacing: 0.08em; +} + +.card-copy strong { + font-size: 15px; + font-weight: 450; + letter-spacing: 0.04em; + text-shadow: 0 2px 16px black; +} + +.card-arrow { + position: absolute; + z-index: 7; + top: 13px; + right: 12px; + display: grid; + width: 27px; + height: 27px; + place-items: center; + border: 1px solid rgba(231, 235, 239, 0.22); + border-radius: 50%; + color: rgba(240, 242, 244, 0.72); + font-size: 11px; + transition: + border-color 220ms ease, + background 220ms ease, + color 220ms ease; +} + +.persona-card:hover .card-arrow, +.persona-card.is-hovered .card-arrow, +.persona-card.is-active .card-arrow { + border-color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.card-index { + position: absolute; + z-index: 2; + right: -3px; + bottom: -18px; + color: rgba(255, 255, 255, 0.045); + font: 300 74px/1 "Avenir Next", sans-serif; +} + +.hero-stage { + position: absolute; + z-index: 7; + top: 72px; + right: 0; + bottom: 0; + width: 57vw; + min-width: 0; + overflow: hidden; + background: + linear-gradient(90deg, #030405 0%, rgba(3, 4, 5, 0.56) 8%, transparent 28%), + linear-gradient(180deg, transparent 62%, rgba(2, 3, 4, 0.84) 94%); + mask-image: linear-gradient(90deg, transparent 0%, #000 13%, #000 100%); +} + +.hero-figure, +.hero-image-wrap, +.hero-image { + position: absolute; + inset: 0; +} + +.hero-figure { + z-index: 3; + transform: translate3d(calc(var(--pointer-x) * -8px), calc(var(--pointer-y) * -5px), 0); + transition: opacity 180ms ease, filter 500ms ease; +} + +.hero-image-wrap { + overflow: hidden; +} + +.hero-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 50%; + filter: grayscale(1) contrast(1.15) brightness(0.77); + transform: scale(1.015); + transition: + transform 8s cubic-bezier(0.2, 0.7, 0.2, 1), + filter 600ms ease; +} + +.hero-stage:hover .hero-image { + transform: scale(1.045); +} + +[data-active-persona="johnny"] .hero-image--base { + object-fit: contain; + object-position: 72% 52%; + filter: grayscale(0.58) saturate(0.58) contrast(1.13) brightness(0.78); +} + +[data-active-persona="jarvis"] .hero-image--base { + object-fit: contain; + object-position: 58% 48%; + filter: grayscale(0.18) saturate(0.72) contrast(1.1) brightness(0.82); +} + +.hero-silver-sweep { + position: absolute; + inset: -20%; + background: linear-gradient(105deg, transparent 37%, rgba(255, 255, 255, 0.16) 49%, rgba(255, 255, 255, 0.02) 56%, transparent 63%); + mix-blend-mode: screen; + transform: translateX(-62%); + animation: hero-sweep 7.8s ease-in-out infinite; +} + +.hero-backlight { + position: absolute; + z-index: 1; + width: 56%; + height: 58%; + top: 10%; + right: 8%; + border-radius: 50%; + background: radial-gradient(circle, rgba(245, 247, 249, 0.12), rgba(164, 171, 178, 0.035) 42%, transparent 70%); + filter: blur(18px); + animation: backlight-breathe 6s ease-in-out infinite; +} + +.jarvis-optics { + position: absolute; + z-index: 5; + width: min(42vw, 440px); + aspect-ratio: 1; + top: 17%; + right: 18%; + opacity: 0; + border: 1px solid rgba(197, 236, 255, 0.12); + border-radius: 50%; + pointer-events: none; + transform: scale(0.82); + transition: opacity 520ms ease, transform 760ms cubic-bezier(0.22, 1, 0.36, 1); + mix-blend-mode: screen; +} + +.jarvis-optics::before, +.jarvis-optics::after, +.jarvis-optics i { + position: absolute; + content: ""; + border-radius: 50%; + border: 1px solid rgba(207, 241, 255, 0.26); +} + +.jarvis-optics::before { + inset: 7%; + border-style: dashed; + box-shadow: 0 0 24px rgba(143, 214, 250, 0.12); + animation: jarvis-orbit 12s linear infinite; +} + +.jarvis-optics::after { + inset: 28%; + border-color: rgba(238, 250, 255, 0.39); + box-shadow: + inset 0 0 22px rgba(206, 239, 255, 0.1), + 0 0 24px rgba(206, 239, 255, 0.14); + animation: jarvis-pulse 3.6s ease-in-out infinite; +} + +.jarvis-optics i:nth-child(1) { + inset: 16%; + border-style: dotted; + animation: jarvis-orbit-reverse 17s linear infinite; +} + +.jarvis-optics i:nth-child(2) { + width: 7px; + height: 7px; + top: 12%; + left: 49%; + border: 0; + background: #f5fcff; + box-shadow: 0 0 18px #b8eaff; + animation: jarvis-node 4s ease-in-out infinite; +} + +.jarvis-optics i:nth-child(3) { + inset: 39%; + border-color: rgba(255, 255, 255, 0.62); + background: radial-gradient(circle, rgba(242, 252, 255, 0.18), transparent 67%); +} + +[data-active-persona="jarvis"] .jarvis-optics { + opacity: 0.82; + transform: scale(1); +} + +.hero-orbit { + position: absolute; + z-index: 2; + border: 1px solid rgba(225, 230, 235, 0.09); + border-radius: 50%; + pointer-events: none; +} + +.hero-orbit::before, +.hero-orbit::after { + position: absolute; + content: ""; + border-radius: 50%; + background: rgba(255, 255, 255, 0.4); + box-shadow: 0 0 12px rgba(255, 255, 255, 0.35); +} + +.hero-orbit--outer { + width: 690px; + height: 690px; + top: 50%; + right: -85px; + transform: translateY(-52%) rotate(17deg); + border-style: dashed; + animation: orbit-turn 44s linear infinite; +} + +.hero-orbit--outer::before { + width: 3px; + height: 3px; + top: 31%; + left: 1%; +} + +.hero-orbit--inner { + width: 470px; + height: 470px; + top: 44%; + right: 74px; + transform: translateY(-50%); + opacity: 0.7; + animation: orbit-turn-reverse 31s linear infinite; +} + +.hero-orbit--inner::after { + width: 2px; + height: 2px; + right: 5%; + bottom: 27%; +} + +.hero-film-flash { + position: absolute; + z-index: 8; + inset: 0; + opacity: 0; + background: + linear-gradient(90deg, transparent 0 12%, rgba(255, 255, 255, 0.64) 13%, rgba(255, 255, 255, 0.08) 15%, transparent 17%), + linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 48%); + mix-blend-mode: screen; + transform: translateX(-60%); + pointer-events: none; +} + +.is-developing .hero-film-flash { + animation: film-develop 520ms cubic-bezier(0.2, 0.7, 0.2, 1); +} + +.is-developing .hero-figure { + opacity: 0.18; + filter: blur(8px); +} + +.hero-index { + position: absolute; + z-index: 11; + top: 13%; + right: 28px; + display: flex; + align-items: center; + gap: 10px; + font-size: 7px; + letter-spacing: 0.12em; + color: rgba(214, 220, 226, 0.38); +} + +.hero-index > span { + color: rgba(249, 250, 251, 0.8); + font-size: 11px; +} + +.hero-index i { + width: 42px; + height: 1px; + background: linear-gradient(90deg, rgba(255, 255, 255, 0.65), transparent); +} + +.hero-caption { + position: absolute; + z-index: 10; + right: 30px; + bottom: 108px; + width: 380px; + padding: 21px 23px; + border-left: 1px solid rgba(245, 247, 249, 0.35); + background: linear-gradient(90deg, rgba(2, 3, 4, 0.78), rgba(2, 3, 4, 0.05)); + backdrop-filter: blur(8px); + transition: opacity 420ms ease, transform 520ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.hero-caption > p:first-child { + margin: 0 0 5px; + color: rgba(228, 232, 236, 0.58); + font-size: 9px; + letter-spacing: 0.18em; +} + +.hero-caption h2 { + margin: 0; + display: grid; + gap: 3px; + font-size: 29px; + font-weight: 370; + letter-spacing: 0.06em; + text-shadow: 0 4px 24px black; +} + +.hero-caption h2 small { + font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.17em; + color: rgba(215, 221, 227, 0.46); +} + +.hero-status { + display: grid; + gap: 6px; + margin-top: 13px; + padding-top: 10px; + border-top: 1px solid rgba(224, 229, 234, 0.12); + color: rgba(211, 217, 223, 0.46); + font-size: 6px; + letter-spacing: 0.11em; +} + +.hero-status span:first-child { + display: flex; + align-items: center; + gap: 7px; +} + +.hero-status i { + width: 4px; + height: 4px; + border-radius: 50%; + background: white; + box-shadow: 0 0 9px white; +} + +.hero-status b { + font-weight: 400; + color: rgba(239, 241, 243, 0.72); +} + +.hero-caption > p:last-child { + max-width: 330px; + margin: 12px 0 0; + color: rgba(207, 213, 219, 0.52); + font-size: 10px; + line-height: 1.65; + letter-spacing: 0.06em; +} + +.projected-arm-layer { + position: absolute; + z-index: 22; + top: 72px; + right: 0; + bottom: 0; + width: 57vw; + min-width: 0; + overflow: visible; + pointer-events: none; + opacity: 0; + transition: opacity 420ms ease; + mask-image: radial-gradient(ellipse 44% 42% at 57% 75%, #000 0%, #000 58%, transparent 74%); +} + +.projected-arm-layer img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 50%; + filter: saturate(1.4) contrast(1.12) brightness(1.02) hue-rotate(calc(var(--arm-hue) - 280deg)); + transform: scale(1.045) translate3d(calc(var(--pointer-x) * 13px), calc(var(--pointer-y) * 8px), 0); + transform-origin: 58% 76%; + transition: + transform 900ms cubic-bezier(0.22, 1, 0.36, 1), + filter 800ms ease; +} + +.projected-arm-layer span { + position: absolute; + width: 56%; + height: 45%; + right: 5%; + bottom: 2%; + border-radius: 50%; + background: radial-gradient(ellipse, hsla(210, 100%, 75%, 0.09), hsla(300, 100%, 72%, 0.04) 44%, transparent 73%); + filter: blur(20px); + mix-blend-mode: screen; + animation: arm-aura 4.6s ease-in-out infinite; +} + +[data-active-persona="custom"] .projected-arm-layer { + opacity: 1; +} + +[data-active-persona="custom"] .hero-image--base { + filter: grayscale(1) contrast(1.18) brightness(0.66); +} + +[data-active-persona="custom"] .hero-caption { + opacity: 0; + transform: translateX(18px); + pointer-events: none; +} + +.edge-readout { + position: absolute; + z-index: 12; + color: rgba(201, 208, 215, 0.24); + font-size: 6px; + letter-spacing: 0.14em; +} + +.edge-readout--top { + top: 85px; + left: 49%; + display: flex; + gap: 18px; +} + +.edge-readout--side { + top: 38%; + right: 13px; + display: flex; + align-items: center; + gap: 8px; + transform: rotate(90deg) translateX(100%); + transform-origin: top right; +} + +.edge-readout--side i { + width: 33px; + height: 1px; + background: rgba(255, 255, 255, 0.18); +} + +.action-dock { + position: absolute; + z-index: auto; + right: 0; + bottom: 0; + width: 48vw; + min-width: 0; + height: 92px; + display: grid; + grid-template-columns: 1.15fr 1.2fr 0.9fr; + background: transparent; +} + +.action-dock::before { + position: absolute; + z-index: 18; + inset: 0; + content: ""; + border-top: 1px solid rgba(228, 232, 236, 0.18); + border-left: 1px solid rgba(228, 232, 236, 0.12); + background: rgba(5, 6, 7, 0.78); + backdrop-filter: blur(24px) saturate(0.7); +} + +.action-dock > * { + position: relative; + z-index: 24; +} + +.selection-state { + display: flex; + align-items: center; + gap: 14px; + padding: 0 20px; + border-right: 1px solid rgba(226, 230, 234, 0.1); +} + +.selection-ring { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: 1px solid rgba(231, 235, 239, 0.22); + border-radius: 50%; +} + +.selection-ring i { + width: 15px; + height: 15px; + border: 1px dashed rgba(255, 255, 255, 0.55); + border-radius: 50%; + animation: orbit-turn 6s linear infinite; +} + +.selection-state p { + margin: 0; + color: rgba(201, 207, 213, 0.4); + font-size: 6px; + letter-spacing: 0.12em; +} + +.selection-state strong { + display: block; + margin-top: 5px; + color: rgba(239, 241, 243, 0.75); + font-size: 8px; + font-weight: 450; +} + +.enter-button, +.add-button { + position: relative; + display: flex; + align-items: center; + background: rgba(5, 6, 7, 0.72); + cursor: pointer; + overflow: hidden; + transition: background 260ms ease; +} + +.enter-button::before, +.add-button::before { + position: absolute; + inset: 0; + content: ""; + opacity: 0; + background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.08), transparent); + transform: translateX(-100%); +} + +.enter-button:hover::before, +.add-button:hover::before { + opacity: 1; + animation: dock-sheen 900ms ease; +} + +.enter-button { + padding: 0 58px 0 22px; + border-right: 1px solid rgba(226, 230, 234, 0.1); + display: grid; + justify-content: start; +} + +.enter-button span { + align-self: end; + margin-bottom: 2px; + font-size: 14px; +} + +.enter-button small { + align-self: start; + margin-top: 2px; + font-size: 6px; + color: rgba(204, 210, 216, 0.42); + letter-spacing: 0.1em; +} + +.enter-button i { + position: absolute; + right: 18px; + top: 50%; + display: grid; + width: 31px; + height: 31px; + place-items: center; + transform: translateY(-50%); + border: 1px solid rgba(235, 238, 241, 0.34); + border-radius: 50%; + font-style: normal; +} + +.add-button { + gap: 12px; + padding: 0 16px; +} + +.add-button > span { + display: grid; + width: 30px; + height: 30px; + flex: 0 0 auto; + place-items: center; + border: 1px solid rgba(229, 233, 237, 0.19); + border-radius: 8px; + color: rgba(235, 238, 241, 0.68); + font-size: 18px; + font-weight: 200; +} + +.add-button p { + margin: 0; + font-size: 9px; + white-space: nowrap; +} + +.add-button small { + display: block; + margin-top: 4px; + color: rgba(200, 206, 212, 0.36); + font-size: 6px; + letter-spacing: 0.08em; +} + +.creation-gate { + position: fixed; + z-index: 100; + inset: 0; + display: grid; + place-items: center; +} + +.gate-backdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.76); + backdrop-filter: blur(15px) saturate(0.4); + cursor: pointer; +} + +.creation-gate > section { + position: relative; + width: min(580px, 64vw); + padding: 50px; + border: 1px solid rgba(229, 233, 237, 0.22); + border-radius: 2px; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.03), transparent 42%), + rgba(7, 8, 9, 0.94); + box-shadow: 0 50px 120px rgba(0, 0, 0, 0.72); + opacity: 0; + transform: translateY(18px) scale(0.98); + transition: opacity 260ms ease, transform 400ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.creation-gate.is-visible > section { + opacity: 1; + transform: translateY(0) scale(1); +} + +.gate-close { + position: absolute; + top: 16px; + right: 18px; + width: 32px; + height: 32px; + background: transparent; + color: rgba(231, 235, 239, 0.5); + font-size: 23px; + font-weight: 200; + cursor: pointer; +} + +.creation-gate > section > small { + font-size: 7px; + letter-spacing: 0.15em; + color: rgba(210, 216, 222, 0.34); +} + +.creation-gate h2 { + margin: 19px 0 15px; + color: transparent; + background: linear-gradient(110deg, #72787e, #f5f6f7 43%, #777d83); + background-clip: text; + -webkit-background-clip: text; + font-size: 38px; + font-weight: 300; + line-height: 1.12; +} + +.creation-gate h2 em { + font-style: normal; +} + +.creation-gate > section > p { + max-width: 480px; + margin: 0; + color: rgba(208, 214, 220, 0.51); + font-size: 12px; + line-height: 1.75; +} + +.gate-spec { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin: 30px 0; +} + +.gate-spec span { + padding: 14px; + border: 1px solid rgba(224, 229, 234, 0.13); + color: rgba(205, 211, 217, 0.43); + font-size: 7px; + letter-spacing: 0.1em; +} + +.gate-spec b { + display: block; + margin-bottom: 13px; + color: rgba(245, 247, 249, 0.82); + font-size: 18px; + font-weight: 300; +} + +.gate-confirm { + width: 100%; + padding: 17px 19px; + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid rgba(239, 241, 243, 0.32); + background: rgba(255, 255, 255, 0.055); + cursor: pointer; +} + +.gate-confirm span { + font-size: 13px; +} + +.gate-confirm small { + color: rgba(212, 218, 224, 0.43); + font-size: 6px; + letter-spacing: 0.1em; +} + +.checkpoint-toast { + position: fixed; + z-index: 110; + right: 30px; + top: 94px; + width: 320px; + padding: 16px 18px; + border: 1px solid rgba(231, 235, 239, 0.24); + background: rgba(8, 9, 10, 0.92); + color: rgba(229, 233, 237, 0.64); + font-size: 10px; + line-height: 1.55; + opacity: 0; + transform: translateX(16px); + transition: opacity 280ms ease, transform 280ms ease; +} + +.checkpoint-toast.is-visible { + opacity: 1; + transform: translateX(0); +} + +.checkpoint-toast span { + display: block; + margin-bottom: 5px; + color: rgba(250, 251, 252, 0.85); + font-size: 7px; + letter-spacing: 0.12em; +} + +@keyframes silver-drift { + to { + background-position: 100% 50%; + } +} + +@keyframes title-sheen { + 0%, + 18% { + background-position: 190% 0; + opacity: 0; + } + 34% { + opacity: 1; + } + 55%, + 100% { + background-position: -80% 0; + opacity: 0.24; + } +} + +@keyframes chamber-breathe { + from { + transform: translate3d(calc(var(--pointer-x) * -12px), calc(var(--pointer-y) * -8px), 0) scale(1.025); + filter: saturate(0.7) contrast(1.08) brightness(0.67); + } + to { + transform: translate3d(calc(var(--pointer-x) * -12px), calc(var(--pointer-y) * -8px), 0) scale(1.07); + filter: saturate(0.8) contrast(1.14) brightness(0.77); + } +} + +@keyframes mirror-float { + from { + transform: translate3d(calc(var(--pointer-x) * -10px), -18px, -35px) rotateY(var(--mirror-tilt)) rotateZ(-1.5deg); + } + to { + transform: translate3d(calc(var(--pointer-x) * 14px), 22px, 36px) rotateY(-7deg) rotateZ(1.6deg); + } +} + +@keyframes lake-ripple { + 0% { + transform: translate(-50%, -50%) scale(0.2); + opacity: 0; + } + 18% { + opacity: 0.52; + } + 100% { + transform: translate(-50%, -50%) scale(1.45); + opacity: 0; + } +} + +@keyframes lake-caustics { + from { + transform: translate3d(-4%, -1%, 0) rotate(-2deg); + } + to { + transform: translate3d(5%, 2%, 0) rotate(2deg); + } +} + +@keyframes exposure-pass { + 0%, + 16% { + transform: skewX(-8deg) translateX(-28vw); + opacity: 0; + } + 28% { + opacity: 0.62; + } + 62%, + 100% { + transform: skewX(-8deg) translateX(118vw); + opacity: 0; + } +} + +@keyframes jarvis-orbit { + to { + transform: rotate(360deg); + } +} + +@keyframes jarvis-orbit-reverse { + to { + transform: rotate(-360deg); + } +} + +@keyframes jarvis-pulse { + 50% { + transform: scale(1.09); + opacity: 0.52; + box-shadow: + inset 0 0 32px rgba(220, 246, 255, 0.18), + 0 0 42px rgba(192, 235, 255, 0.26); + } +} + +@keyframes jarvis-node { + 50% { + transform: scale(0.56); + opacity: 0.35; + } +} + +@keyframes pulse { + 50% { + opacity: 0.3; + transform: scale(0.7); + } +} + +@keyframes sound-wave { + to { + transform: scaleY(0.35); + } +} + +@keyframes orbit-turn { + to { + transform: translateY(-52%) rotate(377deg); + } +} + +@keyframes orbit-turn-reverse { + to { + transform: translateY(-50%) rotate(-360deg); + } +} + +@keyframes hero-sweep { + 0%, + 16% { + transform: translateX(-68%); + opacity: 0; + } + 35% { + opacity: 0.8; + } + 58%, + 100% { + transform: translateX(68%); + opacity: 0; + } +} + +@keyframes backlight-breathe { + 50% { + transform: scale(1.08); + opacity: 0.64; + } +} + +@keyframes film-develop { + 0% { + opacity: 0; + transform: translateX(-70%); + } + 32% { + opacity: 0.75; + } + 100% { + opacity: 0; + transform: translateX(80%); + } +} + +@keyframes arm-aura { + 50% { + transform: scale(1.09); + opacity: 0.55; + } +} + +@keyframes dock-sheen { + to { + transform: translateX(100%); + } +} + +@media (max-width: 1199px) { + .topbar { + padding: 0 22px; + } + + .system-line { + gap: 16px; + font-size: 7px; + } + + .intro-block, + .identity-matrix { + left: 66px; + } + + .intro-block { + width: 47vw; + } + + .silver-title { + min-width: 420px; + min-height: 126px; + font-size: 54px; + } + + .identity-matrix { + width: 57vw; + } + + .persona-card { + height: 158px; + } + + .card-copy strong { + font-size: 13px; + } + + .hero-stage, + .projected-arm-layer { + width: 58vw; + } + + .hero-caption { + right: 18px; + width: 315px; + } + + .action-dock { + width: 48vw; + grid-template-columns: 1fr 1.15fr 0.82fr; + } + + .selection-state { + padding: 0 13px; + } + + .selection-state strong { + max-width: 112px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .enter-button { + padding-left: 16px; + } + + .add-button p { + font-size: 8px; + } +} + +@media (max-height: 780px) { + .intro-block { + top: 98px; + } + + .silver-title { + min-height: 116px; + font-size: 50px; + } + + .intro-copy { + margin-top: 10px; + } + + .intro-meta { + margin-top: 14px; + } + + .identity-matrix { + bottom: 18px; + } + + .persona-card { + height: 150px; + } + + .hero-caption { + bottom: 102px; + } +} + +@media (min-width: 1800px) { + .identity-matrix { + max-width: 960px; + } + + .persona-card { + height: 210px; + } + + .hero-caption { + right: 54px; + width: 430px; + } +} + +/* Performance pass: keep the selector cinematic without permanent full-screen motion. */ +.crystal-chamber, +.mirror-array > i, +.lake-ripple, +.lake-caustics, +.exposure-sweeps i, +.hero-backlight, +.hero-orbit, +.jarvis-optics::before, +.jarvis-optics::after, +.jarvis-optics i, +.projected-arm-layer span { + animation: none; +} + +.lake-ripple, +.lake-caustics { + display: none; +} + +.crystal-chamber { + filter: saturate(0.48) contrast(1.16) brightness(0.48); + transform: scale(1.02); +} + +.mirror-array > i { + opacity: calc(var(--mirror-opacity) * 0.52); + backdrop-filter: none; + box-shadow: + inset 1px 0 rgba(237, 248, 255, 0.12), + 0 0 20px rgba(94, 174, 218, 0.025); +} + +.mirror-lake { + transform: none; + opacity: 0.42; +} + +.hero-figure { + transform: translate3d( + calc(var(--pointer-x) * -5px), + calc(var(--pointer-y) * -3px), + 0 + ); +} + +.hero-image { + transition: + transform 480ms cubic-bezier(0.22, 1, 0.36, 1), + filter 420ms ease; +} + +.hero-stage:hover .hero-image { + transform: scale(1.025); +} + +.hero-silver-sweep { + opacity: 0; + animation: none; +} + +.is-developing .hero-silver-sweep { + opacity: 1; + animation: hero-sweep 620ms cubic-bezier(0.22, 1, 0.36, 1) 1; +} + +[data-active-persona="winter"] .hero-stage { + background: + radial-gradient(ellipse at 69% 43%, rgba(117, 155, 181, 0.16), transparent 34%), + linear-gradient(90deg, #030405 0%, rgba(4, 7, 9, 0.84) 18%, rgba(7, 13, 18, 0.32) 58%, rgba(1, 3, 5, 0.74) 100%); +} + +[data-active-persona="winter"] .hero-image-wrap { + mask-image: + linear-gradient(180deg, #000 0%, #000 72%, transparent 98%), + linear-gradient(90deg, transparent 0%, #000 19%, #000 100%); + mask-composite: intersect; +} + +[data-active-persona="winter"] .hero-image-wrap::after { + position: absolute; + inset: 0; + content: ""; + pointer-events: none; + background: + linear-gradient(90deg, rgba(2, 5, 8, 0.34), transparent 35%, rgba(119, 169, 201, 0.08) 72%, rgba(2, 5, 8, 0.18)), + linear-gradient(180deg, transparent 50%, rgba(2, 4, 6, 0.56) 94%); + mix-blend-mode: multiply; +} + +[data-active-persona="winter"] .hero-image--base { + object-fit: cover; + object-position: 68% 48%; + filter: + grayscale(0.82) + saturate(0.5) + contrast(1.2) + brightness(0.72) + drop-shadow(14px 0 22px rgba(121, 174, 207, 0.16)); + transform: scale(1.08); +} + +/* Display typography: sharp editorial serifs with static chromatic metal. */ +.silver-title, +.xyz-intro h1 { + font-family: "Songti SC", "STSong", "Noto Serif SC", serif; + font-weight: 900; + letter-spacing: -0.105em; + transform: skewX(-6deg); + transform-origin: left center; +} + +.silver-title em, +.xyz-intro h1 em { + font-weight: 900; +} + +.silver-title__base { + color: transparent; + background: + linear-gradient( + 158deg, + #f8fbff 0%, + #ffe889 18%, + #ff9bd9 36%, + #9d8cff 51%, + #62efff 68%, + #b7ffdc 82%, + #fff4cf 100% + ); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-stroke: 0.55px rgba(255, 255, 255, 0.88); + filter: + drop-shadow(2px 0 0 rgba(53, 229, 255, 0.82)) + drop-shadow(-2px 0 0 rgba(255, 63, 190, 0.68)) + drop-shadow(4px 7px 0 rgba(17, 13, 40, 0.82)) + drop-shadow(0 12px 30px rgba(0, 0, 0, 0.6)); + animation: none; +} + +.silver-title__sheen { + opacity: 0.62; + background: + repeating-linear-gradient( + 180deg, + transparent 0 18px, + rgba(255, 255, 255, 0.88) 19px 21px, + transparent 22px 36px + ), + linear-gradient(100deg, transparent 24%, #ffffff 47%, transparent 66%); + background-clip: text; + -webkit-background-clip: text; + clip-path: polygon(0 10%, 100% 0, 100% 34%, 0 42%, 0 64%, 100% 52%, 100% 79%, 0 90%); + transform: translateX(3px); + animation: none; +} + +.xyz-intro h1, +.xyz-intro h1 em { + color: transparent; + background: + linear-gradient( + 156deg, + #ffffff 2%, + #ffe78d 22%, + #ff94d5 39%, + #7bdfff 63%, + #b8ffe4 82%, + #fff5d6 100% + ); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-stroke: 0.6px rgba(255, 255, 255, 0.75); + text-shadow: + 2px 0 rgba(53, 229, 255, 0.55), + -2px 0 rgba(255, 63, 190, 0.48), + 5px 7px rgba(15, 11, 37, 0.82); +} + +.xyz-workspace { + --xyz-line: rgba(220, 231, 239, 0.14); + position: relative; + width: 100vw; + height: 100vh; + min-height: 680px; + overflow: hidden; + color: #e9edf1; + background: + radial-gradient(circle at 50% 49%, rgba(108, 151, 181, 0.12), transparent 26%), + radial-gradient(circle at 47% 52%, rgba(255, 255, 255, 0.035), transparent 45%), + linear-gradient(115deg, #030405 0%, #07090b 49%, #020304 100%); +} + +.xyz-workspace::before, +.xyz-workspace::after { + position: absolute; + inset: 0; + content: ""; + pointer-events: none; +} + +.xyz-workspace::before { + opacity: 0.38; + background: + linear-gradient(90deg, transparent 0 49.95%, rgba(220, 231, 239, 0.08) 50%, transparent 50.05%), + linear-gradient(180deg, transparent 0 49.95%, rgba(220, 231, 239, 0.055) 50%, transparent 50.05%), + repeating-linear-gradient(90deg, transparent 0 119px, rgba(220, 231, 239, 0.025) 120px), + repeating-linear-gradient(0deg, transparent 0 119px, rgba(220, 231, 239, 0.025) 120px); + mask-image: radial-gradient(circle at 50% 52%, #000, transparent 68%); +} + +.xyz-workspace::after { + z-index: 20; + opacity: 0.12; + background: repeating-linear-gradient(180deg, transparent 0 3px, rgba(255, 255, 255, 0.055) 4px); + mix-blend-mode: overlay; +} + +.xyz-noise { + position: absolute; + inset: 0; + opacity: 0.16; + pointer-events: none; + background: + radial-gradient(circle at 18% 31%, white 0 0.5px, transparent 0.8px), + radial-gradient(circle at 79% 68%, white 0 0.4px, transparent 0.8px); + background-size: 37px 41px, 53px 47px; +} + +.workspace-topbar { + position: absolute; + z-index: 30; + inset: 0 0 auto; + height: 72px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 0 34px; + border-bottom: 1px solid rgba(222, 231, 238, 0.11); + background: rgba(3, 5, 7, 0.72); + backdrop-filter: blur(14px); +} + +.workspace-back { + justify-self: start; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + color: rgba(230, 235, 239, 0.55); + background: transparent; + font-size: 10px; + letter-spacing: 0.13em; + cursor: pointer; +} + +.workspace-back span { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 1px solid rgba(232, 237, 241, 0.21); + border-radius: 50%; + color: white; + transition: background 180ms ease, color 180ms ease; +} + +.workspace-back:hover span { + color: #050607; + background: #eef2f5; +} + +.workspace-brand { + display: grid; + justify-items: center; + gap: 3px; +} + +.workspace-brand b { + font-size: 16px; + letter-spacing: 0.24em; +} + +.workspace-brand small, +.workspace-topbar > p { + color: rgba(206, 216, 224, 0.36); + font: 6px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.18em; +} + +.workspace-topbar > p { + justify-self: end; + display: flex; + align-items: center; + gap: 9px; +} + +.workspace-topbar > p i { + width: 5px; + height: 5px; + border-radius: 50%; + background: #ccecff; + box-shadow: 0 0 12px #9edfff; +} + +.workspace-topbar > p span { + margin-left: 13px; + color: rgba(235, 240, 244, 0.64); +} + +.xyz-intro { + position: absolute; + z-index: 12; + left: 42px; + top: 116px; + width: 290px; +} + +.xyz-intro > p, +.xyz-controls__heading > p { + margin: 0 0 16px; + color: rgba(209, 219, 227, 0.39); + font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.16em; +} + +.xyz-intro h1 { + margin: 0; + color: #eff2f4; + font-size: clamp(36px, 4vw, 64px); + font-weight: 650; + line-height: 0.96; + letter-spacing: -0.055em; + text-shadow: 0 10px 36px rgba(0, 0, 0, 0.75); +} + +.xyz-intro h1 em { + color: transparent; + font-style: normal; + -webkit-text-stroke: 1px rgba(229, 235, 240, 0.54); +} + +.xyz-intro > span { + display: block; + max-width: 250px; + margin-top: 20px; + color: rgba(207, 216, 224, 0.48); + font-size: 10px; + line-height: 1.8; + letter-spacing: 0.08em; +} + +.xyz-stage { + position: absolute; + z-index: 5; + left: 25%; + top: 84px; + right: 340px; + bottom: 150px; + min-width: 420px; + min-height: 430px; +} + +.xyz-stage::before, +.xyz-stage::after { + position: absolute; + left: 50%; + top: 50%; + content: ""; + border: 1px solid rgba(219, 231, 240, 0.1); + border-radius: 50%; + pointer-events: none; + transform: translate(-50%, -50%); +} + +.xyz-stage::before { + width: min(57vh, 50vw); + aspect-ratio: 1; + box-shadow: + inset 0 0 65px rgba(141, 196, 230, 0.025), + 0 0 70px rgba(141, 196, 230, 0.035); +} + +.xyz-stage::after { + width: min(68vh, 60vw); + aspect-ratio: 1; + border-style: dashed; + opacity: 0.38; +} + +.xyz-canvas-host, +.xyz-webgl { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.xyz-webgl { + z-index: 3; + cursor: grab; + touch-action: none; +} + +.xyz-webgl:active { + cursor: grabbing; +} + +.axis-tag { + position: absolute; + z-index: 6; + color: rgba(221, 232, 240, 0.42); + font: 7px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.15em; + pointer-events: none; +} + +.axis-tag--x { + right: 8%; + top: 50%; +} + +.axis-tag--y { + left: 50%; + top: 8%; +} + +.axis-tag--z { + left: 21%; + bottom: 17%; +} + +.plane-selector { + position: absolute; + z-index: 8; + left: 50%; + bottom: 2px; + display: flex; + gap: 5px; + transform: translateX(-50%); +} + +.plane-selector button { + width: 42px; + height: 26px; + border: 1px solid rgba(220, 231, 239, 0.14); + border-radius: 14px; + color: rgba(222, 231, 238, 0.4); + background: rgba(4, 6, 8, 0.65); + font: 7px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.1em; + cursor: pointer; +} + +.plane-selector button.is-active { + border-color: rgba(224, 239, 249, 0.65); + color: #f3f8fb; + background: rgba(191, 225, 246, 0.12); + box-shadow: 0 0 22px rgba(159, 216, 249, 0.1); +} + +.xyz-readout { + position: absolute; + z-index: 6; + left: 10px; + bottom: 4px; + display: grid; + grid-template-columns: auto auto; + gap: 3px 8px; + margin: 0; + color: rgba(205, 215, 223, 0.3); + font: 6px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.12em; +} + +.xyz-readout b { + color: #ddecf5; +} + +.xyz-readout span { + grid-column: 1 / -1; +} + +.xyz-controls { + position: absolute; + z-index: 12; + top: 100px; + right: 32px; + width: 310px; + padding: 23px 22px; + border: 1px solid var(--xyz-line); + border-radius: 18px; + background: linear-gradient(145deg, rgba(16, 20, 24, 0.83), rgba(4, 6, 8, 0.68)); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.3); + backdrop-filter: blur(18px); +} + +.xyz-controls::before { + position: absolute; + inset: 0; + content: ""; + border-radius: inherit; + pointer-events: none; + background: linear-gradient(125deg, rgba(255, 255, 255, 0.055), transparent 26%); +} + +.xyz-controls__heading { + display: flex; + align-items: baseline; + justify-content: space-between; + padding-bottom: 14px; + border-bottom: 1px solid rgba(218, 228, 236, 0.1); +} + +.xyz-controls__heading > p { + margin: 0; +} + +.xyz-controls__heading > span { + color: rgba(236, 241, 244, 0.74); + font-size: 12px; +} + +.axis-controls { + display: grid; + gap: 20px; + margin-top: 21px; +} + +.axis-control { + display: grid; + gap: 9px; +} + +.axis-control__head { + display: grid; + grid-template-columns: 25px 1fr auto; + align-items: center; + gap: 7px; +} + +.axis-control__head b { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid rgba(232, 239, 244, 0.28); + border-radius: 50%; + color: #f3f6f8; + font: 8px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.axis-control__head > span { + color: rgba(221, 228, 234, 0.62); + font-size: 11px; + letter-spacing: 0.08em; +} + +.axis-control output { + color: #f7fafb; + font: 15px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.axis-control input { + width: 100%; + height: 2px; + margin: 7px 0 2px; + appearance: none; + background: + linear-gradient(90deg, rgba(220, 235, 244, 0.82) 0 var(--axis-value), rgba(220, 235, 244, 0.12) var(--axis-value) 100%); + cursor: ew-resize; +} + +.axis-control input::-webkit-slider-thumb { + width: 13px; + height: 13px; + appearance: none; + border: 2px solid #07090b; + border-radius: 50%; + background: #f2f7fa; + box-shadow: 0 0 0 1px rgba(218, 238, 249, 0.45), 0 0 15px rgba(181, 226, 252, 0.34); +} + +.axis-control__ends { + display: flex; + justify-content: space-between; + color: rgba(204, 214, 222, 0.3); + font-size: 7px; + letter-spacing: 0.12em; +} + +.profile-summary { + margin-top: 22px; + padding: 14px 15px; + border-left: 1px solid rgba(208, 229, 242, 0.4); + background: rgba(193, 222, 239, 0.025); +} + +.profile-summary small { + color: rgba(206, 218, 226, 0.33); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.13em; +} + +.profile-summary p { + margin: 9px 0 0; + color: rgba(229, 235, 239, 0.68); + font-size: 10px; + line-height: 1.7; + letter-spacing: 0.04em; +} + +.xyz-footer { + position: absolute; + z-index: 14; + left: 32px; + right: 32px; + bottom: 22px; + height: 108px; + display: grid; + grid-template-columns: 1fr 145px 260px; + gap: 10px; +} + +.mbti-picker, +.reset-profile, +.save-profile { + border: 1px solid var(--xyz-line); + border-radius: 14px; + background: rgba(4, 6, 8, 0.72); + backdrop-filter: blur(16px); +} + +.mbti-picker { + display: grid; + grid-template-columns: 100px 1fr; + align-items: center; + gap: 16px; + padding: 13px 17px; +} + +.mbti-picker > div:first-child { + display: grid; + gap: 4px; +} + +.mbti-picker small, +.reset-profile small, +.save-profile small { + color: rgba(207, 218, 226, 0.33); + font: 6px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.13em; +} + +.mbti-picker strong { + font-size: 20px; + letter-spacing: 0.05em; +} + +.mbti-grid { + display: grid; + grid-template-columns: repeat(8, minmax(34px, 1fr)); + gap: 5px; +} + +.mbti-grid button { + height: 31px; + border: 1px solid rgba(221, 231, 238, 0.1); + border-radius: 5px; + color: rgba(216, 225, 232, 0.38); + background: rgba(255, 255, 255, 0.012); + font: 7px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + cursor: pointer; +} + +.mbti-grid button:hover, +.mbti-grid button.is-active { + border-color: rgba(226, 239, 247, 0.55); + color: #f2f8fb; + background: rgba(199, 229, 246, 0.1); +} + +.reset-profile, +.save-profile { + color: rgba(235, 240, 244, 0.75); + cursor: pointer; +} + +.reset-profile { + display: grid; + place-content: center; + gap: 5px; +} + +.save-profile { + position: relative; + display: grid; + align-content: center; + justify-items: start; + gap: 7px; + padding: 0 68px 0 25px; + text-align: left; + background: + linear-gradient(120deg, rgba(217, 236, 247, 0.08), rgba(4, 6, 8, 0.7)), + rgba(4, 6, 8, 0.75); +} + +.save-profile > span { + font-size: 15px; + letter-spacing: 0.08em; +} + +.save-profile i { + position: absolute; + right: 20px; + display: grid; + width: 35px; + height: 35px; + place-items: center; + border: 1px solid rgba(232, 241, 247, 0.4); + border-radius: 50%; + font-style: normal; + transition: background 180ms ease, color 180ms ease; +} + +.save-profile:hover i { + color: #050607; + background: #edf5f9; +} + +.workspace-placeholder { + display: grid; + width: 100vw; + height: 100vh; + place-content: center; + justify-items: center; + gap: 18px; + background: #030405; +} + +.workspace-placeholder p { + color: rgba(225, 232, 238, 0.35); + font: 8px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.18em; +} + +.workspace-placeholder h1 { + margin: 0; + font-size: 44px; +} + +.workspace-placeholder button { + padding: 12px 20px; + border: 1px solid rgba(225, 232, 238, 0.2); + border-radius: 22px; + color: rgba(235, 240, 244, 0.7); + background: transparent; + cursor: pointer; +} + +.chat-workspace { + position: relative; + width: 100vw; + height: 100vh; + min-height: 680px; + overflow: hidden; + background: + radial-gradient(circle at 18% 44%, rgba(103, 151, 181, 0.1), transparent 28%), + linear-gradient(112deg, #030405 0%, #07090b 42%, #020304 100%); +} + +.chat-workspace::before, +.chat-workspace::after, +.chat-atmosphere { + position: absolute; + inset: 0; + content: ""; + pointer-events: none; +} + +.chat-workspace::before { + opacity: 0.23; + background: + repeating-linear-gradient(90deg, transparent 0 159px, rgba(225, 235, 242, 0.035) 160px), + repeating-linear-gradient(0deg, transparent 0 119px, rgba(225, 235, 242, 0.024) 120px); + mask-image: linear-gradient(90deg, #000, transparent 80%); +} + +.chat-workspace::after { + z-index: 50; + opacity: 0.1; + background: repeating-linear-gradient(180deg, transparent 0 3px, rgba(255, 255, 255, 0.06) 4px); + mix-blend-mode: overlay; +} + +.chat-atmosphere { + opacity: 0.18; + background: + radial-gradient(circle at 15% 25%, rgba(255, 255, 255, 0.16) 0 0.5px, transparent 0.8px), + radial-gradient(circle at 78% 62%, rgba(255, 255, 255, 0.13) 0 0.5px, transparent 0.8px); + background-size: 39px 43px, 51px 47px; +} + +.chat-identity { + position: absolute; + z-index: 3; + left: 0; + top: 72px; + bottom: 0; + width: 31vw; + min-width: 330px; + overflow: hidden; + border-right: 1px solid rgba(220, 230, 238, 0.1); + background: + radial-gradient(ellipse at 58% 40%, rgba(146, 189, 216, 0.1), transparent 36%), + rgba(4, 6, 8, 0.34); +} + +.chat-identity::after { + position: absolute; + inset: 0; + z-index: 4; + content: ""; + pointer-events: none; + background: + linear-gradient(90deg, rgba(3, 5, 7, 0.08), transparent 58%, rgba(3, 5, 7, 0.82)), + linear-gradient(180deg, transparent 52%, rgba(3, 5, 7, 0.96) 91%); +} + +.chat-portrait, +.chat-portrait img, +.chat-portrait i { + position: absolute; + inset: 0; +} + +.chat-portrait { + top: -4%; + height: 82%; + overflow: hidden; + mask-image: linear-gradient(180deg, #000 0%, #000 71%, transparent 100%); +} + +.chat-portrait img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: 55% 42%; + filter: grayscale(0.88) saturate(0.48) contrast(1.18) brightness(0.56); + transform: scale(1.045); +} + +.chat-portrait i { + background: + linear-gradient(103deg, transparent 44%, rgba(166, 218, 247, 0.12) 53%, transparent 61%), + radial-gradient(ellipse at 60% 44%, transparent 0 35%, rgba(130, 191, 224, 0.08) 51%, transparent 65%); + mix-blend-mode: screen; +} + +[data-chat-persona="johnny"] .chat-portrait img, +[data-chat-persona="jarvis"] .chat-portrait img { + object-fit: contain; + object-position: 50% 45%; +} + +[data-chat-persona="custom"] .chat-portrait img { + object-position: 55% 50%; + filter: grayscale(0.72) saturate(0.74) contrast(1.22) brightness(0.64); +} + +.chat-identity__copy { + position: absolute; + z-index: 8; + left: 34px; + right: 27px; + bottom: 38px; + padding-top: 17px; + border-top: 1px solid rgba(224, 234, 241, 0.2); +} + +.chat-identity__copy > small, +.chat-main__heading small, +.chat-side-panel small { + color: rgba(208, 219, 227, 0.34); + font: 6px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.15em; +} + +.chat-identity__copy h1 { + width: max-content; + margin: 6px 0 0; + color: transparent; + background: + linear-gradient(154deg, #ffffff, #ffe789 25%, #ff91d6 44%, #75e9ff 69%, #c3ffe5); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-stroke: 0.45px rgba(255, 255, 255, 0.65); + font-family: "Songti SC", "STSong", "Noto Serif SC", serif; + font-size: clamp(35px, 3.6vw, 58px); + font-weight: 900; + font-style: normal; + line-height: 1; + letter-spacing: -0.1em; + text-shadow: + 2px 0 rgba(61, 224, 255, 0.43), + -2px 0 rgba(255, 72, 190, 0.4), + 4px 7px rgba(14, 10, 35, 0.72); + transform: skewX(-6deg); +} + +.chat-identity__copy > p { + margin: 8px 0 18px; + color: rgba(222, 230, 236, 0.45); + font: 7px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.18em; +} + +.chat-identity__copy > div { + display: flex; + align-items: center; + gap: 9px; + color: rgba(218, 227, 234, 0.35); + font: 6px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.11em; +} + +.chat-identity__copy > div span { + width: 5px; + height: 5px; + border-radius: 50%; + background: #d9f1ff; + box-shadow: 0 0 12px #a6deff; +} + +.chat-main { + position: absolute; + z-index: 7; + left: max(31vw, 330px); + right: 292px; + top: 72px; + bottom: 126px; + min-width: 430px; + display: flex; + flex-direction: column; + padding: 30px 34px 0; +} + +.chat-main__heading { + display: flex; + align-items: end; + justify-content: space-between; + padding-bottom: 19px; + border-bottom: 1px solid rgba(222, 232, 239, 0.11); +} + +.chat-main__heading h2 { + margin: 5px 0 0; + color: rgba(240, 244, 247, 0.86); + font-size: 22px; + font-weight: 600; + letter-spacing: 0.07em; +} + +.chat-main__heading > p { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: rgba(209, 220, 228, 0.32); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.13em; +} + +.chat-main__heading > p i { + width: 4px; + height: 4px; + border-radius: 50%; + background: #dcefff; + box-shadow: 0 0 10px #b2ddff; +} + +.chat-thread { + flex: 1; + min-height: 0; + overflow: auto; + padding: 25px 4px 30px; + scrollbar-width: thin; + scrollbar-color: rgba(217, 229, 238, 0.16) transparent; +} + +.chat-message { + width: min(76%, 620px); + margin-bottom: 22px; +} + +.chat-message--user { + margin-left: auto; +} + +.chat-message__meta { + display: flex; + justify-content: space-between; + margin-bottom: 7px; + color: rgba(205, 216, 224, 0.3); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.13em; +} + +.chat-message p { + margin: 0; + padding: 15px 17px; + border: 1px solid rgba(221, 232, 239, 0.11); + border-radius: 3px 17px 17px 17px; + color: rgba(232, 237, 241, 0.76); + background: + linear-gradient(135deg, rgba(216, 235, 247, 0.045), transparent), + rgba(8, 11, 14, 0.62); + font-size: 12px; + line-height: 1.75; + letter-spacing: 0.045em; +} + +.chat-message--user p { + border-color: rgba(177, 218, 244, 0.2); + border-radius: 17px 3px 17px 17px; + background: + linear-gradient(125deg, rgba(152, 207, 239, 0.11), rgba(8, 11, 14, 0.68)); +} + +.chat-generating { + position: absolute; + left: 38px; + bottom: 7px; + display: flex; + align-items: center; + gap: 5px; + color: rgba(210, 221, 229, 0.36); +} + +.chat-generating span { + width: 3px; + height: 3px; + border-radius: 50%; + background: #dff3ff; + animation: chat-dot 900ms ease-in-out infinite alternate; +} + +.chat-generating span:nth-child(2) { + animation-delay: 160ms; +} + +.chat-generating span:nth-child(3) { + animation-delay: 320ms; +} + +.chat-generating p { + margin: 0 0 0 7px; + font-size: 8px; + letter-spacing: 0.1em; +} + +.chat-error { + position: absolute; + left: 34px; + right: 34px; + bottom: 0; + margin: 0; + color: rgba(255, 163, 170, 0.74); + font-size: 9px; +} + +.chat-side-panel { + position: absolute; + z-index: 8; + top: 72px; + right: 0; + bottom: 126px; + width: 292px; + padding: 30px 26px; + border-left: 1px solid rgba(220, 231, 239, 0.1); + background: rgba(4, 6, 8, 0.48); +} + +.chat-side-panel > section, +.vision-cycle { + display: block; + width: 100%; + padding: 0 0 21px; + margin: 0 0 21px; + border: 0; + overflow: visible; + border-bottom: 1px solid rgba(220, 231, 239, 0.09); + color: inherit; + background: transparent; + text-align: left; +} + +.chat-side-panel strong { + display: block; + margin-top: 7px; + color: rgba(237, 241, 244, 0.76); + font-size: 14px; + font-weight: 500; + letter-spacing: 0.08em; +} + +.chat-side-panel section > p { + margin: 10px 0 0; + color: rgba(208, 218, 226, 0.4); + font-size: 9px; + line-height: 1.7; +} + +.side-status { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; +} + +.side-status > i { + width: 7px; + height: 7px; + border: 1px solid rgba(223, 238, 248, 0.7); + border-radius: 50%; + box-shadow: 0 0 12px rgba(159, 216, 251, 0.4); +} + +.side-status > span { + display: grid; + gap: 4px; + color: rgba(229, 235, 239, 0.66); + font-size: 10px; +} + +.side-status b { + color: rgba(205, 217, 225, 0.27); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.12em; +} + +.vision-cycle { + cursor: pointer; +} + +.vision-cycle em { + display: block; + margin-top: 10px; + color: rgba(204, 215, 223, 0.23); + font-size: 7px; + font-style: normal; + letter-spacing: 0.08em; +} + +.vision-cycle[data-state="detected"] .side-status > i, +.vision-cycle[data-state="greeted"] .side-status > i { + background: #d9f2ff; +} + +.profile-vector { + border-bottom: 0 !important; +} + +.chat-composer { + position: absolute; + z-index: 14; + left: max(31vw, 330px); + right: 0; + bottom: 0; + height: 126px; + display: grid; + grid-template-columns: 1fr 116px 216px; + border-top: 1px solid rgba(221, 232, 239, 0.11); + background: rgba(3, 5, 7, 0.82); + backdrop-filter: blur(18px); +} + +.composer-field { + position: relative; + padding: 23px 26px; +} + +.composer-field label { + display: block; + margin-bottom: 9px; + color: rgba(205, 217, 225, 0.3); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.14em; +} + +.composer-field textarea { + width: 100%; + height: 38px; + resize: none; + border: 0; + outline: 0; + color: rgba(239, 243, 246, 0.84); + background: transparent; + font: 13px/1.6 "Avenir Next", "PingFang SC", sans-serif; +} + +.composer-field textarea::placeholder { + color: rgba(207, 218, 226, 0.24); +} + +.composer-field > span { + position: absolute; + right: 25px; + bottom: 14px; + color: rgba(200, 212, 220, 0.2); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.1em; +} + +.mic-button, +.send-button { + position: relative; + border-left: 1px solid rgba(221, 232, 239, 0.1); + color: rgba(232, 238, 242, 0.7); + background: rgba(255, 255, 255, 0.012); + cursor: pointer; +} + +.mic-button { + display: flex; + align-items: center; + justify-content: center; + gap: 11px; +} + +.mic-button > i { + width: 19px; + height: 27px; + border: 1px solid rgba(226, 237, 244, 0.48); + border-radius: 12px; + box-shadow: inset 0 -8px 12px rgba(166, 218, 248, 0.08); +} + +.mic-button > span, +.send-button > span { + display: grid; + gap: 5px; + font-size: 11px; +} + +.mic-button small, +.send-button small { + color: rgba(203, 214, 222, 0.27); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.12em; +} + +.mic-button[data-state="recording"] { + color: #ffb8c0; + background: rgba(255, 77, 96, 0.06); +} + +.mic-button[data-state="recording"] > i { + border-color: rgba(255, 149, 160, 0.8); + box-shadow: 0 0 18px rgba(255, 96, 111, 0.3); +} + +.mic-button[data-state="transcribing"] { + color: #bfe9ff; +} + +.send-button { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + background: + linear-gradient(120deg, rgba(184, 225, 249, 0.07), transparent), + rgba(255, 255, 255, 0.012); +} + +.send-button > i { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid rgba(225, 239, 247, 0.35); + border-radius: 50%; + font-style: normal; + transition: color 180ms ease, background 180ms ease; +} + +.send-button:hover > i { + color: #030405; + background: #edf6fb; +} + +.send-button:disabled { + cursor: wait; + opacity: 0.45; +} + +@keyframes chat-dot { + to { + opacity: 0.16; + transform: translateY(-3px); + } +} + +.view-transition-layer { + position: fixed; + z-index: 999; + inset: 0; + overflow: hidden; + pointer-events: none; + background: + linear-gradient(112deg, transparent 0 34%, rgba(230, 244, 252, 0.92) 48%, rgba(92, 193, 239, 0.16) 55%, transparent 68%), + rgba(2, 4, 6, 0.72); + clip-path: inset(0 100% 0 0); + animation: view-transfer 600ms cubic-bezier(0.76, 0, 0.24, 1) both; + backdrop-filter: blur(9px); +} + +.view-transition-layer::before, +.view-transition-layer::after { + position: absolute; + content: ""; + pointer-events: none; +} + +.view-transition-layer::before { + inset: 0; + background: + repeating-linear-gradient(180deg, transparent 0 7px, rgba(255, 255, 255, 0.18) 8px, transparent 9px 17px), + linear-gradient(90deg, transparent 35%, rgba(255, 255, 255, 0.82) 50%, transparent 66%); + mix-blend-mode: screen; +} + +.view-transition-layer::after { + left: 50%; + top: 0; + bottom: 0; + width: 1px; + background: white; + box-shadow: 0 0 35px 8px rgba(162, 222, 253, 0.72); +} + +.view-transition-layer i { + position: absolute; + display: block; + width: 2px; + height: 42vh; + top: var(--transition-top); + left: var(--transition-left); + background: linear-gradient(180deg, transparent, white, transparent); + box-shadow: 0 0 18px rgba(160, 224, 255, 0.7); + transform: rotate(var(--transition-angle)); +} + +.view-transition-layer i:nth-child(1) { + --transition-top: 7%; + --transition-left: 39%; + --transition-angle: 18deg; +} + +.view-transition-layer i:nth-child(2) { + --transition-top: 41%; + --transition-left: 53%; + --transition-angle: -31deg; +} + +.view-transition-layer i:nth-child(3) { + --transition-top: 17%; + --transition-left: 66%; + --transition-angle: 42deg; +} + +.view-transition-layer span { + position: absolute; + left: 50%; + bottom: 28px; + color: rgba(4, 8, 11, 0.72); + font: 7px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.18em; + transform: translateX(-50%); +} + +@keyframes view-transfer { + 0% { + clip-path: inset(0 100% 0 0); + } + 44%, + 58% { + clip-path: inset(0 0 0 0); + } + 100% { + clip-path: inset(0 0 0 100%); + } +} + +/* Stability and composition revision: denser selector, legible controls, calmer metal. */ +.intro-block { + top: clamp(96px, 11.5vh, 116px); + left: clamp(82px, 6.8vw, 118px); + width: 49vw; + max-width: 760px; +} + +.silver-title { + min-width: 650px; + min-height: 186px; + font-size: clamp(66px, 6.5vw, 100px); + line-height: 0.86; + letter-spacing: -0.115em; +} + +.silver-title__base { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.24), transparent 34%, rgba(0, 0, 0, 0.44) 86%), + linear-gradient( + 103deg, + #3b4045 0%, + #8e969c 18%, + #d3d8db 32%, + #666d72 48%, + #b7bec2 63%, + #4a5055 79%, + #92999e 100% + ); + background-size: 280% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-stroke: 0.45px rgba(226, 233, 237, 0.34); + filter: + drop-shadow(1px 0 0 rgba(209, 233, 245, 0.2)) + drop-shadow(-2px 1px 0 rgba(18, 22, 26, 0.92)) + drop-shadow(0 13px 31px rgba(0, 0, 0, 0.72)); + animation: muted-metal-flow 8.5s ease-in-out infinite alternate; +} + +.silver-title__sheen { + color: transparent; + opacity: 0.34; + background: + repeating-linear-gradient( + 180deg, + transparent 0 23px, + rgba(222, 231, 236, 0.58) 24px 25px, + transparent 26px 45px + ), + linear-gradient(98deg, transparent 26%, rgba(238, 244, 247, 0.72) 47%, transparent 65%); + background-size: 220% 100%; + background-clip: text; + -webkit-background-clip: text; + clip-path: polygon(0 8%, 100% 0, 100% 35%, 0 43%, 0 61%, 100% 51%, 100% 82%, 0 91%); + filter: blur(0.2px) drop-shadow(0 0 13px rgba(190, 215, 228, 0.2)); + animation: muted-title-sheen 6.8s cubic-bezier(0.45, 0, 0.2, 1) infinite; +} + +.intro-copy { + margin-top: 10px; + font-size: 14px; +} + +.intro-meta { + margin-top: 18px; +} + +.identity-matrix { + left: clamp(82px, 6.8vw, 118px); + bottom: 34px; + width: min(58vw, 920px); +} + +.persona-card { + height: clamp(166px, 20vh, 205px); +} + +.card-copy { + bottom: 18px; +} + +.action-dock { + right: 18px; + bottom: 18px; + width: min(34vw, 520px); + height: 104px; + grid-template-columns: 1.28fr 0.9fr; + border-radius: 16px 0 0 0; + overflow: hidden; +} + +.action-dock::before { + border-bottom: 1px solid rgba(228, 232, 236, 0.09); + background: + linear-gradient(115deg, rgba(219, 230, 237, 0.045), transparent 38%), + rgba(4, 6, 8, 0.8); +} + +.selection-state { + gap: 13px; + padding: 0 22px; +} + +.selection-signal { + display: grid; + width: 26px; + flex: 0 0 auto; + justify-items: center; + gap: 7px; + color: rgba(208, 217, 224, 0.28); + font: 5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.12em; +} + +.selection-signal i { + position: relative; + width: 1px; + height: 28px; + background: linear-gradient(180deg, transparent, rgba(226, 236, 242, 0.7), transparent); +} + +.selection-signal i::after { + position: absolute; + left: 50%; + top: 9px; + width: 5px; + height: 5px; + content: ""; + border-radius: 50%; + background: #e5f4fc; + box-shadow: 0 0 12px rgba(181, 224, 248, 0.72); + transform: translateX(-50%); +} + +.enter-button i { + top: 43%; +} + +.add-button > span { + transform: translateY(-4px); +} + +.axis-control { + position: relative; + padding: 7px 10px 8px; + border-left: 1px solid transparent; + transition: border-color 180ms ease, background 180ms ease; +} + +.axis-control[data-polarity="negative"] { + border-left-color: rgba(157, 177, 191, 0.32); + background: linear-gradient(90deg, rgba(148, 174, 190, 0.045), transparent 44%); +} + +.axis-control[data-polarity="positive"] { + border-left-color: rgba(221, 235, 243, 0.52); + background: linear-gradient(90deg, rgba(199, 224, 238, 0.07), transparent 48%); +} + +.axis-control__reading { + display: flex; + min-width: 78px; + align-items: baseline; + justify-content: flex-end; + gap: 8px; +} + +.axis-control__reading i { + color: rgba(216, 227, 234, 0.38); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + font-style: normal; + letter-spacing: 0.1em; +} + +.axis-control__ends { + display: grid; + grid-template-columns: 1fr 1fr 1fr; +} + +.axis-control__ends small:nth-child(2) { + text-align: center; + opacity: 0.5; +} + +.axis-control__ends small:last-child { + text-align: right; +} + +.axis-control__depth-note { + display: flex; + align-items: center; + gap: 7px; + margin-top: 7px; + color: rgba(195, 210, 220, 0.27); + font: 5px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.13em; +} + +.axis-control__depth-note i { + width: 26px; + height: 1px; + background: linear-gradient(90deg, rgba(220, 237, 247, 0.72), transparent); + box-shadow: 0 0 8px rgba(176, 220, 245, 0.32); +} + +.chat-signal-field { + position: absolute; + z-index: 1; + left: max(31vw, 330px); + right: 292px; + top: 72px; + bottom: 126px; + overflow: hidden; + pointer-events: none; + opacity: 0.35; +} + +.chat-signal-field::before { + position: absolute; + inset: 12% 8%; + content: ""; + border: 1px solid rgba(214, 230, 239, 0.05); + border-radius: 50%; + transform: scaleX(1.6); +} + +.chat-signal-field i { + position: absolute; + width: 34%; + height: 1px; + left: var(--signal-left); + top: var(--signal-top); + background: linear-gradient(90deg, transparent, rgba(218, 236, 246, 0.23), transparent); + transform: rotate(var(--signal-angle)); + animation: chat-signal-drift 7s ease-in-out infinite alternate; +} + +.chat-signal-field i:nth-child(1) { + --signal-left: 9%; + --signal-top: 24%; + --signal-angle: 23deg; +} + +.chat-signal-field i:nth-child(2) { + --signal-left: 53%; + --signal-top: 38%; + --signal-angle: -31deg; + animation-delay: -2s; +} + +.chat-signal-field i:nth-child(3) { + --signal-left: 17%; + --signal-top: 73%; + --signal-angle: -12deg; + animation-delay: -4s; +} + +.chat-signal-field i:nth-child(4) { + --signal-left: 62%; + --signal-top: 82%; + --signal-angle: 34deg; + animation-delay: -5s; +} + +.chat-main__heading h2 { + color: transparent; + background: linear-gradient(105deg, #596168, #d2d8dc 42%, #70787e 67%, #abb2b7); + background-size: 220% 100%; + background-clip: text; + -webkit-background-clip: text; + font-family: "Songti SC", "STSong", "Noto Serif SC", serif; + font-size: 32px; + font-weight: 900; + letter-spacing: -0.02em; + text-shadow: 0 10px 25px rgba(0, 0, 0, 0.42); + animation: muted-metal-flow 9s ease-in-out infinite alternate; +} + +.chat-prompt-deck { + position: relative; + z-index: 3; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + padding: 0 0 26px; +} + +.chat-prompt-deck[hidden] { + display: none; +} + +.chat-prompt-deck button { + position: relative; + min-height: 76px; + display: grid; + grid-template-columns: 24px 1fr 18px; + align-items: center; + gap: 8px; + padding: 12px; + overflow: hidden; + border: 1px solid rgba(218, 230, 238, 0.1); + border-radius: 12px; + color: rgba(227, 234, 239, 0.66); + background: + linear-gradient(135deg, rgba(200, 224, 238, 0.045), transparent 54%), + rgba(7, 10, 12, 0.72); + text-align: left; + cursor: pointer; + transition: border-color 180ms ease, transform 180ms ease, background 180ms ease; +} + +.chat-prompt-deck button::before { + position: absolute; + inset: 0; + content: ""; + opacity: 0; + background: linear-gradient(110deg, transparent 24%, rgba(218, 235, 244, 0.1), transparent 70%); + transition: opacity 180ms ease; +} + +.chat-prompt-deck button:hover { + border-color: rgba(224, 236, 243, 0.34); + background: + linear-gradient(135deg, rgba(197, 225, 241, 0.08), transparent 58%), + rgba(9, 12, 15, 0.82); + transform: translateY(-3px); +} + +.chat-prompt-deck button:hover::before { + opacity: 1; +} + +.chat-prompt-deck small { + color: rgba(210, 223, 231, 0.25); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.chat-prompt-deck span { + font-size: 10px; + line-height: 1.45; + letter-spacing: 0.04em; +} + +.chat-prompt-deck i { + color: rgba(225, 235, 241, 0.34); + font-style: normal; +} + +.chat-message p { + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.025), + 0 15px 38px rgba(0, 0, 0, 0.12); + backdrop-filter: blur(12px); +} + +.chat-composer { + background: + linear-gradient(110deg, rgba(203, 225, 238, 0.035), transparent 46%), + rgba(3, 5, 7, 0.88); +} + +@keyframes muted-metal-flow { + from { + background-position: 0% 50%; + } + to { + background-position: 100% 50%; + } +} + +@keyframes muted-title-sheen { + 0%, + 18% { + background-position: 190% 0; + opacity: 0.16; + } + 48% { + opacity: 0.42; + } + 72%, + 100% { + background-position: -75% 0; + opacity: 0.2; + } +} + +@keyframes chat-signal-drift { + to { + opacity: 0.25; + transform: translate3d(16px, -9px, 0) rotate(var(--signal-angle)); + } +} + +@media (max-width: 1199px) { + .silver-title { + min-width: 540px; + min-height: 158px; + font-size: 68px; + } + + .identity-matrix { + left: 66px; + width: 58vw; + } + + .action-dock { + right: 10px; + bottom: 10px; + width: 36vw; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* Central command gate: occupies the user-marked negative space. */ +.action-dock { + z-index: 40; + left: 40vw; + right: auto; + top: 14vh; + bottom: auto; + width: min(24vw, 460px); + height: min(54vh, 620px); + min-height: 360px; + grid-template-columns: 1fr; + grid-template-rows: 1.08fr 0.92fr; + border: 1px solid rgba(218, 226, 231, 0.13); + border-radius: 18px; + background: + radial-gradient(circle at 76% 22%, rgba(209, 227, 237, 0.055), transparent 31%), + rgba(3, 5, 7, 0.34); + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.025), + 0 35px 90px rgba(0, 0, 0, 0.24); + backdrop-filter: blur(12px); +} + +.action-dock::before { + border: 0; + background: + linear-gradient(115deg, rgba(224, 233, 238, 0.035), transparent 36%), + repeating-linear-gradient(180deg, transparent 0 48px, rgba(224, 232, 237, 0.02) 49px); + backdrop-filter: none; +} + +.action-dock::after { + position: absolute; + z-index: 20; + left: 0; + right: 0; + top: 53.8%; + height: 1px; + content: ""; + background: linear-gradient(90deg, transparent, rgba(225, 233, 238, 0.34), transparent); + box-shadow: 0 0 16px rgba(187, 216, 232, 0.13); +} + +.enter-button, +.add-button { + display: grid; + min-width: 0; + padding: 34px 36px; + align-content: end; + justify-content: stretch; + border: 0; + overflow: visible; + color: rgba(224, 230, 234, 0.72); + background: + linear-gradient(132deg, rgba(229, 237, 241, 0.022), transparent 52%), + rgba(2, 4, 6, 0.16); +} + +.enter-button::before, +.add-button::before { + opacity: 0.32; + background: + linear-gradient( + 102deg, + transparent 18%, + rgba(230, 237, 241, 0.025) 34%, + rgba(246, 249, 250, 0.14) 48%, + rgba(202, 214, 221, 0.035) 62%, + transparent 78% + ); + animation: command-silver-sweep 5.8s ease-in-out infinite; + transform: translateX(-105%); +} + +.enter-button:hover::before, +.add-button:hover::before { + opacity: 0.58; + animation: command-silver-sweep 1.8s ease-in-out infinite; +} + +.enter-button { + border-right: 0; +} + +.add-button { + gap: 0; + align-content: center; +} + +.enter-button span, +.add-button p { + position: relative; + z-index: 4; + width: max-content; + max-width: 100%; + padding-right: 0.18em; + margin: 0; + color: #aeb6bb; + background: none; + -webkit-text-fill-color: currentColor; + -webkit-text-stroke: 0.28px rgba(239, 243, 245, 0.24); + font-family: "Songti SC", "STSong", "Noto Serif SC", serif; + font-size: clamp(28px, 2.45vw, 48px); + font-weight: 900; + line-height: 1; + letter-spacing: 0.015em; + white-space: nowrap; + text-shadow: + 0 0 1px rgba(238, 243, 246, 0.52), + 0 0 16px rgba(196, 211, 219, 0.13), + 0 11px 28px rgba(0, 0, 0, 0.48); + filter: drop-shadow(0 0 12px rgba(198, 217, 227, 0.12)); + animation: command-type-glow 4.8s ease-in-out infinite alternate; +} + +.enter-button small, +.add-button small { + display: block; + margin-top: 12px; + color: rgba(200, 210, 216, 0.28); + font: 6px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.18em; +} + +.enter-button i, +.add-button > span { + position: absolute; + z-index: 5; + right: 28px; + top: 28px; + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 1px solid rgba(232, 238, 241, 0.82); + border-radius: 50%; + color: rgba(244, 247, 248, 0.94); + background: rgba(226, 235, 240, 0.12); + box-shadow: + inset 0 0 16px rgba(231, 239, 243, 0.045), + 0 0 22px rgba(196, 220, 233, 0.12); + font-size: 18px; + font-style: normal; + font-weight: 200; + transform: none; + transition: + border-color 180ms ease, + color 180ms ease, + background 180ms ease, + transform 180ms ease; +} + +.enter-button:hover i, +.add-button:hover > span { + border-color: rgba(238, 242, 245, 0.62); + color: #f1f4f6; + background: rgba(222, 232, 238, 0.06); + transform: translateY(-3px); +} + +.enter-button:hover, +.add-button:hover { + background: + radial-gradient(circle at 72% 24%, rgba(210, 228, 238, 0.075), transparent 30%), + rgba(6, 8, 10, 0.38); +} + +@keyframes command-silver-sweep { + 0%, + 14% { + transform: translateX(-105%); + } + 72%, + 100% { + transform: translateX(105%); + } +} + +@keyframes command-type-glow { + from { + color: #899197; + text-shadow: + 0 0 1px rgba(238, 243, 246, 0.28), + 0 0 11px rgba(196, 211, 219, 0.07), + 0 11px 28px rgba(0, 0, 0, 0.48); + } + to { + color: #c4cbd0; + text-shadow: + 0 0 1px rgba(249, 251, 252, 0.68), + 0 0 19px rgba(211, 225, 233, 0.18), + 0 11px 28px rgba(0, 0, 0, 0.48); + } +} + +@media (max-width: 1199px) { + .action-dock { + left: 41vw; + right: auto; + top: 15vh; + bottom: auto; + width: 27vw; + min-width: 250px; + height: 52vh; + } + + .enter-button, + .add-button { + padding: 28px; + } +} diff --git a/frontend/tests/app-state.test.js b/frontend/tests/app-state.test.js new file mode 100644 index 0000000..e49c380 --- /dev/null +++ b/frontend/tests/app-state.test.js @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { APP_MODES, createAppState } from "../src/lib/app-state.js"; + +test("opens chat with the selected persona", () => { + const app = createAppState({ personaId: "winter" }); + + app.openChat(); + + assert.equal(app.getState().mode, APP_MODES.CHAT); + assert.equal(app.getState().personaId, "winter"); +}); + +test("opens XYZ only after create confirmation", () => { + const app = createAppState(); + + app.openEditor(); + assert.equal(app.getState().mode, APP_MODES.XYZ_EDITOR); + + app.closeWorkspace(); + assert.equal(app.getState().mode, APP_MODES.PERSONA_SELECT); +}); + +test("notifies subscribers with immutable state snapshots", () => { + const app = createAppState(); + const snapshots = []; + const unsubscribe = app.subscribe((state) => snapshots.push(state)); + + app.selectPersona("johnny"); + unsubscribe(); + app.selectPersona("winter"); + + assert.equal(snapshots.length, 1); + assert.equal(snapshots[0].personaId, "johnny"); + assert.notEqual(snapshots[0], app.getState()); +}); + +test("does not notify subscribers when a value is unchanged", () => { + const app = createAppState({ personaId: "custom" }); + let notifications = 0; + app.subscribe(() => { notifications += 1; }); + + app.selectPersona("custom"); + + assert.equal(notifications, 0); +}); diff --git a/frontend/tests/chat-session.test.js b/frontend/tests/chat-session.test.js new file mode 100644 index 0000000..3cc0a92 --- /dev/null +++ b/frontend/tests/chat-session.test.js @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createChatSession } from "../src/lib/chat-session.js"; + +test("ignores empty messages", async () => { + const session = createChatSession({ + personaId: "winter", + agentAdapter: { reply: async () => "ok" }, + }); + + await session.send(" "); + + assert.equal(session.getMessages().length, 0); +}); + +test("appends user and assistant messages in order", async () => { + const session = createChatSession({ + personaId: "jarvis", + agentAdapter: { reply: async () => "系统已就绪。" }, + }); + + await session.send("状态?"); + + assert.deepEqual( + session.getMessages().map(({ role }) => role), + ["user", "assistant"], + ); + assert.equal(session.getMessages()[1].content, "系统已就绪。"); +}); + +test("exposes generating state while waiting for the adapter", async () => { + let resolveReply; + const session = createChatSession({ + personaId: "johnny", + agentAdapter: { + reply: () => new Promise((resolve) => { resolveReply = resolve; }), + }, + }); + + const pending = session.send("在吗?"); + assert.equal(session.getState().generating, true); + + resolveReply("一直都在。"); + await pending; + + assert.equal(session.getState().generating, false); +}); + +test("records an inline error when the adapter fails", async () => { + const session = createChatSession({ + personaId: "custom", + agentAdapter: { + reply: async () => { throw new Error("offline"); }, + }, + }); + + await session.send("测试"); + + assert.equal(session.getState().generating, false); + assert.equal(session.getState().error, "对话通道暂时不可用,请重试。"); +}); diff --git a/frontend/tests/crystal-shards.test.js b/frontend/tests/crystal-shards.test.js new file mode 100644 index 0000000..ad3987f --- /dev/null +++ b/frontend/tests/crystal-shards.test.js @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createCrystalShards } from "../src/lib/crystal-shards.js"; + +test("creates deterministic crystal shards across three depth groups", () => { + const shards = createCrystalShards({ count: 30, seed: 25 }); + + assert.deepEqual(shards, createCrystalShards({ count: 30, seed: 25 })); + assert.deepEqual([...new Set(shards.map(({ depth }) => depth))].sort(), [0, 1, 2]); +}); + +test("keeps shard geometry and motion inside configured ranges", () => { + const shards = createCrystalShards({ count: 48, seed: 9 }); + + assert.ok( + shards.every( + ({ x, y, width, height, speed, opacity, blur }) => + x >= 0 && + x <= 1 && + y >= 0 && + y <= 1 && + width >= 0.008 && + width <= 0.07 && + height >= 0.08 && + height <= 0.42 && + speed >= 0.000006 && + speed <= 0.000024 && + opacity >= 0.05 && + opacity <= 0.28 && + blur >= 0 && + blur <= 5, + ), + ); +}); + +test("returns no shards for invalid counts", () => { + assert.deepEqual(createCrystalShards({ count: 0, seed: 25 }), []); + assert.deepEqual(createCrystalShards({ count: -4, seed: 25 }), []); +}); diff --git a/frontend/tests/idle-renderer.test.js b/frontend/tests/idle-renderer.test.js new file mode 100644 index 0000000..a614f07 --- /dev/null +++ b/frontend/tests/idle-renderer.test.js @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createIdleScheduler } from "../src/lib/idle-scheduler.js"; + +test("stops scheduling after the activity window expires", () => { + let frames = 0; + const scheduler = createIdleScheduler({ + duration: 800, + requestFrame(callback) { + frames += 1; + callback(900); + return frames; + }, + cancelFrame() {}, + }); + + scheduler.wake(0); + + assert.equal(frames, 1); + assert.equal(scheduler.isAwake(), false); +}); + +test("renders a static frame without starting a loop", () => { + let renders = 0; + const scheduler = createIdleScheduler({ + render: () => { renders += 1; }, + requestFrame: () => { + throw new Error("static render should not schedule a frame"); + }, + cancelFrame() {}, + }); + + scheduler.renderOnce(25); + + assert.equal(renders, 1); + assert.equal(scheduler.isAwake(), false); +}); + +test("stop cancels the pending frame", () => { + let cancelled; + const scheduler = createIdleScheduler({ + render: () => {}, + requestFrame: () => 42, + cancelFrame: (frame) => { cancelled = frame; }, + }); + + scheduler.wake(0); + scheduler.stop(); + + assert.equal(cancelled, 42); + assert.equal(scheduler.isAwake(), false); +}); diff --git a/frontend/tests/interaction.test.js b/frontend/tests/interaction.test.js new file mode 100644 index 0000000..5dcfb14 --- /dev/null +++ b/frontend/tests/interaction.test.js @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + getCardVisualState, + normalizePointer, +} from "../src/lib/interaction.js"; + +test("normalizes the pointer around the card center", () => { + const rect = { left: 100, top: 50, width: 200, height: 100 }; + + assert.deepEqual(normalizePointer(200, 100, rect), { x: 0, y: 0 }); + assert.deepEqual(normalizePointer(100, 50, rect), { x: -1, y: -1 }); + assert.deepEqual(normalizePointer(300, 150, rect), { x: 1, y: 1 }); +}); + +test("clamps pointer values outside the card", () => { + const rect = { left: 0, top: 0, width: 100, height: 100 }; + + assert.deepEqual(normalizePointer(-20, 140, rect), { x: -1, y: 1 }); +}); + +test("derives a stable but pointer-reactive visual state", () => { + assert.deepEqual(getCardVisualState(0, { x: 0, y: 0 }), { + hue: 188, + lightX: 50, + lightY: 50, + tiltX: 0, + tiltY: 0, + }); + + assert.deepEqual(getCardVisualState(2, { x: 0.5, y: -0.5 }), { + hue: 291, + lightX: 75, + lightY: 25, + tiltX: 2, + tiltY: 2.5, + }); +}); diff --git a/frontend/tests/montage.test.js b/frontend/tests/montage.test.js new file mode 100644 index 0000000..c3ff870 --- /dev/null +++ b/frontend/tests/montage.test.js @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createMontageSchedule } from "../src/lib/montage.js"; + +test("creates a deterministic silver-dream montage schedule", () => { + assert.deepEqual( + createMontageSchedule({ count: 20, seed: 25 }), + createMontageSchedule({ count: 20, seed: 25 }), + ); +}); + +test("keeps memory fragments to thirty percent of the field", () => { + const schedule = createMontageSchedule({ count: 20, seed: 25 }); + const memoryCount = schedule.filter(({ kind }) => kind === "memory").length; + + assert.equal(schedule.length, 20); + assert.equal(memoryCount, 6); +}); + +test("keeps montage values normalized for canvas rendering", () => { + const schedule = createMontageSchedule({ count: 32, seed: 9 }); + + assert.ok( + schedule.every( + ({ x, y, scale, opacity, start, duration }) => + x >= 0 && + x <= 1 && + y >= 0 && + y <= 1 && + scale >= 0.3 && + scale <= 1.4 && + opacity >= 0.03 && + opacity <= 0.22 && + start >= 0 && + duration >= 2800, + ), + ); +}); diff --git a/frontend/tests/particles.test.js b/frontend/tests/particles.test.js new file mode 100644 index 0000000..6f2ecca --- /dev/null +++ b/frontend/tests/particles.test.js @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createParticles } from "../src/lib/particles.js"; + +test("creates deterministic particle data for a seed", () => { + assert.deepEqual( + createParticles({ count: 3, width: 800, height: 600, seed: 9 }), + createParticles({ count: 3, width: 800, height: 600, seed: 9 }), + ); +}); + +test("keeps particles inside the viewport with normalized depth", () => { + const particles = createParticles({ + count: 64, + width: 1440, + height: 900, + seed: 42, + }); + + assert.equal(particles.length, 64); + assert.ok( + particles.every( + ({ x, y, depth }) => + x >= 0 && + x <= 1440 && + y >= 0 && + y <= 900 && + depth >= 0.2 && + depth <= 1, + ), + ); +}); + +test("returns no particles for an empty or invalid field", () => { + assert.deepEqual(createParticles({ count: 0, width: 100, height: 100 }), []); + assert.deepEqual(createParticles({ count: 10, width: 0, height: 100 }), []); +}); diff --git a/frontend/tests/performance-tier.test.js b/frontend/tests/performance-tier.test.js new file mode 100644 index 0000000..9a170b9 --- /dev/null +++ b/frontend/tests/performance-tier.test.js @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + getParticleBudget, + getPerformanceTier, +} from "../src/lib/performance-tier.js"; + +test("caps high DPR devices to a normal particle budget", () => { + assert.equal( + getPerformanceTier({ hardwareConcurrency: 8, dpr: 3 }), + "normal", + ); + assert.equal(getParticleBudget("normal"), 2000); +}); + +test("uses the low tier for four or fewer logical cores", () => { + assert.equal( + getPerformanceTier({ hardwareConcurrency: 4, dpr: 1 }), + "low", + ); + assert.equal(getParticleBudget("low"), 1000); +}); + +test("uses the high tier only for capable standard-DPR devices", () => { + assert.equal( + getPerformanceTier({ hardwareConcurrency: 10, dpr: 2 }), + "high", + ); + assert.equal(getParticleBudget("high"), 4000); +}); diff --git a/frontend/tests/persona-profile.test.js b/frontend/tests/persona-profile.test.js new file mode 100644 index 0000000..fab4b33 --- /dev/null +++ b/frontend/tests/persona-profile.test.js @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_PROFILE, + describeProfile, + loadProfile, + normalizeAxis, + saveProfile, + toInternalAxis, +} from "../src/lib/persona-profile.js"; + +test("normalizes display axes and converts to internal values", () => { + assert.equal(normalizeAxis(140), 100); + assert.equal(normalizeAxis(-140), -100); + assert.equal(normalizeAxis("45.4"), 45); + assert.equal(toInternalAxis(45), 0.45); +}); + +test("describes strong positive affinity consistently", () => { + assert.match( + describeProfile({ x: 75, y: 0, z: 0, mbti: "INFJ" }), + /温暖|主动关心/, + ); +}); + +test("describes negative rationality and expressive values", () => { + const description = describeProfile({ + x: 0, + y: -65, + z: -40, + mbti: "ISFP", + }); + + assert.match(description, /感受|直觉/); + assert.match(description, /克制|平静/); +}); + +test("falls back when storage contains invalid JSON", () => { + assert.deepEqual(loadProfile({ getItem: () => "{" }), DEFAULT_PROFILE); +}); + +test("saves validated profile values and restores them", () => { + let stored = ""; + const storage = { + getItem: () => stored, + setItem: (_key, value) => { stored = value; }, + }; + + const saved = saveProfile(storage, { + x: 120, + y: -15, + z: 42, + mbti: "XXXX", + }); + + assert.deepEqual(saved, { x: 100, y: -15, z: 42, mbti: "INFJ" }); + assert.deepEqual(loadProfile(storage), saved); +}); diff --git a/frontend/tests/personas.test.js b/frontend/tests/personas.test.js new file mode 100644 index 0000000..484e1dd --- /dev/null +++ b/frontend/tests/personas.test.js @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { PERSONAS, getPersona } from "../src/lib/personas.js"; + +test("defines four personas with versioned local hero and card assets", () => { + assert.equal(PERSONAS.length, 4); + assert.ok( + PERSONAS.every( + ({ heroImage, cardImage }) => + /^\/public\/assets\/v[23]\//.test(heroImage) && + cardImage.startsWith("/public/assets/v2/"), + ), + ); +}); + +test("uses a distinct editorial image for every persona card", () => { + const cardImages = PERSONAS.map(({ cardImage }) => cardImage); + assert.equal(new Set(cardImages).size, PERSONAS.length); +}); + +test("only the custom persona allows a spectral arm", () => { + assert.deepEqual( + PERSONAS.filter(({ allowsSpectralArm }) => allowsSpectralArm).map(({ id }) => id), + ["custom"], + ); + assert.equal(getPersona("winter").name, "冬兵"); +}); + +test("uses the user-specified Johnny and Jarvis v3 hero assets", () => { + assert.equal(getPersona("johnny").heroImage, "/public/assets/v3/johnny-exact.png"); + assert.equal(getPersona("jarvis").heroImage, "/public/assets/v3/jarvis-reactor.png"); +}); diff --git a/frontend/tests/transition-layer.test.js b/frontend/tests/transition-layer.test.js new file mode 100644 index 0000000..697e559 --- /dev/null +++ b/frontend/tests/transition-layer.test.js @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getTransitionTiming } from "../src/components/transition-layer.js"; + +test("places the view swap before the transition completes", () => { + assert.deepEqual(getTransitionTiming(600), { + swapAt: 270, + endAt: 600, + }); +}); + +test("uses a safe minimum duration", () => { + assert.deepEqual(getTransitionTiming(100), { + swapAt: 90, + endAt: 200, + }); +}); diff --git a/frontend/tests/xyz-geometry.test.js b/frontend/tests/xyz-geometry.test.js new file mode 100644 index 0000000..7e323d8 --- /dev/null +++ b/frontend/tests/xyz-geometry.test.js @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + applyPlaneDrag, + axisToPosition, + createSpherePoints, + getDepthCompensatedScale, +} from "../src/lib/xyz-geometry.js"; + +test("creates deterministic unit-sphere points", () => { + const points = createSpherePoints({ count: 100, seed: 25 }); + + assert.deepEqual( + points, + createSpherePoints({ count: 100, seed: 25 }), + ); + assert.equal(points.length, 300); + + for (let index = 0; index < points.length; index += 3) { + const radius = Math.hypot( + points[index], + points[index + 1], + points[index + 2], + ); + assert.ok(radius >= 0.88); + assert.ok(radius <= 1.001); + } +}); + +test("maps XYZ display values into the editor volume", () => { + assert.deepEqual( + axisToPosition({ x: 100, y: -50, z: 0 }), + { x: 1, y: -0.5, z: 0 }, + ); +}); + +test("clamps invalid axis positions", () => { + assert.deepEqual( + axisToPosition({ x: 220, y: Number.NaN, z: -400 }), + { x: 1, y: 0, z: -1 }, + ); +}); + +test("keeps the persona marker visually stable across the Z axis", () => { + assert.equal(getDepthCompensatedScale({ z: 0, cameraZ: 4.65 }), 1); + assert.equal(getDepthCompensatedScale({ z: 1, cameraZ: 4.65 }), 0.785); + assert.equal(getDepthCompensatedScale({ z: -1, cameraZ: 4.65 }), 1.215); +}); + +test("falls back to a safe marker scale for invalid depth inputs", () => { + assert.equal(getDepthCompensatedScale({ z: Number.NaN, cameraZ: 4.65 }), 1); + assert.equal(getDepthCompensatedScale({ z: 0.5, cameraZ: 0 }), 1); +}); + +test("maps XZ screen drag continuously into X and Z values", () => { + assert.deepEqual( + applyPlaneDrag( + { x: 10, y: 20, z: 0 }, + { plane: "xz", deltaX: 40, deltaY: -30, width: 400, height: 300 }, + ), + { x: 37, y: 20, z: 20 }, + ); +}); + +test("maps YZ screen drag continuously into Y and Z values", () => { + assert.deepEqual( + applyPlaneDrag( + { x: 10, y: 20, z: 0 }, + { plane: "yz", deltaX: 50, deltaY: 30, width: 400, height: 300 }, + ), + { x: 10, y: 0, z: 33 }, + ); +}); + +test("clamps plane drag values instead of jumping beyond the profile range", () => { + assert.deepEqual( + applyPlaneDrag( + { x: 90, y: -90, z: 90 }, + { plane: "xz", deltaX: 500, deltaY: -500, width: 400, height: 300 }, + ), + { x: 100, y: -90, z: 100 }, + ); +});