diff --git a/wonderweave/.env.example b/wonderweave/.env.example new file mode 100644 index 0000000..8f43eac --- /dev/null +++ b/wonderweave/.env.example @@ -0,0 +1,13 @@ +# Server-only provider credentials. Production values live in Sites as masked +# secrets. For local live work, keep values in a chmod 600 file outside the repo +# and point LIVE_*_ENV_FILE at it. Never use NEXT_PUBLIC_* for these values. +ANTHROPIC_API_KEY= +OPENAI_API_KEY= + +# Pinned production model roles. +LEARNING_MODEL=claude-sonnet-5 +REALTIME_MODEL=gpt-realtime-2.1 + +# Optional: point live tests at separate approved local env files. +LIVE_MODEL_ENV_FILE= +LIVE_REALTIME_ENV_FILE= diff --git a/wonderweave/.gitignore b/wonderweave/.gitignore new file mode 100644 index 0000000..c34a16e --- /dev/null +++ b/wonderweave/.gitignore @@ -0,0 +1,50 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage +/test-results/ + +# next.js +/.next/ +/.vinext/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example +.dev.vars* +.secrets/ +secrets.local/ +*.secret +*.secrets + +# vercel +.vercel + +# typescript +next-env.d.ts +*.tsbuildinfo +/dist/ +/.wrangler/ +/outputs/ +/work/ diff --git a/wonderweave/.openai/hosting.json b/wonderweave/.openai/hosting.json new file mode 100644 index 0000000..c1a50ff --- /dev/null +++ b/wonderweave/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "project_id": "appgprj_6a7250e1630c819181675062ecacefa4", + "d1": null, + "r2": null +} diff --git a/wonderweave/.vercelignore b/wonderweave/.vercelignore new file mode 100644 index 0000000..3596292 --- /dev/null +++ b/wonderweave/.vercelignore @@ -0,0 +1,9 @@ +# Build output and local tool state from the Cloudflare/vinext target. +# Everything else must stay: tsconfig type-checks `**/*.ts`, and vite.config.ts +# and drizzle.config.ts import from build/, worker/, and db/ — removing those +# would fail `next build` at the TypeScript step. +dist +.wrangler +.vinext +outputs +work diff --git a/wonderweave/README.md b/wonderweave/README.md new file mode 100644 index 0000000..8c84671 --- /dev/null +++ b/wonderweave/README.md @@ -0,0 +1,343 @@ +# Wonderweave + +Wonderweave turns a teacher's learning goal into a reviewed, audio-first learning adventure for children ages 3–6 who may not read independently yet. A teacher creates the brief; a live model drafts a typed lesson artifact; deterministic code validates it; the teacher reviews it; and the child gets a visual, spoken, touch-first experience. + +The current product includes: + +- a teacher studio for reading, math, blended, and topic-based goals such as seeds; +- schema- and policy-checked lesson generation with a real Anthropic model; +- a pre-reader learner experience built around pictures, sound, movement, large targets, and visible words that never carry the interaction by themselves; +- an OpenAI Realtime voice guide that can narrate, repeat, listen, respond, and stop immediately when the learner moves to the next page; +- bounded voice tools: the model may explain approved content and offer approved hints, but application code owns correctness and progression; +- a real Playwright journey that exercises generation, a Realtime client secret, WebRTC audio, learner interaction, keyboard access, and responsive layouts. + +This is an additive app. In `thebuggeddev/anatomy`, it lives entirely under `wonderweave/`; the original Anatomy app remains untouched. + +## Product status + +The picture-based model/classify/explain/transfer lesson is implemented and live. The specifications also define a broader pre-reader lesson system—sound hunts, counting and cardinality, composing quantities, patterns, sequencing, spatial language, compare/predict/reveal, movement, story retell, and real-world transfer—but those additional archetypes are a roadmap, not completed product behavior. + +The pedagogy, presentation rules, and educator review gates are documented. Do not claim independent educator sign-off until the review evidence required by those specifications has actually been collected. + +## Repository location + +In the additive upstream layout: + +```text +thebuggeddev/anatomy/ +├── ...original Anatomy files +└── wonderweave/ # this independent application +``` + +Run every command below from the directory containing this README. If you cloned the upstream Anatomy branch, start with `cd wonderweave`. If Wonderweave is checked out as a standalone repository, you are already in the right directory. + +## Prerequisites + +- Node.js `>=22.13.0` +- npm and the committed `package-lock.json` +- provider keys only for live generation and voice +- OpenAI Sites access only for hosted-environment changes or deployment + +## Run locally without provider keys + +This starts the complete UI and deterministic learner renderer. Live lesson generation and live voice return explicit `503` configuration errors until keys are supplied. + +```bash +npm ci +npm run dev +``` + +Open the exact local URL printed by the development server. Before committing: + +```bash +npm test +``` + +`npm test` runs the secret contract, scans the working tree for credential signatures, builds the production worker, and lints the code. + +## Get the two provider keys + +Wonderweave currently requires exactly two permanent provider credentials: + +| Variable | Classification | Server purpose | Browser exposure | +|---|---|---|---| +| `ANTHROPIC_API_KEY` | secret | Generate the typed lesson artifact | never | +| `OPENAI_API_KEY` | secret | Mint a short-lived Realtime client secret | never | +| `LEARNING_MODEL` | non-secret setting | Pin the generation model; default `claude-sonnet-5` | safe | +| `REALTIME_MODEL` | non-secret setting | Pin the voice model; default `gpt-realtime-2.1` | safe | + +Create dedicated, project-scoped credentials in the [Anthropic Console](https://console.anthropic.com/settings/keys) and [OpenAI API key settings](https://platform.openai.com/settings/organization/api-keys). Use separate local/test and production keys, least available privilege, provider spend alerts, and rate limits. Do not copy a shared key from another `_catchall` project: reuse makes revocation, attribution, and blast-radius control unreliable. + +A key pasted into chat, a ticket, a PR, a shell command, a screenshot, or a retained trace must be treated as exposed and replaced. Never commit a real value to `.env.example`. + +## Run locally with real generation and voice + +Put the provider values in a file outside the repository. Create it in a password manager or trusted editor so the values do not become shell history: + +```dotenv +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +``` + +Restrict the file, then inject it only into the live process: + +```bash +chmod 600 /approved/private/path/wonderweave.env + +LIVE_MODEL_ENV_FILE=/approved/private/path/wonderweave.env \ +LIVE_REALTIME_ENV_FILE=/approved/private/path/wonderweave.env \ +npm run dev:live +``` + +`dev:live` validates that both keys exist, warns when the file is broadly readable, and launches the dev server without printing the values. The two `LIVE_*_ENV_FILE` variables may point to separate files if the credentials have different owners. + +Do not use a `NEXT_PUBLIC_*` variable for either key. In Next.js, that prefix is an intentional client-exposure boundary. + +## How voice works + +The browser never receives `OPENAI_API_KEY`. + +```text +Browser ──POST /api/realtime/token──> Wonderweave server + │ + ├── permanent OPENAI_API_KEY + │ stays server-side + ▼ + OpenAI Realtime + │ +Browser <── short-lived client secret ──────┘ + │ + └── WebRTC speech-to-speech session +``` + +`POST /api/realtime/token` mints a ten-minute client secret, returns it with `Cache-Control: no-store`, and never returns the permanent key. This follows OpenAI's documented [Realtime WebRTC ephemeral-token pattern](https://developers.openai.com/api/docs/guides/realtime-webrtc#creating-an-ephemeral-token). The session uses server VAD, low interruption eagerness, the `marin` voice, and tracing disabled. + +The voice model is intentionally not the lesson engine. `LearningExperience` owns the current page, allowed answers, correctness, navigation, and visual feedback. Voice tools can read current state, replay the current approved script, reveal one approved hint, or request a grown-up; they cannot advance the lesson or mark an answer correct. + +Audio and transcript state are held in memory for the active session. The application does not intentionally persist child audio or transcripts. Before a public child-facing launch, complete the privacy review and add the privacy-preserving safety identifier recommended in the current OpenAI Realtime guidance. + +## How lesson generation works + +```text +Teacher brief + ↓ +POST /api/generate + ↓ +Anthropic lesson draft + ↓ +JSON schema validation + product policy validation + ↓ +Teacher preview and approval + ↓ +Deterministic learner renderer +``` + +The model generates data, not executable UI code. That boundary makes the learner experience fast, testable, and constrained to reviewed primitives. Generated artifacts must include spoken directions, picture semantics, explicit teaching, guided practice, retrieval, transfer, and error feedback. Visible words support print awareness and grown-up participation; instructions, choices, and state changes must remain understandable through pictures, audio, animation, and position alone. + +The implementation contract is in: + +- [Learning artifact compiler engineering specification](docs/LEARNING_ARTIFACT_COMPILER_ENGINEERING_SPEC.md) +- [Pre-reader interaction standard](docs/PRE_READER_INTERACTION_STANDARD.md) +- [Non-reader lesson archetype system](docs/NON_READER_LESSON_ARCHETYPE_SYSTEM_SPEC.md) + +## Real end-to-end tests + +These are real browser journeys, not unit tests and not mocked provider tests. They consume provider quota. + +### Local live E2E + +```bash +LIVE_E2E=1 \ +LIVE_MODEL_ENV_FILE=/approved/private/path/wonderweave.env \ +LIVE_REALTIME_ENV_FILE=/approved/private/path/wonderweave.env \ +npm run test:e2e:live -- --project desktop-chromium +``` + +Run both configured browser sizes before release by omitting `--project desktop-chromium`. + +The suite proves: + +- a real provider-generated artifact passes schema and policy validation; +- the server mints a real ephemeral Realtime credential; +- the browser opens a real WebRTC connection and receives generated audio; +- the learner can complete the visual interaction without independent reading; +- repeat, next-page cancellation, keyboard access, and responsive layouts work. + +Test attachments redact the ephemeral client secret. Production traces are disabled because authorization headers and short-lived credentials can otherwise be retained. + +### Private production E2E + +The current production site is private: [Wonderweave Learning Studio](https://wonderweave-learning-studio.philconimous.chatgpt.site). + +For an automated test, generate a just-in-time Sites bypass bearer, store it in a mode-`0600` file outside the repository, and load it into the process without typing the value into the command: + +```dotenv +PRODUCTION_BASE_URL=https://wonderweave-learning-studio.philconimous.chatgpt.site +SITES_BYPASS_BEARER= +``` + +```bash +chmod 600 /approved/private/path/wonderweave-production-test.env +set -a +source /approved/private/path/wonderweave-production-test.env +set +a + +LIVE_E2E=1 npx playwright test \ + --config playwright.production.config.ts \ + --project desktop-chromium + +unset SITES_BYPASS_BEARER +``` + +Rotate the bypass bearer immediately after the evidence run. It is a test credential, not an application environment variable, and must never be stored in Sites runtime variables, source control, CI artifacts, or Playwright traces. + +## Secret management + +### Production inventory + +Production values live in the OpenAI Sites environment store: + +- `ANTHROPIC_API_KEY`: required, marked secret +- `OPENAI_API_KEY`: required, marked secret +- `LEARNING_MODEL`: required non-secret setting +- `REALTIME_MODEL`: required non-secret setting + +`.openai/hosting.json` stores only the opaque Sites project identifier and resource bindings. It never stores environment values. Provider keys are read only by server modules through `app/lib/server-environment.ts`; client modules cannot import that server-only boundary. + +OpenAI recommends keeping API keys out of code and public repositories and injecting them through environment variables or a secret-management service; see [OpenAI production API-key guidance](https://developers.openai.com/api/docs/guides/production-best-practices#api-keys). + +### Rotation procedure + +For either provider: + +1. Create a replacement project-scoped key. Do not revoke the working key yet. +2. Update the matching Sites value and mark it secret. +3. Deploy or restart on the new environment revision. +4. Run the real provider probe and the complete private-production E2E. +5. Revoke the previous provider key. +6. Record date, operator, provider key label or ID, and evidence trace ID—never the value. + +Rotate on suspected exposure, personnel or vendor-access changes, provider request, and at least every 90 days. A suspected leak triggers immediate replacement rather than waiting for proof of misuse. + +### Leak response + +1. Revoke or disable the exposed key. +2. Create a replacement and update the matching Sites secret. +3. Rotate any Sites bypass bearer involved in the incident. +4. Run `npm run secrets:scan` and `npm run secrets:scan:history`. +5. Inspect provider usage and spend from the earliest possible exposure. +6. Re-run real generation, Realtime, and learner E2E evidence. +7. Document impact and remediation without copying secret values. + +Deleting a visible secret from the latest commit does not remove it from Git history. History rewriting is a separate, reviewed incident-response operation. + +The complete operating contract is in [the secrets management runbook](docs/SECRETS_MANAGEMENT.md). + +## Deploy with OpenAI Sites + +The project is already bound to a Sites project through `.openai/hosting.json`. Preserve the existing `project_id`; do not create a second site or replace the file with credentials. + +### Release gate + +From the app directory: + +```bash +npm ci +npm test +npm run secrets:scan:history +``` + +For a behavior change, also run the local live E2E. Commit the exact validated source before publishing. + +### First-time environment setup + +In the Sites environment editor, create the four variables in the production inventory above. Mark both API keys secret. Never put a provider value in `.openai/hosting.json`, the source repository, a deployment archive, or a command-line URL. + +After changing a provider key or model setting, publish a new version so the release is tied to an explicit environment revision. + +### Publish sequence + +Sites deployments are versioned and source-backed. The release operator or Codex Sites workflow must: + +1. Obtain a short-lived source-repository credential from the existing Sites project. +2. Push the exact validated commit using per-command authorization; never embed the credential in a remote URL or Git config. +3. Build and package the Cloudflare Worker-compatible `dist/` output with `.openai/hosting.json`. +4. Save one Sites version associated with that pushed commit SHA. +5. Deploy the saved version privately. +6. Wait for deployment status `succeeded` and use the returned production URL. +7. Run real provider probes and the private-production E2E. +8. Rotate the just-in-time Sites bypass bearer used for testing. + +When using Codex, a sufficient release request is: `Validate the current Wonderweave commit, publish that exact source privately with Sites, run the real production probes and E2E, then rotate the test bypass credential.` Sites source credentials and bypass bearers are short-lived operator credentials; they are never application secrets. + +Do not make the site public merely to simplify automated testing. Changing private, shared, or public access is a separate product-owner decision. + +## Release checklist + +- [ ] `npm test` passes +- [ ] `npm run secrets:scan:history` passes +- [ ] behavior changes pass the real local E2E +- [ ] no child PII, audio, or transcript persistence was introduced +- [ ] provider secrets are server-only and Sites marks them secret +- [ ] generated artifacts still pass schema and policy validation +- [ ] the exact validated commit is the Sites version source +- [ ] private deployment succeeds +- [ ] real generation and Realtime probes succeed in production +- [ ] private-production learner E2E succeeds +- [ ] the test bypass bearer is rotated immediately +- [ ] previous provider keys are revoked after a successful rotation + +## Troubleshooting + +| Symptom | Meaning | Fix | +|---|---|---| +| `MODEL_NOT_CONFIGURED` or lesson-generation `503` | The server cannot read `ANTHROPIC_API_KEY` | Check the external local file or masked Sites secret, then restart/redeploy | +| `VOICE_NOT_CONFIGURED` or voice `503` | The server cannot mint a Realtime credential | Check `OPENAI_API_KEY`, account/model access, and the current environment revision | +| Voice starts but no sound plays | Browser audio was not unlocked or output is muted | Tap the voice control, allow microphone/audio, check device output, then use Repeat | +| Voice cuts off after Next | Expected cancellation of stale narration | The next page should begin its own current narration | +| Voice cuts off without navigation | Session/VAD or provider failure | Capture the application trace ID, reproduce once, and inspect redacted server logs | +| Production Playwright fails before navigation | Private Sites authorization is absent | Generate a fresh just-in-time bypass bearer; never make the site public as a workaround | +| Secret scanner fails | A credential-like signature exists in tracked or non-ignored content | Do not print it; revoke if real, remove safely, scan current Git and history, and follow incident response | + +Provider error bodies are deliberately not copied into public errors or logs. Use the stable application error code and `X-Trace-Id` to correlate a failure. + +## Useful commands + +| Command | Purpose | +|---|---| +| `npm run dev` | Local UI without loading external credential files | +| `npm run dev:live` | Local UI with approved external live credentials | +| `npm run build` | Build the vinext/Cloudflare Worker output | +| `npm run lint` | Run ESLint | +| `npm run preflight` | Validate the secret contract and scan current files | +| `npm test` | Preflight, production build, and lint | +| `npm run secrets:check` | Validate declared secret names and client boundaries | +| `npm run secrets:runtime` | Require the runtime secret contract in the current process | +| `npm run secrets:scan` | Scan tracked and non-ignored current files without printing values | +| `npm run secrets:scan:history` | Scan reachable Git history without printing values | +| `npm run test:e2e:live` | Run real-provider Playwright evidence | +| `npm run db:generate` | Generate Drizzle migrations after a schema change | + +## Architecture map + +```text +app/api/generate/ server generation boundary +app/api/realtime/token/ server Realtime credential boundary +app/components/AnatomyApp.tsx teacher studio and orchestration +app/components/LearningExperience.tsx + deterministic learner renderer +app/hooks/useRealtimeTutor.ts browser WebRTC voice session +app/lib/learning-artifact.ts artifact schema and validation +app/lib/server-environment.ts server-only environment boundary +e2e/live-generation.spec.ts real provider/browser journey +docs/ engineering, pedagogy, UX, and security specs +.openai/hosting.json Sites project binding; never secret values +``` + +## Further reading + +- [Secrets management runbook](docs/SECRETS_MANAGEMENT.md) +- [OpenAI voice agents](https://developers.openai.com/api/docs/guides/voice-agents) +- [OpenAI Realtime with WebRTC](https://developers.openai.com/api/docs/guides/realtime-webrtc) +- [OpenAI production best practices](https://developers.openai.com/api/docs/guides/production-best-practices) +- [vinext](https://github.com/cloudflare/vinext) diff --git a/wonderweave/app/anatomy/page.tsx b/wonderweave/app/anatomy/page.tsx new file mode 100644 index 0000000..98b695a --- /dev/null +++ b/wonderweave/app/anatomy/page.tsx @@ -0,0 +1,5 @@ +import { AnatomyApp } from "../components/AnatomyApp"; + +export default function AnatomyReferencePage() { + return ; +} diff --git a/wonderweave/app/api/generate/route.ts b/wonderweave/app/api/generate/route.ts new file mode 100644 index 0000000..9324197 --- /dev/null +++ b/wonderweave/app/api/generate/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server"; +import { + ARTIFACT_SCHEMA_VERSION, + POLICY_VERSION, + PROMPT_VERSION, + parseGenerationBrief, + type GenerateResponse, +} from "@/app/lib/learning-artifact"; +import { generateLearningArtifact, ModelProviderError } from "@/app/lib/model-provider"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_REQUEST_BYTES = 8_192; + +export async function POST(request: Request) { + const traceId = `gen_${crypto.randomUUID()}`; + const startedAt = Date.now(); + const responseHeaders = { + "Cache-Control": "no-store, max-age=0", + "X-Trace-Id": traceId, + }; + + const contentLength = Number(request.headers.get("content-length") || 0); + if (contentLength > MAX_REQUEST_BYTES) { + return errorResponse(413, "REQUEST_TOO_LARGE", "Keep the lesson brief under 8 KB.", traceId, responseHeaders); + } + + let input: unknown; + try { + const body = await request.text(); + if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) { + return errorResponse(413, "REQUEST_TOO_LARGE", "Keep the lesson brief under 8 KB.", traceId, responseHeaders); + } + input = JSON.parse(body); + } catch { + return errorResponse(400, "INVALID_JSON", "Send a valid JSON lesson brief.", traceId, responseHeaders); + } + + const brief = parseGenerationBrief(input); + if (!brief.ok) { + return NextResponse.json( + { error: { code: brief.code, message: brief.message, traceId, fields: brief.fields } }, + { status: 400, headers: responseHeaders }, + ); + } + + try { + const generated = await generateLearningArtifact(brief.value); + const payload: GenerateResponse = { + artifact: generated.artifact, + receipt: { + provider: generated.provider, + model: generated.model, + promptVersion: PROMPT_VERSION, + schemaVersion: ARTIFACT_SCHEMA_VERSION, + policyVersion: POLICY_VERSION, + generatedAt: new Date().toISOString(), + traceId, + latencyMs: Date.now() - startedAt, + attempts: generated.attempts, + validation: { schema: "pass", policy: "pass" }, + }, + }; + return NextResponse.json(payload, { status: 200, headers: responseHeaders }); + } catch (error) { + const providerError = error instanceof ModelProviderError ? error : null; + const status = providerError?.kind === "rate_limit" ? 429 + : providerError?.kind === "timeout" ? 504 + : providerError?.kind === "invalid_output" ? 422 + : providerError?.kind === "configuration" || providerError?.kind === "authentication" ? 503 + : 502; + const code = providerError?.kind === "rate_limit" ? "MODEL_RATE_LIMITED" + : providerError?.kind === "timeout" ? "MODEL_TIMEOUT" + : providerError?.kind === "invalid_output" ? "ARTIFACT_REJECTED" + : providerError?.kind === "configuration" || providerError?.kind === "authentication" ? "MODEL_NOT_CONFIGURED" + : "MODEL_UNAVAILABLE"; + + // The provider error body, prompt, brief, and credentials are deliberately excluded. + console.error(JSON.stringify({ + event: "generation.failed", + traceId, + code, + latencyMs: Date.now() - startedAt, + diagnostic: providerError?.kind === "invalid_output" ? providerError.message : undefined, + validationIssues: providerError?.kind === "invalid_output" ? providerError.issues : undefined, + })); + return errorResponse(status, code, publicMessage(code), traceId, responseHeaders); + } +} + +function errorResponse( + status: number, + code: string, + message: string, + traceId: string, + headers: Record, +) { + return NextResponse.json({ error: { code, message, traceId } }, { status, headers }); +} + +function publicMessage(code: string) { + switch (code) { + case "MODEL_RATE_LIMITED": return "The lesson workshop is busy. Please try again in a moment."; + case "MODEL_TIMEOUT": return "The lesson took too long to design. Your brief is saved; please retry."; + case "ARTIFACT_REJECTED": return "The draft did not pass our lesson checks. Please retry or simplify the objective."; + case "MODEL_NOT_CONFIGURED": return "Live lesson generation is not configured on this server."; + default: return "The lesson workshop could not finish this draft. Please try again."; + } +} diff --git a/wonderweave/app/api/realtime/token/route.ts b/wonderweave/app/api/realtime/token/route.ts new file mode 100644 index 0000000..e058197 --- /dev/null +++ b/wonderweave/app/api/realtime/token/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { readServerSecret, readServerSetting } from "@/app/lib/server-environment"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const DEFAULT_REALTIME_MODEL = "gpt-realtime-2.1"; +const TOKEN_TTL_SECONDS = 600; + +type ClientSecretResponse = { + value?: string; + expires_at?: number; + error?: { type?: string }; +}; + +export async function POST() { + const traceId = `voice_${crypto.randomUUID()}`; + const headers = { + "Cache-Control": "no-store, max-age=0", + "X-Trace-Id": traceId, + }; + const apiKey = readServerSecret("OPENAI_API_KEY"); + const model = readServerSetting("REALTIME_MODEL", DEFAULT_REALTIME_MODEL); + + if (!apiKey) { + return NextResponse.json( + { error: { code: "VOICE_NOT_CONFIGURED", message: "Live voice is not configured on this server.", traceId } }, + { status: 503, headers }, + ); + } + + let response: Response; + try { + response = await fetch("https://api.openai.com/v1/realtime/client_secrets", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + expires_after: { + anchor: "created_at", + seconds: TOKEN_TTL_SECONDS, + }, + session: { + type: "realtime", + model, + instructions: [ + "You are the warm voice inside Wonderweave, an adult-supervised learning experience for ages 3 to 6.", + "Use one short spoken idea at a time. Never ask for a child's name, location, contact details, school, or private information.", + "Stay inside the teacher-approved lesson supplied by the application. The application state and tools are authoritative.", + ].join(" "), + output_modalities: ["audio"], + reasoning: { effort: "low" }, + audio: { + input: { + noise_reduction: { type: "near_field" }, + turn_detection: { + type: "semantic_vad", + eagerness: "low", + create_response: true, + interrupt_response: true, + }, + }, + output: { + voice: "marin", + speed: 0.94, + }, + }, + // Page narration is bounded by our artifact schema, but audio tokens + // accumulate much faster than written words. A numeric ceiling can + // stop a healthy read-aloud mid-sentence, so use the API's unbounded + // per-response setting and enforce brevity in the lesson schema. + max_output_tokens: "inf", + tracing: null, + }, + }), + cache: "no-store", + }); + } catch { + return NextResponse.json( + { error: { code: "VOICE_PROVIDER_UNAVAILABLE", message: "The voice guide could not be reached.", traceId } }, + { status: 502, headers }, + ); + } + + let payload: ClientSecretResponse = {}; + try { + payload = await response.json() as ClientSecretResponse; + } catch { + // Provider bodies are deliberately not copied into application errors. + } + + if (!response.ok || !payload.value || !payload.expires_at) { + console.error(JSON.stringify({ + event: "realtime.client_secret_failed", + traceId, + status: response.status, + providerType: payload.error?.type || "unknown", + })); + const status = response.status === 401 || response.status === 403 ? 503 + : response.status === 429 ? 429 + : 502; + const code = status === 429 ? "VOICE_BUSY" + : status === 503 ? "VOICE_NOT_CONFIGURED" + : "VOICE_PROVIDER_UNAVAILABLE"; + const message = status === 429 + ? "The voice guide is busy. Please try again in a moment." + : status === 503 + ? "Live voice is not configured on this server." + : "The voice guide could not start."; + return NextResponse.json({ error: { code, message, traceId } }, { status, headers }); + } + + return NextResponse.json( + { value: payload.value, expiresAt: payload.expires_at, model }, + { status: 200, headers }, + ); +} diff --git a/wonderweave/app/chatgpt-auth.ts b/wonderweave/app/chatgpt-auth.ts new file mode 100644 index 0000000..8d1fb35 --- /dev/null +++ b/wonderweave/app/chatgpt-auth.ts @@ -0,0 +1,86 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export type ChatGPTUser = { + displayName: string; + email: string; + fullName: string | null; +}; + +const USER_EMAIL_HEADER = "oai-authenticated-user-email"; +const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name"; +const USER_FULL_NAME_ENCODING_HEADER = + "oai-authenticated-user-full-name-encoding"; +const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8"; +const SIGN_IN_PATH = "/signin-with-chatgpt"; +const SIGN_OUT_PATH = "/signout-with-chatgpt"; +const CALLBACK_PATH = "/callback"; + +export async function getChatGPTUser(): Promise { + const requestHeaders = await headers(); + const email = requestHeaders.get(USER_EMAIL_HEADER); + if (!email) return null; + + const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); + const fullName = + encodedFullName && + requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 + ? safeDecodeURIComponent(encodedFullName) + : null; + + return { + displayName: fullName ?? email, + email, + fullName, + }; +} + +export async function requireChatGPTUser( + returnTo: string, +): Promise { + const user = await getChatGPTUser(); + if (user) return user; + + redirect(chatGPTSignInPath(returnTo)); +} + +export function chatGPTSignInPath(returnTo: string): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +export function chatGPTSignOutPath(returnTo = "/"): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +function safeRelativeReturnPath(value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) return "/"; + + let url: URL; + try { + url = new URL(value, "https://app.local"); + } catch { + return "/"; + } + if (url.origin !== "https://app.local") return "/"; + if (isReservedAuthPath(url.pathname)) return "/"; + + return `${url.pathname}${url.search}${url.hash}`; +} + +function isReservedAuthPath(pathname: string): boolean { + return ( + pathname === SIGN_IN_PATH || + pathname === SIGN_OUT_PATH || + pathname === CALLBACK_PATH + ); +} + +function safeDecodeURIComponent(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} diff --git a/wonderweave/app/components/AnatomyApp.tsx b/wonderweave/app/components/AnatomyApp.tsx new file mode 100644 index 0000000..a133e79 --- /dev/null +++ b/wonderweave/app/components/AnatomyApp.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import gsap from "gsap"; +import { + ArrowRight, + BookOpen, + Bookmark, + BrainCircuit, + ChevronDown, + CircleHelp, + Compass, + FileText, + Heart, + LibraryBig, + Microscope, + NotebookPen, + Play, + Search, + Share2, + Sparkles, + Stethoscope, + X, +} from "lucide-react"; +import { OrganViewer } from "./OrganViewer"; +import { organById, organs, type Organ, type OrganId } from "../lib/anatomy-data"; + +type Modal = "lesson" | "quiz" | "animation" | "system" | null; + +/** + * Renders an organ illustration, or its accent glyph for organs that ship as a + * 3D model without the painted asset set. Keeps every image slot filled instead + * of leaving a broken `` behind. + */ +function OrganArt({ + organ, + asset, + alt, + size, +}: { + organ: Organ; + asset: "thumb" | "organ" | "microscopic" | "compare" | "location"; + alt: string; + size?: number; +}) { + if (!organ.illustrated) { + // An empty alt means a surrounding control already names this, so the + // glyph should be skipped rather than announced with no label. + const labelling = alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true }; + return ( + + {organ.icon} + + ); + } + return ( + {alt} + ); +} + +export function AnatomyApp() { + const [organId, setOrganId] = useState("heart"); + const [autoRotate, setAutoRotate] = useState(true); + const [compare, setCompare] = useState(false); + const [modal, setModal] = useState(null); + const [query, setQuery] = useState(""); + const [mobileLibrary, setMobileLibrary] = useState(false); + const contentRef = useRef(null); + const prefetched = useRef(new Set()); + const organ = organById[organId]; + const reference = organById[organId === "heart" ? "brain" : "heart"]; + const filteredOrgans = useMemo( + () => organs.filter((item) => `${item.name} ${item.system}`.toLowerCase().includes(query.toLowerCase())), + [query], + ); + + useEffect(() => { + if (!contentRef.current) return; + gsap.fromTo(contentRef.current.querySelectorAll("[data-reveal]"), + { opacity: 0, y: 8 }, + { opacity: 1, y: 0, duration: 0.48, stagger: 0.035, ease: "power2.out", overwrite: true }, + ); + }, [organId]); + + const selectOrgan = (id: OrganId) => { + if (organById[id].illustrated) { + ["organ", "microscopic", "compare", "location"].forEach((asset) => { + const image = new Image(); + image.src = `/anatomy/${id}/${asset}.webp`; + }); + } + setOrganId(id); + setMobileLibrary(false); + setCompare(false); + }; + + // Warms the model in the HTTP cache while the pointer is still travelling, + // so the switch usually renders without a visible loading pass. + const prefetchOrgan = (id: OrganId) => { + if (id === organId || prefetched.current.has(id)) return; + prefetched.current.add(id); + void fetch(organById[id].model, { priority: "low" } as RequestInit).catch(() => {}); + }; + + return ( +
+
+ + + + + +
+ +
+ + + setCompare(!compare)} + /> + + +
+ + {compare && ( +
+
Comparing{organ.name}{organ.system}
+ vs. +
Reference{reference.name}{reference.system}
+
Primary role
{organ.function}
Scale
{organ.size}
+ +
+ )} + +
+
+

Learning is
an act of curiosity.

Keep exploring! +
+
+
Microscopic view

{organ.tissue}

+
+ +
+
+
Compare organs

{organ.comparison}

+
+ +
+
+
Function animation

{organ.function}

+ {/* The artwork itself is the control, so the play badge inside it is + decorative rather than a nested button. */} + + +
+
+
Clinical notes

Common conditions

+
    {organ.conditions.map((condition) =>
  • {condition}
  • )}
+ +
+
+
Where it works

{organ.system}

+ + +
+
+ + {modal && setModal(null)} />} + {mobileLibrary &&
+ ); +} + +const MODAL_ICON: Record, string> = { + quiz: "?", + animation: "▶", + system: "⌖", + lesson: "✦", +}; + +function LearningModal({ type, organ, onClose }: { type: Exclude; organ: Organ; onClose: () => void }) { + const organName = organ.name; + const title = + type === "quiz" ? `${organName} quick quiz` + : type === "animation" ? `${organName} in motion` + // Avoids gluing onto `system`, whose wording varies per organ + // ("Cardiovascular" vs "Nervous System"), and stays grammatical for the + // plural organs too. + : type === "system" ? `${organName} in the body` + : `Inside the ${organName.toLowerCase()}`; + return ( +
+
event.stopPropagation()} + > + + {MODAL_ICON[type]} + Guided discovery + + {type === "quiz" ? ( +
+

Which statement best describes the {organName.toLowerCase()}?

+ + + +
+ ) : type === "system" ? ( + <> +

{organ.location}. Trace how the {organName.toLowerCase()} connects to the rest of the body.

+ {/* Shown whole rather than cropped into the circular demo — the + point of this view is the figure and its vessels. */} +
+ +
+
+
System
{organ.system}
+
Primary role
{organ.function}
+
Blood supply
{organ.bloodSupply}
+
+ + + ) : ( + <> +

Follow the highlighted structures, rotate the specimen, and connect form with function. This short study moment is designed to build a durable mental model.

+
+ + + )} +
+
+ ); +} diff --git a/wonderweave/app/components/LearningExperience.tsx b/wonderweave/app/components/LearningExperience.tsx new file mode 100644 index 0000000..312b3db --- /dev/null +++ b/wonderweave/app/components/LearningExperience.tsx @@ -0,0 +1,683 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + ArrowLeft, + ArrowRight, + CircleAlert, + Check, + Ear, + Eye, + Hand, + Heart, + LoaderCircle, + Mic, + MicOff, + RotateCcw, + Sparkles, + UsersRound, + Volume2, + VolumeX, + X, +} from "lucide-react"; +import type { LearningArtifact } from "@/app/lib/learning-artifact"; +import { useRealtimeTutor, type TutorVoiceStatus } from "@/app/lib/realtime-tutor"; +import styles from "./LearningStudio.module.css"; + +type LearnStage = "hook" | "model" | "explore" | "check" | "transfer"; +type ExploreReaction = "ready" | "listen" | "correct" | "try-again" | "complete"; + +const STAGES: LearnStage[] = ["hook", "model", "explore", "check", "transfer"]; + +export function LearningExperience({ + artifact, + initialMicrophoneStream, + onExit, +}: { + artifact: LearningArtifact; + initialMicrophoneStream?: MediaStream | null; + onExit: () => void; +}) { + const [stage, setStage] = useState("hook"); + const [selectedItem, setSelectedItem] = useState(null); + const [placed, setPlaced] = useState>({}); + const [feedback, setFeedback] = useState("Tap the wiggling picture. Listen. Then tap a glowing picture home."); + const [exploreReaction, setExploreReaction] = useState("ready"); + const [wrongChoice, setWrongChoice] = useState(null); + const [solved, setSolved] = useState(false); + const [confetti, setConfetti] = useState(false); + const [adultHelpRequested, setAdultHelpRequested] = useState(false); + const [voiceCue, setVoiceCue] = useState(null); + const autoVoiceStartedRef = useRef(false); + + const stageIndex = STAGES.indexOf(stage); + const placedCount = Object.keys(placed).length; + const allPlaced = placedCount === artifact.explore.items.length; + const unplaced = useMemo(() => artifact.explore.items.filter((item) => !placed[item.id]), [artifact.explore.items, placed]); + const learningFocus = findLearningFocus(artifact); + const focusHome = findFocusHome(artifact, learningFocus); + const exampleHome = resolveModelHome(artifact, learningFocus); + const stageNarration = useMemo(() => buildCompleteStageNarration(artifact), [artifact]); + const narrator = useRealtimeTutor({ + artifact, + stage, + instruction: stageNarration[stage], + feedback, + progress: stage === "explore" + ? `${placedCount} of ${artifact.explore.items.length} pictures placed` + : stage === "check" ? solved ? "the interface confirmed the answer" : "waiting for a picture choice" + : `step ${stageIndex + 1} of ${STAGES.length}`, + onRepeatRequested: (instruction) => setVoiceCue(instruction), + onHintRequested: (hint) => { + setVoiceCue(hint); + if (stage === "explore") setFeedback(hint); + }, + onAdultHelpRequested: () => setAdultHelpRequested(true), + }, initialMicrophoneStream); + const voiceEnabled = narrator.connected; + const voiceRefreshRequired = narrator.error?.startsWith("Wonderweave was updated") ?? false; + const autoConnectVoice = narrator.connect; + const realtimeSupported = narrator.supported; + + useEffect(() => { + window.scrollTo({ top: 0, behavior: "smooth" }); + }, [stage]); + + useEffect(() => { + if (!realtimeSupported || autoVoiceStartedRef.current) return; + autoVoiceStartedRef.current = true; + void autoConnectVoice(stageNarration.hook); + }, [autoConnectVoice, realtimeSupported, stageNarration.hook]); + + const enableVoice = async () => { + await narrator.connect(stageNarration[stage]); + }; + + const toggleVoice = () => { + if (voiceEnabled) narrator.toggleMute(); + else void enableVoice(); + }; + + const repeatInstruction = () => { + setVoiceCue(stageNarration[stage]); + if (voiceEnabled) narrator.speak(stageNarration[stage], "instruction"); + else void narrator.connect(stageNarration[stage]); + }; + + const say = (message: string, purpose: "feedback" | "celebration" = "feedback") => { + if (voiceEnabled) narrator.speak(message, purpose); + }; + + const readAloud = (message: string) => { + if (voiceEnabled) narrator.speak(message, "instruction"); + else void narrator.connect(message); + }; + + const moveToStage = (next: LearnStage) => { + setStage(next); + setVoiceCue(null); + if (voiceEnabled) narrator.speak(stageNarration[next], "instruction", { interruptCurrent: true }); + }; + + const hearHookPicture = (itemId: string) => { + const item = artifact.explore.items.find((candidate) => candidate.id === itemId); + if (!item) return; + say(`${item.label}. You found the ${item.label} picture. Tap another picture, or tap the big glowing eye button when you are ready.`); + }; + + const selectItem = (itemId: string) => { + const item = artifact.explore.items.find((candidate) => candidate.id === itemId); + if (!item) return; + const message = `${item.label}. ${buildHomeMap(artifact)} Now both big picture homes are glowing. Tap the matching home picture.`; + setSelectedItem(item.id); + setExploreReaction("listen"); + setFeedback(message); + say(message); + }; + + const putInCategory = (categoryId: string) => { + const category = artifact.explore.categories.find((candidate) => candidate.id === categoryId); + if (!category) return; + if (!selectedItem) { + const message = `${category.visualCue}. This picture is ${spokenHomeLabel(category.label)}. ${category.description} Now tap the wiggling picture above.`; + setFeedback(message); + setExploreReaction("ready"); + say(message); + return; + } + const item = artifact.explore.items.find((candidate) => candidate.id === selectedItem); + if (!item) return; + if (item.category !== categoryId) { + const message = `Good try. ${item.label} does not go with ${category.visualCue}. The other big picture home is wiggling now. Tap the other home.`; + setFeedback(message); + setExploreReaction("try-again"); + say(message); + return; + } + const finishesGame = placedCount + 1 === artifact.explore.items.length; + const message = `${item.label} goes with ${category.visualCue}. You did it.${finishesGame ? " Every picture is home. Tap the big glowing arrow at the bottom." : " Now another picture is wiggling. Tap it to keep going."}`; + setPlaced((current) => ({ ...current, [item.id]: categoryId })); + setSelectedItem(null); + setExploreReaction(finishesGame ? "complete" : "correct"); + setFeedback(message); + say(message, finishesGame ? "celebration" : "feedback"); + }; + + const answer = (choiceId: string) => { + const choice = artifact.check.choices.find((candidate) => candidate.id === choiceId); + if (!choice) return; + if (choice.correct) { + setWrongChoice(null); + setSolved(true); + setConfetti(true); + say(`${choice.label}. ${choice.explanation} ${artifact.celebration.message} Tap the big glowing arrow for one last activity.`, "celebration"); + window.setTimeout(() => setConfetti(false), 1_500); + } else { + setWrongChoice(choiceId); + setSolved(false); + say(`${choice.label}. Good try. ${choice.explanation} ${artifact.check.hint} Look for the other picture that is wiggling.`); + } + }; + + const restart = () => { + moveToStage("hook"); + setSelectedItem(null); + setPlaced({}); + setFeedback("Tap the wiggling picture. Listen. Then tap a glowing picture home."); + setExploreReaction("ready"); + setWrongChoice(null); + setSolved(false); + }; + + return ( +
+
+ + +
+ 0}> + 1}> + 2}> + 3}> + +
+ +
+ + +
+
+ + + + {!narrator.supported && ( +
+ Grown-up help needed. This browser cannot use the live voice guide. +
+ )} + + {narrator.error && ( +
+ Grown-up help needed. {narrator.error} + +
+ )} + + {voiceCue && voiceEnabled && ( +
+ + {voiceCue} + +
+ )} + + {adultHelpRequested && ( +
+ Grown-up help, please. Stay together for this part. + +
+ )} + + {stage === "hook" && ( +
+

{artifact.title}

+
+ {artifact.explore.items.slice(0, 4).map((item, index) => ( + + ))} + +
+ + + + {!voiceEnabled && narrator.supported && ( + + )} + moveToStage("model")}> + + +
+ )} + + {stage === "model" && ( +
+

{artifact.model.headline}

+ + + + moveToStage("explore")}> + + +
+ )} + + {stage === "explore" && ( +
+

Find each picture's home

+
+ + + + + + + {artifact.explore.items.map((item) => {placed[item.id] ? : null})} + +
+ +
+ {unplaced.map((item, index) => ( + + ))} + {unplaced.length === 0 && } +
+ +
+ {artifact.explore.categories.map((category, index) => { + const isWrongHome = exploreReaction === "try-again" && selectedItem + && artifact.explore.items.find((item) => item.id === selectedItem)?.category !== category.id; + return ( +
+ + +
+ ); + })} +
+ +
+ + + {feedback} + {allPlaced && ( + + )} +
+
+ )} + + {stage === "check" && ( +
+ {confetti && } +

{artifact.check.prompt}

+ + + + +
+ {artifact.check.choices.map((choice, index) => ( + + ))} +
+ {wrongChoice && !solved && ( +
+ + + Good try. {artifact.check.choices.find((choice) => choice.id === wrongChoice)?.explanation} {artifact.check.hint} +
+ )} + {solved && ( +
+ + {artifact.celebration.headline}. {artifact.check.choices.find((choice) => choice.correct)?.explanation} + +
+ )} +
+ )} + + {stage === "transfer" && ( +
+

{artifact.celebration.headline}

+ + + +

{artifact.celebration.message} {artifact.transfer.prompt} {artifact.transfer.adultCue}

+ +
+ )} +
+ ); +} + +function StageDot({ active, complete, children }: { active: boolean; complete: boolean; children: ReactNode }) { + return ; +} + +function PicturePath({ label, children }: { label: string; children: ReactNode }) { + return
{children}{label}
; +} + +function NextPictureButton({ testId, label, onClick, children }: { testId: string; label: string; onClick: () => void; children: ReactNode }) { + return ( + + ); +} + +function ReactionPicture({ reaction }: { reaction: ExploreReaction }) { + switch (reaction) { + case "listen": return ; + case "correct": return ; + case "try-again": return ; + case "complete": return ; + default: return ; + } +} + +function buildCompleteStageNarration(artifact: LearningArtifact): Record { + const learningFocus = findLearningFocus(artifact); + const modelHome = resolveModelHome(artifact, learningFocus); + return { + hook: joinForSpeech([ + "Hi! Let's look at these pictures together.", + learningFocus ? `The large ${learningFocus} is today's learning letter. Listen for its sound.` : "", + artifact.narration.hook, + artifact.hook.prompt, + artifact.hook.wonderQuestion, + "You can tap any floating picture and I will name it. When you are ready, tap the large glowing eye and arrow button near the bottom.", + ]), + model: joinForSpeech([ + "Watch and listen. The big picture in the middle is our example.", + artifact.narration.model, + artifact.model.instruction, + `${artifact.model.exampleLabel}. ${artifact.model.explanation}`, + modelHome + ? `The screen shows the ${artifact.model.exampleLabel} picture, then the listening ear, then ${modelHome.visualCue}. The spoken answer and the picture always match.` + : "", + artifact.model.gestureCue, + "Tap the big picture if you want to hear the example again. Then tap the large glowing hand and arrow button near the bottom for your turn.", + ]), + explore: buildExploreGameNarration(artifact), + check: joinForSpeech([ + "Now listen, then tap one big picture.", + artifact.narration.check, + artifact.check.prompt, + `The picture choices are ${spokenList(artifact.check.choices.map((choice) => choice.label))}.`, + "Tap the picture that answers the question. Tap the large round speaker if you want to hear everything again.", + ]), + transfer: joinForSpeech([ + "You did it! Find your grown-up for this last together activity.", + artifact.narration.transfer, + artifact.celebration.message, + artifact.transfer.prompt, + artifact.transfer.adultCue, + "Tap the large speaker to hear the activity again. Tap the round replay arrow when you want to play the whole lesson again.", + ]), + }; +} + +function buildHomeMap(artifact: LearningArtifact) { + const homes = artifact.explore.categories.map((category) => + `${category.visualCue} means ${spokenHomeLabel(category.label)}. ${category.description}`, + ); + return `Look below. There are two large picture homes. ${homes.join(" ")}`; +} + +function buildExploreGameNarration(artifact: LearningArtifact) { + const example = artifact.explore.items[0]; + const exampleHome = artifact.explore.categories.find((category) => category.id === example?.category); + const learningFocus = findLearningFocus(artifact); + const focusHome = findFocusHome(artifact, learningFocus); + return joinForSpeech([ + "Here is your picture game.", + learningFocus && focusHome + ? `The large letter ${learningFocus} stays on ${focusHome.visualCue}. That picture is the ${learningFocus} sound home.` + : "", + "First, one picture at the top wiggles. Tap that picture. I will say its name.", + "Next, the two large picture homes below glow. Tap the matching home picture.", + buildHomeMap(artifact), + example && exampleHome + ? `Let's do the first one together. Find the wiggling ${example.label} picture at the top and tap it. Listen: ${example.label}. ${exampleHome.description} Now look below and tap the large ${exampleHome.visualCue} picture home.` + : "Find the wiggling picture at the top. Tap it, listen, then tap its glowing picture home below.", + "A round speaker on each home repeats that home's clue. The speaker at the bottom repeats my last clue.", + ]); +} + +function findLearningFocus(artifact: LearningArtifact) { + if (artifact.domain !== "early-literacy") return null; + const evidence = [artifact.topic, artifact.objective, artifact.title, artifact.model.explanation] + .join(" "); + const phoneme = evidence.match(/\/([a-z]{1,3})\//i)?.[1]; + if (phoneme) return phoneme.toLocaleUpperCase(); + const letter = evidence.match(/\bletter\s+([a-z])\b/i)?.[1]; + return letter?.toLocaleUpperCase() ?? null; +} + +function findFocusHome(artifact: LearningArtifact, learningFocus: string | null) { + if (!learningFocus) return undefined; + const focus = learningFocus.toLocaleLowerCase(); + const scored = artifact.explore.categories.map((category) => ({ + category, + score: artifact.explore.items.filter((item) => + item.category === category.id && item.label.trim().toLocaleLowerCase().startsWith(focus), + ).length, + })).sort((left, right) => right.score - left.score); + if (scored[0]?.score) return scored[0].category; + + return artifact.explore.categories.find((category) => { + const evidence = `${category.label} ${category.description}`.toLocaleLowerCase(); + return evidence.includes(`/${focus}/`) + && /(start|begin|same)/.test(evidence) + && !/(different|other|not\s)/.test(evidence); + }); +} + +function resolveModelHome(artifact: LearningArtifact, learningFocus: string | null) { + const modelItem = artifact.explore.items.find((item) => + item.emoji === artifact.model.exampleEmoji + || item.label.trim().toLocaleLowerCase() === artifact.model.exampleLabel.trim().toLocaleLowerCase(), + ); + if (modelItem) return artifact.explore.categories.find((category) => category.id === modelItem.category); + + const modelEvidence = `${artifact.model.exampleLabel} ${artifact.model.instruction} ${artifact.model.explanation}`.toLocaleLowerCase(); + const directlyNamed = artifact.explore.categories.find((category) => { + const label = category.label.replace(/\bhome\b/gi, "").trim().toLocaleLowerCase(); + const visualCue = category.visualCue.trim().toLocaleLowerCase(); + return (label.length > 2 && modelEvidence.includes(label)) + || (visualCue.length > 2 && modelEvidence.includes(visualCue)); + }); + if (directlyNamed) return directlyNamed; + + const focusHome = findFocusHome(artifact, learningFocus); + if (!learningFocus || !focusHome) return undefined; + const exampleStartsWithFocus = artifact.model.exampleLabel.trim().toLocaleLowerCase() + .startsWith(learningFocus.toLocaleLowerCase()); + if (exampleStartsWithFocus) return focusHome; + return artifact.explore.categories.find((category) => category.id !== focusHome.id); +} + +function spokenHomeLabel(label: string) { + return label.trim().toLocaleLowerCase().endsWith("home") ? label : `${label} home`; +} + +function joinForSpeech(parts: string[]) { + const seen = new Set(); + return parts + .map((part) => part.replace(/\s+/g, " ").trim()) + .filter((part) => { + const key = part.toLocaleLowerCase(); + if (!part || seen.has(key)) return false; + seen.add(key); + return true; + }) + .join(" "); +} + +function spokenList(items: string[]) { + if (items.length < 2) return items[0] ?? ""; + return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`; +} + +function voiceStatusLabel(status: TutorVoiceStatus, connected: boolean, narrationProtected: boolean) { + switch (status) { + case "connecting": return "The voice guide is waking up."; + case "listening": return "The voice guide is listening."; + case "thinking": return narrationProtected ? "The next direction is getting ready." : "The voice guide is thinking."; + case "speaking": return narrationProtected ? "Listen until this direction finishes." : "The voice guide is talking and can be interrupted."; + case "muted": return "The microphone is resting."; + case "error": return "The voice guide needs grown-up help."; + default: return connected ? "The voice guide is ready." : "Sound starts with the lesson."; + } +} diff --git a/wonderweave/app/components/LearningStudio.module.css b/wonderweave/app/components/LearningStudio.module.css new file mode 100644 index 0000000..45b1cfa --- /dev/null +++ b/wonderweave/app/components/LearningStudio.module.css @@ -0,0 +1,1730 @@ +.shell, +.learnShell { + --studio-ink: #23352f; + --studio-muted: #6b7770; + --studio-paper: #fffdf7; + --studio-line: rgba(45, 74, 64, 0.14); + --studio-accent: #eb6b53; + --studio-accent-soft: #ffe9df; + min-height: 100vh; + color: var(--studio-ink); + background: + radial-gradient(circle at 9% 3%, rgba(255, 218, 151, 0.34), transparent 25rem), + radial-gradient(circle at 88% 18%, rgba(151, 213, 190, 0.3), transparent 27rem), + #f7f4e9; + font-family: var(--font-sans), sans-serif; +} + +.topbar { + height: 82px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + max-width: 1500px; + margin: 0 auto; + padding: 0 34px; + border-bottom: 1px solid var(--studio-line); +} + +.brand, +.profile, +.nav button, +.nav a, +.previewTopline button, +.learnHeader button { + border: 0; + background: none; + color: inherit; + font: inherit; +} + +.brand { + justify-self: start; + display: flex; + align-items: center; + gap: 11px; + padding: 0; + cursor: pointer; + text-align: left; +} + +.brandMark, +.learnBrand > span { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 14px 14px 14px 5px; + color: white; + background: #ed6b53; + box-shadow: 0 8px 18px rgba(195, 83, 62, 0.18); +} + +.brand strong { + display: block; + font: 650 24px/1 var(--font-serif), serif; + letter-spacing: -0.025em; +} + +.brand small { + display: block; + margin-top: 4px; + color: #8a958e; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.nav { + display: flex; + align-items: center; + gap: 4px; + padding: 5px; + border: 1px solid var(--studio-line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.44); +} + +.nav button, +.nav a { + min-height: 38px; + display: flex; + align-items: center; + gap: 7px; + padding: 0 15px; + border-radius: 999px; + text-decoration: none; + cursor: pointer; + font-size: 12px; + font-weight: 650; +} + +.nav .navActive { + color: #b74837; + background: #fff0e9; +} + +.profile { + justify-self: end; + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.profile span { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 50%; + color: white; + background: #375f53; + box-shadow: inset 0 0 0 3px #e8eee7; + font-size: 11px; + font-weight: 750; +} + +.studioGrid { + max-width: 1500px; + min-height: calc(100vh - 82px); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(500px, 0.92fr) minmax(540px, 1.08fr); +} + +.formPanel { + padding: clamp(56px, 7vh, 90px) clamp(48px, 6vw, 96px) 70px 54px; + border-right: 1px solid var(--studio-line); +} + +.kicker, +.learnEyebrow { + display: flex; + align-items: center; + gap: 10px; + color: #6c7c73; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.kicker span { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 50%; + color: #ba4f3f; + background: #fee6db; + letter-spacing: 0; +} + +.formPanel h1 { + max-width: 620px; + margin: 20px 0 14px; + font: 520 clamp(52px, 5.2vw, 78px)/0.92 var(--font-serif), serif; + letter-spacing: -0.055em; +} + +.intro { + max-width: 540px; + margin: 0 0 30px; + color: var(--studio-muted); + font: 400 16px/1.65 var(--font-serif), serif; +} + +.formPanel form { + max-width: 640px; +} + +.exampleRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; + margin-bottom: 24px; +} + +.exampleRow > span { + margin-right: 2px; + color: #87928b; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.exampleRow button { + min-height: 31px; + padding: 0 11px; + border: 1px solid var(--studio-line); + border-radius: 999px; + color: #53665e; + background: rgba(255, 255, 255, 0.48); + cursor: pointer; + font-size: 10px; +} + +.field { + display: block; + margin: 0 0 17px; +} + +.field > span { + display: flex; + justify-content: space-between; + margin: 0 0 7px; + color: #44594f; + font-size: 11px; + font-weight: 750; +} + +.field > span em { + color: #9ba39e; + font-size: 9px; + font-style: normal; + font-weight: 600; + text-transform: uppercase; +} + +.field input, +.field textarea, +.field select { + width: 100%; + border: 1px solid rgba(46, 75, 64, 0.18); + border-radius: 13px; + color: var(--studio-ink); + background: rgba(255, 254, 249, 0.85); + box-shadow: inset 0 1px 0 white; + font: 500 14px/1.5 var(--font-sans), sans-serif; + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.field input, +.field select { + height: 48px; + padding: 0 14px; +} + +.field textarea { + min-height: 80px; + padding: 12px 14px; + resize: vertical; +} + +.field textarea.shortArea { + min-height: 62px; +} + +.field input:focus, +.field textarea:focus, +.field select:focus { + outline: none; + border-color: #659886; + background: white; + box-shadow: 0 0 0 4px rgba(101, 152, 134, 0.14); +} + +.field input[aria-invalid="true"], +.field textarea[aria-invalid="true"] { + border-color: #c75645; +} + +.fieldPair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.domainField { + min-width: 0; + margin: 0 0 18px; + padding: 0; + border: 0; +} +.domainField legend { margin-bottom: 8px; color: #44594f; font-size: 11px; font-weight: 750; } +.domainOptions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.domainOptions button { + min-width: 0; + min-height: 68px; + display: flex; + align-items: center; + gap: 9px; + padding: 10px 11px; + border: 1px solid rgba(46, 75, 64, 0.16); + border-radius: 14px; + color: #557067; + background: rgba(255, 254, 249, 0.76); + cursor: pointer; + text-align: left; +} +.domainOptions button > svg:first-child { flex: 0 0 auto; color: #729286; } +.domainOptions button > svg:last-child { flex: 0 0 auto; margin-left: auto; } +.domainOptions button > span { min-width: 0; } +.domainOptions strong, .domainOptions small { display: block; } +.domainOptions strong { color: #35554a; font-size: 11px; } +.domainOptions small { margin-top: 3px; overflow: hidden; color: #85928c; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } +.domainOptions button.domainSelected { border-color: #5f9380; color: #356350; background: #e7f2e9; box-shadow: 0 0 0 3px rgba(95,147,128,.1); } + +.fieldHelp, +.fieldError { + display: block; + margin: 5px 2px 0; + color: #8a958e; + font-size: 10px; + line-height: 1.4; +} + +.fieldError { + color: #b13f31; + font-weight: 650; +} + +.privacyNote { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 7px 0 15px; + padding: 12px 14px; + border-radius: 12px; + color: #536b61; + background: rgba(218, 236, 225, 0.64); + font-size: 10px; + line-height: 1.5; +} + +.privacyNote svg { + flex: 0 0 auto; + margin-top: 1px; +} + +.privacyNote strong { + display: block; + color: #345448; +} + +.errorBanner { + display: flex; + gap: 10px; + margin: 10px 0; + padding: 13px 14px; + border: 1px solid rgba(178, 60, 43, 0.22); + border-radius: 12px; + color: #944334; + background: #fff0ec; + font-size: 11px; + line-height: 1.45; +} + +.errorBanner svg { flex: 0 0 auto; } +.errorBanner strong, +.errorBanner small { display: block; } +.errorBanner small { margin-top: 4px; opacity: 0.7; } + +.generateButton { + width: 100%; + min-height: 54px; + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + border: 0; + border-radius: 14px; + color: white; + background: #ea6b53; + box-shadow: 0 14px 28px rgba(190, 76, 55, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.3); + cursor: pointer; + font-size: 13px; + font-weight: 750; + transition: transform 150ms ease, box-shadow 150ms ease; +} + +.generateButton:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 18px 34px rgba(190, 76, 55, 0.25); +} + +.generateButton:disabled { cursor: wait; opacity: 0.9; } +.generateButton svg:last-child { margin-left: auto; margin-right: 15px; } + +.loader { + width: 17px; + height: 17px; + border: 2px solid rgba(255,255,255,.35); + border-top-color: white; + border-radius: 50%; + animation: spin 0.75s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.generateCaption { + margin: 8px 0 0; + color: #909991; + text-align: center; + font-size: 9px; +} + +.visionPanel { + position: relative; + min-height: 760px; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + padding: 66px clamp(50px, 7vw, 110px) 74px; + background: + linear-gradient(rgba(55, 94, 80, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(55, 94, 80, 0.04) 1px, transparent 1px), + linear-gradient(150deg, rgba(230, 239, 219, 0.4), rgba(216, 235, 227, 0.74)); + background-size: 28px 28px, 28px 28px, auto; +} + +.orbit { + position: relative; + width: min(100%, 480px); + aspect-ratio: 1.8; + margin: 0 auto 28px; +} + +.orbit::before, +.orbit::after { + content: ""; + position: absolute; + inset: 8% 7%; + border: 1px dashed rgba(53, 92, 78, 0.22); + border-radius: 50%; + transform: rotate(-7deg); +} + +.orbit::after { + inset: 25% 22%; + transform: rotate(9deg); +} + +.orbitCore { + position: absolute; + left: 50%; + top: 50%; + width: 112px; + height: 112px; + display: grid; + place-items: center; + align-content: center; + transform: translate(-50%, -50%) rotate(-3deg); + border-radius: 50% 44% 47% 43%; + color: white; + background: #ed765e; + box-shadow: 0 20px 38px rgba(127, 71, 52, 0.2); +} + +.orbitCore strong { font: italic 18px var(--font-serif), serif; } +.orbit > span { + position: absolute; + z-index: 1; + padding: 9px 13px; + border: 1px solid rgba(52, 86, 73, 0.12); + border-radius: 999px; + color: #45665a; + background: rgba(255, 255, 255, 0.78); + box-shadow: 0 8px 20px rgba(59, 93, 80, 0.09); + font: italic 14px var(--font-serif), serif; +} + +.orbitOne { left: 4%; top: 23%; transform: rotate(-7deg); } +.orbitTwo { right: 2%; top: 17%; transform: rotate(8deg); } +.orbitThree { right: 13%; bottom: 6%; transform: rotate(-4deg); } + +.visionCopy { max-width: 570px; margin: 0 auto; } +.handNote { color: #ce5e49; font: italic 14px "Comic Sans MS", cursive; } +.visionCopy h2 { + margin: 8px 0 10px; + font: 520 clamp(46px, 4.6vw, 70px)/0.92 var(--font-serif), serif; + letter-spacing: -0.05em; +} +.visionCopy > p { max-width: 480px; color: #61736b; font: 400 15px/1.55 var(--font-serif), serif; } +.visionCopy ol { margin: 26px 0 0; padding: 0; list-style: none; } +.visionCopy li { display: flex; gap: 13px; margin: 0 0 16px; } +.visionCopy li > span { + width: 27px; + height: 27px; + flex: 0 0 auto; + display: grid; + place-items: center; + border-radius: 9px 9px 9px 3px; + color: #a84939; + background: #ffded2; + font-size: 10px; + font-weight: 800; +} +.visionCopy li strong { display: block; font: 650 15px var(--font-serif), serif; } +.visionCopy li small { display: block; margin-top: 3px; color: #728078; font-size: 10px; } + +.trustStrip { + display: flex; + justify-content: center; + gap: 18px; + margin: 36px auto 0; + padding-top: 18px; + border-top: 1px solid rgba(50, 82, 70, 0.11); + color: #5f746a; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.trustStrip span { display: flex; align-items: center; gap: 5px; } + +/* Teacher preview */ +.previewShell { + max-width: 1500px; + margin: 0 auto; + padding: 28px 34px 54px; +} + +.previewTopline, +.previewFooter { + display: flex; + align-items: center; + justify-content: space-between; +} + +.previewTopline { margin-bottom: 18px; } +.previewTopline button { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 0; + cursor: pointer; + font-size: 11px; + font-weight: 700; +} +.previewTopline > div { display: flex; gap: 8px; } +.previewTopline span { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 9px; + border-radius: 999px; + color: #356052; + background: #deefe5; + font-size: 9px; + font-weight: 750; + text-transform: uppercase; +} + +.previewHero { + --lesson-accent: #ea6b53; + --lesson-soft: #ffe6dc; + min-height: 340px; + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(250px, 0.55fr); + grid-template-rows: auto 1fr; + gap: 20px 45px; + padding: 28px clamp(30px, 5vw, 75px) 42px; + overflow: hidden; + border: 1px solid var(--studio-line); + border-radius: 28px; + background: + radial-gradient(circle at 78% 23%, color-mix(in srgb, var(--lesson-accent), transparent 82%), transparent 18rem), + linear-gradient(135deg, #fffef8, color-mix(in srgb, var(--lesson-soft), white 55%)); + box-shadow: 0 18px 50px rgba(47, 79, 67, 0.08); +} + +.accent_coral { --lesson-accent: #eb6b53; --lesson-soft: #ffe2d8; } +.accent_sun { --lesson-accent: #df9e23; --lesson-soft: #fff0c7; } +.accent_leaf { --lesson-accent: #4f9474; --lesson-soft: #dcefe1; } +.accent_sky { --lesson-accent: #4a8eb6; --lesson-soft: #dceefa; } +.accent_grape { --lesson-accent: #7d65ad; --lesson-soft: #e9e1f5; } + +.previewMeta { + grid-column: 1 / -1; + display: flex; + gap: 8px; +} +.previewMeta span { + min-height: 28px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--lesson-accent), transparent 70%); + border-radius: 999px; + color: color-mix(in srgb, var(--lesson-accent), #23352f 30%); + background: rgba(255,255,255,.58); + font-size: 9px; + font-weight: 750; + text-transform: uppercase; +} + +.previewTitle { align-self: end; } +.previewTitle > p { margin: 0 0 7px; color: var(--lesson-accent); font-size: 10px; font-weight: 800; letter-spacing: .15em; text-transform: uppercase; } +.previewTitle h1 { margin: 0; font: 520 clamp(52px, 6vw, 88px)/.86 var(--font-serif), serif; letter-spacing: -.055em; } +.previewTitle h2 { margin: 14px 0 0; color: #6c7b73; font: italic 22px var(--font-serif), serif; } + +.previewAction { + align-self: end; + padding: 22px; + border: 1px solid rgba(44,73,62,.12); + border-radius: 18px; + background: rgba(255,255,255,.62); + backdrop-filter: blur(10px); +} +.previewAction p { margin: 0 0 16px; color: #5e7067; font-size: 11px; line-height: 1.55; } +.previewAction p strong { display: block; margin-bottom: 4px; color: #2d493e; font-size: 9px; letter-spacing: .1em; text-transform: uppercase; } +.previewAction button, +.previewFooter button, +.solvedCard button { + min-height: 46px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 0; + border-radius: 12px; + color: white; + background: var(--lesson-accent, #ea6b53); + cursor: pointer; + font-size: 11px; + font-weight: 750; +} +.previewAction button { width: 100%; } +.previewAction small { display: block; margin-top: 8px; color: #8c9791; text-align: center; font-size: 9px; } + +.previewGrid { + display: grid; + grid-template-columns: 1.35fr .65fr; + gap: 16px; + margin-top: 16px; +} + +.sequenceCard, +.materialCard, +.teacherCard, +.receiptCard { + padding: 23px; + border: 1px solid var(--studio-line); + border-radius: 20px; + background: rgba(255, 253, 247, 0.75); + box-shadow: 0 12px 30px rgba(47, 79, 67, 0.04); +} + +.sectionHeading { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 18px; +} +.sectionHeading span { font: 650 20px var(--font-serif), serif; } +.sectionHeading small { color: #8a958f; font-size: 9px; } + +.pathRow { + display: grid; + grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr auto 1fr; + align-items: center; + gap: 9px; +} +.pathRow > svg { color: #a6afa9; } +.pathRow article { min-height: 130px; padding: 13px; border-radius: 14px; background: #f3f1e7; } +.pathRow article > span { color: var(--lesson-accent, #d7614b); font-size: 9px; font-weight: 800; } +.pathRow article strong { display: block; margin-top: 7px; font: 650 14px var(--font-serif), serif; } +.pathRow article p { margin: 7px 0 0; color: #6c7972; font-size: 9px; line-height: 1.5; } + +.materialCategories { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.materialCategories > div { padding: 15px; border-radius: 14px; background: #f1f3e8; } +.materialCategories strong, .materialCategories small { display: block; } +.materialCategories strong { font: 650 15px var(--font-serif), serif; } +.materialCategories small { margin-top: 4px; min-height: 27px; color: #748078; font-size: 9px; line-height: 1.4; } +.materialCategories p { display: flex; gap: 6px; margin: 12px 0 0; } +.materialCategories p span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; background: white; font-size: 19px; } + +.teacherCard dl { margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.teacherCard dl > div { padding: 14px; border-left: 3px solid #bfd9c8; background: #f1f4ea; } +.teacherCard dt { color: #4f6a5e; font-size: 9px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.teacherCard dd { margin: 6px 0 0; color: #65736c; font-size: 10px; line-height: 1.5; } + +.receiptCard > strong { font: 650 18px var(--font-serif), serif; } +.receiptCard dl { margin: 15px 0; } +.receiptCard dl div { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--studio-line); font-size: 9px; } +.receiptCard dt { color: #8a958f; text-transform: uppercase; } +.receiptCard dd { margin: 0; max-width: 130px; overflow: hidden; color: #40594e; text-overflow: ellipsis; white-space: nowrap; } +.receiptCard p { display: flex; align-items: flex-start; gap: 8px; margin: 0; padding: 12px; border-radius: 11px; color: #476459; background: #e6f1e7; font-size: 9px; line-height: 1.45; } +.receiptCard p svg { flex: 0 0 auto; } + +.previewFooter { justify-content: flex-end; gap: 9px; margin-top: 18px; } +.previewFooter button { min-width: 150px; padding: 0 18px; } +.previewFooter button:first-child { color: #52665d; border: 1px solid var(--studio-line); background: white; } + +/* Learner experience */ +.learnShell { + --lesson-accent: #eb6b53; + --lesson-soft: #ffe2d8; + min-height: 100vh; + background: + radial-gradient(circle at 14% 10%, color-mix(in srgb, var(--lesson-soft), transparent 34%), transparent 26rem), + radial-gradient(circle at 88% 80%, rgba(196, 226, 211, 0.55), transparent 30rem), + #faf7ec; +} + +.learnHeader { + height: 74px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 0 28px; + border-bottom: 1px solid rgba(44,73,62,.1); +} +.learnHeader > button { justify-self: start; display: flex; align-items: center; gap: 7px; min-height: 44px; cursor: pointer; color: #5f7068; font-size: 10px; font-weight: 700; } +.learnBrand { display: flex; align-items: center; gap: 8px; font: 650 20px var(--font-serif), serif; } +.learnBrand > span { width: 32px; height: 32px; border-radius: 11px 11px 11px 4px; background: var(--lesson-accent); } +.stageDots { justify-self: end; display: flex; gap: 6px; } +.stageDots i { width: 9px; height: 9px; border-radius: 50%; background: #d7ddd8; } +.stageDots i.stageDotActive { width: 22px; border-radius: 999px; background: var(--lesson-accent); } +.childTools { justify-self: end; display: flex; align-items: center; gap: 8px; } +.childTools .stageDots { margin-right: 7px; } +.childTools > button { min-height: 46px; display: flex; align-items: center; justify-content: center; gap: 7px; border: 0; border-radius: 15px; cursor: pointer; font-size: 11px; font-weight: 800; } +.childTools .soundOn, .childTools .soundOff { width: 46px; color: white; background: var(--lesson-accent); } +.childTools .soundOff { color: #65786f; background: #e7ece7; } +.childTools .repeatButton { padding: 0 15px; color: #34594c; background: white; box-shadow: 0 6px 18px rgba(48,80,68,.1); } +.repeatButton:disabled { opacity: .45; cursor: not-allowed; } + +.voiceStatus { + width: fit-content; + min-height: 32px; + display: flex; + align-items: center; + gap: 9px; + margin: 9px auto -41px; + padding: 0 13px; + position: relative; + z-index: 3; + border-radius: 999px; + color: #63746c; + background: rgba(255,255,255,.78); + box-shadow: 0 5px 16px rgba(48,80,68,.08); + font-size: 10px; + font-weight: 750; +} +.voiceStatus > span { height: 15px; display: flex; align-items: center; gap: 2px; } +.voiceStatus > span i { width: 3px; height: 5px; border-radius: 3px; background: var(--lesson-accent); } +.voiceConnected { color: #315d4d; background: rgba(239, 251, 244, .92); box-shadow: 0 7px 22px rgba(48,80,68,.12); } +.voiceSpeaking > span i { animation: voiceWave .7s ease-in-out infinite alternate; } +.voiceSpeaking > span i:nth-child(2) { animation-delay: .15s; } +.voiceSpeaking > span i:nth-child(3) { animation-delay: .3s; } +.voiceSpeaking > span i:nth-child(4) { animation-delay: .45s; } +@keyframes voiceWave { to { height: 15px; } } +.spin { animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.audioFallback { width: fit-content; max-width: min(92vw, 660px); margin: 48px auto -34px; padding: 10px 14px; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px; position: relative; z-index: 4; border-radius: 12px; color: #7b5b2e; background: #fff0c9; box-shadow: 0 8px 24px rgba(106,75,32,.1); text-align: center; font-size: 11px; } +.audioFallback button { min-height: 34px; padding: 7px 11px; border: 0; border-radius: 999px; color: white; background: #7b5b2e; font: 700 11px var(--font-sans), sans-serif; white-space: nowrap; cursor: pointer; } +.voiceCue, +.grownupCallout { + width: min(92vw, 660px); + margin: 48px auto -34px; + padding: 11px 13px; + display: flex; + align-items: center; + gap: 10px; + position: relative; + z-index: 4; + border: 1px solid rgba(53,89,76,.12); + border-radius: 14px; + color: #375e50; + background: rgba(255,255,255,.94); + box-shadow: 0 10px 28px rgba(48,80,68,.11); + font: 600 11px/1.4 var(--font-sans), sans-serif; +} +.voiceCue > svg, +.grownupCallout > svg { flex: 0 0 auto; color: var(--lesson-accent); } +.voiceCue > span, +.grownupCallout > span { flex: 1; } +.voiceCue > button, +.grownupCallout > button { width: 34px; height: 34px; flex: 0 0 auto; display: grid; place-items: center; border: 0; border-radius: 50%; color: #60736a; background: #edf2ed; cursor: pointer; } +.grownupCallout { color: #5d4b2d; background: #fff4cf; } +.grownupCallout strong { display: block; font-size: 12px; } + +.hookStage { + max-width: 1240px; + min-height: calc(100vh - 74px); + margin: 0 auto; + padding: clamp(50px, 8vh, 100px) 36px; + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: clamp(40px, 8vw, 110px); +} + +.motifScene { + position: relative; + width: min(100%, 530px); + aspect-ratio: 1; + margin: auto; + border-radius: 47% 53% 49% 51%; + background: + radial-gradient(circle at 32% 28%, rgba(255,255,255,.92), transparent 12%), + linear-gradient(145deg, var(--lesson-soft), color-mix(in srgb, var(--lesson-accent), white 76%)); + box-shadow: inset 0 0 0 1px rgba(44,73,62,.08), 0 35px 70px rgba(58,87,75,.12); + transform: rotate(-3deg); +} +.motifScene > span { + position: absolute; + width: clamp(78px, 8vw, 112px); + height: clamp(78px, 8vw, 112px); + display: grid; + place-items: center; + border: 1px solid rgba(44,73,62,.1); + border-radius: 28px 28px 28px 10px; + background: rgba(255,255,255,.82); + box-shadow: 0 18px 30px rgba(47,75,65,.1); + font-size: clamp(38px, 4vw, 58px); + transform: rotate(3deg); +} +.motifScene > span:nth-child(1) { left: 10%; top: 12%; } +.motifScene > span:nth-child(2) { right: 9%; top: 17%; transform: rotate(10deg); } +.motifScene > span:nth-child(3) { left: 15%; bottom: 11%; transform: rotate(-7deg); } +.motifScene > span:nth-child(4) { right: 12%; bottom: 13%; transform: rotate(6deg); } +.motifScene > div { position: absolute; left: 50%; top: 50%; width: 116px; height: 116px; display: grid; place-items: center; transform: translate(-50%,-50%) rotate(3deg); border-radius: 50%; color: white; background: var(--lesson-accent); box-shadow: 0 20px 40px color-mix(in srgb, var(--lesson-accent), transparent 65%); } + +.hookCopy h1, +.learnTitleRow h1, +.checkStage h1, +.transferStage h1 { + margin: 13px 0 17px; + font: 530 clamp(58px, 7vw, 96px)/.88 var(--font-serif), serif; + letter-spacing: -.055em; +} +.hookCopy > p { max-width: 540px; color: #63736b; font: 450 20px/1.5 var(--font-serif), serif; } +.hookCopy blockquote { max-width: 500px; margin: 22px 0; padding: 16px 20px; border-left: 4px solid var(--lesson-accent); border-radius: 0 13px 13px 0; color: #39574b; background: rgba(255,255,255,.6); font: italic 22px/1.35 var(--font-serif), serif; } +.hookCopy blockquote svg { display: inline; margin-right: 7px; color: var(--lesson-accent); vertical-align: -3px; } +.hookCopy > button, +.transferStage > button { + min-height: 58px; + display: flex; + align-items: center; + gap: 10px; + padding: 0 25px; + border: 0; + border-radius: 16px; + color: white; + background: var(--lesson-accent); + box-shadow: 0 14px 30px color-mix(in srgb, var(--lesson-accent), transparent 72%); + cursor: pointer; + font-size: 15px; + font-weight: 750; +} +.hookCopy > button:not(.soundStart) > span, +.childNextButton > span, +.solvedCard button > span { font-size: 16px; } +.hookCopy > button:not(.soundStart) > svg:last-child, +.childNextButton > svg:last-child { margin-left: 8px; } +.hookCopy button.soundStart { + width: min(100%, 440px); + min-height: 94px; + display: grid; + grid-template-columns: 64px 1fr; + grid-template-rows: 1fr 1fr; + gap: 0 13px; + margin: 0 0 25px; + padding: 12px 18px; + color: #35564a; + background: white; + box-shadow: 0 16px 34px rgba(48,80,68,.12); + text-align: left; +} +.soundStart > span { grid-row: 1 / 3; width: 64px; height: 64px; display: grid; place-items: center; border-radius: 50%; color: white; background: var(--lesson-accent); animation: soundPulse 1.6s ease-in-out infinite; } +.soundStart strong { align-self: end; font: 700 20px var(--font-serif), serif; } +.soundStart small { align-self: start; color: #7d8a84; font-size: 10px; } +@keyframes soundPulse { 50% { transform: scale(1.07); box-shadow: 0 0 0 9px color-mix(in srgb, var(--lesson-accent), transparent 83%); } } + +.modelStage, +.exploreStage, +.checkStage, +.transferStage { + max-width: 1180px; + min-height: calc(100vh - 74px); + margin: 0 auto; + padding: 46px 34px 70px; +} +.modelStage { max-width: 1040px; min-height: calc(100vh - 74px); margin: 0 auto; padding: 56px 34px 70px; } +.modelStage .learnTitleRow { justify-content: center; text-align: center; } +.modelStage .learnEyebrow { justify-content: center; } +.modelStage .learnTitleRow p { margin-inline: auto; } +.modelBoard { display: grid; grid-template-columns: minmax(220px, .72fr) 1.28fr; gap: 22px; align-items: stretch; margin: 24px 0; } +.modelPicture { min-height: 340px; display: grid; place-items: center; border: 2px solid rgba(47,76,65,.09); border-radius: 34px; background: linear-gradient(145deg, white, var(--lesson-soft)); box-shadow: 0 22px 50px rgba(48,80,68,.11); font-size: clamp(110px, 16vw, 190px); } +.modelThinking { display: flex; flex-direction: column; justify-content: center; padding: 36px; border-radius: 34px; color: #38584c; background: rgba(255,255,255,.72); } +.modelThinking > span { display: flex; align-items: center; gap: 8px; color: var(--lesson-accent); font-size: 11px; font-weight: 850; letter-spacing: .12em; text-transform: uppercase; } +.modelThinking > strong { margin-top: 12px; font: 650 clamp(35px, 5vw, 58px)/1 var(--font-serif), serif; } +.modelThinking > p { margin: 16px 0; color: #5d7067; font: 450 20px/1.5 var(--font-serif), serif; } +.modelThinking > div { display: flex; align-items: center; gap: 10px; padding: 15px 17px; border-radius: 17px; color: #5e5333; background: #fff0c9; font: 650 15px/1.35 var(--font-serif), serif; } +.childNextButton { min-height: 60px; display: flex; align-items: center; gap: 10px; margin: 24px auto 0; padding: 0 28px; border: 0; border-radius: 18px; color: white; background: var(--lesson-accent); box-shadow: 0 14px 30px color-mix(in srgb, var(--lesson-accent), transparent 72%); cursor: pointer; font-weight: 800; } +.learnTitleRow { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 28px; } +.learnTitleRow h1 { margin: 7px 0; font-size: clamp(45px, 5vw, 70px); } +.learnTitleRow p { max-width: 670px; margin: 0; color: #66766e; font: 450 18px/1.45 var(--font-serif), serif; } +.titleActions { flex: 0 0 auto; display: flex; align-items: center; gap: 10px; } +.sectionSpeaker { + width: 58px; + height: 58px; + flex: 0 0 auto; + display: grid; + place-items: center; + border: 0; + border-radius: 50%; + color: white; + background: var(--lesson-accent); + box-shadow: 0 11px 24px color-mix(in srgb, var(--lesson-accent), transparent 73%); + cursor: pointer; +} +.sectionSpeaker:hover { transform: scale(1.04); } +.progressPill { flex: 0 0 auto; display: flex; gap: 5px; margin-bottom: 4px; padding: 9px 13px; border-radius: 999px; color: #50665d; background: #e4eee5; font-size: 10px; font-weight: 750; } +.progressPill i { width: 13px; height: 13px; border: 2px solid #aec1b7; border-radius: 50%; background: white; } +.progressPill i.progressDone { border-color: var(--lesson-accent); background: var(--lesson-accent); } +.visualInstruction { width: fit-content; display: flex; align-items: center; gap: 10px; margin: -5px auto 13px; color: #6c7c74; } +.visualInstruction span { min-width: 105px; min-height: 58px; display: grid; grid-template-columns: auto auto; grid-template-rows: auto auto; align-items: center; justify-content: center; gap: 0 7px; padding: 7px 12px; border-radius: 16px; background: white; box-shadow: 0 7px 17px rgba(48,80,68,.08); font-weight: 850; } +.visualInstruction span > svg, +.visualInstruction span > b:first-child { grid-row: 1 / 3; font-size: 25px; } +.visualInstruction span > b { font-size: 15px; } +.visualInstruction span > small { color: #7a8982; font-size: 9px; font-weight: 750; } +.visualInstruction > i { color: var(--lesson-accent); font-size: 21px; font-style: normal; font-weight: 900; } + +.itemTray { + min-height: 158px; + display: flex; + justify-content: center; + align-items: stretch; + gap: 11px; + padding: 17px; + border: 1px dashed rgba(47,76,65,.25); + border-radius: 22px; + background: rgba(255,255,255,.5); +} +.itemTray > button { + min-width: 130px; + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + padding: 12px; + border: 1px solid rgba(45,73,63,.12); + border-radius: 18px; + color: inherit; + background: #fffdf7; + box-shadow: 0 8px 18px rgba(42,73,61,.06); + cursor: pointer; + transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease; +} +.itemTray > button:hover, +.itemTray > button.itemSelected { transform: translateY(-5px); border-color: var(--lesson-accent); box-shadow: 0 14px 24px color-mix(in srgb, var(--lesson-accent), transparent 86%); } +.itemTray > button.startHere { border-color: var(--lesson-accent); box-shadow: 0 0 0 5px color-mix(in srgb, var(--lesson-accent), transparent 84%); animation: startHerePulse 1.5s ease-in-out infinite; } +@keyframes startHerePulse { 50% { transform: translateY(-4px) scale(1.025); } } +.itemTray > button > span { font-size: 58px; line-height: 1.1; } +.itemTray > button strong { font: 650 18px var(--font-serif), serif; } +.itemTray > button small { color: #8b9690; font-size: 9px; } +.trayComplete { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: #3c705d; } +.trayComplete strong { font: 650 22px var(--font-serif), serif; } + +.categoryGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; } +.categoryCard { + min-height: 178px; + position: relative; + border: 2px solid transparent; + border-radius: 22px; + color: #304b40; + background: color-mix(in srgb, var(--lesson-soft), white 45%); + transition: border-color 150ms ease, transform 150ms ease; + overflow: hidden; +} +.categoryCard:hover { transform: translateY(-2px); border-color: var(--lesson-accent); } +.categoryCard:nth-child(2) { background: #e4efe9; } +.categoryTarget { + width: 100%; + min-height: 178px; + display: block; + padding: 21px; + border: 0; + color: inherit; + background: transparent; + cursor: pointer; + text-align: left; +} +.categoryListen { + min-height: 48px; + display: flex; + align-items: center; + gap: 8px; + position: absolute; + right: 17px; + top: 91px; + z-index: 2; + padding: 0 13px; + border: 0; + border-radius: 999px; + color: white; + background: var(--lesson-accent); + box-shadow: 0 8px 20px color-mix(in srgb, var(--lesson-accent), transparent 75%); + cursor: pointer; + font-size: 10px; + font-weight: 850; +} +.categoryIcon { float: right; width: 64px; height: 64px; display: grid; place-items: center; border-radius: 18px; color: var(--lesson-accent); background: rgba(255,255,255,.7); font-size: 37px; } +.categoryGrid strong { display: block; padding-top: 5px; font: 650 26px var(--font-serif), serif; } +.categoryGrid small { display: block; max-width: calc(100% - 185px); min-height: 46px; margin-top: 5px; color: #62746b; font-size: 11px; line-height: 1.45; } +.categoryTarget > div { min-height: 52px; display: flex; gap: 7px; margin-top: 17px; } +.categoryTarget > div span { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 13px; background: rgba(255,255,255,.8); font-size: 25px; } + +.feedbackBar { + min-height: 64px; + display: flex; + align-items: center; + gap: 11px; + margin-top: 16px; + padding: 12px 15px; + border: 1px solid rgba(47,76,65,.12); + border-radius: 16px; + color: #51675e; + background: rgba(255,255,255,.7); + font: 500 14px/1.4 var(--font-serif), serif; +} +.feedbackBar > button { min-height: 46px; display: flex; align-items: center; gap: 7px; padding: 0 15px; border: 0; border-radius: 14px; color: white; background: var(--lesson-accent); cursor: pointer; font-size: 11px; font-weight: 750; } +.feedbackBar > button:first-child { width: 46px; flex: 0 0 auto; justify-content: center; padding: 0; border-radius: 50%; } +.feedbackBar > button:last-child:not(:first-child) { margin-left: auto; } + +.checkStage { max-width: 960px; text-align: center; } +.checkStage > .learnEyebrow { justify-content: center; margin-top: 15px; } +.promptSpeaker { width: 62px; height: 62px; display: grid; place-items: center; margin: 15px auto 0; border: 0; border-radius: 50%; color: white; background: var(--lesson-accent); box-shadow: 0 12px 27px color-mix(in srgb, var(--lesson-accent), transparent 73%); cursor: pointer; } +.checkStage h1 { max-width: 850px; margin: 14px auto 34px; font-size: clamp(42px, 5.5vw, 72px); line-height: .98; } +.choiceGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; } +.choiceGrid > button { + min-height: 96px; + display: flex; + align-items: center; + gap: 15px; + padding: 14px 17px; + border: 2px solid rgba(47,76,65,.1); + border-radius: 19px; + color: #304b40; + background: rgba(255,255,255,.74); + cursor: pointer; + text-align: left; + transition: border-color 150ms ease, transform 150ms ease; +} +.choiceGrid > button:hover:not(:disabled) { transform: translateY(-2px); border-color: var(--lesson-accent); } +.choiceGrid > button > span.choicePicture { width: 88px; height: 88px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 22px; color: var(--lesson-accent); background: var(--lesson-soft); font-size: 52px; font-style: normal; } +.choiceGrid > button strong { font: 620 17px/1.3 var(--font-serif), serif; } +.choiceGrid > button > i { width: 40px; height: 40px; display: grid; place-items: center; margin-left: auto; border-radius: 50%; color: var(--lesson-accent); background: white; font-style: normal; } +.choiceGrid > button.choiceWrong { border-color: #d98e7c; background: #fff2ed; } +.choiceGrid > button.choiceCorrect { border-color: #61a17e; background: #e5f3e8; } + +.hintCard, +.solvedCard { + display: flex; + align-items: center; + gap: 14px; + margin: 18px auto 0; + padding: 17px 19px; + border-radius: 17px; + text-align: left; +} +.hintCard { max-width: 760px; color: #6f552e; background: #fff0c9; } +.hintCard > button { width: 50px; height: 50px; flex: 0 0 auto; display: grid; place-items: center; border: 0; border-radius: 50%; color: white; background: #c28a29; cursor: pointer; } +.hintCard strong, .solvedCard strong { display: block; font: 650 17px var(--font-serif), serif; } +.hintCard p, .solvedCard p { margin: 4px 0 0; font-size: 11px; line-height: 1.5; } +.solvedCard { color: #365b4c; background: #dff1e4; } +.solvedCard > span { width: 46px; height: 46px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 50%; color: #db654f; background: white; } +.solvedCard > button { min-width: 170px; margin-left: auto; padding: 0 14px; } + +.confetti { position: fixed; inset: 0; z-index: 5; pointer-events: none; overflow: hidden; } +.confetti i { position: absolute; top: -30px; color: var(--lesson-accent); font-style: normal; animation: fall 1.4s ease-in forwards; } +.confetti i:nth-child(1) { left: 12%; animation-delay: .1s; } +.confetti i:nth-child(2) { left: 28%; animation-delay: .25s; color: #e4a424; } +.confetti i:nth-child(3) { left: 45%; animation-delay: .02s; color: #5d9b7f; } +.confetti i:nth-child(4) { left: 62%; animation-delay: .18s; } +.confetti i:nth-child(5) { left: 77%; animation-delay: .3s; color: #7b66ab; } +.confetti i:nth-child(6) { left: 90%; animation-delay: .12s; color: #e4a424; } +@keyframes fall { to { transform: translateY(95vh) rotate(540deg); opacity: .1; } } + +.transferStage { max-width: 820px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } +.celebrationOrb { position: relative; width: 150px; height: 150px; display: grid; place-items: center; margin: 15px 0 25px; border-radius: 46% 54% 48% 52%; color: white; background: var(--lesson-accent); box-shadow: 0 24px 50px color-mix(in srgb, var(--lesson-accent), transparent 65%); transform: rotate(-4deg); } +.celebrationOrb > span { position: absolute; color: #d7a323; font-size: 22px; } +.celebrationOrb > span:nth-child(2) { left: -34px; top: 17px; } +.celebrationOrb > span:nth-child(3) { right: -28px; top: 42px; color: #5e9b7f; } +.celebrationOrb > span:nth-child(4) { left: -20px; bottom: 5px; color: #7a64aa; } +.transferStage h1 { margin: 10px 0; font-size: clamp(48px, 7vw, 80px); } +.transferStage > p { max-width: 600px; margin: 0 0 25px; color: #63746c; font: 450 19px/1.5 var(--font-serif), serif; } +.transferPrompt { width: 100%; padding: 22px; border: 1px solid rgba(46,75,64,.12); border-radius: 20px; background: rgba(255,255,255,.72); text-align: left; } +.transferPrompt > button { width: 58px; height: 58px; float: right; display: grid; place-items: center; margin: 0 0 12px 15px; border: 0; border-radius: 50%; color: white; background: var(--lesson-accent); cursor: pointer; } +.transferPrompt > strong { display: block; color: #315447; font: 650 23px/1.3 var(--font-serif), serif; } +.transferPrompt > span { display: flex; gap: 10px; margin-top: 14px; padding-top: 14px; border-top: 1px solid rgba(46,75,64,.1); color: #6a776f; } +.transferPrompt i { font: italic 14px/1.5 var(--font-serif), serif; } +.transferStage > button { margin-top: 20px; min-height: 50px; font-size: 12px; } + +@media (max-width: 1050px) { + .studioGrid { grid-template-columns: 1fr; } + .formPanel { border-right: 0; padding-inline: clamp(28px, 8vw, 90px); } + .visionPanel { min-height: auto; padding-block: 70px; } + .previewHero { grid-template-columns: 1fr; } + .previewAction { max-width: 480px; } + .previewGrid { grid-template-columns: 1fr; } + .hookStage { grid-template-columns: 1fr; padding-top: 42px; } + .motifScene { max-width: 390px; } + .hookCopy { max-width: 700px; text-align: center; margin: auto; } + .hookCopy > p, .hookCopy blockquote { margin-left: auto; margin-right: auto; } + .hookCopy > button { margin: auto; } +} + +@media (max-width: 720px) { + .topbar { height: 68px; grid-template-columns: 1fr auto; padding: 0 18px; } + .nav { display: none; } + .brand strong { font-size: 21px; } + .brandMark { width: 36px; height: 36px; } + .profile { grid-column: 2; } + .formPanel { padding: 40px 20px 54px; } + .formPanel h1 { font-size: 50px; } + .fieldPair { grid-template-columns: 1fr; gap: 0; } + .domainOptions { grid-template-columns: 1fr; } + .visionPanel { padding: 58px 24px; } + .orbit { transform: scale(.88); margin-block: -10px 20px; } + .trustStrip { flex-wrap: wrap; } + .previewShell { padding: 18px 14px 40px; } + .previewTopline > div span:nth-child(2) { display: none; } + .previewHero { min-height: 0; padding: 22px; border-radius: 22px; } + .previewTitle h1 { font-size: 52px; } + .pathRow { grid-template-columns: 1fr 1fr; } + .pathRow > svg { display: none; } + .pathRow article { min-height: 120px; } + .teacherCard dl { grid-template-columns: 1fr; } + .previewFooter { flex-direction: column-reverse; } + .previewFooter button { width: 100%; } + .learnHeader { height: 70px; grid-template-columns: auto 1fr auto; padding: 0 10px; } + .learnHeader > button { font-size: 0; } + .learnBrand { justify-self: center; font-size: 0; } + .childTools { gap: 5px; } + .childTools .repeatButton { width: 48px; padding: 0; } + .childTools .repeatButton span { display: none; } + .stageDots { display: none; } + .voiceStatus { margin-top: 7px; margin-bottom: -35px; } + .voiceStatus { max-width: calc(100vw - 28px); text-align: center; } + .voiceCue, .grownupCallout, .audioFallback { margin-top: 42px; margin-bottom: -28px; } + .hookStage { min-height: calc(100vh - 66px); padding: 30px 18px 50px; gap: 36px; } + .motifScene { max-width: 310px; } + .motifScene > span { width: 68px; height: 68px; border-radius: 20px; font-size: 34px; } + .motifScene > div { width: 84px; height: 84px; } + .hookCopy h1 { font-size: 56px; } + .hookCopy > p { font-size: 18px; } + .hookCopy blockquote { font-size: 19px; } + .modelStage, .exploreStage, .checkStage, .transferStage { min-height: calc(100vh - 70px); padding: 40px 16px 55px; } + .modelBoard { grid-template-columns: 1fr; } + .modelPicture { min-height: 220px; } + .modelThinking { padding: 24px; } + .learnTitleRow { display: block; } + .progressPill { display: inline-block; margin-top: 13px; } + .itemTray { justify-content: flex-start; overflow-x: auto; scroll-snap-type: x mandatory; } + .itemTray > button { min-width: 132px; scroll-snap-align: start; } + .categoryGrid { grid-template-columns: 1fr; } + .categoryCard, .categoryTarget { min-height: 170px; } + .categoryListen { right: 14px; top: 94px; } + .categoryListen span { display: none; } + .categoryGrid small { max-width: calc(100% - 120px); } + .feedbackBar { align-items: flex-start; flex-wrap: wrap; } + .feedbackBar > button { width: 100%; margin-left: 0; justify-content: center; } + .choiceGrid { grid-template-columns: 1fr; } + .choiceGrid > button { min-height: 112px; } + .choiceGrid > button > span.choicePicture { width: 72px; height: 72px; font-size: 43px; } + .checkStage h1 { font-size: 43px; } + .solvedCard { align-items: flex-start; flex-wrap: wrap; } + .solvedCard > button { width: 100%; margin-left: 0; } + .transferStage h1 { font-size: 50px; } + .transferPrompt > strong { font-size: 20px; } +} + +@media (prefers-reduced-motion: reduce) { + .shell *, .learnShell * { scroll-behavior: auto !important; animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } + .generateButton:hover:not(:disabled), .itemTray > button:hover, .itemTray > button.itemSelected, .categoryCard:hover, .choiceGrid > button:hover:not(:disabled), .sectionSpeaker:hover { transform: none; } +} + +/* Pre-reader learner mode: visible meaning comes from pictures, placement, + motion, shape, and sound. Text is retained only for assistive technology or + explicit grown-up recovery. */ +.srOnly { + width: 1px !important; + height: 1px !important; + padding: 0 !important; + position: absolute !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.preReaderShell { + min-height: 100svh; + overflow-x: hidden; + background: + radial-gradient(circle at 16% 14%, color-mix(in srgb, var(--lesson-soft), transparent 18%), transparent 26rem), + radial-gradient(circle at 86% 84%, rgba(196, 226, 211, .68), transparent 30rem), + #fffaf0; +} + +.childHeader { + min-height: 76px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + padding: 10px 24px; + position: relative; + z-index: 20; + border-bottom: 1px solid rgba(45, 73, 63, .08); + background: rgba(255, 250, 240, .88); + backdrop-filter: blur(14px); +} + +.adultExit, +.pictureTools button, +.pictureVoiceCue button { + border: 0; + cursor: pointer; +} + +.adultExit { + width: 60px; + min-height: 48px; + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + border-radius: 16px; + color: #61756c; + background: rgba(255,255,255,.72); + box-shadow: 0 7px 18px rgba(47, 76, 65, .08); +} + +.pictureProgress { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.pictureProgress > span { + width: 34px; + height: 34px; + display: grid; + place-items: center; + position: relative; + border: 2px solid #d8e0da; + border-radius: 50%; + color: #9aa8a1; + background: white; + transition: width 180ms ease, color 180ms ease, border-color 180ms ease, background 180ms ease; +} + +.pictureProgress > span:not(:last-child)::after { + content: ""; + width: 10px; + height: 3px; + position: absolute; + left: 32px; + border-radius: 999px; + background: #d8e0da; +} + +.pictureProgress > span.pictureStageActive { + width: 51px; + border-color: var(--lesson-accent); + color: white; + background: var(--lesson-accent); + box-shadow: 0 0 0 7px color-mix(in srgb, var(--lesson-accent), transparent 84%); +} + +.pictureProgress > span.pictureStageComplete { + border-color: #68a987; + color: white; + background: #68a987; +} + +.pictureTools { + justify-self: end; + display: flex; + gap: 9px; +} + +.pictureTools button { + width: 52px; + height: 52px; + display: grid; + place-items: center; + position: relative; + border-radius: 17px; +} + +.pictureTools .soundOn, +.pictureTools .soundOff { + color: white; + background: var(--lesson-accent); + box-shadow: 0 8px 20px color-mix(in srgb, var(--lesson-accent), transparent 72%); +} + +.pictureTools .soundOff { color: #697d74; background: #e5ebe6; box-shadow: none; } +.pictureTools .pictureRepeat { color: #365f50; background: white; box-shadow: 0 8px 20px rgba(47, 76, 65, .1); } +.pictureRepeat > svg:last-of-type { position: absolute; right: 6px; bottom: 6px; color: var(--lesson-accent); } +.pictureRepeat:disabled { opacity: .45; cursor: not-allowed; } + +.voiceGuide { + width: 114px; + height: 58px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 10px auto -68px; + position: relative; + z-index: 12; + border: 2px solid rgba(45,73,63,.08); + border-radius: 999px; + color: #72847b; + background: rgba(255,255,255,.9); + box-shadow: 0 9px 23px rgba(47,76,65,.11); + cursor: pointer; +} + +.voiceGuide.voiceConnected { border-color: color-mix(in srgb, var(--lesson-accent), white 58%); color: var(--lesson-accent); } +.voiceBuddy { width: 43px; height: 43px; display: grid; place-items: center; position: relative; border-radius: 47% 53% 45% 55%; color: white; background: var(--lesson-accent); box-shadow: inset 0 -4px 0 rgba(101,41,28,.09); } +.buddySpark { position: absolute; right: -5px; top: -5px; color: #e8aa2b; filter: drop-shadow(0 2px 1px rgba(87,61,9,.15)); } +.buddyEyes { display: flex; gap: 7px; position: absolute; left: 10px; top: 13px; } +.buddyEyes i { width: 6px; height: 8px; border-radius: 50%; background: white; box-shadow: inset 0 -2px 0 rgba(77,42,34,.16); } +.buddySmile { width: 15px; height: 8px; position: absolute; left: 14px; top: 24px; border-bottom: 3px solid white; border-radius: 0 0 12px 12px; } +.voiceWave { display: flex; align-items: center; justify-content: center; gap: 2px; } +.voiceWave i { width: 3px; height: 6px; border-radius: 4px; background: currentColor; } +.voiceSpeaking .voiceBuddy { animation: buddyGlow 1s ease-in-out infinite; } +.voiceSpeaking .voiceWave i, +.pictureVoiceCue .voiceWave i { animation: pictureWave .65s ease-in-out infinite alternate; } +.voiceWave i:nth-child(2) { animation-delay: .12s !important; } +.voiceWave i:nth-child(3) { animation-delay: .24s !important; } +.voiceWave i:nth-child(4) { animation-delay: .36s !important; } +.voiceWave i:nth-child(5) { animation-delay: .48s !important; } +@keyframes pictureWave { to { height: 19px; } } +@keyframes buddyGlow { 50% { transform: scale(1.09); box-shadow: 0 0 0 7px color-mix(in srgb, var(--lesson-accent), transparent 82%); } } + +.grownupRecovery { + width: min(92vw, 720px); + min-height: 58px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin: 58px auto -48px; + padding: 11px 14px; + position: relative; + z-index: 30; + border: 2px solid #e5bd61; + border-radius: 16px; + color: #654f28; + background: #fff1c6; + box-shadow: 0 12px 28px rgba(92,67,28,.14); + font-size: 12px; +} + +.grownupRecovery > svg { flex: 0 0 auto; } +.grownupRecovery > span { flex: 1; } +.grownupRecovery strong { display: block; } +.grownupRecovery button { min-height: 38px; padding: 0 13px; border: 0; border-radius: 12px; color: white; background: #795f2f; cursor: pointer; font-weight: 800; } + +.pictureVoiceCue { + width: 96px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin: 58px auto -48px; + position: relative; + z-index: 25; + border-radius: 999px; + color: var(--lesson-accent); + background: white; + box-shadow: 0 10px 25px rgba(47,76,65,.13); +} +.pictureVoiceCue > button { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 50%; color: #6b7b74; background: #eef2ee; } + +.childStage { + width: min(100%, 1180px); + min-height: calc(100svh - 76px); + margin: 0 auto; + padding: 62px 30px 42px; + position: relative; +} + +.picturePath { + min-height: 68px; + display: flex; + align-items: center; + justify-content: center; + gap: 13px; + padding: 10px 18px; + border: 2px solid rgba(47,76,65,.08); + border-radius: 22px; + color: #547065; + background: rgba(255,255,255,.82); + box-shadow: 0 9px 22px rgba(47,76,65,.08); +} +.picturePath > svg:nth-of-type(even), .picturePath > svg:last-child { color: var(--lesson-accent); } +.pathWave { display: flex; align-items: center; gap: 3px; } +.pathWave i { width: 5px; height: 12px; border-radius: 999px; background: var(--lesson-accent); animation: pictureWave .7s ease-in-out infinite alternate; } +.pathWave i:nth-child(2) { animation-delay: .15s; } +.pathWave i:nth-child(3) { animation-delay: .3s; } + +.pictureHookStage { + max-width: 1050px; + display: grid; + grid-template-columns: minmax(330px, 1fr) 250px; + grid-template-rows: 1fr auto; + align-items: center; + gap: 22px 52px; +} + +.pictureGalaxy { + width: min(100%, 620px); + aspect-ratio: 1.23; + position: relative; + justify-self: center; + grid-row: 1 / 3; + border-radius: 46% 54% 48% 52%; + background: + radial-gradient(circle at 32% 27%, rgba(255,255,255,.94), transparent 12%), + linear-gradient(145deg, var(--lesson-soft), color-mix(in srgb, var(--lesson-accent), white 80%)); + box-shadow: inset 0 0 0 2px rgba(44,73,62,.06), 0 34px 70px rgba(58,87,75,.13); +} + +.pictureGalaxy > button { + width: clamp(92px, 11vw, 132px); + height: clamp(92px, 11vw, 132px); + display: grid; + place-items: center; + position: absolute; + z-index: 2; + border: 3px solid rgba(45,73,63,.07); + border-radius: 32px 32px 32px 12px; + background: white; + box-shadow: 0 18px 34px rgba(47,75,65,.12); + cursor: pointer; +} +.pictureGalaxy > button > span:first-child { font-size: clamp(48px, 6vw, 72px); } +.pictureWord, +.modelWord, +.homeWord, +.choiceWord { + color: #344f44; + font: 750 15px/1.15 var(--font-sans), sans-serif; + letter-spacing: .01em; +} +.pictureWord { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pictureGalaxy > button:nth-child(1) { left: 7%; top: 10%; } +.pictureGalaxy > button:nth-child(2) { right: 7%; top: 12%; transform: rotate(7deg); } +.pictureGalaxy > button:nth-child(3) { left: 13%; bottom: 8%; transform: rotate(-6deg); } +.pictureGalaxy > button:nth-child(4) { right: 10%; bottom: 9%; transform: rotate(5deg); } +.topicSpark { width: 120px; height: 120px; display: grid; place-items: center; position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%) rotate(4deg); border-radius: 50%; color: white; background: var(--lesson-accent); box-shadow: 0 18px 36px color-mix(in srgb, var(--lesson-accent), transparent 62%); } +.focusGlyph { color: white; font: 900 clamp(58px, 7vw, 82px)/1 var(--font-sans), sans-serif; text-shadow: 0 3px 0 rgba(91,39,28,.13); } + +.pictureHookStage > .picturePath { align-self: end; } +.soundStart { + width: 92px; + height: 92px; + display: grid; + place-items: center; + justify-self: center; + border: 0; + border-radius: 50%; + color: white; + background: var(--lesson-accent); + box-shadow: 0 0 0 11px color-mix(in srgb, var(--lesson-accent), transparent 82%), 0 18px 35px rgba(47,76,65,.18); + cursor: pointer; + animation: soundPulse 1.4s ease-in-out infinite; +} + +.bigPictureNext, +.miniNextButton { + border: 0; + color: white; + background: #3f9a6d; + cursor: pointer; + box-shadow: 0 0 0 10px rgba(63,154,109,.14), 0 18px 36px rgba(50,118,84,.28); + animation: nextBeacon 1.45s ease-in-out infinite; +} +.bigPictureNext { + width: 150px; + height: 86px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + justify-self: center; + align-self: start; + border-radius: 28px; +} +.miniNextButton { width: 74px; height: 60px; display: grid; place-items: center; border-radius: 21px; } +@keyframes nextBeacon { 50% { transform: translateY(-4px) scale(1.035); box-shadow: 0 0 0 17px rgba(63,154,109,.08), 0 23px 40px rgba(50,118,84,.28); } } + +.pictureModelStage { + max-width: 920px; + display: grid; + grid-template-columns: 90px 1fr 90px; + grid-template-rows: auto 1fr auto; + align-items: center; + gap: 18px; +} +.replayStageButton { + width: 66px; + height: 66px; + display: grid; + place-items: center; + position: relative; + border: 0; + border-radius: 50%; + color: white; + background: var(--lesson-accent); + box-shadow: 0 11px 26px color-mix(in srgb, var(--lesson-accent), transparent 70%); + cursor: pointer; +} +.replayStageButton > svg:last-child { position: absolute; right: 5px; bottom: 6px; } +.pictureModelStage > .replayStageButton { grid-column: 3; justify-self: end; } +.modelVisual { + min-height: 390px; + display: grid; + grid-template-columns: 1fr 120px 1fr; + align-items: center; + gap: 20px; + grid-column: 1 / 4; + padding: 34px; + border: 3px solid rgba(47,76,65,.08); + border-radius: 42px; + background: rgba(255,255,255,.84); + box-shadow: 0 28px 60px rgba(47,76,65,.12); + cursor: pointer; +} +.modelHeroEmoji, +.modelHomeEmoji { min-height: 250px; display: grid; grid-template-rows: 1fr auto; place-items: center; gap: 7px; padding: 16px; position: relative; border-radius: 34px; background: linear-gradient(145deg, white, var(--lesson-soft)); } +.modelHomeEmoji { background: linear-gradient(145deg, #edf8f0, #d5eadc); } +.modelHeroEmoji > i, +.modelHomeEmoji > i { display: grid; place-items: center; align-self: end; font-size: clamp(100px, 14vw, 165px); font-style: normal; line-height: 1; } +.modelWord { align-self: start; font-size: 19px; } +.homeFocusGlyph { + min-width: 64px; + min-height: 64px; + display: grid; + place-items: center; + border: 5px solid white; + border-radius: 22px; + color: white; + background: var(--lesson-accent); + box-shadow: 0 10px 22px color-mix(in srgb, var(--lesson-accent), transparent 66%); + font: 900 38px/1 var(--font-sans), sans-serif; +} +.modelHomeEmoji > .homeFocusGlyph { position: absolute; right: 13px; bottom: 13px; } +.modelSoundBridge { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; color: var(--lesson-accent); } +.modelSoundBridge i { width: 62px; height: 7px; border-radius: 999px; background: currentColor; animation: soundTravel 1.1s ease-in-out infinite; } +.modelSoundBridge i:nth-of-type(2) { width: 44px; animation-delay: .15s; } +.modelSoundBridge i:nth-of-type(3) { width: 27px; animation-delay: .3s; } +@keyframes soundTravel { 50% { transform: translateX(8px); opacity: .45; } } +.gestureBubble { width: 94px; height: 58px; display: flex; align-items: center; justify-content: center; gap: 4px; grid-column: 1; justify-self: center; border-radius: 22px; color: #886b25; background: #fff0bf; } +.gestureBubble span { font-size: 30px; } +.pictureModelStage > .bigPictureNext { grid-column: 2; } + +.pictureExploreStage { max-width: 1120px; padding-top: 60px; } +.exploreTopline { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 14px; margin-bottom: 17px; } +.exploreTopline .picturePath { justify-self: start; } +.twoHomes { display: flex; gap: 5px; } +.twoHomes i { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px 9px 4px 4px; color: white; background: var(--lesson-accent); font-size: 11px; font-style: normal; } +.twoHomes i:nth-child(2) { background: #5d9b7f; } +.pictureProgressDots { display: flex; gap: 6px; padding: 10px; border-radius: 999px; background: rgba(255,255,255,.78); } +.pictureProgressDots i { width: 23px; height: 23px; display: grid; place-items: center; border: 2px solid #c6d2ca; border-radius: 50%; color: white; font-style: normal; } +.pictureProgressDots i.progressDone { border-color: #58a078; background: #58a078; } + +.pictureTray { + min-height: 158px; + display: flex; + justify-content: center; + align-items: stretch; + gap: 14px; + padding: 18px; + border: 3px dashed rgba(47,76,65,.16); + border-radius: 30px; + background: rgba(255,255,255,.48); +} +.pictureTray > button { + min-width: 124px; + min-height: 126px; + flex: 1 1 0; + display: grid; + place-items: center; + position: relative; + border: 3px solid rgba(45,73,63,.08); + border-radius: 27px; + background: white; + box-shadow: 0 11px 25px rgba(42,73,61,.08); + cursor: pointer; +} +.pictureTray > button > span:first-child { font-size: clamp(58px, 7vw, 80px); } +.pictureTray .pictureWord { margin-top: -5px; font-size: 14px; } +.pictureTray > button.itemSelected { border-color: var(--lesson-accent); transform: translateY(-6px) scale(1.035); box-shadow: 0 0 0 8px color-mix(in srgb, var(--lesson-accent), transparent 82%), 0 18px 32px rgba(42,73,61,.14); } +.selectedSound { width: 39px; height: 39px; display: grid; place-items: center; position: absolute; right: -8px; top: -8px; border-radius: 50%; color: white; background: var(--lesson-accent); font-style: normal; } +.gentleWiggle { animation: gentleWiggle 1.25s ease-in-out infinite !important; } +@keyframes gentleWiggle { 25% { transform: rotate(-2deg) translateY(-5px); } 75% { transform: rotate(2deg) translateY(-5px); } } +.pictureTrayComplete { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 12px; color: #429169; } + +.pictureHomeGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 18px; } +.pictureHomeCard { min-height: 220px; position: relative; border: 4px solid transparent; border-radius: 34px 34px 16px 16px; background: color-mix(in srgb, var(--lesson-soft), white 32%); box-shadow: 0 16px 32px rgba(47,76,65,.1); overflow: hidden; transition: transform 180ms ease, border-color 180ms ease; } +.pictureHomeCard:nth-child(2) { background: #dff0e6; } +.pictureHomeCard.homeReady { border-color: color-mix(in srgb, var(--lesson-accent), white 26%); animation: homeGlow 1.35s ease-in-out infinite; } +.pictureHomeCard.homeReady:nth-child(2) { border-color: #5eaa82; animation-delay: .16s; } +.pictureHomeCard.homeTryOther { opacity: .43; filter: grayscale(.2); animation: none; } +@keyframes homeGlow { 50% { transform: translateY(-5px); box-shadow: 0 0 0 9px color-mix(in srgb, var(--lesson-accent), transparent 86%), 0 20px 36px rgba(47,76,65,.14); } } +.homeSpeaker { width: 52px; height: 52px; display: grid; place-items: center; position: absolute; right: 14px; top: 14px; z-index: 3; border: 0; border-radius: 50%; color: white; background: var(--lesson-accent); box-shadow: 0 8px 18px color-mix(in srgb, var(--lesson-accent), transparent 70%); cursor: pointer; } +.pictureHomeCard:nth-child(2) .homeSpeaker { background: #4d9973; } +.pictureHomeTarget { width: 100%; min-height: 220px; display: grid; grid-template-columns: 145px 1fr; align-items: center; gap: 18px; padding: 22px; border: 0; color: inherit; background: transparent; cursor: pointer; } +.homeHeroEmoji { width: 132px; height: 132px; display: grid; grid-column: 1; grid-row: 1 / 3; place-items: center; border-radius: 38px 38px 18px 18px; background: rgba(255,255,255,.75); box-shadow: inset 0 0 0 2px rgba(45,73,63,.05); font-size: 78px; } +.homeWord { grid-column: 2; align-self: end; padding-right: 48px; font-size: 18px; text-align: left; } +.pictureHomeTarget > .homeFocusGlyph { grid-column: 2; align-self: end; justify-self: start; } +.homeContents { min-height: 76px; display: flex; flex-wrap: wrap; grid-column: 2; align-self: start; align-content: flex-start; gap: 9px; padding: 7px 45px 0 0; } +.homeContents span { width: 59px; height: 59px; display: grid; place-items: center; border-radius: 17px; background: rgba(255,255,255,.84); font-size: 34px; animation: popHome .35s ease-out both; } +@keyframes popHome { from { transform: scale(.5); opacity: 0; } } + +.pictureReaction { min-height: 72px; display: grid; grid-template-columns: 58px 1fr auto; align-items: center; gap: 13px; margin-top: 17px; padding: 8px 11px; border: 2px solid rgba(47,76,65,.08); border-radius: 24px; background: rgba(255,255,255,.82); box-shadow: 0 9px 22px rgba(47,76,65,.08); } +.pictureReaction > button:first-child { width: 54px; height: 54px; display: grid; place-items: center; border: 0; border-radius: 50%; color: white; background: var(--lesson-accent); cursor: pointer; } +.pictureReaction > span:not(.srOnly) { display: flex; align-items: center; justify-content: center; gap: 13px; color: var(--lesson-accent); } +.pictureReaction.reaction_correct { color: #3f8f66; background: #e4f3e8; } +.pictureReaction.reaction_try_again { color: #aa7a29; background: #fff0c9; } +.pictureReaction.reaction_complete { color: #3f8f66; background: #dff1e4; } + +.pictureCheckStage { max-width: 940px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; } +.pictureCheckStage > .picturePath { min-width: 300px; } +.questionSpeaker { width: 82px; height: 82px; display: grid; place-items: center; position: relative; border: 0; border-radius: 50%; color: white; background: var(--lesson-accent); box-shadow: 0 0 0 11px color-mix(in srgb, var(--lesson-accent), transparent 84%), 0 18px 35px rgba(47,76,65,.16); cursor: pointer; } +.questionSpeaker > svg:last-child { position: absolute; right: 8px; bottom: 8px; } +.pictureChoiceGrid { width: 100%; display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px; } +.pictureChoiceGrid > button { min-height: 180px; display: grid; place-items: center; gap: 3px; position: relative; padding: 14px; border: 4px solid rgba(47,76,65,.08); border-radius: 34px; background: white; box-shadow: 0 15px 30px rgba(47,76,65,.09); cursor: pointer; } +.pictureChoiceGrid > button > span:first-child { font-size: clamp(72px, 10vw, 118px); } +.choiceWord { font-size: 18px; } +.pictureChoiceGrid > button > i { width: 52px; height: 52px; display: grid; place-items: center; position: absolute; right: 12px; top: 12px; border-radius: 50%; color: white; background: var(--lesson-accent); font-style: normal; } +.pictureChoiceGrid > button.choiceWrong { border-color: #d58c6f; background: #fff2ed; animation: wrongNudge .4s ease-in-out; } +.pictureChoiceGrid > button.choiceCorrect { border-color: #55a077; background: #e5f3e8; } +@keyframes wrongNudge { 33% { transform: translateX(-8px); } 66% { transform: translateX(8px); } } +.pictureHint, +.pictureSolved { min-width: min(100%, 460px); min-height: 74px; display: flex; align-items: center; justify-content: center; gap: 16px; padding: 10px 14px; border-radius: 24px; } +.pictureHint { color: #9a7129; background: #fff0c9; } +.pictureHint > button { width: 54px; height: 54px; display: grid; place-items: center; border: 0; border-radius: 50%; color: white; background: #bc8425; cursor: pointer; } +.pictureSolved { color: #3f8f66; background: #dff1e4; } +.pictureSolved .miniNextButton { margin-left: auto; } + +.pictureTransferStage { max-width: 880px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 23px; } +.pictureTransferStage .celebrationOrb { margin: 0; } +.togetherVisual { width: min(100%, 700px); min-height: 180px; display: grid; grid-template-columns: 100px 1fr 90px; align-items: center; gap: 20px; padding: 24px; border: 3px solid rgba(47,76,65,.08); border-radius: 36px; color: var(--lesson-accent); background: rgba(255,255,255,.82); box-shadow: 0 20px 44px rgba(47,76,65,.11); } +.togetherPictures { display: flex; justify-content: center; gap: 10px; } +.togetherPictures i { width: 86px; height: 86px; display: grid; place-items: center; border-radius: 25px; background: var(--lesson-soft); font-size: 49px; font-style: normal; } +.finalListenButton { width: 92px; height: 92px; display: grid; place-items: center; position: relative; border: 0; border-radius: 50%; color: white; background: var(--lesson-accent); box-shadow: 0 0 0 11px color-mix(in srgb, var(--lesson-accent), transparent 84%), 0 18px 35px rgba(47,76,65,.17); cursor: pointer; } +.finalListenButton > svg:last-child { position: absolute; right: 9px; bottom: 9px; } +.playAgainPicture { width: 118px; height: 65px; display: flex; align-items: center; justify-content: center; gap: 8px; border: 0; border-radius: 23px; color: white; background: #4f9b74; box-shadow: 0 14px 28px rgba(54,119,85,.23); cursor: pointer; } + +.preReaderShell button:focus-visible { outline: 5px solid #2c6fef; outline-offset: 4px; } +.preReaderShell button:active:not(:disabled) { transform: scale(.97); } + +@media (max-width: 760px) { + .childHeader { min-height: 68px; grid-template-columns: 52px 1fr auto; gap: 7px; padding: 8px 10px; } + .adultExit { width: 50px; min-height: 46px; } + .pictureProgress { gap: 4px; } + .pictureProgress > span { width: 25px; height: 25px; } + .pictureProgress > span:not(:last-child)::after { width: 6px; left: 23px; } + .pictureProgress > span.pictureStageActive { width: 37px; } + .pictureTools { gap: 5px; } + .pictureTools button { width: 46px; height: 46px; } + .voiceGuide { width: 104px; height: 54px; margin-top: 7px; margin-bottom: -61px; } + .childStage { min-height: calc(100svh - 68px); padding: 56px 14px 34px; } + .pictureHookStage { display: flex; flex-direction: column; gap: 16px; } + .pictureGalaxy { width: min(100%, 410px); flex: 0 0 auto; } + .pictureGalaxy > button { width: 78px; height: 78px; border-radius: 22px 22px 22px 9px; } + .pictureGalaxy > button > span:first-child { font-size: 43px; } + .topicSpark { width: 83px; height: 83px; } + .pictureHookStage > .picturePath { min-height: 58px; } + .bigPictureNext { width: 128px; height: 72px; } + .pictureModelStage { display: flex; flex-direction: column; } + .pictureModelStage > .replayStageButton { align-self: flex-end; } + .modelVisual { width: 100%; min-height: 290px; grid-template-columns: 1fr 65px 1fr; gap: 8px; padding: 15px; } + .modelHeroEmoji, .modelHomeEmoji { min-height: 170px; padding: 10px; border-radius: 25px; } + .modelHeroEmoji > i, .modelHomeEmoji > i { font-size: 77px; } + .modelWord { font-size: 14px; } + .modelSoundBridge i { width: 38px; } + .gestureBubble { display: none; } + .exploreTopline { grid-template-columns: 1fr auto; } + .exploreTopline .picturePath { grid-column: 1 / 3; width: 100%; } + .pictureProgressDots { justify-self: end; } + .pictureTray { min-height: 135px; justify-content: flex-start; overflow-x: auto; scroll-snap-type: x mandatory; } + .pictureTray > button { min-width: 108px; min-height: 105px; scroll-snap-align: start; } + .pictureHomeGrid { gap: 9px; } + .pictureHomeCard, .pictureHomeTarget { min-height: 210px; } + .pictureHomeTarget { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; gap: 4px; padding: 16px 8px; } + .homeHeroEmoji { width: 96px; height: 96px; grid-column: 1; grid-row: 1; justify-self: center; border-radius: 27px 27px 13px 13px; font-size: 58px; } + .homeWord { grid-column: 1; grid-row: 2; justify-self: center; padding: 0; font-size: 14px; text-align: center; } + .pictureHomeTarget > .homeFocusGlyph { min-width: 54px; min-height: 54px; grid-column: 1; grid-row: 2; justify-self: center; border-width: 4px; border-radius: 18px; font-size: 31px; } + .homeContents { min-height: 58px; grid-column: 1; grid-row: 3; justify-content: center; padding: 3px 0 0; } + .homeContents span { width: 42px; height: 42px; border-radius: 12px; font-size: 25px; } + .homeSpeaker { width: 44px; height: 44px; right: 8px; top: 8px; } + .pictureReaction { grid-template-columns: 52px 1fr auto; } + .pictureChoiceGrid { gap: 10px; } + .pictureChoiceGrid > button { min-height: 145px; border-radius: 26px; } + .pictureChoiceGrid > button > span:first-child { font-size: 74px; } + .togetherVisual { min-height: 150px; grid-template-columns: 58px 1fr 50px; gap: 8px; padding: 15px; } + .togetherPictures { gap: 5px; } + .togetherPictures i { width: 58px; height: 58px; border-radius: 17px; font-size: 34px; } +} + +@media (prefers-reduced-motion: reduce) { + .preReaderShell *, + .gentleWiggle, + .bigPictureNext, + .miniNextButton, + .pictureHomeCard.homeReady { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; } +} diff --git a/wonderweave/app/components/LearningStudio.tsx b/wonderweave/app/components/LearningStudio.tsx new file mode 100644 index 0000000..c70521a --- /dev/null +++ b/wonderweave/app/components/LearningStudio.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { useEffect, useId, useState, useSyncExternalStore } from "react"; +import { + ArrowLeft, + ArrowRight, + BookOpen, + BookOpenCheck, + Calculator, + Check, + ChevronDown, + CircleAlert, + Clock3, + Eye, + LibraryBig, + LockKeyhole, + Play, + RotateCcw, + ShieldCheck, + Shapes, + Sparkles, + WandSparkles, +} from "lucide-react"; +import type { GenerateResponse, GenerationBrief } from "@/app/lib/learning-artifact"; +import { LearningExperience } from "./LearningExperience"; +import styles from "./LearningStudio.module.css"; + +type StudioMode = "brief" | "preview" | "learn"; +type FieldErrors = Partial>; + +const LOADING_STAGES = [ + "Choosing the right learning progression…", + "Writing the listen-look-tap journey…", + "Designing visual cues and spoken feedback…", + "Checking age fit, method, and safety…", +]; + +const EXAMPLE_BRIEFS: Array> = [ + { + topic: "Seeds and the /s/ sound", + objective: "Hear the first sound in familiar words and identify which words begin with /s/.", + domain: "early-literacy", + context: "Use seed and garden pictures. Children do not need to read independently.", + }, + { + topic: "Counting seeds", + objective: "Recognize and compare small groups of seeds using quantities from 1 to 5.", + domain: "early-math", + context: "Use structured dot-like seed arrangements and one-to-one touching cues.", + }, + { + topic: "How a seed grows", + objective: "Sequence the first stages of seed growth while practicing oral vocabulary and counting to 4.", + domain: "integrated", + context: "Make plant science the meaningful theme; keep one lead skill and one light connection.", + }, +]; + +const DOMAIN_OPTIONS = [ + { value: "early-literacy", label: "Reading", detail: "Sounds, letters & words", icon: BookOpen }, + { value: "early-math", label: "Math", detail: "Number sense & patterns", icon: Calculator }, + { value: "integrated", label: "Blend", detail: "One lead skill + a theme", icon: Shapes }, +] as const; + +const EMPTY_RESULT_ERROR = "The lesson workshop returned an empty draft. Please retry."; +const SAVED_LESSON_KEY = "wonderweave.saved-lesson.v1"; + +export function LearningStudio() { + const [mode, setMode] = useState("brief"); + const [brief, setBrief] = useState({ + topic: EXAMPLE_BRIEFS[0].topic, + objective: EXAMPLE_BRIEFS[0].objective, + domain: EXAMPLE_BRIEFS[0].domain, + age: 6, + durationMinutes: 8, + context: EXAMPLE_BRIEFS[0].context, + }); + const [result, setResult] = useState(null); + const [errors, setErrors] = useState({}); + const [error, setError] = useState(null); + const [errorTrace, setErrorTrace] = useState(null); + const [loadingStage, setLoadingStage] = useState(0); + const [generating, setGenerating] = useState(false); + const [preparedVoiceStream, setPreparedVoiceStream] = useState(null); + const hydrated = useSyncExternalStore(emptySubscribe, () => true, () => false); + const formId = useId(); + + useEffect(() => { + if (!generating) return; + const interval = window.setInterval(() => { + setLoadingStage((current) => Math.min(current + 1, LOADING_STAGES.length - 1)); + }, 3_700); + return () => window.clearInterval(interval); + }, [generating]); + + useEffect(() => { + try { + const saved = window.sessionStorage.getItem(SAVED_LESSON_KEY); + if (!saved) return; + const parsed = JSON.parse(saved) as { mode?: StudioMode; result?: GenerateResponse }; + if (!parsed.result?.artifact || !parsed.result.receipt) return; + const restore = window.setTimeout(() => { + setResult(parsed.result ?? null); + setMode(parsed.mode === "learn" ? "learn" : "preview"); + }, 0); + return () => window.clearTimeout(restore); + } catch { + window.sessionStorage.removeItem(SAVED_LESSON_KEY); + } + }, []); + + const saveLesson = (nextResult: GenerateResponse, nextMode: "preview" | "learn") => { + try { + window.sessionStorage.setItem(SAVED_LESSON_KEY, JSON.stringify({ result: nextResult, mode: nextMode })); + } catch { + // The lesson still works when storage is unavailable; only refresh recovery is lost. + } + }; + + const startLearningWithSound = async () => { + let stream: MediaStream | null = null; + try { + if (navigator.mediaDevices?.getUserMedia) { + // Begin microphone access inside the grown-up's play tap. Keeping the + // page actively capturing lets the learner screen start audio without + // a second autoplay-permission tap in supported browsers. + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } + } catch { + // Enter the lesson anyway. The learner screen provides a clear retry. + } + setPreparedVoiceStream(stream); + setMode("learn"); + if (result) saveLesson(result, "learn"); + }; + + const update = (field: K, value: GenerationBrief[K]) => { + setBrief((current) => ({ ...current, [field]: value })); + setErrors((current) => ({ ...current, [field]: undefined })); + setError(null); + }; + + const applyExample = (index: number) => { + setBrief((current) => ({ ...current, ...EXAMPLE_BRIEFS[index] })); + setErrors({}); + setError(null); + }; + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setGenerating(true); + setLoadingStage(0); + setErrors({}); + setError(null); + setErrorTrace(null); + + try { + const response = await fetch("/api/generate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(brief), + }); + const payload = await response.json() as GenerateResponse & { + error?: { message?: string; traceId?: string; fields?: FieldErrors }; + }; + if (!response.ok) { + setErrors(payload.error?.fields ?? {}); + setError(payload.error?.message ?? "The lesson workshop could not finish this draft."); + setErrorTrace(payload.error?.traceId ?? null); + return; + } + if (!payload.artifact || !payload.receipt) { + setError(EMPTY_RESULT_ERROR); + return; + } + setResult(payload); + setMode("preview"); + saveLesson(payload, "preview"); + window.scrollTo({ top: 0, behavior: "smooth" }); + } catch { + setError("The lesson workshop could not be reached. Check the connection and try again."); + } finally { + setGenerating(false); + } + }; + + if (mode === "learn" && result) { + return { + preparedVoiceStream?.getTracks().forEach((track) => track.stop()); + setPreparedVoiceStream(null); + setMode("preview"); + saveLesson(result, "preview"); + }} />; + } + + return ( +
+
+ + + +
+ + {mode === "brief" ? ( +
+
+
01 Shape the learning moment
+

What should click
for them today?

+

Choose reading, math, or a blend. We’ll turn any topic into a tiny listen-look-tap world—no independent reading required.

+ +
+
+ Try seeds + {EXAMPLE_BRIEFS.map((example, index) => ( + + ))} +
+ +
+ Learning pathway +
+ {DOMAIN_OPTIONS.map((option) => { + const Icon = option.icon; + const selected = brief.domain === option.value; + return ( + + ); + })} +
+ {errors.domain && {errors.domain}} +
+ + + +