From 74f2e8287ae682349a1b412aee905322a82b2c0e Mon Sep 17 00:00:00 2001 From: Jignasu Pathak <63716051+Mister-JP@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:50:17 -0500 Subject: [PATCH 1/5] Refine WonderDrive research journey experience --- .github/workflows/ci.yml | 1 + .gitignore | 3 + CONTRIBUTING.md | 9 +- README.md | 7 +- app/api/bootstrap/route.ts | 14 +- app/api/compare/route.ts | 15 +- app/api/journeys/[journeyId]/advance/route.ts | 32 +- app/api/journeys/[journeyId]/route.ts | 32 +- .../journeys/[journeyId]/snapshots/route.ts | 24 +- app/api/journeys/route.ts | 23 +- app/api/preferences/route.ts | 18 +- app/api/research/[runId]/route.ts | 12 +- app/api/research/route.ts | 43 +- app/api/session/route.ts | 11 +- app/api/session/upgrade/route.ts | 19 +- app/api/starters/route.ts | 14 + app/chatgpt-auth.ts | 48 +- app/client-api.ts | 95 + app/globals.css | 496 +- app/wonderdrive-experience.tsx | 1106 ++- db/index.ts | 12 - db/schema.ts | 13 + .../wonderdrive-content-brief.excalidraw | 3726 +++++++++ .../wonderdrive-content-brief.png | Bin 0 -> 214280 bytes .../wonderdrive-content-brief.svg | 108 + .../wonderdrive-content-editorial.excalidraw | 3703 +++++++++ .../wonderdrive-content-editorial.png | Bin 0 -> 233060 bytes .../wonderdrive-content-editorial.svg | 106 + ...onderdrive-content-image-banner.excalidraw | 3866 ++++++++++ .../wonderdrive-content-image-banner.png | Bin 0 -> 214896 bytes .../wonderdrive-content-image-banner.svg | 108 + ...wonderdrive-content-image-split.excalidraw | 4479 +++++++++++ .../wonderdrive-content-image-split.png | Bin 0 -> 219737 bytes .../wonderdrive-content-image-split.svg | 136 + ...erdrive-content-template-contact-sheet.png | Bin 0 -> 462959 bytes ...derdrive-card-journey-screen-deep-dive.png | Bin 0 -> 294286 bytes ...onderdrive-card-journey-screen-loading.png | Bin 0 -> 78422 bytes .../wonderdrive-card-journey-screen-main.png | Bin 0 -> 344309 bytes ...wonderdrive-card-journey-screen.excalidraw | 6680 +++++++++++++++++ design/wonderdrive-card-journey-screen.png | Bin 0 -> 815844 bytes design/wonderdrive-card-journey-screen.svg | 197 + ...rdrive-editorial-journey-screen.excalidraw | 4189 +++++++++++ .../wonderdrive-editorial-journey-screen.png | Bin 0 -> 603796 bytes .../wonderdrive-editorial-journey-screen.svg | 124 + .../wonderdrive-playful-landing-v3.excalidraw | 3519 +++++++++ design/wonderdrive-playful-landing-v3.png | Bin 0 -> 530449 bytes design/wonderdrive-playful-landing-v3.svg | 337 + ...onderdrive-question-and-loading.excalidraw | 4714 ++++++++++++ design/wonderdrive-question-and-loading.png | Bin 0 -> 425849 bytes design/wonderdrive-question-and-loading.svg | 141 + ...-simple-personalized-landing-v4.excalidraw | 2927 ++++++++ ...erdrive-simple-personalized-landing-v4.png | Bin 0 -> 426637 bytes ...erdrive-simple-personalized-landing-v4.svg | 283 + ...rive-simple-progressive-journey.excalidraw | 5730 ++++++++++++++ ...wonderdrive-simple-progressive-journey.png | Bin 0 -> 416292 bytes ...wonderdrive-simple-progressive-journey.svg | 168 + ...ive-unified-information-loading.excalidraw | 6353 ++++++++++++++++ ...onderdrive-unified-information-loading.png | Bin 0 -> 438875 bytes ...onderdrive-unified-information-loading.svg | 182 + ...derdrive-utilitarian-landing-v2.excalidraw | 5865 +++++++++++++++ design/wonderdrive-utilitarian-landing-v2.png | Bin 0 -> 688968 bytes design/wonderdrive-utilitarian-landing-v2.svg | 573 ++ ...wonderdrive-utilitarian-landing.excalidraw | 3101 ++++++++ design/wonderdrive-utilitarian-landing.png | Bin 0 -> 449099 bytes design/wonderdrive-utilitarian-landing.svg | 311 + docs/architecture.md | 34 +- docs/code-index.md | 64 + docs/phase-1.md | 5 +- docs/phase-2.md | 6 +- drizzle/0004_confused_rage.sql | 8 + drizzle/meta/0004_snapshot.json | 2498 ++++++ drizzle/meta/_journal.json | 7 + lib/api.ts | 58 +- lib/catalog.ts | 32 +- lib/contracts.ts | 23 +- lib/errors.ts | 27 + lib/fixtures.ts | 48 +- lib/live-redraw.ts | 137 + lib/live-repository.ts | 178 +- lib/live-research.ts | 402 +- lib/openai.ts | 67 + lib/product-repository.ts | 11 +- lib/repository.ts | 294 +- lib/request.ts | 40 + lib/starter-recommendations.ts | 166 + lib/turn-options.ts | 29 + next.config.ts | 6 +- package.json | 2 + scripts/architecture-index.mjs | 78 + scripts/generate-card-journey-screen.mjs | 338 + scripts/generate-content-template-set.mjs | 102 + scripts/generate-editorial-journey-screen.mjs | 395 + scripts/generate-landing-wireframe.mjs | 167 + scripts/generate-question-loading-board.mjs | 243 + ...erate-simple-progressive-journey-board.mjs | 67 + ...rate-unified-information-loading-board.mjs | 92 + tests/live-research.test.mjs | 167 +- tests/rendered-html.test.mjs | 9 +- tsconfig.json | 2 + 99 files changed, 68007 insertions(+), 1468 deletions(-) create mode 100644 app/api/starters/route.ts create mode 100644 app/client-api.ts create mode 100644 design/content-templates/wonderdrive-content-brief.excalidraw create mode 100644 design/content-templates/wonderdrive-content-brief.png create mode 100644 design/content-templates/wonderdrive-content-brief.svg create mode 100644 design/content-templates/wonderdrive-content-editorial.excalidraw create mode 100644 design/content-templates/wonderdrive-content-editorial.png create mode 100644 design/content-templates/wonderdrive-content-editorial.svg create mode 100644 design/content-templates/wonderdrive-content-image-banner.excalidraw create mode 100644 design/content-templates/wonderdrive-content-image-banner.png create mode 100644 design/content-templates/wonderdrive-content-image-banner.svg create mode 100644 design/content-templates/wonderdrive-content-image-split.excalidraw create mode 100644 design/content-templates/wonderdrive-content-image-split.png create mode 100644 design/content-templates/wonderdrive-content-image-split.svg create mode 100644 design/content-templates/wonderdrive-content-template-contact-sheet.png create mode 100644 design/wonderdrive-card-journey-screen-deep-dive.png create mode 100644 design/wonderdrive-card-journey-screen-loading.png create mode 100644 design/wonderdrive-card-journey-screen-main.png create mode 100644 design/wonderdrive-card-journey-screen.excalidraw create mode 100644 design/wonderdrive-card-journey-screen.png create mode 100644 design/wonderdrive-card-journey-screen.svg create mode 100644 design/wonderdrive-editorial-journey-screen.excalidraw create mode 100644 design/wonderdrive-editorial-journey-screen.png create mode 100644 design/wonderdrive-editorial-journey-screen.svg create mode 100644 design/wonderdrive-playful-landing-v3.excalidraw create mode 100644 design/wonderdrive-playful-landing-v3.png create mode 100644 design/wonderdrive-playful-landing-v3.svg create mode 100644 design/wonderdrive-question-and-loading.excalidraw create mode 100644 design/wonderdrive-question-and-loading.png create mode 100644 design/wonderdrive-question-and-loading.svg create mode 100644 design/wonderdrive-simple-personalized-landing-v4.excalidraw create mode 100644 design/wonderdrive-simple-personalized-landing-v4.png create mode 100644 design/wonderdrive-simple-personalized-landing-v4.svg create mode 100644 design/wonderdrive-simple-progressive-journey.excalidraw create mode 100644 design/wonderdrive-simple-progressive-journey.png create mode 100644 design/wonderdrive-simple-progressive-journey.svg create mode 100644 design/wonderdrive-unified-information-loading.excalidraw create mode 100644 design/wonderdrive-unified-information-loading.png create mode 100644 design/wonderdrive-unified-information-loading.svg create mode 100644 design/wonderdrive-utilitarian-landing-v2.excalidraw create mode 100644 design/wonderdrive-utilitarian-landing-v2.png create mode 100644 design/wonderdrive-utilitarian-landing-v2.svg create mode 100644 design/wonderdrive-utilitarian-landing.excalidraw create mode 100644 design/wonderdrive-utilitarian-landing.png create mode 100644 design/wonderdrive-utilitarian-landing.svg create mode 100644 docs/code-index.md create mode 100644 drizzle/0004_confused_rage.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 lib/errors.ts create mode 100644 lib/live-redraw.ts create mode 100644 lib/openai.ts create mode 100644 lib/request.ts create mode 100644 lib/starter-recommendations.ts create mode 100644 lib/turn-options.ts create mode 100644 scripts/architecture-index.mjs create mode 100644 scripts/generate-card-journey-screen.mjs create mode 100644 scripts/generate-content-template-set.mjs create mode 100644 scripts/generate-editorial-journey-screen.mjs create mode 100644 scripts/generate-landing-wireframe.mjs create mode 100644 scripts/generate-question-loading-board.mjs create mode 100644 scripts/generate-simple-progressive-journey-board.mjs create mode 100644 scripts/generate-unified-information-loading-board.mjs 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..1d984a5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Apply all SQL files in `drizzle/` to a fresh local D1 database before exercising ## 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,6 +74,7 @@ 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 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..d22d362 100644 --- a/app/api/journeys/[journeyId]/advance/route.ts +++ b/app/api/journeys/[journeyId]/advance/route.ts @@ -1,18 +1,28 @@ -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, + 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..efce854 --- /dev/null +++ b/app/api/starters/route.ts @@ -0,0 +1,14 @@ +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 requested = new URL(request.url).searchParams.get("performer"); + const performerId = PERFORMERS.some((performer) => performer.id === requested) + ? requested as PerformerId + : "sage"; + return query(async (viewer) => ({ starters: await getPersonalizedStarters(viewer, performerId) })); +} 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..6ab2aa1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -92,11 +92,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 +127,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 +146,277 @@ 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: baseline; + 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 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; +} +.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: 70px; } +.buffering-header { min-height: 225px; } +.buffering-header h1 { font-size: clamp(2.9rem, 4.5vw, 5.4rem); max-width: 960px; } +.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: 8px 8px 0 rgba(19, 36, 30, .055); margin: 32px auto 0; max-width: 1180px; min-height: 390px; padding: 24px 30px 18px; 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: 13px; grid-template-columns: auto 1fr auto; padding: 0 4px 18px; } +.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: 44px; grid-template-columns: 1.35fr .65fr; padding: 30px 4px 22px; } +.buffering-copy { display: flex; flex-direction: column; gap: 13px; } +.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: 24px; margin-bottom: 8px; width: 58%; } +.skeleton-line.long { width: 96%; } +.skeleton-line.medium { width: 84%; } +.skeleton-line.short { width: 65%; } +.skeleton-tags { display: flex; gap: 9px; margin-top: 11px; } +.skeleton-tags i { background: color-mix(in srgb, var(--line) 70%, transparent); border-radius: 999px; height: 27px; width: 112px; } +.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: 228px; overflow: hidden; padding: 174px 16px 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: 42px; padding: 0 16px; } +.buffering-evidence span { width: 220px; } +.buffering-evidence i { width: 150px; } +.buffering-directions { margin: 34px 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.55rem; margin: 8px 0 15px; } +.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: 70px; } +.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 +443,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 +496,105 @@ 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: 70px; } +.article-journey-header { min-height: 178px; padding: 28px 0 22px; } +.article-journey-header h1 { font-size: clamp(2.25rem, 3.45vw, 4.15rem); line-height: .96; max-width: 1180px; } +.contained-answer-card { background: var(--paper-light); border: 1px solid var(--line-strong); box-shadow: 9px 9px 0 rgba(19, 36, 30, .055); margin: 26px auto 0; max-width: 1320px; padding: 22px 26px 18px 31px; 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: 60px; padding: 0 3px 18px; } +.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: 34px; grid-template-columns: minmax(0, 1.32fr) minmax(350px, .68fr); padding: 27px 3px 22px; } +.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: .56rem; font-weight: 700; letter-spacing: .11em; margin: 0 0 12px; text-transform: uppercase; } +.contained-answer-summary h2 { font-family: var(--font-display); font-size: clamp(1.45rem, 2vw, 2.2rem); font-weight: 500; letter-spacing: -.03em; line-height: 1.04; margin: 0 0 14px; text-transform: capitalize; } +.short-answer-copy { font-family: var(--font-display); font-size: clamp(.92rem, 1.15vw, 1.07rem); line-height: 1.52; margin: 0; } +.answer-tags { display: flex; flex-wrap: wrap; gap: 7px; margin: 20px 0 23px; } +.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: 7px; } +.contained-answer-media > a { display: block; overflow: hidden; } +.contained-answer-media img { aspect-ratio: 16 / 9; display: block; object-fit: cover; transition: transform 450ms ease; width: 100%; } +.contained-answer-media:hover img { transform: scale(1.018); } +.contained-answer-media figcaption { align-items: center; display: flex; font-size: .5rem; gap: 12px; justify-content: space-between; padding: 8px 3px 2px; } +.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; } +.evidence-research-row { align-items: center; background: transparent; border: 1px solid var(--line); display: grid; gap: 20px; grid-template-columns: 1fr auto auto; min-height: 48px; padding: 8px 10px 8px 15px; 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: .54rem; font-weight: 700; min-width: 110px; padding: 8px 10px; text-align: center; text-transform: uppercase; } +.evidence-research-row:hover .deep-dive-cta { background: var(--coral); color: var(--ink); } + +.journey-directions { margin: 34px 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.5rem, 2vw, 2.1rem); font-weight: 500; letter-spacing: -.025em; margin: 7px 0 14px; } +.journey-path-grid { display: grid; gap: 20px; grid-template-columns: 1fr 1fr; } +.journey-path-card { align-items: center; border: 1px solid var(--ink); display: grid; gap: 7px 14px; grid-template-columns: 1fr auto; min-height: 94px; padding: 16px 19px; 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(1rem, 1.4vw, 1.28rem); 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: 13px 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; } @@ -366,13 +713,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; } @@ -404,6 +746,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; } @@ -431,17 +774,36 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius .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: flex-start; flex-direction: column; gap: 2px; margin-left: calc(50% - 50vw); padding-inline: 16px; } + .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, .map-layout { grid-template-columns: 1fr; } .performance-header { align-items: start; min-height: 0; padding-top: 55px; } + .article-journey-header { padding-top: 45px; } + .buffering-status { width: 100%; } + .buffering-content-grid { grid-template-columns: 1fr; } + .buffering-directions > div { grid-template-columns: 1fr; } + .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; } @@ -461,13 +823,35 @@ legend > span { align-items: center; border: 1px solid var(--ink); border-radius } @media (max-width: 500px) { + .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; } + .article-journey-header h1 { font-size: 2.3rem; line-height: .98; } + .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: 24px; padding-top: 22px; } + .journey-path-grid { grid-template-columns: 1fr; } + .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 { padding-inline: 20px; } + .buffering-content-grid { gap: 24px; } .direction-panel { margin-inline: -16px; } .answer-byline { grid-template-columns: auto 1fr; } .ready-stamp { display: none; } @@ -481,4 +865,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..c13b9d8 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,14 +53,11 @@ 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 }> = [ @@ -76,7 +75,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 +84,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 +99,9 @@ export function WonderDriveExperience() { setJourneys(session.data.journeys); setCatalog(bootstrap.data.catalog); setPreferences(bootstrap.data.preferences); + void api("/api/starters?performer=sage") + .then((payload) => setPersonalizedStarters(payload.data.starters)) + .catch(() => setPersonalizedStarters(bootstrap.data.catalog.discoveryStarters)); } catch (cause) { setError(messageFrom(cause)); } finally { @@ -105,6 +109,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 +147,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,18 +161,16 @@ export function WonderDriveExperience() { answerDensity: AnswerDensity; imagePreference: ImagePreference; }) { - setMutation("create"); - setError(null); - try { + await runMutation("create", async () => { if (config.modelId === "gpt-5.6-luna") { setView("journey"); setLiveResearch({ question: config.seed, + performerId: config.performerId, message: "Connecting to live foreground research…", events: [], status: "running", result: null, - interlude: null, error: null, }); const complete = await streamLiveResearch( @@ -159,16 +182,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("/api/journeys", { @@ -178,21 +197,12 @@ export function WonderDriveExperience() { idempotencyKey: crypto.randomUUID(), }), }); - setViewer(payload.viewer); - setActiveJourney(payload.data); - setActiveTurnId(payload.data.currentTurnId); - setJourneys((current) => upsertSummary(current, payload.data)); - setView("journey"); - setReplaying(true); - } catch (cause) { - const message = messageFrom(cause); - setError(message); + presentJourney(payload.data, payload.viewer); + }, (message) => { setLiveResearch((current) => current ? { ...current, status: "error", error: message, message: "Research stopped" } : null, ); - } finally { - setMutation(null); - } + }); } async function advance( @@ -200,9 +210,7 @@ export function WonderDriveExperience() { input: { turnId: string; optionId?: string; adventure?: number; reason?: string }, ) { if (!activeJourney) return; - setMutation(action); - setError(null); - try { + await runMutation(action, async () => { if (activeJourney.modelId === "gpt-5.6-luna" && action !== "reject") { const fromTurn = activeJourney.turns.find((turn) => turn.id === input.turnId); const selected = @@ -213,11 +221,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 +240,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 +263,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 +284,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 +296,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 +371,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 +403,7 @@ export function WonderDriveExperience() { {loading ? ( ) : liveResearch ? ( - { if (liveResearch.result) { @@ -454,6 +425,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 ? ( @@ -512,17 +478,13 @@ export function WonderDriveExperience() { activeTurnId={activeTurn.id} onSelect={(turnId) => { setActiveTurnId(turnId); - setReplaying(false); }} onContinue={(turnId) => { setActiveTurnId(turnId); - setReplaying(false); setView("journey"); }} onChoose={(turnId, optionId) => void advance("choose", { turnId, optionId })} /> - ) : replaying ? ( - setReplaying(false)} /> ) : ( setView("library")} /> )} -
-

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

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

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

+ +
+ )} ); } @@ -554,9 +518,9 @@ export function WonderDriveExperience() { function StartStage({ onCreate, creating, - journeyCount, catalog, preferences, + starters, }: { onCreate: (config: { seed: string; @@ -570,16 +534,71 @@ 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); + } + } function submit(event: FormEvent) { event.preventDefault(); @@ -588,173 +607,178 @@ 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 -
- -
- 1 Choose a performer -
- {catalog.performers.map((item) => ( - - ))} -
-
+ function completeQuestion(event: KeyboardEvent) { + if (event.key === "Tab" && autocompleteMatch) { + event.preventDefault(); + setSeed(autocompleteMatch.question); + } + } -
- {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} -
- )} + return ( +
+ +
+ {visibleStarters.length} questions for you + {startersLoading ? `Refreshing in ${performer.name}’s style…` : `Shaped by ${performer.name} and your question history`}
- -
- 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?

+
+