diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b67d723..5902c50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: node-version-file: .nvmrc cache: npm - run: npm ci + - run: npm run architecture:check - run: npm run lint - run: npm run typecheck - run: npm test diff --git a/.gitignore b/.gitignore index fe8d9f2..2777ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ next-env.d.ts /dist/ /.wrangler/ /outputs/ +/output/ +/audit/ +/.playwright-cli/ /work/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d020500..e69e894 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,18 +4,21 @@ WonderDrive is in an intentionally narrow hackathon build. Contributions should ## Before a pull request -1. Read `docs/phase-0.md` and the relevant section of `docs/architecture.md`. +1. Read `docs/architecture.md`, then use `docs/code-index.md` to locate the smallest responsible module. 2. Keep secrets and provider keys server-side. Never add a provider key to browser code, fixtures, logs, or screenshots. 3. Preserve the product invariants: one performer, bounded foreground research, honest evidence, exactly two options, and no invisible continuation. 4. Add or update tests for the behavior being changed. -5. Run: +5. If files or local imports changed, refresh the checked architecture index with `npm run architecture:update`. +6. Run: ```bash + npm run architecture:check npm run lint + npm run typecheck npm test ``` -6. If the D1 schema changes, run `npm run db:generate`, inspect the SQL, and commit the migration. +7. If the D1 schema changes, run `npm run db:generate`, inspect the SQL, and commit the migration. ## Pull requests diff --git a/README.md b/README.md index e7700a4..a4905fc 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,14 @@ cp .env.example .env.local npm run dev ``` -The local site runs at `http://localhost:3000`. Set `OPENAI_API_KEY` in `.env.local` to exercise live mode locally; the free demo and build tests require no provider key. `WONDERDRIVE_DAILY_BUDGET_USD` optionally changes the default $25 rolling project ceiling. Never expose either value through a `NEXT_PUBLIC_` variable. +The local site runs at `http://localhost:3000`. Set `OPENAI_API_KEY` in `.env.local` to exercise live mode locally; build tests require no provider request. `WONDERDRIVE_DAILY_BUDGET_USD` optionally changes the default $25 rolling project ceiling. Never expose either value through a `NEXT_PUBLIC_` variable. Apply all SQL files in `drizzle/` to a fresh local D1 database before exercising the API. Sites applies the packaged migrations when a version is deployed. ## Validation ```bash +npm run architecture:check npm run lint npm run typecheck npm test @@ -59,8 +60,9 @@ npm run db:generate app/ Product experience, identity helper, and server routes db/ Canonical D1 schema drizzle/ Generated, reviewed SQL migrations -lib/ Contracts, reviewed fixtures, identity, and D1 repository -docs/ Final blueprint, architecture, and phase gates +lib/ Contracts, domain boundaries, providers, fixtures, and D1 repositories +scripts/ Architecture-index maintenance tooling +docs/ Blueprint, architecture, generated code index, and phase gates tests/ Rendered production and fixture checks .openai/hosting.json Logical Sites-managed bindings ``` @@ -72,11 +74,12 @@ tests/ Rendered production and fixture checks - [Phase 2 live research contract](docs/phase-2.md) - [V3 implementation contract](docs/v3-implementation.md) - [Final architecture decisions](docs/architecture.md) +- [Current code index](docs/code-index.md) - [Final product and engineering blueprint](docs/WonderDrive_Final_Product_and_Engineering_Blueprint_v3_Research_First.docx) ## Status and scope -Live mode is one foreground OpenAI Responses request with built-in web search. Presets cap tool calls, output tokens, reasoning effort, and wall time; guest and signed-in identities also have rolling live-run and estimated-spend limits. Consulted URLs, cited relations, provider request ID, complete usage, price snapshot, prompt/performer/model versions, and research handoff are saved with the committed turn. The free demo remains available for zero-provider-cost judging and development. +Every journey uses a user-selected, compatible OpenAI model through one foreground Responses request with built-in text and optional image search. Presets cap tool calls, output tokens, reasoning effort, and wall time; guest and signed-in identities also have rolling live-run and estimated-spend limits. Consulted URLs, cited relations, sourced image results, provider request ID, complete usage, price snapshot, prompt/performer/model versions, and research handoff are saved with the committed turn. Automatic journeys, scheduled/background continuation, Trigger.dev, provider fan-out, and live parallel comparison are outside the hackathon scope. diff --git a/app/api/bootstrap/route.ts b/app/api/bootstrap/route.ts index d547b73..c2ef84b 100644 --- a/app/api/bootstrap/route.ts +++ b/app/api/bootstrap/route.ts @@ -1,14 +1,10 @@ import { BOOTSTRAP_CATALOG } from "../../../lib/catalog"; -import { failure, success } from "../../../lib/api"; +import { query } from "../../../lib/api"; import { getPreferences } from "../../../lib/product-repository"; -import { resolveViewer } from "../../../lib/viewer"; export async function GET() { - try { - const viewer = await resolveViewer(); - const preferences = await getPreferences(viewer); - return success({ catalog: BOOTSTRAP_CATALOG, preferences }, viewer); - } catch (error) { - return failure(error); - } + return query(async (viewer) => ({ + catalog: BOOTSTRAP_CATALOG, + preferences: await getPreferences(viewer), + })); } diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index 960ce5b..1134810 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -1,18 +1,15 @@ -import { failure, success } from "../../../lib/api"; -import { compareJourneys, RepositoryError } from "../../../lib/repository"; -import { resolveViewer } from "../../../lib/viewer"; +import { query } from "../../../lib/api"; +import { RepositoryError } from "../../../lib/errors"; +import { compareJourneys } from "../../../lib/repository"; export async function GET(request: Request) { - try { - const viewer = await resolveViewer(); + return query(async (viewer) => { const url = new URL(request.url); const left = url.searchParams.get("left"); const right = url.searchParams.get("right"); if (!left || !right) { throw new RepositoryError("BAD_REQUEST", "Choose two journeys to compare.", 400); } - return success(await compareJourneys(viewer, left, right), viewer); - } catch (error) { - return failure(error); - } + return compareJourneys(viewer, left, right); + }); } diff --git a/app/api/journeys/[journeyId]/advance/route.ts b/app/api/journeys/[journeyId]/advance/route.ts index 28fe64e..1195113 100644 --- a/app/api/journeys/[journeyId]/advance/route.ts +++ b/app/api/journeys/[journeyId]/advance/route.ts @@ -1,18 +1,29 @@ -import { assertMutationOrigin, failure, readJson, success } from "../../../../../lib/api"; +import { mutation, readJson } from "../../../../../lib/api"; import type { AdvanceJourneyRequest } from "../../../../../lib/contracts"; -import { advanceJourney } from "../../../../../lib/repository"; -import { resolveViewer } from "../../../../../lib/viewer"; +import { runLiveRedraw } from "../../../../../lib/live-redraw"; +import { + advanceJourney, + listRejectedQuestions, +} from "../../../../../lib/repository"; type Context = { params: Promise<{ journeyId: string }> }; export async function POST(request: Request, context: Context) { - try { - assertMutationOrigin(request); - const viewer = await resolveViewer(); + return mutation(request, async (viewer) => { const { journeyId } = await context.params; - const body = (await readJson(request)) as AdvanceJourneyRequest; - return success(await advanceJourney(viewer, journeyId, body), viewer); - } catch (error) { - return failure(error); - } + const body = await readJson(request); + return advanceJourney(viewer, journeyId, body, async ({ journey, turn }) => + runLiveRedraw({ + turn, + performerId: journey.performerId, + modelId: journey.modelId, + rejectedQuestions: [ + ...await listRejectedQuestions(viewer, journeyId), + ...turn.options.map((option) => option.question), + ], + adventure: body.adventure ?? 50, + reason: body.reason?.trim() || undefined, + }), + ); + }); } diff --git a/app/api/journeys/[journeyId]/route.ts b/app/api/journeys/[journeyId]/route.ts index 9351aa8..8258cd0 100644 --- a/app/api/journeys/[journeyId]/route.ts +++ b/app/api/journeys/[journeyId]/route.ts @@ -1,38 +1,26 @@ -import { assertMutationOrigin, failure, readJson, success } from "../../../../lib/api"; +import { mutation, query, readJson } from "../../../../lib/api"; import { updateJourneyManagement } from "../../../../lib/product-repository"; import { deleteJourney, getJourney } from "../../../../lib/repository"; -import { resolveViewer } from "../../../../lib/viewer"; type Context = { params: Promise<{ journeyId: string }> }; export async function GET(_request: Request, context: Context) { - try { - const viewer = await resolveViewer(); + return query(async (viewer) => { const { journeyId } = await context.params; - return success(await getJourney(viewer, journeyId), viewer); - } catch (error) { - return failure(error); - } + return getJourney(viewer, journeyId); + }); } export async function PATCH(request: Request, context: Context) { - try { - assertMutationOrigin(request); - const viewer = await resolveViewer(); + return mutation(request, async (viewer) => { const { journeyId } = await context.params; - return success(await updateJourneyManagement(viewer, journeyId, await readJson(request)), viewer); - } catch (error) { - return failure(error); - } + return updateJourneyManagement(viewer, journeyId, await readJson(request)); + }); } export async function DELETE(request: Request, context: Context) { - try { - assertMutationOrigin(request); - const viewer = await resolveViewer(); + return mutation(request, async (viewer) => { const { journeyId } = await context.params; - return success(await deleteJourney(viewer, journeyId), viewer); - } catch (error) { - return failure(error); - } + return deleteJourney(viewer, journeyId); + }); } diff --git a/app/api/journeys/[journeyId]/snapshots/route.ts b/app/api/journeys/[journeyId]/snapshots/route.ts index 2752d33..5471184 100644 --- a/app/api/journeys/[journeyId]/snapshots/route.ts +++ b/app/api/journeys/[journeyId]/snapshots/route.ts @@ -1,27 +1,19 @@ -import { assertMutationOrigin, failure, readJson, success } from "../../../../../lib/api"; +import { mutation, query, readJson } from "../../../../../lib/api"; import { createSnapshot, listSnapshots } from "../../../../../lib/product-repository"; -import { resolveViewer } from "../../../../../lib/viewer"; type Context = { params: Promise<{ journeyId: string }> }; export async function GET(_request: Request, context: Context) { - try { - const viewer = await resolveViewer(); + return query(async (viewer) => { const { journeyId } = await context.params; - return success(await listSnapshots(viewer, journeyId), viewer); - } catch (error) { - return failure(error); - } + return listSnapshots(viewer, journeyId); + }); } export async function POST(request: Request, context: Context) { - try { - assertMutationOrigin(request); - const viewer = await resolveViewer(); + return mutation(request, async (viewer) => { const { journeyId } = await context.params; - const body = (await readJson(request)) as { label?: unknown }; - return success(await createSnapshot(viewer, journeyId, body.label), viewer, 201); - } catch (error) { - return failure(error); - } + const body = await readJson<{ label?: unknown }>(request); + return createSnapshot(viewer, journeyId, body.label); + }, 201); } diff --git a/app/api/journeys/route.ts b/app/api/journeys/route.ts index f8f8891..91d0721 100644 --- a/app/api/journeys/route.ts +++ b/app/api/journeys/route.ts @@ -1,24 +1,15 @@ -import { assertMutationOrigin, failure, readJson, success } from "../../../lib/api"; +import { mutation, query, readJson } from "../../../lib/api"; import type { CreateJourneyRequest } from "../../../lib/contracts"; import { createJourney, listJourneys } from "../../../lib/repository"; -import { resolveViewer } from "../../../lib/viewer"; export async function GET() { - try { - const viewer = await resolveViewer(); - return success(await listJourneys(viewer), viewer); - } catch (error) { - return failure(error); - } + return query(listJourneys); } export async function POST(request: Request) { - try { - assertMutationOrigin(request); - const viewer = await resolveViewer(); - const body = (await readJson(request)) as CreateJourneyRequest; - return success(await createJourney(viewer, body), viewer, 201); - } catch (error) { - return failure(error); - } + return mutation( + request, + async (viewer) => createJourney(viewer, await readJson(request)), + 201, + ); } diff --git a/app/api/preferences/route.ts b/app/api/preferences/route.ts index 9585084..9d97d80 100644 --- a/app/api/preferences/route.ts +++ b/app/api/preferences/route.ts @@ -1,22 +1,10 @@ -import { assertMutationOrigin, failure, readJson, success } from "../../../lib/api"; +import { mutation, query, readJson } from "../../../lib/api"; import { getPreferences, updatePreferences } from "../../../lib/product-repository"; -import { resolveViewer } from "../../../lib/viewer"; export async function GET() { - try { - const viewer = await resolveViewer(); - return success(await getPreferences(viewer), viewer); - } catch (error) { - return failure(error); - } + return query(getPreferences); } export async function PUT(request: Request) { - try { - assertMutationOrigin(request); - const viewer = await resolveViewer(); - return success(await updatePreferences(viewer, await readJson(request)), viewer); - } catch (error) { - return failure(error); - } + return mutation(request, async (viewer) => updatePreferences(viewer, await readJson(request))); } diff --git a/app/api/research/[runId]/route.ts b/app/api/research/[runId]/route.ts index e5ff7fd..0042240 100644 --- a/app/api/research/[runId]/route.ts +++ b/app/api/research/[runId]/route.ts @@ -1,15 +1,11 @@ -import { failure, success } from "../../../../lib/api"; +import { query } from "../../../../lib/api"; import { getResearchStatus } from "../../../../lib/product-repository"; -import { resolveViewer } from "../../../../lib/viewer"; type Context = { params: Promise<{ runId: string }> }; export async function GET(_request: Request, context: Context) { - try { - const viewer = await resolveViewer(); + return query(async (viewer) => { const { runId } = await context.params; - return success(await getResearchStatus(viewer, runId), viewer); - } catch (error) { - return failure(error); - } + return getResearchStatus(viewer, runId); + }); } diff --git a/app/api/research/route.ts b/app/api/research/route.ts index 8afef8a..4f53847 100644 --- a/app/api/research/route.ts +++ b/app/api/research/route.ts @@ -1,6 +1,5 @@ import { assertMutationOrigin, failure, readJson } from "../../../lib/api"; import type { - ApiFailure, LiveResearchRequest, LiveResearchStreamEvent, } from "../../../lib/contracts"; @@ -10,8 +9,7 @@ import { prepareLiveResearch, } from "../../../lib/live-repository"; import { runLiveResearch } from "../../../lib/live-research"; -import { buildFixtureTurn } from "../../../lib/fixtures"; -import { RepositoryError } from "../../../lib/repository"; +import { publicError } from "../../../lib/errors"; import { publicViewer, resolveViewer } from "../../../lib/viewer"; export const dynamic = "force-dynamic"; @@ -62,19 +60,6 @@ export async function POST(request: Request) { controller.close(); return; } - const interlude = buildFixtureTurn({ - question: preparation.prepared.question, - depth: preparation.prepared.depth, - performerId: preparation.prepared.performerId, - }).interlude; - send({ - type: "interlude", - interlude: { - text: interlude.text, - sourceTitle: interlude.sourceTitle, - sourceUrl: interlude.sourceUrl, - }, - }); try { const draft = await runLiveResearch( preparation.prepared, @@ -85,7 +70,15 @@ export async function POST(request: Request) { send({ type: "complete", data: journey, viewer: publicViewer(viewer) }); } catch (error) { await markLiveResearchFailed(viewer, preparation.prepared.requestId, error); - if (!closed) send({ type: "error", error: publicError(error) }); + if (!closed) { + send({ + type: "error", + error: publicError( + error, + "WonderDrive could not complete live research. No partial journey was saved.", + ), + }); + } } finally { clearInterval(heartbeat); if (!closed) { @@ -113,19 +106,3 @@ export async function POST(request: Request) { return failure(error); } } - -function publicError(error: unknown): ApiFailure["error"] { - if (error instanceof RepositoryError) { - return { - code: error.code, - message: error.message, - retryable: error.retryable, - }; - } - console.error("WonderDrive live research error", error); - return { - code: "INTERNAL_ERROR", - message: "WonderDrive could not complete live research. No partial journey was saved.", - retryable: true, - }; -} diff --git a/app/api/session/route.ts b/app/api/session/route.ts index 2aa97a9..624651c 100644 --- a/app/api/session/route.ts +++ b/app/api/session/route.ts @@ -1,13 +1,6 @@ -import { failure, success } from "../../../lib/api"; +import { query } from "../../../lib/api"; import { listJourneys } from "../../../lib/repository"; -import { resolveViewer } from "../../../lib/viewer"; export async function GET() { - try { - const viewer = await resolveViewer(); - const journeys = await listJourneys(viewer); - return success({ journeys }, viewer); - } catch (error) { - return failure(error); - } + return query(async (viewer) => ({ journeys: await listJourneys(viewer) })); } diff --git a/app/api/session/upgrade/route.ts b/app/api/session/upgrade/route.ts index c788332..e1602c0 100644 --- a/app/api/session/upgrade/route.ts +++ b/app/api/session/upgrade/route.ts @@ -1,16 +1,11 @@ -import { assertMutationOrigin, failure, readJson, success } from "../../../../lib/api"; -import { resolveViewer, upgradeGuestJourneys } from "../../../../lib/viewer"; +import { mutation, readJson } from "../../../../lib/api"; +import { upgradeGuestJourneys } from "../../../../lib/viewer"; export async function POST(request: Request) { - try { - assertMutationOrigin(request); - const viewer = await resolveViewer(); - const body = (await readJson(request)) as { idempotencyKey?: unknown }; + return mutation(request, async (viewer) => { + const body = await readJson<{ idempotencyKey?: unknown }>(request); const result = await upgradeGuestJourneys(viewer, String(body.idempotencyKey ?? "")); - const response = success({ transferred: result.transferred }, viewer); - if (result.setCookie) response.headers.append("set-cookie", result.setCookie); - return response; - } catch (error) { - return failure(error); - } + if (result.setCookie) viewer.setCookie = result.setCookie; + return { transferred: result.transferred }; + }); } diff --git a/app/api/starters/route.ts b/app/api/starters/route.ts new file mode 100644 index 0000000..ab67776 --- /dev/null +++ b/app/api/starters/route.ts @@ -0,0 +1,16 @@ +import { query } from "../../../lib/api"; +import { PERFORMERS } from "../../../lib/catalog"; +import type { PerformerId } from "../../../lib/contracts"; +import { getPersonalizedStarters } from "../../../lib/starter-recommendations"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const searchParams = new URL(request.url).searchParams; + const requested = searchParams.get("performer"); + const performerId = PERFORMERS.some((performer) => performer.id === requested) + ? requested as PerformerId + : "sage"; + const refresh = searchParams.get("refresh") === "1"; + return query(async (viewer) => ({ starters: await getPersonalizedStarters(viewer, performerId, { refresh }) })); +} diff --git a/app/chatgpt-auth.ts b/app/chatgpt-auth.ts index 3b4ad58..6fbc51e 100644 --- a/app/chatgpt-auth.ts +++ b/app/chatgpt-auth.ts @@ -1,7 +1,6 @@ import { headers } from "next/headers"; -import { redirect } from "next/navigation"; -export type ChatGPTUser = { +type ChatGPTUser = { subject: string; displayName: string; email: string; @@ -14,9 +13,6 @@ 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(); @@ -39,48 +35,6 @@ export async function getChatGPTUser(): Promise { }; } -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); diff --git a/app/client-api.ts b/app/client-api.ts new file mode 100644 index 0000000..342662e --- /dev/null +++ b/app/client-api.ts @@ -0,0 +1,95 @@ +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import type { + ApiFailure, + ApiSuccess, + JourneyDetail, + LiveResearchRequest, + LiveResearchStreamEvent, + PerformerId, + ResearchEvent, +} from "../lib/contracts"; + +export type LiveResearchState = { + question: string; + performerId: PerformerId; + message: string; + events: ResearchEvent[]; + status: "running" | "complete" | "error"; + result: JourneyDetail | null; + error: string | null; +}; + +export async function api(url: string, init?: RequestInit): Promise> { + const response = await fetch(url, { + ...init, + headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, + }); + const payload = (await response.json()) as ApiSuccess | ApiFailure; + if (!response.ok || "error" in payload) { + throw new Error("error" in payload ? payload.error.message : "The request failed."); + } + return payload; +} + +export async function streamLiveResearch( + request: LiveResearchRequest, + setState: Dispatch>, +) { + const response = await fetch("/api/research", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + if (!response.ok) { + const payload = (await response.json()) as ApiFailure; + throw new Error(payload.error?.message ?? "Live research could not start."); + } + if (!response.body) throw new Error("Live research did not return a readable stream."); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let complete: Extract | null = null; + try { + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + const frames = buffer.split("\n\n"); + buffer = frames.pop() ?? ""; + for (const frame of frames) { + const data = frame + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) continue; + const event = JSON.parse(data) as LiveResearchStreamEvent; + if (event.type === "started") { + setState((current) => current && { ...current, question: event.question, message: event.message }); + } else if (event.type === "activity") { + setState((current) => current && { + ...current, + events: current.events.some(({ id }) => id === event.event.id) + ? current.events + : [...current.events, event.event], + }); + } else if (event.type === "error") { + throw new Error(event.error.message); + } else if (event.type === "complete") { + complete = event; + } + } + if (done) break; + } + } finally { + reader.releaseLock(); + } + if (!complete) throw new Error("Live research ended before a turn was committed."); + return complete; +} + +export function messageFrom(cause: unknown): string { + return cause instanceof Error ? cause.message : "WonderDrive could not complete that request."; +} diff --git a/app/globals.css b/app/globals.css index d77acbf..dd15e49 100644 --- a/app/globals.css +++ b/app/globals.css @@ -55,6 +55,13 @@ a { color: inherit; text-decoration: none; } .app-nav button { background: transparent; border: 0; border-inline-start: 1px solid var(--line); color: var(--muted); font-size: .67rem; font-weight: 600; letter-spacing: .1em; min-width: 96px; padding: 0 16px; text-transform: uppercase; } .app-nav button:last-child { border-inline-end: 1px solid var(--line); } .app-nav button.active { background: var(--ink); color: var(--paper-light); } +.active-journey-shell { min-height: calc(100vh - 115px); } +.journey-view-switcher { align-items: center; background: color-mix(in srgb, var(--paper-light) 82%, transparent); border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; min-height: 42px; padding: 5px clamp(24px, 4.8vw, 72px); } +.journey-view-switcher > span { color: var(--muted); font-size: .58rem; max-width: 45vw; overflow: hidden; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.journey-view-switcher > div { border: 1px solid var(--ink); display: flex; } +.journey-view-switcher button { background: transparent; border: 0; font-size: .58rem; font-weight: 700; min-width: 94px; padding: 6px 12px; text-transform: uppercase; } +.journey-view-switcher button + button { border-left: 1px solid var(--ink); } +.journey-view-switcher button.active { background: var(--ink); color: var(--paper-light); } .identity-control { align-items: center; display: flex; gap: 9px; justify-self: end; } .identity-control > span:nth-child(2) { display: flex; flex-direction: column; font-size: .7rem; line-height: 1.2; max-width: 150px; } .identity-control strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -92,11 +99,11 @@ a { color: inherit; text-decoration: none; } .contract-strip span { align-items: center; border-inline-end: 1px solid var(--line); color: var(--muted); display: flex; font-size: .63rem; gap: 8px; letter-spacing: .05em; min-height: 58px; padding: 8px; text-transform: uppercase; } .contract-strip span:last-child { border: 0; } .contract-strip strong { color: var(--ink); font-family: var(--font-display); font-size: 1.08rem; font-style: italic; } -.drive-console { background: var(--paper-light); border-inline-start: 1px solid var(--ink); box-shadow: -11px 11px 0 rgba(19, 36, 30, .05); padding: 0 clamp(30px, 4vw, 68px) 46px; } +.drive-console { background: var(--paper-light); border-inline-start: 1px solid var(--ink); box-shadow: -11px 11px 0 rgba(19, 36, 30, .05); min-width: 0; padding: 0 clamp(30px, 4vw, 68px) 46px; } .console-heading { align-items: center; border-bottom: 1px solid var(--ink); display: flex; font-size: .65rem; font-weight: 600; justify-content: space-between; letter-spacing: .1em; margin-bottom: 34px; min-height: 66px; text-transform: uppercase; } .console-status { align-items: center; color: var(--muted); display: flex; gap: 7px; } .console-status i { animation: pulse 2s ease-in-out infinite; background: var(--acid); border: 1px solid var(--ink); border-radius: 50%; height: 8px; width: 8px; } -fieldset { border: 0; margin: 0 0 25px; padding: 0; } +fieldset { border: 0; margin: 0 0 25px; min-inline-size: 0; padding: 0; } legend { align-items: center; display: flex; font-size: .69rem; font-weight: 600; gap: 9px; letter-spacing: .08em; margin-bottom: 13px; text-transform: uppercase; } legend > span { align-items: center; border: 1px solid var(--ink); border-radius: 50%; display: inline-flex; font-family: var(--font-display); font-size: .8rem; height: 24px; justify-content: center; width: 24px; } .performer-grid { display: grid; gap: 9px; grid-template-columns: repeat(3, 1fr); } @@ -127,9 +134,16 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius .preset-tabs button { background: transparent; border: 0; border-inline-end: 1px solid var(--line); font-size: .64rem; font-weight: 600; padding: 10px 5px; text-transform: uppercase; } .preset-tabs button:last-child { border: 0; } .preset-tabs button.active { background: var(--acid); } -.starter-chips { display: flex; gap: 6px; margin-bottom: 8px; overflow-x: auto; padding: 2px; scrollbar-width: thin; } -.starter-chips button { background: var(--paper); border: 1px solid var(--line); flex: 0 0 auto; font-size: .59rem; padding: 6px 8px; } -.starter-chips button:hover { background: var(--sky); border-color: var(--ink); } +.starter-marquee { border: 1px solid var(--ink); margin-bottom: 10px; max-width: 100%; overflow: hidden; width: 100%; } +.starter-marquee-label { align-items: center; background: var(--ink); color: var(--paper-light); display: flex; font-size: .55rem; font-weight: 700; gap: 7px; letter-spacing: .11em; min-height: 28px; padding: 4px 10px; text-transform: uppercase; } +.starter-marquee-label span { background: var(--acid); border-radius: 50%; height: 7px; width: 7px; } +.starter-marquee-window { max-width: 100%; overflow: hidden; width: 100%; } +.starter-marquee-track { animation: starter-crawl 118s linear infinite; display: flex; width: max-content; } +.starter-marquee:hover .starter-marquee-track, .starter-marquee:focus-within .starter-marquee-track { animation-play-state: paused; } +.starter-marquee-set { display: flex; } +.starter-marquee button { align-items: baseline; background: var(--paper); border: 0; border-inline-end: 1px solid var(--line-strong); display: flex; flex: 0 0 auto; font-family: var(--font-display); font-size: .82rem; gap: 9px; min-height: 43px; padding: 8px 13px; white-space: nowrap; } +.starter-marquee button span { color: var(--muted); font-family: var(--font-body); font-size: .48rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.starter-marquee button:hover, .starter-marquee button:focus-visible { background: var(--sky); } .question-input { background: var(--paper); border: 1px solid var(--ink); display: block; padding: 13px 14px 6px; position: relative; } .question-input textarea { background: transparent; border: 0; font-family: var(--font-display); font-size: clamp(1.35rem, 2vw, 1.85rem); line-height: 1.08; outline: 0; resize: vertical; width: 100%; } .question-input small { color: var(--muted); display: block; font-size: .55rem; text-align: right; } @@ -139,43 +153,282 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius .honesty-note { align-items: center; color: var(--muted); display: flex; font-size: .59rem; gap: 7px; justify-content: center; margin: 15px 0 0; } .honesty-note span { color: var(--coral); } -/* Research replay */ -.research-stage { background: var(--ink); color: var(--paper-light); min-height: 780px; padding: 0 max(24px, calc((100vw - 1320px) / 2)) 70px; } -.research-topline { align-items: center; border-bottom: 1px solid rgba(255, 255, 255, .17); display: flex; justify-content: space-between; min-height: 70px; } -.research-topline p { align-items: center; display: flex; font-size: .65rem; letter-spacing: .12em; margin: 0; text-transform: uppercase; } -.live-dot { animation: pulse 1.8s infinite; background: var(--coral); border-radius: 50%; height: 8px; margin-right: 10px; width: 8px; } -.research-topline button { background: transparent; border: 0; color: var(--acid); font-size: .68rem; font-weight: 600; letter-spacing: .07em; text-transform: uppercase; } -.research-topline button span { font-size: 1rem; margin-left: 8px; } -.foreground-note { color: var(--acid); font-size: .6rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; } -.research-question { border-bottom: 1px solid rgba(255, 255, 255, .17); padding: 55px 0 48px; } -.research-question > span, .interlude-card > span { color: var(--sky); font-size: .62rem; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; } -.research-question h1 { font-family: var(--font-display); font-size: clamp(3rem, 5vw, 5.9rem); font-weight: 400; letter-spacing: -.045em; line-height: .95; margin: 18px 0 0; max-width: 980px; } -.research-layout { display: grid; gap: clamp(30px, 6vw, 95px); grid-template-columns: 1.2fr .8fr; padding: 48px 0 35px; } -.research-feed { list-style: none; margin: 0; padding: 0; } -.research-feed li { align-items: center; border-bottom: 1px solid rgba(255, 255, 255, .14); display: grid; gap: 15px; grid-template-columns: 30px 16px 1fr 20px; min-height: 75px; transition: opacity 300ms ease, transform 300ms ease; } -.research-feed li.waiting { opacity: .24; } -.research-feed li.visible { animation: rise 360ms ease both; } -.research-feed li > span { color: rgba(255, 255, 255, .35); font-family: var(--font-display); font-style: italic; } -.research-feed li > div { display: flex; flex-direction: column; } -.research-feed small { color: var(--acid); font-size: .55rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; } -.research-feed p { font-size: .83rem; margin: 3px 0 0; } -.research-feed strong { color: var(--acid); } -.event-icon { border: 1px solid var(--sky); border-radius: 50%; height: 11px; width: 11px; } -.event-icon.source { border-radius: 0; transform: rotate(45deg); } -.event-icon.check { border-color: var(--acid); border-style: dashed; } -.event-icon.synthesis { background: var(--coral); border-color: var(--coral); } -.interlude-card { align-self: center; background: var(--sky); border: 1px solid var(--paper-light); box-shadow: 9px 9px 0 var(--coral); color: var(--ink); opacity: .14; padding: clamp(25px, 4vw, 48px); transform: rotate(2deg) scale(.96); transition: opacity 500ms ease, transform 500ms ease; } -.interlude-card.revealed { opacity: 1; transform: rotate(2deg) scale(1); } -.interlude-card > span { color: var(--ink); } -.interlude-card blockquote { font-family: var(--font-display); font-size: clamp(1.8rem, 3vw, 3rem); letter-spacing: -.035em; line-height: 1.03; margin: 30px 0; } -.interlude-card a { border-bottom: 1px solid; font-size: .65rem; font-weight: 600; } -.interlude-card small { display: block; font-size: .64rem; line-height: 1.6; } -.research-holding-card { opacity: .62; transform: rotate(1deg) scale(.98); } -.research-holding-card blockquote { font-size: clamp(1.5rem, 2.3vw, 2.35rem); } -.research-feed li.research-error { color: var(--coral); } -.research-progress { background: rgba(255, 255, 255, .14); height: 4px; } -.research-progress span { background: var(--acid); display: block; height: 100%; transition: width 400ms ease; } -.fixture-disclosure { color: rgba(255, 255, 255, .48); font-size: .63rem; margin: 18px 0 0; max-width: 740px; } +/* Simple personalized start */ +.start-stage-simple { + min-height: calc(100vh - 84px); + padding: clamp(20px, 2.5vw, 34px) 0 24px; +} +.start-console-simple { + margin: 0 auto; + max-width: 1040px; + padding-inline: 20px; +} +.recommendation-heading { + align-items: center; + display: flex; + gap: 16px; + justify-content: space-between; + margin: 0 0 7px calc(50% - 50vw); + padding-inline: clamp(20px, 5vw, 76px); + width: 100vw; +} +.recommendation-heading > div { display: flex; flex: 1; gap: 16px; min-width: 0; } +.recommendation-heading strong { + font-family: var(--font-display); + font-size: .86rem; + font-weight: 500; +} +.recommendation-heading span { + color: var(--muted); + font-size: .58rem; + letter-spacing: .04em; + text-align: right; +} +.refresh-starters { align-items: center; background: var(--ink); border: 1px solid var(--ink); color: var(--paper-light); display: inline-flex; flex: 0 0 auto; font-size: .54rem; font-weight: 700; gap: 7px; letter-spacing: .06em; padding: 6px 10px; text-transform: uppercase; } +.refresh-starters span { color: var(--acid); font-size: .85rem; line-height: .7; } +.refresh-starters:hover:not(:disabled) { background: var(--coral); color: var(--ink); } +.starter-marquee-simple { + background: color-mix(in srgb, var(--acid) 18%, var(--paper-light)); + border-color: var(--line-strong); + border-inline: 0; + box-shadow: 0 5px 0 color-mix(in srgb, var(--ink) 7%, transparent); + margin: 0 0 28px calc(50% - 50vw); + max-width: none; + padding-block: 7px; + transform: none; + width: 100vw; +} +.starter-marquee-simple .starter-marquee-track { animation-duration: 110s; } +.starter-marquee-simple button { + background: var(--paper-light); + border: 1px solid var(--line-strong); + margin-inline-start: 7px; + min-height: 47px; + padding: 7px 13px; +} +.starter-marquee-simple button:nth-child(3n + 2) { background: color-mix(in srgb, var(--sky) 38%, var(--paper-light)); } +.starter-marquee-simple button:nth-child(3n) { background: color-mix(in srgb, var(--lavender) 32%, var(--paper-light)); } +.starter-marquee-simple button span { color: var(--ink-2); } +.start-console-simple > h1 { + font-family: var(--font-display); + font-size: clamp(2rem, 3.7vw, 3.7rem); + font-weight: 400; + letter-spacing: -.055em; + line-height: .95; + margin: 0 0 14px; + text-align: center; +} +.question-field-shell { margin: 0 auto; max-width: 960px; } +.question-input-simple { + background: var(--white); + box-shadow: 5px 5px 0 var(--ink); + margin-inline: auto; + min-height: 88px; + padding: 15px 18px 6px; +} +.question-input-simple textarea { + font-size: clamp(1.15rem, 1.8vw, 1.55rem); + min-height: 52px; + resize: none; +} +.question-input-simple textarea::placeholder { color: color-mix(in srgb, var(--muted) 74%, transparent); } +.question-autocomplete { + align-items: center; + display: flex; + min-height: 33px; + padding: 6px 7px 0; +} +.question-autocomplete button { + align-items: baseline; + background: transparent; + border: 0; + color: var(--muted); + display: flex; + font-family: var(--font-display); + font-size: .78rem; + gap: 10px; + max-width: 100%; + overflow: hidden; + padding: 0; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.question-autocomplete button span, +.question-autocomplete > span strong { + color: var(--ink); + font-family: var(--font-body); + font-size: .49rem; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} +.question-autocomplete > span { color: var(--muted); display: flex; font-size: .6rem; gap: 10px; } +.question-autocomplete-idle { opacity: .65; } +.start-selectors { + display: grid; + gap: 14px; + grid-template-columns: 1fr 1fr; + margin: 12px auto; + max-width: 960px; +} +.start-selectors > label { + display: flex; + flex-direction: column; + gap: 7px; +} +.start-selectors > label > span:first-child { + font-size: .53rem; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; +} +.start-select-wrap { + align-items: center; + background: var(--white); + border: 1px solid var(--ink); + display: grid; + grid-template-columns: auto 1fr; + min-height: 50px; + padding-inline-start: 10px; + position: relative; +} +.start-select-wrap::after { + content: "⌄"; + font-family: var(--font-display); + font-size: 1rem; + pointer-events: none; + position: absolute; + right: 13px; + top: 13px; +} +.start-select-wrap .performer-mark { + background: var(--coral); + color: var(--ink); + font-size: 1rem; + height: 30px; + width: 30px; +} +.start-select-wrap select { + appearance: none; + background: transparent; + border: 0; + font-family: var(--font-display); + font-size: .84rem; + min-height: 48px; + outline: 0; + padding: 8px 48px 8px 12px; + width: 100%; +} +.model-select-wrap { + border-color: var(--ink-2); + grid-template-columns: 1fr; + padding-inline-start: 0; +} +.model-select-wrap select { padding-left: 17px; } +.performer-layer { + background: color-mix(in srgb, var(--coral) 13%, var(--paper-light)); + border: 1px solid var(--line-strong); + align-items: center; + display: grid; + gap: 9px 17px; + grid-template-columns: 170px 1fr auto; + margin: 0 auto 13px; + max-width: 960px; + padding: 10px 14px; +} +.performer-layer.sky { background: color-mix(in srgb, var(--sky) 30%, var(--paper-light)); } +.performer-layer.acid { background: color-mix(in srgb, var(--acid) 28%, var(--paper-light)); } +.performer-layer > span { + color: var(--muted); + display: block; + font-size: .55rem; + font-weight: 700; + letter-spacing: .1em; + text-transform: uppercase; +} +.performer-layer p { + font-family: var(--font-display); + font-size: clamp(.82rem, 1.15vw, 1rem); + line-height: 1.25; + margin: 0; +} +.performer-layer small { + color: var(--muted); + font-size: .49rem; + letter-spacing: .06em; + text-transform: uppercase; +} +.launch-button-simple { + background: var(--ink); + box-shadow: 7px 7px 0 var(--coral); + color: var(--paper-light); + margin: 0 auto; + max-width: 960px; + min-height: 52px; + padding-inline: 18px; +} +.launch-button-simple:hover:not(:disabled) { + box-shadow: 3px 3px 0 var(--coral); +} +.launch-button-simple i { color: var(--acid); } +.start-console-simple > .honesty-note { margin-top: 8px; } + +/* In-place research buffering: the journey never swaps to an intermediate page. */ +.buffering-stage { padding-bottom: 42px; } +.performance-header.buffering-header { min-height: 108px; padding: 14px 0 12px; } +.performance-header.buffering-header .eyebrow { margin-bottom: 8px; } +.performance-header.buffering-header h1 { font-size: clamp(1.5rem, 2vw, 2.35rem); line-height: 1; max-width: 920px; } +.buffering-status { align-items: center; border: 1px solid var(--line); display: grid; gap: 1px 10px; grid-template-columns: auto 1fr; min-width: 220px; padding: 11px 14px; } +.buffering-status strong { font-size: .7rem; } +.buffering-status small { color: var(--muted); font-size: .56rem; grid-column: 2; } +.buffering-status.complete { background: color-mix(in srgb, var(--acid) 30%, var(--paper-light)); } +.buffering-status.error { background: color-mix(in srgb, var(--coral) 28%, var(--paper-light)); } +.buffering-dot { animation: pulse 1.35s ease-in-out infinite; background: var(--acid); border: 1px solid var(--ink); border-radius: 50%; grid-row: 1 / 3; height: 10px; width: 10px; } +.buffering-status.complete .buffering-dot { animation: none; background: var(--sky); } +.buffering-status.error .buffering-dot { animation: none; background: var(--coral); } +.buffering-answer-card { background: var(--paper-light); border: 1px solid var(--line-strong); box-shadow: 7px 7px 0 rgba(19, 36, 30, .055); margin: 14px auto 0; max-width: 1180px; min-height: 240px; padding: 13px 18px 11px 23px; position: relative; } +.buffering-answer-card::before { background: var(--coral); content: ""; inset: -1px auto -1px -1px; position: absolute; width: 6px; } +.buffering-byline { align-items: center; border-bottom: 1px solid var(--line); display: grid; gap: 11px; grid-template-columns: auto 1fr auto; padding: 0 3px 9px; } +.buffering-byline > div { display: flex; flex-direction: column; } +.buffering-byline strong { font-family: var(--font-display); font-size: 1rem; } +.buffering-byline small { color: var(--muted); font-size: .58rem; } +.buffering-ellipsis { display: flex; gap: 5px; padding-right: 3px; } +.buffering-ellipsis i { animation: buffering-bounce 1.1s ease-in-out infinite; background: var(--ink); border-radius: 50%; height: 6px; width: 6px; } +.buffering-ellipsis i:nth-child(2) { animation-delay: 120ms; } +.buffering-ellipsis i:nth-child(3) { animation-delay: 240ms; } +.buffering-content-grid { display: grid; gap: 22px; grid-template-columns: 1.35fr .65fr; padding: 14px 3px 11px; } +.buffering-copy { display: flex; flex-direction: column; gap: 9px; } +.skeleton-line { animation: skeleton-shimmer 1.7s ease-in-out infinite; background: var(--line); border-radius: 999px; display: block; height: 13px; overflow: hidden; position: relative; } +.skeleton-line.title { height: 19px; margin-bottom: 5px; width: 58%; } +.skeleton-line.long { width: 96%; } +.skeleton-line.medium { width: 84%; } +.skeleton-line.short { width: 65%; } +.skeleton-tags { display: flex; gap: 7px; margin-top: 7px; } +.skeleton-tags i { background: color-mix(in srgb, var(--line) 70%, transparent); border-radius: 999px; height: 20px; width: 92px; } +.skeleton-media span, .buffering-evidence span, .buffering-evidence i { background: var(--line); border-radius: 999px; display: block; height: 9px; } +.skeleton-media { background: color-mix(in srgb, var(--line) 42%, var(--paper-light)); border: 1px solid var(--line); min-height: 130px; overflow: hidden; padding: 91px 12px 0; position: relative; } +.skeleton-media::before, .skeleton-media::after { background: var(--line); content: ""; height: 1px; left: -8%; position: absolute; top: 42%; transform: rotate(27deg); width: 116%; } +.skeleton-media::after { transform: rotate(-27deg); } +.skeleton-media i { background: var(--paper-light); border-radius: 50%; height: 27px; left: 24px; opacity: .7; position: absolute; top: 23px; width: 27px; } +.skeleton-media span { height: 8px; margin-top: 9px; width: 80%; } +.skeleton-media span:last-child { width: 58%; } +.buffering-evidence { align-items: center; border: 1px solid var(--line); display: flex; justify-content: space-between; min-height: 39px; padding: 0 11px; } +.buffering-evidence span { width: 220px; } +.buffering-evidence i { width: 150px; } +.buffering-directions { margin: 15px auto 0; max-width: 1180px; } +.buffering-directions > p { color: var(--coral); font-size: .58rem; font-weight: 600; letter-spacing: .1em; margin: 0; text-transform: uppercase; } +.buffering-directions h2 { font-family: var(--font-display); font-size: 1.25rem; margin: 4px 0 8px; } +.buffering-directions > div { display: grid; gap: 20px; grid-template-columns: 1fr 1fr; } +.buffering-directions > div span { background: color-mix(in srgb, var(--line) 52%, var(--paper-light)); border: 1px solid var(--line); min-height: 55px; } +.buffering-directions small { color: var(--muted); display: block; font-size: .58rem; margin-top: 10px; text-align: center; } +.buffering-error { align-items: center; display: grid; gap: 18px; grid-template-columns: auto 1fr auto; min-height: 270px; padding: 35px; } +.buffering-error > span { align-items: center; background: var(--coral); border: 1px solid var(--ink); border-radius: 50%; display: flex; font-family: var(--font-display); font-size: 1.5rem; height: 48px; justify-content: center; width: 48px; } +.buffering-error strong { font-family: var(--font-display); font-size: 1.3rem; } +.buffering-error p { color: var(--muted); font-size: .75rem; margin: 5px 0 0; } +.buffering-error button { background: var(--ink); border: 1px solid var(--ink); color: var(--paper-light); font-size: .65rem; padding: 11px 14px; text-transform: uppercase; } +@keyframes buffering-bounce { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-5px); } } +@keyframes skeleton-shimmer { 0%, 100% { opacity: .52; } 50% { opacity: .92; } } /* Performance */ .performance-stage { margin: 0 auto; max-width: 1536px; min-height: 820px; padding: 0 clamp(24px, 4.8vw, 72px) 80px; } @@ -202,6 +455,13 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius .answer-copy { padding: 38px 0 15px; } .answer-copy p { font-family: var(--font-display); font-size: clamp(1.25rem, 1.65vw, 1.64rem); line-height: 1.48; margin: 0 0 27px; } .answer-copy p:first-child::first-letter { float: left; font-size: 4.5em; line-height: .73; margin: .08em .08em 0 0; } +.answer-media { background: var(--ink); border: 1px solid var(--ink); margin: 15px 0 35px; } +.answer-media > a { display: block; overflow: hidden; } +.answer-media img { aspect-ratio: 16 / 9; display: block; object-fit: cover; transition: transform 500ms ease; width: 100%; } +.answer-media:hover img { transform: scale(1.015); } +.answer-media figcaption { align-items: center; color: var(--paper-light); display: flex; font-size: .65rem; gap: 20px; justify-content: space-between; padding: 12px 14px; } +.answer-media figcaption span { color: rgba(255,255,255,.68); } +.answer-media figcaption a { border-bottom: 1px solid var(--acid); color: var(--acid); flex: 0 0 auto; font-weight: 700; text-transform: uppercase; } .citation { align-items: center; background: var(--sky); border: 1px solid var(--ink); border-radius: 50%; display: inline-flex; font-family: var(--font-body); font-size: .52rem; font-weight: 600; height: 18px; justify-content: center; margin-inline: 2px; position: relative; top: -.15em; width: 18px; } .transition-line { background: color-mix(in srgb, var(--acid) 35%, var(--paper)); border-left: 4px solid var(--ink); font-family: var(--font-display); font-size: 1.12rem; font-style: italic; margin: 15px 0 33px; padding: 18px 22px; } .transition-line span { display: block; font-family: var(--font-body); font-size: .55rem; font-style: normal; font-weight: 600; letter-spacing: .11em; margin-bottom: 6px; text-transform: uppercase; } @@ -248,6 +508,134 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius .reject-control input { accent-color: var(--coral); width: 100%; } .reject-control > button { background: var(--coral); border: 1px solid var(--paper-light); color: var(--ink); font-size: .62rem; font-weight: 600; padding: 10px; text-transform: uppercase; width: 100%; } +/* Condensed editorial answer card */ +.article-journey-stage { max-width: 1536px; padding-bottom: 42px; } +.article-journey-header { min-height: 108px; padding: 14px 0 12px; } +.article-journey-header .eyebrow { margin-bottom: 8px; } +.article-journey-header h1 { font-size: clamp(1.65rem, 2.35vw, 2.8rem); line-height: 1; max-width: 1080px; } +.contained-answer-card { background: var(--paper-light); border: 1px solid var(--line-strong); box-shadow: 7px 7px 0 rgba(19, 36, 30, .055); margin: 14px auto 0; max-width: 1320px; padding: 13px 18px 11px 23px; position: relative; } +.contained-answer-card::before { background: var(--coral); content: ""; inset: -1px auto -1px -1px; position: absolute; width: 7px; } +.contained-answer-topline { align-items: center; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; min-height: 40px; padding: 0 3px 9px; } +.compact-byline { border: 0; flex: 1; padding: 0; } +.compact-byline .performer-mark.coral { background: var(--coral); color: var(--ink); } +.compact-byline .performer-mark.sky { background: var(--sky); color: var(--ink); } +.compact-byline .performer-mark.acid { background: var(--acid); color: var(--ink); } +.contained-answer-tools { align-items: center; display: flex; gap: 8px; position: relative; } +.contained-answer-tools > button { background: transparent; border: 1px solid var(--line-strong); font-size: .6rem; padding: 8px 11px; text-transform: uppercase; } +.answer-overflow { position: relative; } +.answer-overflow summary { align-items: center; border: 1px solid var(--line); cursor: pointer; display: flex; font-size: .72rem; height: 33px; justify-content: center; list-style: none; width: 40px; } +.answer-overflow summary::-webkit-details-marker { display: none; } +.answer-overflow > div { background: var(--paper-light); border: 1px solid var(--ink); box-shadow: 4px 4px 0 var(--ink); display: grid; min-width: 150px; position: absolute; right: 0; top: 40px; z-index: 4; } +.answer-overflow button, .answer-overflow a { background: transparent; border: 0; border-bottom: 1px solid var(--line); font-size: .62rem; padding: 10px 12px; text-align: left; } +.contained-answer-content { display: grid; gap: 22px; grid-template-columns: minmax(0, 1.32fr) minmax(310px, .68fr); padding: 14px 3px 11px; } +.contained-answer-card.without-media .contained-answer-content { grid-template-columns: 1fr; } +.contained-answer-card.without-media .contained-answer-summary { max-width: 1000px; } +.card-kicker { color: var(--coral); font-size: .53rem; font-weight: 700; letter-spacing: .11em; margin: 0 0 6px; text-transform: uppercase; } +.contained-answer-summary h2 { font-family: var(--font-display); font-size: clamp(1.15rem, 1.55vw, 1.7rem); font-weight: 500; letter-spacing: -.03em; line-height: 1.04; margin: 0 0 7px; text-transform: capitalize; } +.short-answer-copy { display: -webkit-box; font-family: var(--font-display); font-size: clamp(.78rem, .92vw, .9rem); line-height: 1.38; margin: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 5; } +.answer-tags { display: flex; flex-wrap: wrap; gap: 5px; margin: 10px 0 0; } +.answer-tags span { border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .5rem; letter-spacing: .05em; padding: 5px 9px; text-transform: uppercase; } +.answer-tags span:nth-child(1) { background: color-mix(in srgb, var(--sky) 35%, var(--paper-light)); } +.answer-tags span:nth-child(2) { background: color-mix(in srgb, var(--coral) 14%, var(--paper-light)); } +.answer-tags span:nth-child(3) { background: color-mix(in srgb, var(--acid) 32%, var(--paper-light)); } +.contained-answer-media { align-self: start; background: #eee3ce; border: 1px solid var(--line); margin: 0; padding: 5px; } +.answer-visual-stage { overflow: hidden; position: relative; } +.answer-visual-source { display: block; inset: 0; position: absolute; } +.contained-answer-media > a { display: block; overflow: hidden; } +.contained-answer-media img { aspect-ratio: 16 / 8; display: block; object-fit: cover; transition: transform 450ms ease; width: 100%; } +.answer-visual-source img { height: 100%; } +.contained-answer-media:hover img { transform: scale(1.018); } +.contained-answer-media figcaption { align-items: center; display: flex; font-size: .48rem; gap: 12px; justify-content: space-between; padding: 5px 3px 1px; } +.contained-answer-media figcaption span { color: var(--muted); line-height: 1.25; } +.contained-answer-media figcaption a { border-bottom: 1px solid var(--ink); flex: 0 0 auto; font-weight: 700; text-transform: uppercase; } +.fallback-art { align-items: flex-start; aspect-ratio: 16 / 8; background: linear-gradient(135deg, var(--sky), color-mix(in srgb, var(--acid) 75%, var(--paper-light))); display: flex; flex-direction: column; justify-content: flex-end; overflow: hidden; padding: 14px; position: relative; } +.fallback-art::before { background: repeating-linear-gradient(90deg, transparent 0 24px, rgba(19,36,30,.1) 25px), repeating-linear-gradient(0deg, transparent 0 24px, rgba(19,36,30,.1) 25px); content: ""; inset: 0; position: absolute; } +.fallback-orbit { border: 1px solid var(--ink); border-radius: 50%; height: 112px; position: absolute; right: -14px; top: -36px; width: 112px; } +.fallback-orbit::after { background: var(--coral); border: 1px solid var(--ink); border-radius: 50%; content: ""; height: 23px; left: 5px; position: absolute; top: 68px; width: 23px; } +.fallback-mark { align-items: center; background: var(--ink); border-radius: 50%; color: var(--paper-light); display: flex; font-family: var(--font-display); font-size: 1.2rem; height: 35px; justify-content: center; position: absolute; right: 12px; top: 12px; transform: rotate(7deg); width: 35px; } +.fallback-art strong, .fallback-art small { position: relative; z-index: 1; } +.fallback-art strong { font-family: var(--font-display); font-size: clamp(1.25rem, 2vw, 2rem); font-weight: 500; letter-spacing: -.035em; line-height: .9; max-width: 75%; text-transform: capitalize; } +.fallback-art small { font-size: .48rem; font-weight: 700; letter-spacing: .1em; margin-top: 7px; text-transform: uppercase; } +.compact-visual { margin-bottom: 18px; } +.answer-gallery { align-self: start; background: #eee3ce; border: 1px solid var(--line); padding: 5px; } +.answer-gallery-heading { align-items: center; display: flex; justify-content: space-between; padding: 4px 4px 8px; } +.answer-gallery-heading span { font-family: var(--font-display); font-size: .72rem; font-weight: 600; } +.answer-gallery-heading small { color: var(--muted); font-size: .48rem; letter-spacing: .07em; text-transform: uppercase; } +.answer-gallery-grid { display: grid; gap: 5px; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.answer-gallery-item { background: var(--paper-light); border: 1px solid color-mix(in srgb, var(--line) 75%, transparent); margin: 0; min-width: 0; overflow: hidden; } +.answer-gallery-item:first-child { grid-column: 1 / -1; } +.answer-gallery-item a { display: block; overflow: hidden; position: relative; } +.answer-gallery-item img { aspect-ratio: 4 / 3; display: block; object-fit: cover; transition: filter 180ms ease, transform 350ms ease; width: 100%; } +.answer-gallery-item:first-child img { aspect-ratio: 16 / 8; } +.answer-gallery-item a:hover img { filter: saturate(1.08); transform: scale(1.025); } +.answer-gallery-item a > span { background: var(--acid); border: 1px solid var(--ink); bottom: 5px; font-size: .46rem; font-weight: 800; left: 5px; padding: 3px 5px; position: absolute; } +.answer-gallery-item figcaption { color: var(--muted); display: -webkit-box; font-size: .48rem; line-height: 1.3; min-height: 30px; overflow: hidden; padding: 5px 6px; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.answer-gallery.compact-visual .answer-gallery-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.answer-gallery.compact-visual .answer-gallery-item:first-child { grid-column: auto; } +.answer-gallery.compact-visual .answer-gallery-item:first-child img { aspect-ratio: 4 / 3; } +.evidence-research-row { align-items: center; background: transparent; border: 1px solid var(--line); display: grid; gap: 16px; grid-template-columns: 1fr auto auto; min-height: 39px; padding: 5px 7px 5px 11px; text-align: left; width: 100%; } +.evidence-research-row > span:first-child { display: flex; flex-direction: column; } +.evidence-research-row strong { font-size: .64rem; } +.evidence-research-row small { color: var(--muted); font-size: .5rem; margin-top: 2px; } +.evidence-row-metrics { color: var(--muted); font-size: .53rem; } +.deep-dive-cta { background: var(--ink); color: var(--paper-light); font-size: .52rem; font-weight: 700; min-width: 105px; padding: 6px 9px; text-align: center; text-transform: uppercase; } +.evidence-research-row:hover .deep-dive-cta { background: var(--coral); color: var(--ink); } + +.journey-directions { margin: 15px auto 0; max-width: 1320px; } +.journey-directions .panel-index { color: var(--coral); margin: 0; } +.journey-directions h2 { font-family: var(--font-display); font-size: clamp(1.15rem, 1.45vw, 1.5rem); font-weight: 500; letter-spacing: -.025em; margin: 3px 0 8px; } +.journey-path-grid { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; } +.journey-path-card { align-items: center; border: 1px solid var(--ink); display: grid; gap: 4px 12px; grid-template-columns: 1fr auto; min-height: 68px; padding: 10px 14px; text-align: left; transition: box-shadow 150ms ease, transform 150ms ease; } +.journey-path-card:hover:not(:disabled) { box-shadow: 5px 5px 0 var(--ink); transform: translate(-3px, -3px); } +.journey-path-1 { background: var(--sky); } +.journey-path-2 { background: var(--acid); } +.journey-path-card > span { color: var(--muted); font-size: .53rem; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; } +.journey-path-card > strong { font-family: var(--font-display); font-size: clamp(.83rem, 1.05vw, 1rem); font-weight: 500; grid-row: 2; line-height: 1.1; } +.journey-path-card > i { font-size: 1.3rem; font-style: normal; grid-column: 2; grid-row: 1 / 3; } +.journey-secondary-actions { display: flex; gap: 56px; justify-content: center; padding: 6px 0 0; } +.journey-secondary-actions button { background: transparent; border: 0; border-bottom: 1px solid transparent; color: var(--muted); font-size: .6rem; padding: 4px; } +.journey-secondary-actions button:hover { border-bottom-color: currentColor; color: var(--ink); } +.redraw-panel { align-items: end; background: var(--paper-light); border: 1px solid var(--line-strong); display: grid; gap: 15px; grid-template-columns: auto 1fr auto; margin-top: 13px; padding: 15px; } +.redraw-modes { display: flex; gap: 6px; } +.redraw-modes button { background: var(--paper); border: 1px solid var(--line); font-size: .55rem; padding: 9px 10px; } +.redraw-modes button.active { background: var(--sky); border-color: var(--ink); } +.redraw-note { display: flex; flex-direction: column; gap: 5px; } +.redraw-note span { color: var(--muted); font-size: .5rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.redraw-note input { background: var(--paper); border: 1px solid var(--line); min-height: 34px; padding: 7px 9px; width: 100%; } +.redraw-submit { background: var(--coral); border: 1px solid var(--ink); font-size: .56rem; font-weight: 700; min-height: 36px; padding: 8px 12px; text-transform: uppercase; } + +.deep-dive-backdrop { align-items: center; animation: overlay-in 170ms ease both; background: rgba(19, 36, 30, .66); display: flex; inset: 0; justify-content: center; padding: 28px; position: fixed; z-index: 50; } +.deep-dive-dialog { animation: dialog-in 230ms ease both; background: var(--paper-light); border: 1px solid var(--ink); box-shadow: 13px 13px 0 rgba(0, 0, 0, .25); max-height: calc(100vh - 56px); max-width: 1100px; overflow: auto; width: 100%; } +.deep-dive-dialog > header { align-items: flex-start; border-bottom: 1px solid var(--ink); display: flex; justify-content: space-between; padding: 22px 26px; position: sticky; top: 0; z-index: 3; background: var(--paper-light); } +.deep-dive-dialog > header p { color: var(--coral); font-size: .55rem; font-weight: 700; letter-spacing: .1em; margin: 0 0 7px; text-transform: uppercase; } +.deep-dive-dialog > header h2 { font-family: var(--font-display); font-size: clamp(1.5rem, 2.4vw, 2.3rem); font-weight: 500; letter-spacing: -.03em; line-height: 1; margin: 0; max-width: 900px; } +.deep-dive-dialog > header button { align-items: center; background: transparent; border: 1px solid var(--line); display: flex; flex: 0 0 auto; font-family: var(--font-display); font-size: 1.5rem; height: 40px; justify-content: center; margin-left: 20px; width: 40px; } +.deep-dive-layout { display: grid; grid-template-columns: 1.2fr .8fr; } +.deep-dive-answer { border-right: 1px solid var(--line); padding: 30px 34px; } +.deep-dive-answer > p { font-family: var(--font-display); font-size: .95rem; line-height: 1.58; margin: 0 0 19px; } +.deep-dive-evidence { padding: 30px 27px; } +.deep-dive-evidence figure { background: #eee3ce; border: 1px solid var(--line); margin: 0 0 25px; padding: 6px; } +.deep-dive-evidence figure img { aspect-ratio: 16 / 9; display: block; object-fit: cover; width: 100%; } +.deep-dive-evidence figcaption { color: var(--muted); font-size: .54rem; padding: 7px 3px 2px; } +.deep-dive-evidence h3 { font-size: .58rem; letter-spacing: .1em; margin: 0 0 10px; text-transform: uppercase; } +.deep-dive-evidence ol { list-style: none; margin: 0; padding: 0; } +.deep-dive-evidence li { align-items: center; border-top: 1px solid var(--line); display: grid; gap: 9px; grid-template-columns: 23px 1fr auto; padding: 10px 0; } +.deep-dive-evidence li > span { align-items: center; border: 1px solid var(--ink); border-radius: 50%; display: flex; font-size: .52rem; height: 21px; justify-content: center; width: 21px; } +.deep-dive-evidence li > div { display: flex; flex-direction: column; } +.deep-dive-evidence li strong { font-size: .64rem; } +.deep-dive-evidence li small, .deep-dive-evidence li a { color: var(--muted); font-size: .52rem; } +.deep-dive-research { background: color-mix(in srgb, var(--sky) 20%, var(--paper-light)); border-top: 1px solid var(--ink); display: grid; gap: 30px; grid-template-columns: 1.1fr .9fr; padding: 22px 30px; } +.deep-dive-research > div > span { color: var(--muted); font-size: .52rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.deep-dive-research > div p { font-family: var(--font-display); font-size: .98rem; margin: 7px 0 0; } +.deep-dive-research dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; } +.deep-dive-research dl div { display: flex; flex-direction: column; padding: 5px; } +.deep-dive-research dt { color: var(--muted); font-size: .5rem; text-transform: uppercase; } +.deep-dive-research dd { font-size: .58rem; margin: 2px 0 0; } +.deep-dive-dialog > footer { border-top: 1px solid var(--line); display: flex; justify-content: flex-end; padding: 13px 25px; } +.deep-dive-dialog > footer button { background: var(--ink); border: 1px solid var(--ink); color: var(--paper-light); font-size: .58rem; padding: 9px 13px; text-transform: uppercase; } +@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes dialog-in { from { opacity: 0; transform: translateY(12px) scale(.99); } to { opacity: 1; transform: translateY(0) scale(1); } } + /* Shared view headings */ .view-heading { align-items: end; border-bottom: 1px solid var(--ink); display: grid; gap: 45px; grid-template-columns: 1fr minmax(240px, .38fr); padding: clamp(60px, 8vw, 110px) 0 45px; } .view-heading h1 { font-size: clamp(4rem, 6.2vw, 7rem); margin: 0; } @@ -258,33 +646,65 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius /* Map */ .map-view, .library-view, .compare-view { margin: 0 auto; max-width: 1390px; min-height: 780px; padding: 0 clamp(24px, 4vw, 55px) 90px; } -.map-layout { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(330px, .75fr); } -.turn-tree { border-inline-start: 1px solid var(--line); min-height: 560px; padding: 28px 35px 50px 0; overflow-x: auto; } -.map-legend { display: flex; font-size: .55rem; gap: 18px; justify-content: flex-end; letter-spacing: .08em; margin-bottom: 26px; text-transform: uppercase; } -.map-legend span { align-items: center; display: flex; gap: 6px; } -.map-legend i { background: var(--paper); border: 1px solid var(--ink); height: 8px; width: 8px; } -.map-legend i.current { background: var(--acid); border-radius: 50%; } -.map-legend i.selected { background: var(--coral); transform: rotate(45deg); } -.turn-node { align-items: center; background: var(--paper-light); border: 1px solid var(--line-strong); display: grid; gap: 12px; grid-template-columns: 28px 14px minmax(240px, 1fr) auto; margin-bottom: 14px; min-height: 82px; padding: 12px 15px; text-align: left; width: min(680px, calc(100% - 10px)); } -.turn-node > span { color: var(--muted); font-family: var(--font-display); font-style: italic; } -.turn-node > i { background: var(--paper); border: 1px solid var(--ink); height: 11px; width: 11px; } -.turn-node.current > i { background: var(--acid); border-radius: 50%; } -.turn-node.selected { background: var(--sky); border-color: var(--ink); box-shadow: 4px 4px 0 var(--ink); } -.turn-node.selected > i { background: var(--coral); border-radius: 0; transform: rotate(45deg); } -.turn-node > div { display: flex; flex-direction: column; } -.turn-node small { color: var(--muted); font-size: .53rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; } -.turn-node strong { font-family: var(--font-display); font-size: 1.16rem; font-weight: 500; line-height: 1.05; margin-top: 5px; } -.turn-node > b { font-weight: 400; } -.map-inspector { background: var(--ink); color: var(--paper-light); min-height: 560px; padding: 45px clamp(28px, 4vw, 55px); } -.map-inspector > span { color: var(--acid); font-size: .58rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; } -.map-inspector h2 { font-family: var(--font-display); font-size: clamp(2.4rem, 3.5vw, 4rem); font-weight: 400; letter-spacing: -.04em; line-height: .93; margin: 30px 0; } -.map-inspector > p { color: rgba(255, 255, 255, .63); font-size: .73rem; line-height: 1.65; } -.map-inspector dl { border-block: 1px solid rgba(255, 255, 255, .2); margin: 30px 0; } -.map-inspector dl div { display: flex; font-size: .63rem; justify-content: space-between; padding: 10px 0; } -.map-inspector dt { color: rgba(255, 255, 255, .48); } -.map-inspector dd { margin: 0; text-transform: capitalize; } -.map-inspector > button { align-items: center; background: var(--acid); border: 1px solid var(--paper-light); color: var(--ink); display: flex; font-weight: 600; justify-content: space-between; padding: 13px 15px; width: 100%; } -.map-inspector > small { color: rgba(255, 255, 255, .4); display: block; font-size: .54rem; margin-top: 13px; } +.map-view { box-sizing: border-box; width: 100%; } +.map-header { align-items: end; border-bottom: 1px solid var(--ink); display: grid; gap: clamp(30px, 5vw, 76px); grid-template-columns: minmax(0, 1fr) auto; padding: clamp(28px, 4vw, 48px) 0 24px; } +.map-header .eyebrow { margin-bottom: 14px; } +.map-header h1 { font-family: var(--font-display); font-size: clamp(1.9rem, 2.7vw, 2.7rem); font-weight: 500; letter-spacing: -.045em; line-height: .96; margin: 0; max-width: 920px; } +.map-header > div > p:last-child { color: var(--muted); font-size: .72rem; line-height: 1.5; margin: 14px 0 0; max-width: 660px; } +.map-header dl { border: 1px solid var(--line-strong); display: grid; grid-template-columns: repeat(3, minmax(92px, 1fr)); margin: 0; min-width: min(100%, 330px); } +.map-header dl div { display: flex; flex-direction: column; gap: 5px; padding: 12px 14px; } +.map-header dl div + div { border-inline-start: 1px solid var(--line); } +.map-header dt { color: var(--muted); font-size: .52rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.map-header dd { font-family: var(--font-display); font-size: 1.05rem; margin: 0; } +.active-path { padding: 28px 0 20px; } +.map-section-heading { align-items: end; display: flex; gap: 24px; justify-content: space-between; margin-bottom: 18px; } +.map-section-heading > div { display: flex; flex-direction: column; gap: 4px; } +.map-section-heading span, .selected-turn-heading span, .off-path-notice > span { color: var(--coral); font-size: .55rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.map-section-heading h2, .selected-turn-heading h2 { font-family: var(--font-display); font-size: 1.3rem; font-weight: 500; margin: 0; } +.map-section-heading > p { color: var(--muted); font-size: .62rem; margin: 0; } +.active-path-list { display: flex; gap: 42px; list-style: none; margin: 0; overflow-x: auto; padding: 3px 5px 12px; scrollbar-color: var(--line-strong) transparent; } +.active-path-list > li { flex: 1 0 250px; max-width: 390px; min-width: 0; position: relative; } +.active-path-list > li:not(:last-child)::after { background: var(--ink); content: ""; height: 2px; left: calc(100% + 1px); position: absolute; top: 50%; width: 40px; } +.path-turn { align-items: start; background: var(--paper-light); border: 1px solid var(--line-strong); display: grid; gap: 11px; grid-template-columns: 34px minmax(0, 1fr); min-height: 152px; padding: 16px; text-align: left; transition: box-shadow 140ms ease, transform 140ms ease; width: 100%; } +.path-turn:hover { box-shadow: 3px 3px 0 var(--ink); transform: translate(-2px, -2px); } +.path-turn[aria-pressed="true"] { background: var(--sky); border-color: var(--ink); box-shadow: 4px 4px 0 var(--ink); } +.path-turn-number { align-items: center; background: var(--paper); border: 1px solid var(--ink); border-radius: 50%; display: flex; font-family: var(--font-display); font-size: .8rem; height: 32px; justify-content: center; width: 32px; } +.path-turn-copy { display: flex; flex-direction: column; gap: 7px; min-width: 0; } +.path-turn-copy small { color: var(--muted); font-size: .5rem; font-weight: 700; letter-spacing: .08em; overflow: hidden; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.path-turn-copy strong { display: -webkit-box; font-family: var(--font-display); font-size: 1rem; font-weight: 500; line-height: 1.08; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 3; } +.path-turn-status { align-self: end; background: var(--paper); border: 1px solid var(--line-strong); font-size: .48rem; font-weight: 700; grid-column: 2; justify-self: start; letter-spacing: .06em; padding: 4px 6px; text-transform: uppercase; } +.path-turn-status.current { background: var(--acid); border-color: var(--ink); } +.off-path-notice { align-items: center; background: color-mix(in srgb, var(--coral) 14%, var(--paper-light)); border: 1px solid var(--line-strong); display: grid; gap: 12px 22px; grid-template-columns: auto 1fr; margin: 0 0 18px; padding: 12px 15px; } +.off-path-notice p { font-size: .66rem; line-height: 1.45; margin: 0; } +.selected-turn-paths { border: 1px solid var(--ink); margin: 0 0 20px; padding: clamp(18px, 3vw, 30px); position: relative; } +.selected-turn-heading { margin-bottom: 18px; padding-inline-end: 180px; } +.selected-turn-heading > div { display: flex; flex-direction: column; gap: 5px; } +.open-turn-answer { background: var(--ink); border: 1px solid var(--ink); color: var(--paper-light); font-size: .58rem; font-weight: 700; min-height: 40px; padding: 9px 13px; position: absolute; right: clamp(18px, 3vw, 30px); text-transform: uppercase; top: clamp(18px, 3vw, 30px); } +.selected-path-grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.selected-path-card { background: var(--paper); border: 1px solid var(--line-strong); display: flex; flex-direction: column; gap: 8px; min-height: 142px; padding: 16px; text-align: left; } +.selected-path-card.open { background: color-mix(in srgb, var(--acid) 30%, var(--paper-light)); border-color: var(--ink); cursor: pointer; transition: box-shadow 140ms ease, transform 140ms ease; } +.selected-path-card.open:nth-child(2) { background: color-mix(in srgb, var(--sky) 42%, var(--paper-light)); } +.selected-path-card.open:hover { box-shadow: 4px 4px 0 var(--ink); transform: translate(-2px, -2px); } +.selected-path-card > span { color: var(--muted); font-size: .5rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.selected-path-card.open > span { color: var(--ink); } +.selected-path-card strong { font-family: var(--font-display); font-size: clamp(1.05rem, 1.8vw, 1.45rem); font-weight: 500; line-height: 1.08; } +.selected-path-card small { color: var(--muted); font-size: .55rem; margin-top: auto; } +.selected-path-card.open small { color: var(--ink); font-weight: 700; text-transform: uppercase; } +.selected-path-card.chosen { background: var(--paper-light); border-style: dashed; } +.selected-path-card.rejected, .selected-path-card.superseded { opacity: .66; } +.other-paths { border: 1px dashed var(--line-strong); } +.other-paths summary { align-items: center; cursor: pointer; display: flex; gap: 20px; justify-content: space-between; list-style: none; min-height: 58px; padding: 12px 16px; } +.other-paths summary::-webkit-details-marker { display: none; } +.other-paths summary span { font-family: var(--font-display); font-size: 1rem; } +.other-paths summary strong { color: var(--muted); font-size: .57rem; font-weight: 600; } +.other-paths summary::after { content: "+"; font-family: var(--font-display); font-size: 1.4rem; } +.other-paths[open] summary::after { content: "−"; } +.other-path-groups { border-top: 1px dashed var(--line-strong); display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } +.other-path-groups button { background: var(--paper-light); border: 0; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 5px; min-height: 84px; padding: 14px 16px; text-align: left; } +.other-path-groups button:nth-child(odd) { border-inline-end: 1px solid var(--line); } +.other-path-groups button span { color: var(--muted); font-size: .5rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } +.other-path-groups button strong { font-family: var(--font-display); font-size: .9rem; font-weight: 500; } +.other-path-groups button:hover { background: color-mix(in srgb, var(--acid) 22%, var(--paper-light)); } /* Library */ .library-grid { display: grid; grid-template-columns: repeat(3, 1fr); } @@ -366,13 +786,8 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius .choice-settings label > span, .library-filters label > span, .settings-form label > span { font-size: .58rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } .choice-settings select, .library-filters input, .library-filters select, .settings-form select { background: var(--white); border: 1px solid var(--ink); min-height: 42px; padding: 8px; } .preset-description { color: var(--muted); font-size: .65rem; margin: 10px 0 0; } -.research-status-line { align-items: center; border-top: 1px solid rgba(255, 255, 255, .18); color: rgba(255,255,255,.68); display: flex; font-size: .64rem; gap: 9px; padding-top: 14px; } -.research-status-line > span { background: var(--coral); border-radius: 50%; height: 9px; width: 9px; } -.research-status-line > span.running { animation: pulse 1.5s infinite; background: var(--acid); } -.research-status-line > span.complete { background: var(--sky); } .performance-tools { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; } .performance-tools button, .performance-tools a { background: var(--paper-light); border: 1px solid var(--ink); font-size: .6rem; font-weight: 700; padding: 7px 10px; text-transform: uppercase; } -.media-fallback { background: rgba(172,216,255,.22); border-left: 3px solid var(--sky); color: var(--muted); font-size: .62rem; padding: 10px 12px; } .metadata-grid { display: grid; gap: 0 20px; grid-template-columns: 1fr 1fr; margin-bottom: 18px; } .metadata-grid div { border-top: 1px solid var(--line); padding: 8px 0; } .metadata-grid dt { color: var(--muted); font-size: .52rem; text-transform: uppercase; } @@ -380,11 +795,6 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius .usage-strip { grid-template-columns: repeat(3, 1fr); } .reason-field { display: flex !important; flex-direction: column; } .reason-field input { background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.35); color: var(--paper-light); padding: 9px; } -.map-options { display: grid; gap: 8px; margin: 22px 0; } -.map-options button { background: var(--paper-light); border: 1px solid var(--paper-light); color: var(--ink); display: flex; flex-direction: column; gap: 4px; padding: 10px; text-align: left; } -.map-options button:nth-child(2) { background: var(--acid); } -.map-options button span { font-size: .5rem; font-weight: 700; text-transform: uppercase; } -.map-options button strong { font-family: var(--font-display); font-weight: 500; } .library-filters { align-items: end; border: 1px solid var(--line-strong); display: grid; gap: 12px; grid-template-columns: 2fr 1fr auto; margin: 24px 0; padding: 15px; } .check-setting { align-items: center !important; flex-direction: row !important; min-height: 42px; } .library-manage { border-top: 1px dashed var(--line); display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding-top: 12px; } @@ -404,6 +814,7 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius @keyframes pulse { 0%, 100% { opacity: .45; transform: scale(.85); } 50% { opacity: 1; transform: scale(1.15); } } @keyframes spin { to { transform: rotate(360deg); } } @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes starter-crawl { to { transform: translateX(-50%); } } @media (max-width: 1180px) { .app-header { grid-template-columns: 1fr auto; } @@ -424,30 +835,60 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius .app-header { min-height: 70px; padding-inline: 16px; } .wordmark small, .identity-control small, .identity-control a { display: none; } .identity-control > span:nth-child(2) { display: flex; max-width: 92px; } - .app-nav { display: grid; grid-template-columns: repeat(3, 1fr); margin-inline: -16px; overflow: visible; width: calc(100% + 32px); } + .app-nav { display: grid; grid-template-columns: repeat(4, 1fr); margin-inline: -16px; overflow: visible; width: calc(100% + 32px); } .app-nav button { min-height: 43px; min-width: 0; padding-inline: 8px; } - .app-nav button:nth-child(3n) { border-inline-end: 1px solid var(--line); } + .app-nav button:nth-child(4n) { border-inline-end: 1px solid var(--line); } + .journey-view-switcher { padding-inline: 16px; } .phase-ribbon { justify-content: flex-start; overflow: hidden; padding-inline: 15px; white-space: nowrap; } .phase-ribbon span { margin-left: 0; } .phase-ribbon::after { display: none; } .start-stage { display: block; } + .start-stage-simple { min-height: 0; padding: 20px 0 30px; } + .start-console-simple { padding-inline: 16px; } + .recommendation-heading { align-items: center; flex-direction: row; gap: 10px; margin-left: calc(50% - 50vw); padding-inline: 16px; } + .recommendation-heading > div { align-items: flex-start; flex-direction: column; gap: 1px; } + .recommendation-heading span { text-align: left; } + .starter-marquee-simple { margin-bottom: 24px; margin-left: calc(50% - 50vw); } + .start-console-simple > h1 { text-align: left; } + .question-input-simple { box-shadow: 4px 4px 0 var(--ink); } + .start-selectors { grid-template-columns: 1fr; margin-top: 8px; } + .performer-layer { grid-template-columns: 1fr; } + .launch-button-simple { box-shadow: 4px 4px 0 var(--coral); } .start-intro { min-height: 630px; padding: 80px 24px; } .start-intro h1 { font-size: clamp(4.3rem, 19vw, 6.8rem); } .drive-console { border-left: 0; border-top: 1px solid var(--ink); padding-inline: 20px; } .performer-grid { grid-template-columns: 1fr; } .config-row { grid-template-columns: 1fr; } - .research-stage { padding-inline: 20px; } - .research-layout, .performance-header, .performance-grid, .view-heading, .map-layout { grid-template-columns: 1fr; } - .research-layout { display: flex; flex-direction: column-reverse; } + .performance-header, .performance-grid, .view-heading { grid-template-columns: 1fr; } .performance-header { align-items: start; min-height: 0; padding-top: 55px; } + .buffering-header { align-items: center; grid-template-columns: minmax(0, 1fr) auto; min-height: 96px; padding-block: 12px; } + .buffering-header h1 { font-size: clamp(1.4rem, 4vw, 2rem); } + .article-journey-header { padding-top: 15px; } + .buffering-status { min-width: 170px; width: auto; } + .buffering-content-grid { grid-template-columns: 1.25fr .75fr; } + .contained-answer-content, .deep-dive-layout, .deep-dive-research { grid-template-columns: 1fr; } + .contained-answer-media { max-width: 600px; } + .evidence-research-row { align-items: flex-start; grid-template-columns: 1fr auto; } + .evidence-row-metrics { grid-column: 1 / -1; grid-row: 2; } + .redraw-panel { align-items: stretch; grid-template-columns: 1fr; } + .deep-dive-answer { border-bottom: 1px solid var(--line); border-right: 0; } .stage-metrics { width: 100%; } .answer-panel { border-inline: 0; padding-inline: 2px; } + .answer-media figcaption { align-items: flex-start; flex-direction: column; gap: 8px; } .direction-panel { margin-inline: -24px; padding-inline: 24px; } .view-heading { align-items: start; padding-top: 65px; } .view-heading > div:last-child { border-left: 0; border-top: 1px solid var(--line); padding: 20px 0 0; } - .turn-tree { border-inline: 0; padding-right: 0; } - .turn-node { margin-inline-start: 0 !important; width: 100%; } - .map-inspector { min-height: 0; } + .map-header { align-items: start; gap: 22px; grid-template-columns: 1fr; padding-top: 35px; } + .map-header dl { max-width: none; width: 100%; } + .map-section-heading { align-items: flex-start; flex-direction: column; gap: 6px; } + .active-path-list { display: grid; gap: 28px; overflow: visible; padding-inline: 4px; } + .active-path-list > li { max-width: none; width: 100%; } + .active-path-list > li:not(:last-child)::after { height: 28px; left: 28px; top: 100%; width: 2px; } + .path-turn { min-height: 116px; } + .selected-turn-heading { padding-inline-end: 0; } + .open-turn-answer { margin-top: 12px; position: static; width: 100%; } + .selected-path-grid, .other-path-groups { grid-template-columns: 1fr; } + .other-path-groups button:nth-child(odd) { border-inline-end: 0; } .library-grid, .compare-picker { grid-template-columns: 1fr; } .library-filters, .settings-form { grid-template-columns: 1fr; } .settings-form .launch-button { grid-column: 1; } @@ -461,19 +902,89 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius } @media (max-width: 500px) { + .phase-ribbon { display: none; } + .starter-marquee-simple button { min-height: 45px; } + .question-input-simple { min-height: 84px; padding: 13px 14px 6px; } + .question-input-simple textarea { min-height: 49px; } + .start-select-wrap { min-height: 48px; } + .start-select-wrap select { min-height: 46px; } + .performer-layer { padding: 10px 12px; } .contract-strip { grid-template-columns: 1fr; } .contract-strip span { border-bottom: 1px solid var(--line); border-right: 0; min-height: 42px; } .console-heading { align-items: flex-start; flex-direction: column; justify-content: center; gap: 4px; } - .research-question h1 { font-size: 2.8rem; } - .research-feed li { gap: 8px; grid-template-columns: 24px 12px 1fr 15px; } .performance-stage { padding-inline: 16px; } .performance-header h1 { font-size: 3rem; } + .buffering-header { gap: 9px; grid-template-columns: minmax(0, 1fr) 124px; min-height: 86px; padding-block: 9px; } + .buffering-header h1 { font-size: 1.22rem; } + .buffering-header .eyebrow { font-size: .54rem; } + .buffering-status { min-width: 0; padding: 7px 8px; } + .buffering-status small { display: none; } + .article-journey-header { align-items: center; gap: 10px; grid-template-columns: minmax(0, 1fr) auto; min-height: 92px; padding-block: 10px; } + .article-journey-header h1 { font-size: 1.42rem; line-height: 1; } + .article-journey-header .eyebrow { font-size: .58rem; } + .article-journey-header .stage-metrics { width: auto; } + .article-journey-header .stage-metrics span { min-width: 48px; padding: 5px 6px; } + .article-journey-header .stage-metrics strong { font-size: 1.05rem; } + .journey-view-switcher > span { display: none; } + .journey-view-switcher > div { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } + .journey-view-switcher button { min-width: 0; } + .contained-answer-card { padding: 17px 16px 14px 21px; } + .contained-answer-topline { align-items: flex-start; gap: 14px; } + .compact-byline .ready-stamp { display: none; } + .contained-answer-tools > button { font-size: 0; } + .contained-answer-tools > button::after { content: "Read"; font-size: .56rem; } + .contained-answer-content { gap: 10px; grid-template-columns: minmax(0, 1.45fr) minmax(104px, .55fr); padding-block: 12px 9px; } + .contained-answer-summary h2 { font-size: 1.05rem; } + .short-answer-copy { font-size: .72rem; -webkit-line-clamp: 5; } + .answer-tags { gap: 3px; margin-top: 7px; } + .answer-tags span { font-size: .42rem; padding: 3px 5px; } + .contained-answer-media img, .fallback-art { aspect-ratio: 4 / 5; } + .contained-answer-media figcaption { display: none; } + .answer-gallery { margin-inline: -2px; } + .answer-gallery-grid { display: flex; gap: 6px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: thin; } + .answer-gallery-item, .answer-gallery-item:first-child { flex: 0 0 82%; scroll-snap-align: start; } + .answer-gallery-item img, .answer-gallery-item:first-child img { aspect-ratio: 4 / 3; } + .fallback-art { padding: 8px; } + .fallback-art strong { font-size: .9rem; max-width: 100%; } + .fallback-art small, .fallback-mark { display: none; } + .evidence-row-metrics { display: none; } + .evidence-research-row { grid-template-columns: 1fr auto; } + .journey-path-grid { grid-template-columns: 1fr; } + .journey-directions { margin-top: 10px; } + .journey-path-grid { gap: 7px; } + .journey-path-card { min-height: 55px; padding: 7px 10px; } + .journey-secondary-actions { align-items: center; flex-direction: column; gap: 3px; } + .redraw-modes { display: grid; grid-template-columns: 1fr; } + .deep-dive-backdrop { align-items: stretch; padding: 0; } + .deep-dive-dialog { border: 0; max-height: 100vh; } + .deep-dive-dialog > header { padding: 18px 16px; } + .deep-dive-answer, .deep-dive-evidence { padding: 24px 18px; } + .deep-dive-research { padding: 20px 18px; } + .deep-dive-research dl { grid-template-columns: 1fr; } + .buffering-answer-card { min-height: 210px; padding: 12px 13px 10px 18px; } + .buffering-content-grid { gap: 10px; grid-template-columns: 1.45fr .55fr; } + .skeleton-media { min-height: 105px; padding-top: 72px; } + .buffering-directions > div { gap: 7px; grid-template-columns: 1fr 1fr; } + .buffering-directions > div span { min-height: 48px; } .direction-panel { margin-inline: -16px; } .answer-byline { grid-template-columns: auto 1fr; } .ready-stamp { display: none; } .view-heading h1 { font-size: 3.8rem; } - .turn-node { grid-template-columns: 23px 10px 1fr; } - .turn-node > b { display: none; } + .map-view { padding-inline: 16px; } + .map-header { padding-top: 28px; } + .map-header h1 { font-size: 1.9rem; } + .map-header > div > p:last-child, .map-section-heading > p { display: none; } + .map-header { gap: 14px; } + .map-header dl { grid-template-columns: repeat(3, 1fr); } + .map-header dl div { padding: 10px 8px; } + .map-header dd { font-size: .92rem; } + .path-turn { grid-template-columns: 34px minmax(0, 1fr); min-height: 110px; padding: 13px; } + .path-turn-copy strong { font-size: .95rem; -webkit-line-clamp: 2; } + .selected-turn-paths { padding: 15px; } + .selected-path-card { min-height: 118px; padding: 14px; } + .selected-path-card strong { font-size: 1.05rem; } + .other-paths summary { align-items: flex-start; flex-direction: column; gap: 4px; padding-right: 46px; position: relative; } + .other-paths summary::after { position: absolute; right: 16px; top: 13px; } .library-card { min-height: 360px; padding-inline: 20px; } .app-nav button { font-size: .57rem; padding-inline: 9px; } .upgrade-banner { align-items: flex-start; flex-direction: column; gap: 7px; } @@ -481,4 +992,12 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } + .starter-marquee-window { overflow-x: auto; } + .starter-marquee-track { animation: none !important; transform: none !important; } + .starter-marquee-set[aria-hidden="true"] { display: none; } } + +.reduce-motion .starter-marquee-window { overflow-x: auto; } +.reduce-motion .starter-marquee-track { animation: none; transform: none; } +.reduce-motion .starter-marquee-set[aria-hidden="true"] { display: none; } +.reduce-motion .buffering-ellipsis i, .reduce-motion .skeleton-line, .reduce-motion .buffering-dot { animation: none; } diff --git a/app/wonderdrive-experience.tsx b/app/wonderdrive-experience.tsx index 44a7762..d143e5b 100644 --- a/app/wonderdrive-experience.tsx +++ b/app/wonderdrive-experience.tsx @@ -3,24 +3,23 @@ import { type Dispatch, type FormEvent, + type KeyboardEvent, type SetStateAction, useCallback, useEffect, useMemo, + useRef, useState, } from "react"; import { BOOTSTRAP_CATALOG, DEFAULT_PREFERENCES, PERFORMERS, - PRESET_LABELS, STARTERS, } from "../lib/catalog"; import type { AdvanceJourneyRequest, AnswerDensity, - ApiFailure, - ApiSuccess, BootstrapCatalog, CompareResult, ImagePreference, @@ -28,17 +27,20 @@ import type { JourneySnapshot, JourneySummary, JourneyTurn, - Interlude, - LiveResearchRequest, - LiveResearchStreamEvent, ModelId, + PersonalizedStarter, PerformerId, - ResearchEvent, ResearchPreset, TextSize, UserPreferences, Viewer, } from "../lib/contracts"; +import { + api, + type LiveResearchState, + messageFrom, + streamLiveResearch, +} from "./client-api"; type View = "start" | "journey" | "map" | "library" | "compare" | "settings"; @@ -51,20 +53,15 @@ type BootstrapPayload = { preferences: UserPreferences; }; -type LiveResearchState = { - question: string; - message: string; - events: ResearchEvent[]; - status: "running" | "complete" | "error"; - result: JourneyDetail | null; - interlude: Omit | null; - error: string | null; +type StarterPayload = { starters: PersonalizedStarter[] }; +type JourneyViewOptions = { + turnId?: string; + view?: View; + syncLibrary?: boolean; }; const navItems: Array<{ id: View; label: string }> = [ { id: "start", label: "New drive" }, - { id: "journey", label: "Stage" }, - { id: "map", label: "Journey map" }, { id: "library", label: "Library" }, { id: "compare", label: "Compare" }, { id: "settings", label: "Settings" }, @@ -76,7 +73,6 @@ export function WonderDriveExperience() { const [journeys, setJourneys] = useState([]); const [activeJourney, setActiveJourney] = useState(null); const [activeTurnId, setActiveTurnId] = useState(null); - const [replaying, setReplaying] = useState(false); const [loading, setLoading] = useState(true); const [mutation, setMutation] = useState(null); const [error, setError] = useState(null); @@ -86,6 +82,9 @@ export function WonderDriveExperience() { const [liveResearch, setLiveResearch] = useState(null); const [catalog, setCatalog] = useState(BOOTSTRAP_CATALOG); const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES); + const [personalizedStarters, setPersonalizedStarters] = useState( + BOOTSTRAP_CATALOG.discoveryStarters, + ); const refreshSession = useCallback(async () => { setError(null); @@ -98,6 +97,9 @@ export function WonderDriveExperience() { setJourneys(session.data.journeys); setCatalog(bootstrap.data.catalog); setPreferences(bootstrap.data.preferences); + void api("/api/starters?performer=sage&refresh=1") + .then((payload) => setPersonalizedStarters(payload.data.starters)) + .catch(() => setPersonalizedStarters(bootstrap.data.catalog.discoveryStarters)); } catch (cause) { setError(messageFrom(cause)); } finally { @@ -105,6 +107,37 @@ export function WonderDriveExperience() { } }, []); + const runMutation = useCallback(async ( + key: string, + work: () => Promise, + onError?: (message: string) => void, + ): Promise => { + setMutation(key); + setError(null); + try { + return await work(); + } catch (cause) { + const message = messageFrom(cause); + setError(message); + onError?.(message); + } finally { + setMutation(null); + } + }, []); + + /** Keeps every client projection of the selected journey in one atomic React update path. */ + const presentJourney = useCallback(( + detail: JourneyDetail, + nextViewer: Viewer, + { turnId = detail.currentTurnId, view = "journey", syncLibrary = true }: JourneyViewOptions = {}, + ) => { + setViewer(nextViewer); + setActiveJourney(detail); + setActiveTurnId(turnId); + setView(view); + if (syncLibrary) setJourneys((current) => upsertSummary(current, detail)); + }, []); + useEffect(() => { // The first client effect hydrates the durable server session; updates happen after fetch resolves. // eslint-disable-next-line react-hooks/set-state-in-effect @@ -112,21 +145,11 @@ export function WonderDriveExperience() { }, [refreshSession]); const openJourney = useCallback(async (journeyId: string, targetView: View = "journey") => { - setMutation(`open-${journeyId}`); - setError(null); - try { + await runMutation(`open-${journeyId}`, async () => { const payload = await api(`/api/journeys/${journeyId}`); - setViewer(payload.viewer); - setActiveJourney(payload.data); - setActiveTurnId(payload.data.currentTurnId); - setReplaying(false); - setView(targetView); - } catch (cause) { - setError(messageFrom(cause)); - } finally { - setMutation(null); - } - }, []); + presentJourney(payload.data, payload.viewer, { view: targetView, syncLibrary: false }); + }); + }, [presentJourney, runMutation]); async function create(config: { seed: string; @@ -136,63 +159,32 @@ export function WonderDriveExperience() { answerDensity: AnswerDensity; imagePreference: ImagePreference; }) { - setMutation("create"); - setError(null); - try { - if (config.modelId === "gpt-5.6-luna") { - setView("journey"); - setLiveResearch({ - question: config.seed, - message: "Connecting to live foreground research…", - events: [], - status: "running", - result: null, - interlude: null, - error: null, - }); - const complete = await streamLiveResearch( - { - kind: "create", - ...config, - modelId: "gpt-5.6-luna", - idempotencyKey: crypto.randomUUID(), - }, - setLiveResearch, - ); - setViewer(complete.viewer); - setActiveJourney(complete.data); - setActiveTurnId(complete.data.currentTurnId); - setJourneys((current) => upsertSummary(current, complete.data)); - setLiveResearch((current) => - current - ? { ...current, status: "complete", result: complete.data, message: "Research committed" } - : current, - ); - setReplaying(false); - return; - } - const payload = await api("/api/journeys", { - method: "POST", - body: JSON.stringify({ - ...config, - idempotencyKey: crypto.randomUUID(), - }), - }); - setViewer(payload.viewer); - setActiveJourney(payload.data); - setActiveTurnId(payload.data.currentTurnId); - setJourneys((current) => upsertSummary(current, payload.data)); + await runMutation("create", async () => { setView("journey"); - setReplaying(true); - } catch (cause) { - const message = messageFrom(cause); - setError(message); + setLiveResearch({ + question: config.seed, + performerId: config.performerId, + message: "Connecting to live foreground research…", + events: [], + status: "running", + result: null, + error: null, + }); + const complete = await streamLiveResearch( + { kind: "create", ...config, idempotencyKey: crypto.randomUUID() }, + setLiveResearch, + ); + presentJourney(complete.data, complete.viewer); + setLiveResearch((current) => + current + ? { ...current, status: "complete", result: complete.data, message: "Research committed" } + : current, + ); + }, (message) => { setLiveResearch((current) => current ? { ...current, status: "error", error: message, message: "Research stopped" } : null, ); - } finally { - setMutation(null); - } + }); } async function advance( @@ -200,10 +192,8 @@ export function WonderDriveExperience() { input: { turnId: string; optionId?: string; adventure?: number; reason?: string }, ) { if (!activeJourney) return; - setMutation(action); - setError(null); - try { - if (activeJourney.modelId === "gpt-5.6-luna" && action !== "reject") { + await runMutation(action, async () => { + if (action !== "reject") { const fromTurn = activeJourney.turns.find((turn) => turn.id === input.turnId); const selected = action === "delegate" @@ -213,11 +203,11 @@ export function WonderDriveExperience() { setView("journey"); setLiveResearch({ question: selected.question, + performerId: activeJourney.performerId, message: "Opening the next live research turn…", events: [], status: "running", result: null, - interlude: null, error: null, }); const complete = await streamLiveResearch( @@ -232,16 +222,12 @@ export function WonderDriveExperience() { }, setLiveResearch, ); - setViewer(complete.viewer); - setActiveJourney(complete.data); - setActiveTurnId(complete.data.currentTurnId); - setJourneys((current) => upsertSummary(current, complete.data)); + presentJourney(complete.data, complete.viewer); setLiveResearch((current) => current ? { ...current, status: "complete", result: complete.data, message: "Research committed" } : current, ); - setReplaying(false); return; } const payload = await api( @@ -259,32 +245,19 @@ export function WonderDriveExperience() { }), }, ); - setViewer(payload.viewer); - setActiveJourney(payload.data); - setJourneys((current) => upsertSummary(current, payload.data)); - if (action === "reject") { - setActiveTurnId(input.turnId); - } else { - setActiveTurnId(payload.data.currentTurnId); - setReplaying(true); - } - setView("journey"); - } catch (cause) { - const message = messageFrom(cause); - setError(message); + presentJourney(payload.data, payload.viewer, { + turnId: action === "reject" ? input.turnId : payload.data.currentTurnId, + }); + }, (message) => { setLiveResearch((current) => current ? { ...current, status: "error", error: message, message: "Research stopped" } : null, ); if (message.toLowerCase().includes("another tab")) void openJourney(activeJourney.id); - } finally { - setMutation(null); - } + }); } async function removeJourney(journeyId: string) { - setMutation(`delete-${journeyId}`); - setError(null); - try { + await runMutation(`delete-${journeyId}`, async () => { await api<{ id: string }>(`/api/journeys/${journeyId}`, { method: "DELETE" }); setJourneys((current) => current.filter((journey) => journey.id !== journeyId)); setCompareIds((current) => current.filter((id) => id !== journeyId)); @@ -293,17 +266,11 @@ export function WonderDriveExperience() { setActiveTurnId(null); setView("library"); } - } catch (cause) { - setError(messageFrom(cause)); - } finally { - setMutation(null); - } + }); } async function manageJourney(journeyId: string, changes: { title?: string; pinned?: boolean; hidden?: boolean }) { - setMutation(`manage-${journeyId}`); - setError(null); - try { + await runMutation(`manage-${journeyId}`, async () => { const payload = await api(`/api/journeys/${journeyId}`, { method: "PATCH", body: JSON.stringify(changes), @@ -311,42 +278,26 @@ export function WonderDriveExperience() { setViewer(payload.viewer); setJourneys((current) => upsertSummary(current, payload.data)); if (activeJourney?.id === journeyId) setActiveJourney(payload.data); - } catch (cause) { - setError(messageFrom(cause)); - } finally { - setMutation(null); - } + }); } async function snapshotJourney(journeyId: string) { - setMutation(`snapshot-${journeyId}`); - setError(null); - try { + await runMutation(`snapshot-${journeyId}`, async () => { const payload = await api(`/api/journeys/${journeyId}/snapshots`, { method: "POST", body: JSON.stringify({}), }); setNotice(`${payload.data.label}: ${payload.data.summary}`); - } catch (cause) { - setError(messageFrom(cause)); - } finally { - setMutation(null); - } + }); } async function compare() { if (compareIds.length !== 2) return; - setMutation("compare"); - setError(null); - try { + await runMutation("compare", async () => { const params = new URLSearchParams({ left: compareIds[0], right: compareIds[1] }); const payload = await api(`/api/compare?${params}`); setComparison(payload.data); - } catch (cause) { - setError(messageFrom(cause)); - } finally { - setMutation(null); - } + }); } const activeTurn = useMemo( @@ -402,10 +353,12 @@ export function WonderDriveExperience() { -
- Research first - Same selected model researches and performs · inspectable sources · durable branching graph -
+ {view !== "start" && ( +
+ Research first + Same selected model researches and performs · inspectable sources · durable branching graph +
+ )} {viewer?.mode === "chatgpt" && viewer.hasGuestUpgrade && (
@@ -432,7 +385,7 @@ export function WonderDriveExperience() { {loading ? ( ) : liveResearch ? ( - { if (liveResearch.result) { @@ -454,6 +407,7 @@ export function WonderDriveExperience() { journeyCount={journeys.length} catalog={catalog} preferences={preferences} + starters={personalizedStarters} /> ) : view === "library" ? ( { - setMutation("preferences"); - setError(null); - try { + await runMutation("preferences", async () => { const payload = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next), }); setViewer(payload.viewer); setPreferences(payload.data); - } catch (cause) { - setError(messageFrom(cause)); - } finally { - setMutation(null); - } + }); }} /> ) : activeJourney && activeTurn ? ( - view === "map" ? ( - { - setActiveTurnId(turnId); - setReplaying(false); - }} - onContinue={(turnId) => { - setActiveTurnId(turnId); - setReplaying(false); - setView("journey"); - }} - onChoose={(turnId, optionId) => void advance("choose", { turnId, optionId })} - /> - ) : replaying ? ( - setReplaying(false)} /> - ) : ( - void advance("choose", { turnId: activeTurn.id, optionId })} - onReject={(adventure, reason) => void advance("reject", { turnId: activeTurn.id, adventure, reason })} - onDelegate={() => void advance("delegate", { turnId: activeTurn.id })} - onMap={() => setView("map")} - speechRate={preferences.speechRate} - onSnapshot={() => void snapshotJourney(activeJourney.id)} - /> - ) +
+ + {view === "map" ? ( + { + setActiveTurnId(turnId); + }} + onContinue={(turnId) => { + setActiveTurnId(turnId); + setView("journey"); + }} + onChoose={(turnId, optionId) => void advance("choose", { turnId, optionId })} + /> + ) : ( + void advance("choose", { turnId: activeTurn.id, optionId })} + onReject={(adventure, reason) => void advance("reject", { turnId: activeTurn.id, adventure, reason })} + onDelegate={() => void advance("delegate", { turnId: activeTurn.id })} + speechRate={preferences.speechRate} + onSnapshot={() => void snapshotJourney(activeJourney.id)} + /> + )} +
) : ( setView("library")} /> )} -
-

One performer. One researched turn. Exactly two ways forward.

- -
+ {view !== "start" && ( +
+

One performer. One researched turn. Exactly two ways forward.

+ +
+ )} ); } @@ -557,6 +511,7 @@ function StartStage({ journeyCount, catalog, preferences, + starters, }: { onCreate: (config: { seed: string; @@ -570,16 +525,86 @@ function StartStage({ journeyCount: number; catalog: BootstrapCatalog; preferences: UserPreferences; + starters: PersonalizedStarter[]; }) { - const [seed, setSeed] = useState(STARTERS.sage[0]); + const [seed, setSeed] = useState(""); const [performerId, setPerformerId] = useState("sage"); const [modelId, setModelId] = useState("gpt-5.6-luna"); - const [preset, setPreset] = useState("standard"); - const [density, setDensity] = useState(preferences.answerDensity); - const [imagePreference, setImagePreference] = useState(preferences.imagePreference); - const [performerDetails, setPerformerDetails] = useState(false); + const performerIdRef = useRef("sage"); + const starterCache = useRef(new Map([["sage", starters]])); + const [visibleStarters, setVisibleStarters] = useState( + () => recommendationsForPerformer("sage", starters), + ); + const [startersLoading, setStartersLoading] = useState(false); const performer = catalog.performers.find((item) => item.id === performerId)!; const model = catalog.models.find((item) => item.id === modelId)!; + const placeholderQuestions = useMemo( + () => visibleStarters.slice(0, 8).map((starter) => starter.question), + [visibleStarters], + ); + const animatedPlaceholder = useQuestionPlaceholder( + placeholderQuestions, + seed.length === 0 && !preferences.reduceMotion, + ); + const normalizedSeed = seed.trim().toLowerCase(); + const autocompleteMatch = normalizedSeed.length >= 3 + ? visibleStarters.find((starter) => { + const question = starter.question.toLowerCase(); + return question.startsWith(normalizedSeed) && question !== normalizedSeed; + }) + : undefined; + const exactMatch = normalizedSeed + ? visibleStarters.find((starter) => starter.question.toLowerCase() === normalizedSeed) + : undefined; + + useEffect(() => { + starterCache.current.set("sage", starters); + if (performerId === "sage") { + // The parent hydrates history-aware suggestions after the rest of the session shell. + // eslint-disable-next-line react-hooks/set-state-in-effect + setVisibleStarters(recommendationsForPerformer("sage", starters)); + } + }, [performerId, starters]); + + async function choosePerformer(nextId: PerformerId) { + performerIdRef.current = nextId; + setPerformerId(nextId); + const cached = starterCache.current.get(nextId); + if (cached) { + setStartersLoading(false); + setVisibleStarters(recommendationsForPerformer(nextId, cached)); + return; + } + + setVisibleStarters(recommendationsForPerformer(nextId, starters)); + setStartersLoading(true); + try { + const payload = await api(`/api/starters?performer=${encodeURIComponent(nextId)}`); + starterCache.current.set(nextId, payload.data.starters); + if (performerIdRef.current === nextId) { + setVisibleStarters(recommendationsForPerformer(nextId, payload.data.starters)); + } + } catch { + // The performer-specific catalog questions are already visible as a safe fallback. + } finally { + if (performerIdRef.current === nextId) setStartersLoading(false); + } + } + + async function refreshStarterQuestions() { + setStartersLoading(true); + try { + const payload = await api( + `/api/starters?performer=${encodeURIComponent(performerId)}&refresh=1`, + ); + starterCache.current.set(performerId, payload.data.starters); + setVisibleStarters(recommendationsForPerformer(performerId, payload.data.starters)); + } catch { + // Keep the current set visible if fresh discovery is temporarily unavailable. + } finally { + setStartersLoading(false); + } + } function submit(event: FormEvent) { event.preventDefault(); @@ -588,173 +613,183 @@ function StartStage({ seed, performerId, modelId, - researchPreset: preset, - answerDensity: density, - imagePreference, + researchPreset: "standard", + answerDensity: preferences.answerDensity, + imagePreference: preferences.imagePreference, }); } } - return ( -
-
-

Live research performance

-

Give curiosity
a direction.

-

- Bring one honest question. Choose who will carry it. WonderDrive will - research the open web, perform a sourced answer, and return exactly two - next questions to you. -

-
- 01 saved to D1 - 02 inspectable sources - 03 user-directed path -
-
- -
-
- New journey / {String(journeyCount + 1).padStart(2, "0")} - ready for a question -
+ function completeQuestion(event: KeyboardEvent) { + if (event.key === "Tab" && autocompleteMatch) { + event.preventDefault(); + setSeed(autocompleteMatch.question); + } + } -
- 1 Choose a performer -
- {catalog.performers.map((item) => ( - - ))} + return ( +
+ +
+
+ {visibleStarters.length} rabbit holes + {startersLoading ? `Scanning what’s unfolding now…` : `Current signals + ${performer.name} + ${journeyCount ? "your history" : "wild-card domains"}`}
-
- -
- {performer.name}’s stage note -

“{performer.cue}”

- - {performerDetails && ( -
-

Sample opening “{performer.sampleOpening}”

-

Values {performer.values.join(" · ")}

-

Voice {performer.voiceTraits.join(" · ")}

-

Avoids {performer.avoids.join(" · ")}

-

Research posture {performer.toolPosture}

- {performer.version} -
- )}
- -
- 2 Set the research -
-
- {catalog.models.map((item) => ( - - ))} -
-
- {(Object.keys(PRESET_LABELS) as ResearchPreset[]).map((id) => ( - +
+
+
+ {[0, 1].map((copy) => ( +
+ {visibleStarters.map((starter, index) => ( + + ))} +
))}
-
- - -
-

- {PRESET_LABELS[preset].name} — {PRESET_LABELS[preset].description} {PRESET_LABELS[preset].sourceRange}; {PRESET_LABELS[preset].waitBand}; {PRESET_LABELS[preset].costBand}. -

-
- -
- 3 Bring a question -
- {catalog.starters[performerId].map((question) => ( - - ))} -
-
+ +

What are you curious about?

+
+