From a95f7c1d18e2d3d1a8d3f42981b6f88b22cbb0d1 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 7 Aug 2026 19:50:38 -0700 Subject: [PATCH 1/5] Harden changed-file lint workflow --- .forgejo/workflows/build.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 7abe151..3be9b0c 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -11,6 +11,9 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v4 + with: + # The changed-file lint step compares the pushed commit with its parent. + fetch-depth: 2 - name: Log into Local Registry run: | @@ -33,8 +36,13 @@ jobs: - name: Lint changed source files run: | - FILES=$(git diff --name-only HEAD^ HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs') - if [ -n "$FILES" ]; then npx eslint $FILES; fi + if BASE=$(git rev-parse HEAD^ 2>/dev/null); then + git diff --name-only -z "$BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs' | + xargs -0 -r npx eslint + else + git ls-files -z -- '*.ts' '*.tsx' '*.js' '*.mjs' | + xargs -0 -r npx eslint + fi - name: Build and smoke production image run: | From 931e6748145d23363419841df77c7d4312cc15a7 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 7 Aug 2026 19:59:02 -0700 Subject: [PATCH 2/5] Fixed CL failure --- .forgejo/workflows/build.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 3be9b0c..839b7b8 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -49,16 +49,22 @@ jobs: IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') IMAGE_SHA="$IMAGE_PATH:${{ github.sha }}" CI_SECRET=$(openssl rand -hex 32) + SMOKE_CONTAINER="study-smoke-$(openssl rand -hex 8)" + cleanup_smoke() { + docker rm -f "$SMOKE_CONTAINER" > /dev/null 2>&1 || true + } + trap cleanup_smoke EXIT docker build -t "$IMAGE_SHA" . - docker run -d --name study-smoke -p 3000:3726 -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA" + docker run -d --name "$SMOKE_CONTAINER" -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA" for attempt in $(seq 1 30); do - if [ "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy" ]; then break; fi + if [ "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy" ]; then break; fi sleep 2 done - test "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy" - curl --fail http://127.0.0.1:3000/login > /dev/null - curl --fail http://127.0.0.1:3000/api/auth/setup-status | grep '"setupRequired":true' - docker rm -f study-smoke + test "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy" + docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/login').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))" + docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/api/auth/setup-status').then(async r => { const body = await r.json(); if (!r.ok || body.setupRequired !== true) process.exit(1) }).catch(() => process.exit(1))" + cleanup_smoke + trap - EXIT docker tag "$IMAGE_SHA" "$IMAGE_PATH:latest" docker push "$IMAGE_SHA" docker push "$IMAGE_PATH:latest" From 5db7fc8afd2004ed6d91649601ef1e17d4fcd249 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 8 Aug 2026 18:17:17 -0700 Subject: [PATCH 3/5] Harden migration preflight and startup adoption checks --- README.md | 8 +- docker-entrypoint.sh | 1 + scripts/migration-preflight.mjs | 146 ++++++++++++++++++++----------- tests/migrationPreflight.test.ts | 127 ++++++++++++++++++++++++++- 4 files changed, 228 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 17eac58..459a4a2 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,18 @@ The material-group preflight reports one of four states: - `APPLY`: migration history is complete and the group schema is absent; run `prisma migrate deploy` normally. - `ADOPT`: prior migrations are tracked and the database exactly matches the - intended group schema. After reviewing the verified backup, explicitly run + intended schema of one or more unrecorded committed migrations. This includes + recovery from P3018 when an already schema-pushed table or column exists. + After reviewing the verified backup, explicitly run `npm run db:preflight -- --resolve --backup `. - `CURRENT`: migration and schema already agree. - `CONFLICT`: partial or unknown state. Stop and recover manually; do not use `db push`, reset, edit applied migrations, or mark anything applied. +The production entrypoint runs this preflight before `prisma migrate deploy`. +It refuses `ADOPT` and `CONFLICT` states without changing migration history; +adoption always remains an explicit backup-gated operator action. + Restore drill: stop Study Desk, keep the damaged database as evidence, restore the verified backup to a new path, run SQLite `integrity_check` plus `npm run db:preflight`, start the same prior application image against the diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0678c2c..dcba759 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -8,5 +8,6 @@ if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SE exit 1 fi +node scripts/migration-preflight.mjs --startup ./node_modules/.bin/prisma migrate deploy exec node server.js diff --git a/scripts/migration-preflight.mjs b/scripts/migration-preflight.mjs index a058181..4a762e8 100644 --- a/scripts/migration-preflight.mjs +++ b/scripts/migration-preflight.mjs @@ -38,10 +38,14 @@ function hasUniqueIndex(database, table, indexName, column) { return columns.length === 1 && columns[0].name === column; } -function schemaSnapshot(database) { +function schemaSnapshot(database, includeMigrationTable = false) { const tables = database .prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations' ORDER BY name" + `SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ${includeMigrationTable ? "" : "AND name <> '_prisma_migrations'"} + ORDER BY name` ) .all() .map((row) => row.name); @@ -113,14 +117,20 @@ export function inspectMigrationState(databasePath) { } const database = new Database(databasePath, { readonly: true, fileMustExist: true }); try { - const migrationNames = tableExists(database, "_prisma_migrations") + const migrationRecords = tableExists(database, "_prisma_migrations") ? database .prepare( - 'SELECT "migration_name" FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL' + 'SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations"' ) .all() - .map((row) => row.migration_name) : []; + const activeRecords = migrationRecords.filter((row) => row.rolled_back_at === null); + const migrationNames = activeRecords + .filter((row) => row.finished_at !== null) + .map((row) => row.migration_name); + const failedMigrationNames = activeRecords + .filter((row) => row.finished_at === null) + .map((row) => row.migration_name); const committedMigrations = readdirSync( path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true } @@ -131,9 +141,7 @@ export function inspectMigrationState(databasePath) { const priorMigrations = committedMigrations.filter( (name) => name < GROUP_MIGRATION ); - const laterMigrations = committedMigrations.filter( - (name) => name > GROUP_MIGRATION - ); + const groupMigrationIndex = committedMigrations.indexOf(GROUP_MIGRATION); const artifacts = { materialGroup: tableExists(database, "MaterialGroup"), @@ -168,19 +176,12 @@ export function inspectMigrationState(databasePath) { "groupId" ), }; - const values = Object.values(artifacts); - const groupArtifactsPresent = values.every(Boolean); - const groupArtifactsAbsent = values.every((value) => !value); - const groupMigrationApplied = migrationNames.includes(GROUP_MIGRATION); const priorHistoryComplete = priorMigrations.every((name) => migrationNames.includes(name) ); const knownMigrationNames = new Set(committedMigrations); - const historyHasUnknown = migrationNames.some( - (name) => !knownMigrationNames.has(name) - ); - const laterHistoryPresent = laterMigrations.some((name) => - migrationNames.includes(name) + const historyHasUnknown = activeRecords.some( + (row) => !knownMigrationNames.has(row.migration_name) ); const actualSchema = schemaSnapshot(database); const matches = (expected) => @@ -189,42 +190,66 @@ export function inspectMigrationState(databasePath) { const adoptedGroupSchemaExact = matches( expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION]) ); - const appliedPrefix = committedMigrations.filter((name, index) => - committedMigrations.slice(0, index + 1).every((candidate) => - migrationNames.includes(candidate) - ) - ); - const appliedHistoryExact = + let historyPrefixLength = 0; + while ( + historyPrefixLength < committedMigrations.length && + migrationNames.includes(committedMigrations[historyPrefixLength]) + ) { + historyPrefixLength += 1; + } + const historyIsExactPrefix = !historyHasUnknown && - migrationNames.length === appliedPrefix.length && - migrationNames.every((name) => appliedPrefix.includes(name)); + migrationNames.length === historyPrefixLength && + migrationNames.every((name) => + committedMigrations.slice(0, historyPrefixLength).includes(name) + ); const trackedSchemaExact = - appliedHistoryExact && matches(expectedSchemaSnapshot(appliedPrefix)); + historyIsExactPrefix && + matches(expectedSchemaSnapshot(committedMigrations.slice(0, historyPrefixLength))); + let schemaPrefixLength = -1; + for (let index = 0; index <= committedMigrations.length; index += 1) { + if (matches(expectedSchemaSnapshot(committedMigrations.slice(0, index)))) { + schemaPrefixLength = index; + break; + } + } + const migrationsToAdopt = + schemaPrefixLength > historyPrefixLength + ? committedMigrations.slice(historyPrefixLength, schemaPrefixLength) + : []; + const failuresAreAdoptable = failedMigrationNames.every((name) => + migrationsToAdopt.includes(name) + ); let classification = "CONFLICT"; - if (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT"; - else if ( - !groupMigrationApplied && - !laterHistoryPresent && + if ( + historyIsExactPrefix && + failedMigrationNames.length === 0 && + schemaPrefixLength === historyPrefixLength + ) { + classification = + historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY"; + } else if ( + historyIsExactPrefix && priorHistoryComplete && - migrationNames.length === priorMigrations.length && - groupArtifactsAbsent && - preGroupSchemaExact - ) classification = "APPLY"; - else if ( - !groupMigrationApplied && - !laterHistoryPresent && - priorHistoryComplete && - migrationNames.length === priorMigrations.length && - groupArtifactsPresent && - adoptedGroupSchemaExact - ) classification = "ADOPT"; + historyPrefixLength >= groupMigrationIndex && + schemaPrefixLength > historyPrefixLength && + failuresAreAdoptable + ) { + classification = "ADOPT"; + } return { classification, artifacts, migrationNames, + failedMigrationNames, + migrationsToAdopt, priorHistoryComplete, + schemaMigrationPrefix: + schemaPrefixLength >= 0 + ? committedMigrations[schemaPrefixLength - 1] ?? null + : null, exactSchema: { preGroup: preGroupSchemaExact, adoptedGroup: adoptedGroupSchemaExact, @@ -249,17 +274,20 @@ function verifyBackup(backupPath, sourcePath) { if (backup.pragma("integrity_check", { simple: true }) !== "ok") { throw new Error("Backup failed SQLite integrity_check"); } - if (JSON.stringify(schemaSnapshot(backup)) !== JSON.stringify(schemaSnapshot(source))) { + if ( + JSON.stringify(schemaSnapshot(backup, true)) !== + JSON.stringify(schemaSnapshot(source, true)) + ) { throw new Error("Backup schema does not match the source database"); } const sourceCounts = Object.fromEntries( - schemaSnapshot(source).map(({ table }) => [ + schemaSnapshot(source, true).map(({ table }) => [ table, source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, ]) ); const backupCounts = Object.fromEntries( - schemaSnapshot(backup).map(({ table }) => [ + schemaSnapshot(backup, true).map(({ table }) => [ table, backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, ]) @@ -273,11 +301,11 @@ function verifyBackup(backupPath, sourcePath) { } } -function resolveMigration(databaseUrl) { +function resolveMigration(databaseUrl, migrationName) { const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js"); const result = spawnSync( process.execPath, - [cli, "migrate", "resolve", "--applied", GROUP_MIGRATION], + [cli, "migrate", "resolve", "--applied", migrationName], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: databaseUrl }, @@ -292,7 +320,16 @@ function resolveMigration(databaseUrl) { if (import.meta.url === pathToFileURL(process.argv[1]).href) { const databaseUrl = process.env.DATABASE_URL; const databasePath = databasePathFromUrl(databaseUrl); - const state = inspectMigrationState(databasePath); + const startup = process.argv.includes("--startup"); + const state = + startup && !existsSync(databasePath) + ? { + classification: "EMPTY", + migrationNames: [], + failedMigrationNames: [], + migrationsToAdopt: [], + } + : inspectMigrationState(databasePath); console.info(JSON.stringify(state, null, 2)); if (process.argv.includes("--resolve")) { @@ -304,8 +341,17 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined, databasePath ); - resolveMigration(databaseUrl); - console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`); + for (const migrationName of state.migrationsToAdopt) { + resolveMigration(databaseUrl, migrationName); + } + console.info( + `Marked ${state.migrationsToAdopt.join(", ")} applied after exact-schema preflight.` + ); + } else if (startup && state.classification === "ADOPT") { + console.error( + "Database schema requires explicit migration adoption. Stop, create and verify a backup, then run db:preflight -- --resolve --backup ." + ); + process.exitCode = 3; } else if (state.classification === "CONFLICT") { process.exitCode = 2; } diff --git a/tests/migrationPreflight.test.ts b/tests/migrationPreflight.test.ts index f41f4cc..ddd768e 100644 --- a/tests/migrationPreflight.test.ts +++ b/tests/migrationPreflight.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { copyFileSync, readFileSync } from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; import { afterEach, describe, expect, it } from "vitest"; @@ -29,10 +29,10 @@ function create(options: { excludeGroup?: boolean } = {}) { return database; } -function inspect(database: DisposableDatabase) { +function inspect(database: DisposableDatabase, args: string[] = []) { const result = spawnSync( process.execPath, - [path.join(process.cwd(), "scripts", "migration-preflight.mjs")], + [path.join(process.cwd(), "scripts", "migration-preflight.mjs"), ...args], { cwd: process.cwd(), env: { ...process.env, DATABASE_URL: database.databaseUrl }, @@ -42,7 +42,29 @@ function inspect(database: DisposableDatabase) { return { status: result.status, output: JSON.parse(result.stdout) }; } +function migrationSql(migrationName: string) { + return readFileSync( + path.join( + process.cwd(), + "prisma", + "migrations", + migrationName, + "migration.sql" + ), + "utf8" + ); +} + describe("material group migration preflight", () => { + it("allows startup when a fresh database does not exist yet", () => { + const database = createDisposableDatabase(); + databases.push(database); + expect(inspect(database, ["--startup"])).toMatchObject({ + status: 0, + output: { classification: "EMPTY", migrationsToAdopt: [] }, + }); + }); + it("preserves populated pre-group rows and relationships", () => { const database = create({ excludeGroup: true }); const client = new Database(database.databasePath); @@ -93,6 +115,105 @@ describe("material group migration preflight", () => { }); }); + it("blocks startup and explicitly adopts a fully schema-pushed database after P3018", () => { + const database = create({ excludeGroup: true }); + const missingMigrations = [ + GROUP_MIGRATION, + "20260807091000_add_quiz_attempt_snapshot", + "20260807092000_add_progress_revisions", + ]; + const client = new Database(database.databasePath); + try { + for (const migrationName of missingMigrations) { + client.exec(migrationSql(migrationName)); + } + client.prepare(` + INSERT INTO "_prisma_migrations" + ("id", "checksum", "finished_at", "migration_name", "logs", "applied_steps_count") + VALUES (?, ?, NULL, ?, ?, 0) + `).run("failed-group-migration", "failed", GROUP_MIGRATION, "P3018"); + } finally { + client.close(); + } + + const startup = inspect(database, ["--startup"]); + expect(startup).toMatchObject({ + status: 3, + output: { + classification: "ADOPT", + migrationsToAdopt: missingMigrations, + }, + }); + + const backupPath = path.join(database.directory, "adoption-backup.test.db"); + copyFileSync(database.databasePath, backupPath); + const resolve = spawnSync( + process.execPath, + [ + path.join(process.cwd(), "scripts", "migration-preflight.mjs"), + "--resolve", + "--backup", + backupPath, + ], + { + cwd: process.cwd(), + env: { ...process.env, DATABASE_URL: database.databaseUrl }, + encoding: "utf8", + } + ); + expect(resolve.status, resolve.stderr).toBe(0); + const deploy = spawnSync( + process.execPath, + [ + path.join(process.cwd(), "node_modules", "prisma", "build", "index.js"), + "migrate", + "deploy", + ], + { + cwd: process.cwd(), + env: { ...process.env, DATABASE_URL: database.databaseUrl }, + encoding: "utf8", + } + ); + expect(deploy.status, `${deploy.stdout}\n${deploy.stderr}`).toBe(0); + expect(inspect(database)).toMatchObject({ + status: 0, + output: { classification: "CURRENT", migrationsToAdopt: [] }, + }); + }, 30_000); + + it("can resume exact adoption after some missing migrations were recorded", () => { + const database = create({ excludeGroup: true }); + const client = new Database(database.databasePath); + try { + for (const migrationName of [ + GROUP_MIGRATION, + "20260807091000_add_quiz_attempt_snapshot", + "20260807092000_add_progress_revisions", + ]) { + client.exec(migrationSql(migrationName)); + } + client.prepare(` + INSERT INTO "_prisma_migrations" + ("id", "checksum", "finished_at", "migration_name", "applied_steps_count") + VALUES (?, ?, current_timestamp, ?, 1) + `).run("adopted-group-migration", "adopted", GROUP_MIGRATION); + } finally { + client.close(); + } + + expect(inspect(database, ["--startup"])).toMatchObject({ + status: 3, + output: { + classification: "ADOPT", + migrationsToAdopt: [ + "20260807091000_add_quiz_attempt_snapshot", + "20260807092000_add_progress_revisions", + ], + }, + }); + }); + it("rejects a partial schema without modifying it", () => { const database = create(); const client = new Database(database.databasePath); From e8dffc9692999efdc87fa71a371a212f3b5af6a8 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sat, 8 Aug 2026 18:50:16 -0700 Subject: [PATCH 4/5] Remove obsolete project artifacts --- benchmarks/remediationBenchmark.test.ts | 6 +- .../migration.sql | 10 + prisma/schema.prisma | 43 +- .../arcade/connections/[packId]/play/page.tsx | 19 - .../[classSlug]/arcade/connections/page.tsx | 12 - .../arcade/crossword/[packId]/play/page.tsx | 21 - .../[classSlug]/arcade/crossword/page.tsx | 12 - .../(protected)/[classSlug]/arcade/layout.tsx | 3 - .../(protected)/[classSlug]/arcade/page.tsx | 46 - src/app/api/arcade/import/preview/route.ts | 19 - .../api/arcade/packs/[id]/attempts/route.ts | 42 - src/app/api/arcade/packs/[id]/layout/route.ts | 23 - src/app/api/arcade/packs/[id]/route.ts | 38 - src/app/api/arcade/packs/route.ts | 34 - .../api/settings/llm-instructions/route.ts | 2 +- src/app/globals.css | 531 ----- src/components/activity/ActivityBanner.tsx | 5 +- src/components/arcade/ArcadeGameShell.tsx | 38 - src/components/arcade/ArcadeImportModal.tsx | 127 -- src/components/arcade/ConnectionsGame.tsx | 237 --- src/components/arcade/ConnectionsHub.tsx | 123 -- src/components/arcade/CrosswordGame.tsx | 269 --- src/components/arcade/CrosswordHub.tsx | 152 -- src/components/arcade/rendererRegistry.ts | 7 - src/components/import/GenerateTab.tsx | 28 +- src/config/arcadeGames.ts | 34 - src/config/studyModes.ts | 1 - src/generated/prisma/browser.ts | 10 - src/generated/prisma/client.ts | 10 - src/generated/prisma/internal/class.ts | 36 +- .../prisma/internal/prismaNamespace.ts | 281 +-- .../prisma/internal/prismaNamespaceBrowser.ts | 39 - src/generated/prisma/models.ts | 2 - src/generated/prisma/models/ArcadeAttempt.ts | 1696 ---------------- src/generated/prisma/models/ArcadePack.ts | 1802 ----------------- src/generated/prisma/models/Class.ts | 138 -- src/lib/arcade/arcadeImport.ts | 5 - src/lib/arcade/connectionsEngine.test.ts | 63 - src/lib/arcade/connectionsEngine.ts | 110 - src/lib/arcade/connectionsImport.test.ts | 74 - src/lib/arcade/connectionsImport.ts | 168 -- src/lib/arcade/crosswordAttempt.test.ts | 34 - src/lib/arcade/crosswordAttempt.ts | 116 -- src/lib/arcade/crosswordEngine.test.ts | 26 - src/lib/arcade/crosswordEngine.ts | 266 --- src/lib/arcade/crosswordImport.test.ts | 47 - src/lib/arcade/crosswordImport.ts | 126 -- src/lib/arcade/crosswordTestData.ts | 22 - src/lib/arcade/registry.ts | 60 - src/lib/validation/activitySchemas.test.ts | 14 + src/lib/validation/activitySchemas.ts | 2 +- src/lib/validation/arcadeSchemas.ts | 107 - src/services/activityService.test.ts | 12 + src/services/activityService.ts | 7 +- src/services/arcadeService.ts | 228 --- src/services/settingsService.ts | 7 +- src/types/arcade.ts | 249 --- tests/arcadeRemovalMigration.test.ts | 98 + 58 files changed, 174 insertions(+), 7563 deletions(-) create mode 100644 prisma/migrations/20260808100000_remove_arcade/migration.sql delete mode 100644 src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx delete mode 100644 src/app/(protected)/[classSlug]/arcade/connections/page.tsx delete mode 100644 src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx delete mode 100644 src/app/(protected)/[classSlug]/arcade/crossword/page.tsx delete mode 100644 src/app/(protected)/[classSlug]/arcade/layout.tsx delete mode 100644 src/app/(protected)/[classSlug]/arcade/page.tsx delete mode 100644 src/app/api/arcade/import/preview/route.ts delete mode 100644 src/app/api/arcade/packs/[id]/attempts/route.ts delete mode 100644 src/app/api/arcade/packs/[id]/layout/route.ts delete mode 100644 src/app/api/arcade/packs/[id]/route.ts delete mode 100644 src/app/api/arcade/packs/route.ts delete mode 100644 src/components/arcade/ArcadeGameShell.tsx delete mode 100644 src/components/arcade/ArcadeImportModal.tsx delete mode 100644 src/components/arcade/ConnectionsGame.tsx delete mode 100644 src/components/arcade/ConnectionsHub.tsx delete mode 100644 src/components/arcade/CrosswordGame.tsx delete mode 100644 src/components/arcade/CrosswordHub.tsx delete mode 100644 src/components/arcade/rendererRegistry.ts delete mode 100644 src/config/arcadeGames.ts delete mode 100644 src/generated/prisma/models/ArcadeAttempt.ts delete mode 100644 src/generated/prisma/models/ArcadePack.ts delete mode 100644 src/lib/arcade/arcadeImport.ts delete mode 100644 src/lib/arcade/connectionsEngine.test.ts delete mode 100644 src/lib/arcade/connectionsEngine.ts delete mode 100644 src/lib/arcade/connectionsImport.test.ts delete mode 100644 src/lib/arcade/connectionsImport.ts delete mode 100644 src/lib/arcade/crosswordAttempt.test.ts delete mode 100644 src/lib/arcade/crosswordAttempt.ts delete mode 100644 src/lib/arcade/crosswordEngine.test.ts delete mode 100644 src/lib/arcade/crosswordEngine.ts delete mode 100644 src/lib/arcade/crosswordImport.test.ts delete mode 100644 src/lib/arcade/crosswordImport.ts delete mode 100644 src/lib/arcade/crosswordTestData.ts delete mode 100644 src/lib/arcade/registry.ts create mode 100644 src/lib/validation/activitySchemas.test.ts delete mode 100644 src/lib/validation/arcadeSchemas.ts delete mode 100644 src/services/arcadeService.ts delete mode 100644 src/types/arcade.ts create mode 100644 tests/arcadeRemovalMigration.test.ts diff --git a/benchmarks/remediationBenchmark.test.ts b/benchmarks/remediationBenchmark.test.ts index 25eb390..73657c3 100644 --- a/benchmarks/remediationBenchmark.test.ts +++ b/benchmarks/remediationBenchmark.test.ts @@ -56,11 +56,7 @@ function seedRealApplicationSchema() { for (let index = 0; index < ACTIVITY_ROWS; index += 1) { const ageDays = index % 730; - const type = index % 3 === 0 - ? "FLASHCARD" - : index % 3 === 1 - ? "QUIZ_QUESTION" - : "ARCADE_GROUP"; + const type = index % 2 === 0 ? "FLASHCARD" : "QUIZ_QUESTION"; insertActivity.run( `activity-${index}`, type, diff --git a/prisma/migrations/20260808100000_remove_arcade/migration.sql b/prisma/migrations/20260808100000_remove_arcade/migration.sql new file mode 100644 index 0000000..35d0d98 --- /dev/null +++ b/prisma/migrations/20260808100000_remove_arcade/migration.sql @@ -0,0 +1,10 @@ +-- Remove Arcade-generated activity and saved instruction settings. +DELETE FROM "StudyActivity" +WHERE "type" IN ('ARCADE_GROUP', 'ARCADE_WORD'); + +DELETE FROM "Setting" +WHERE "key" IN ('llmInstructionsConnections', 'llmInstructionsCrossword'); + +-- Drop the dependent table before its parent while SQLite foreign keys are enabled. +DROP TABLE IF EXISTS "ArcadeAttempt"; +DROP TABLE IF EXISTS "ArcadePack"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45d61ec..5d7bfda 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,7 +18,6 @@ model Class { quizSets QuizSet[] materialGroups MaterialGroup[] spacedRepetitionSets SpacedRepetitionSet[] - arcadePacks ArcadePack[] } model Deck { @@ -150,52 +149,12 @@ model Setting { model StudyActivity { id String @id @default(uuid()) - type String // "FLASHCARD" | "QUIZ_QUESTION" | "ARCADE_GROUP" + type String // "FLASHCARD" | "QUIZ_QUESTION" occurredAt DateTime @default(now()) @@index([occurredAt]) } -model ArcadePack { - id String @id @default(uuid()) - classId String - gameType String - name String - description String? - schemaVersion Int - sourceJson String - normalizedJson String - validationReportJson String - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) - attempts ArcadeAttempt[] - - @@index([classId, gameType, sortOrder]) -} - -model ArcadeAttempt { - id String @id @default(uuid()) - arcadePackId String - mode String - score Int - maxScore Int - accuracy Float - durationSeconds Int - mistakes Int - hintsUsed Int - settingsJson String - resultsJson String - seed String - completedAt DateTime @default(now()) - - arcadePack ArcadePack @relation(fields: [arcadePackId], references: [id], onDelete: Cascade) - - @@index([arcadePackId, completedAt]) -} - model MaterialGroup { id String @id @default(uuid()) classId String diff --git a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx deleted file mode 100644 index 423ee89..0000000 --- a/src/app/(protected)/[classSlug]/arcade/connections/[packId]/play/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { notFound } from "next/navigation"; -import { randomUUID } from "node:crypto"; -import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry"; -import { getArcadePack } from "@/services/arcadeService"; - -export default async function ConnectionsPlayPage( - props: PageProps<"/[classSlug]/arcade/connections/[packId]/play"> -) { - const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]); - const pack = await getArcadePack(packId); - if (!pack || pack.class.slug !== classSlug || pack.gameType !== "connections" || pack.normalized.type !== "connections") notFound(); - const normalized = pack.normalized; - const mistakesValue = Number(query.mistakes); - const allowedMistakes = Number.isInteger(mistakesValue) && mistakesValue >= 1 && mistakesValue <= 8 - ? mistakesValue - : normalized.settings.allowedMistakes; - const Renderer = ARCADE_RENDERERS.connections; - return ; -} diff --git a/src/app/(protected)/[classSlug]/arcade/connections/page.tsx b/src/app/(protected)/[classSlug]/arcade/connections/page.tsx deleted file mode 100644 index e27c052..0000000 --- a/src/app/(protected)/[classSlug]/arcade/connections/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { notFound } from "next/navigation"; -import { ConnectionsHub } from "@/components/arcade/ConnectionsHub"; -import { getClassBySlug } from "@/services/classService"; -import { listArcadePacks } from "@/services/arcadeService"; - -export default async function ConnectionsHubPage(props: PageProps<"/[classSlug]/arcade/connections">) { - const { classSlug } = await props.params; - const classItem = await getClassBySlug(classSlug); - if (!classItem) notFound(); - const packs = await listArcadePacks(classItem.id, "connections"); - return ; -} diff --git a/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx b/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx deleted file mode 100644 index e197665..0000000 --- a/src/app/(protected)/[classSlug]/arcade/crossword/[packId]/play/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { notFound } from "next/navigation"; -import { ARCADE_RENDERERS } from "@/components/arcade/rendererRegistry"; -import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine"; -import type { CrosswordSize, NormalizedCrosswordPack } from "@/types/arcade"; -import { getArcadePack } from "@/services/arcadeService"; - -const SIZES = new Set(["mini", "standard", "large", "extra-large"]); - -export default async function CrosswordPlayPage(props: { params: Promise<{ classSlug: string; packId: string }>; searchParams: Promise> }) { - const [{ classSlug, packId }, query] = await Promise.all([props.params, props.searchParams]); - const pack = await getArcadePack(packId); - if (!pack || pack.class.slug !== classSlug || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") notFound(); - const requestedSize = typeof query.size === "string" ? query.size : "standard"; - const size: CrosswordSize = SIZES.has(requestedSize as CrosswordSize) ? requestedSize as CrosswordSize : "standard"; - const seed = randomUUID(); - const normalized = pack.normalized as NormalizedCrosswordPack; - const layout = generateCrosswordLayout(normalized, size, seed); - const Renderer = ARCADE_RENDERERS.crossword; - return ; -} diff --git a/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx b/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx deleted file mode 100644 index f96fcba..0000000 --- a/src/app/(protected)/[classSlug]/arcade/crossword/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { notFound } from "next/navigation"; -import { CrosswordHub } from "@/components/arcade/CrosswordHub"; -import { getClassBySlug } from "@/services/classService"; -import { listArcadePacks } from "@/services/arcadeService"; - -export default async function CrosswordHubPage(props: { params: Promise<{ classSlug: string }> }) { - const { classSlug } = await props.params; - const classItem = await getClassBySlug(classSlug); - if (!classItem) notFound(); - const packs = await listArcadePacks(classItem.id, "crossword"); - return ; -} diff --git a/src/app/(protected)/[classSlug]/arcade/layout.tsx b/src/app/(protected)/[classSlug]/arcade/layout.tsx deleted file mode 100644 index 5718c28..0000000 --- a/src/app/(protected)/[classSlug]/arcade/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ArcadeLayout({ children }: { children: React.ReactNode }) { - return
{children}
; -} diff --git a/src/app/(protected)/[classSlug]/arcade/page.tsx b/src/app/(protected)/[classSlug]/arcade/page.tsx deleted file mode 100644 index 27aa1b4..0000000 --- a/src/app/(protected)/[classSlug]/arcade/page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import Link from "next/link"; -import { ARCADE_GAMES } from "@/config/arcadeGames"; - -function ConnectionsVisual() { - return
{Array.from({ length: 16 }, (_, index) => )}
4 hidden groups
; -} - -function CrosswordVisual() { - const cells = ["C", "", "", "", "R", "", "O", "", "S", "T", "U", "D", "Y", "", "S", "", "", "", "W", "", "", "", "O", "", "", "", "R", "", "", "", "D", ""]; - return
{cells.map((letter, index) => {letter})}
; -} - -function FallingBlocksVisual() { - return
{Array.from({ length: 9 }, (_, index) => )}
; -} - -function AsteroidVisual() { - return
; -} - -function GameVisual({ gameKey }: { gameKey: (typeof ARCADE_GAMES)[number]["key"] }) { - if (gameKey === "connections") return ; - if (gameKey === "crossword") return ; - if (gameKey === "falling-blocks") return ; - return ; -} - -export default async function ArcadePage(props: PageProps<"/[classSlug]/arcade">) { - const { classSlug } = await props.params; - return ( -
-
-

Insert curiosity

-

Choose your cabinet

-

Step away from the desk and turn your study material into a quick challenge.

-
- -
- {ARCADE_GAMES.map((game) => { - const card =

{game.estimatedMinutes}

{game.name}

{game.description}

{game.available ? `Play ${game.name} →` : "Coming soon"}
; - return game.available ? {card} :
{card}
; - })} -
-
- ); -} diff --git a/src/app/api/arcade/import/preview/route.ts b/src/app/api/arcade/import/preview/route.ts deleted file mode 100644 index 89370dd..0000000 --- a/src/app/api/arcade/import/preview/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { ArcadeImportError } from "@/lib/arcade/arcadeImport"; -import { arcadePreviewRequestSchema } from "@/lib/validation/arcadeSchemas"; -import * as arcadeService from "@/services/arcadeService"; - -export async function POST(request: NextRequest) { - const parsed = arcadePreviewRequestSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "A game type and raw JSON are required." }, { status: 400 }); - } - try { - return NextResponse.json(arcadeService.previewArcadeImport(parsed.data.gameType, parsed.data.rawJson)); - } catch (error) { - if (error instanceof ArcadeImportError) { - return NextResponse.json({ error: error.message, details: error.details }, { status: 400 }); - } - throw error; - } -} diff --git a/src/app/api/arcade/packs/[id]/attempts/route.ts b/src/app/api/arcade/packs/[id]/attempts/route.ts deleted file mode 100644 index f36cf38..0000000 --- a/src/app/api/arcade/packs/[id]/attempts/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { arcadeAttemptCreateSchema, crosswordAttemptCreateSchema } from "@/lib/validation/arcadeSchemas"; -import * as arcadeService from "@/services/arcadeService"; - -export async function GET( - _request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]/attempts"> -) { - const { id } = await context.params; - const pack = await arcadeService.getArcadePack(id); - if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); - return NextResponse.json(await arcadeService.listArcadeAttempts(id)); -} - -export async function POST( - request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]/attempts"> -) { - const { id } = await context.params; - const pack = await arcadeService.getArcadePack(id); - if (!pack) return NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); - const body = await request.json().catch(() => null); - const parsed = pack.gameType === "crossword" - ? crosswordAttemptCreateSchema.safeParse(body) - : arcadeAttemptCreateSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid completed attempt", details: parsed.error.issues }, { status: 400 }); - } - try { - const attempt = pack.gameType === "crossword" - ? await arcadeService.createCrosswordAttempt(id, parsed.data as Parameters[1]) - : await arcadeService.createArcadeAttempt(id, parsed.data as Parameters[1]); - return attempt - ? NextResponse.json(attempt, { status: 201 }) - : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Attempt could not be saved" }, - { status: 400 } - ); - } -} diff --git a/src/app/api/arcade/packs/[id]/layout/route.ts b/src/app/api/arcade/packs/[id]/layout/route.ts deleted file mode 100644 index db4cd23..0000000 --- a/src/app/api/arcade/packs/[id]/layout/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { generateCrosswordLayout } from "@/lib/arcade/crosswordEngine"; -import type { CrosswordSize } from "@/types/arcade"; -import { getArcadePack } from "@/services/arcadeService"; - -const CROSSWORD_SIZES = new Set(["mini", "standard", "large", "extra-large"]); - -export async function GET( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const sizeValue = request.nextUrl.searchParams.get("size") ?? "standard"; - if (!CROSSWORD_SIZES.has(sizeValue as CrosswordSize)) { - return NextResponse.json({ error: "A valid Crossword size is required." }, { status: 400 }); - } - const pack = await getArcadePack(id); - if (!pack || pack.gameType !== "crossword" || pack.normalized.type !== "crossword") { - return NextResponse.json({ error: "Crossword pack not found" }, { status: 404 }); - } - const size = sizeValue as CrosswordSize; - return NextResponse.json(generateCrosswordLayout(pack.normalized, size, `crossword-hub-preview-v1:${id}:${size}`)); -} diff --git a/src/app/api/arcade/packs/[id]/route.ts b/src/app/api/arcade/packs/[id]/route.ts deleted file mode 100644 index 4ad04f4..0000000 --- a/src/app/api/arcade/packs/[id]/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { arcadePackUpdateSchema } from "@/lib/validation/arcadeSchemas"; -import * as arcadeService from "@/services/arcadeService"; - -export async function GET( - _request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]"> -) { - const { id } = await context.params; - const pack = await arcadeService.getArcadePack(id); - return pack - ? NextResponse.json(pack) - : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); -} - -export async function PATCH( - request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]"> -) { - const { id } = await context.params; - const parsed = arcadePackUpdateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) return NextResponse.json({ error: "A valid name is required" }, { status: 400 }); - const pack = await arcadeService.updateArcadePack(id, parsed.data.name); - return pack - ? NextResponse.json(pack) - : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); -} - -export async function DELETE( - _request: NextRequest, - context: RouteContext<"/api/arcade/packs/[id]"> -) { - const { id } = await context.params; - const deleted = await arcadeService.deleteArcadePack(id); - return deleted - ? NextResponse.json({ success: true }) - : NextResponse.json({ error: "Arcade pack not found" }, { status: 404 }); -} diff --git a/src/app/api/arcade/packs/route.ts b/src/app/api/arcade/packs/route.ts deleted file mode 100644 index e86938e..0000000 --- a/src/app/api/arcade/packs/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { ArcadeImportError } from "@/lib/arcade/arcadeImport"; -import { arcadePackCreateSchema } from "@/lib/validation/arcadeSchemas"; -import * as arcadeService from "@/services/arcadeService"; - -export async function GET(request: NextRequest) { - const params = new URL(request.url).searchParams; - const classId = params.get("classId"); - const gameType = params.get("gameType"); - if (!classId || (gameType !== "connections" && gameType !== "crossword")) { - return NextResponse.json({ error: "Valid classId and gameType values are required." }, { status: 400 }); - } - return NextResponse.json(await arcadeService.listArcadePacks(classId, gameType)); -} - -export async function POST(request: NextRequest) { - const parsed = arcadePackCreateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid Arcade pack request", details: parsed.error.issues }, { status: 400 }); - } - try { - const packs = await arcadeService.createArcadePacks(parsed.data); - return NextResponse.json({ packs, count: packs.length }, { status: 201 }); - } catch (error) { - if (error instanceof ArcadeImportError) { - return NextResponse.json({ error: error.message, details: error.details }, { status: 400 }); - } - if (error instanceof Error) { - const status = error.message === "Class not found" ? 404 : 400; - return NextResponse.json({ error: error.message }, { status }); - } - throw error; - } -} diff --git a/src/app/api/settings/llm-instructions/route.ts b/src/app/api/settings/llm-instructions/route.ts index 87d57cf..527a94b 100644 --- a/src/app/api/settings/llm-instructions/route.ts +++ b/src/app/api/settings/llm-instructions/route.ts @@ -3,7 +3,7 @@ import * as settingsService from "@/services/settingsService"; function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null { const type = new URL(request.url).searchParams.get("type") ?? "flashcards"; - return type === "flashcards" || type === "quizzes" || type === "connections" || type === "crossword" ? type : null; + return type === "flashcards" || type === "quizzes" ? type : null; } export async function GET(request: NextRequest) { diff --git a/src/app/globals.css b/src/app/globals.css index c95f558..bfb7cad 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -231,537 +231,6 @@ color: transparent; } -/* Arcade routes take over the complete application shell, including navigation. */ -body:has(.arcade-route) { - --theme-bg-base: #070b18; - --theme-bg-surface: #11182a; - --theme-bg-surface-alt: #19233a; - --theme-bg-callout: #282357; - --theme-primary: #9a8cff; - --theme-primary-hover: #b5aaff; - --theme-text-heading: #f9f7ff; - --theme-text-body: #dbe2f4; - --theme-text-secondary: #aab6d1; - --theme-text-muted: #71809e; - --theme-border: #34415c; - --theme-border-light: #242f46; - min-height: 100vh; - background: - radial-gradient(circle at 76% 4%, rgba(116, 91, 255, .2), transparent 28rem), - radial-gradient(circle at 30% 92%, rgba(5, 198, 181, .11), transparent 34rem), - linear-gradient(145deg, #080c19, #0c1221 58%, #0b1020); -} - -body:has(.arcade-route)::before { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - pointer-events: none; - opacity: .11; - background-image: radial-gradient(rgba(201, 211, 255, .7) .7px, transparent .7px); - background-size: 22px 22px; -} - -body:has(.arcade-route) main { min-height: 100vh; } -body:has(.arcade-route) .app-page { max-width: none; min-height: 100vh; } -body:has(.arcade-route) .arcade-route { width: min(100%, 1360px); margin-inline: auto; } -body:has(.arcade-route) .app-sidebar, -body:has(.arcade-route) .app-mobile-header { - border-color: rgba(138, 157, 201, .15); - background: rgba(8, 13, 27, .9); - box-shadow: 18px 0 55px rgba(0, 0, 0, .16); -} - -body:has(.connections-world) { - background: - radial-gradient(circle at 28% 0%, rgba(88, 101, 242, .34), transparent 36rem), - radial-gradient(circle at 96% 28%, rgba(239, 71, 111, .16), transparent 30rem), - linear-gradient(145deg, #0b1329, #0e1932 54%, #142440); -} - -.arcade-lobby-heading { position: relative; } -.arcade-lobby-heading::after { - content: "SELECT GAME"; - position: absolute; - right: 0; - top: .5rem; - color: rgba(154, 140, 255, .08); - font-size: clamp(3rem, 8vw, 7rem); - font-weight: 950; - letter-spacing: -.06em; - line-height: .8; - pointer-events: none; -} - -.arcade-cabinet { - position: relative; - display: flex; - min-height: 30rem; - flex-direction: column; - overflow: hidden; - border: 1px solid rgba(148, 163, 202, .2); - border-radius: 1.75rem; - background: #11182a; - box-shadow: inset 0 1px rgba(255,255,255,.06), 0 22px 55px rgba(0,0,0,.28); - transition: transform .24s var(--ease-spring), box-shadow .24s ease, border-color .24s ease; -} -.arcade-cabinet.is-playable:hover { transform: translateY(-7px) rotate(-.3deg); border-color: rgba(181,170,255,.65); box-shadow: 0 30px 70px rgba(0,0,0,.38), 0 0 45px rgba(126,103,255,.13); } -.arcade-cabinet.is-coming { filter: saturate(.82); } -.arcade-visual { position: relative; height: 13.5rem; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.1); } -.arcade-cabinet-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; } -.arcade-cabinet-status { font-size: .65rem; font-weight: 900; letter-spacing: .18em; text-transform: uppercase; } -.arcade-cabinet-copy h3 { margin-top: .35rem; color: white; font-size: 1.55rem; font-weight: 900; letter-spacing: -.04em; } -.arcade-cabinet-copy > p:not(.arcade-cabinet-status) { margin-top: .55rem; flex: 1; color: #aab6d1; font-size: .86rem; line-height: 1.65; } -.arcade-cabinet-action { display: grid; min-height: 2.9rem; margin-top: 1.1rem; place-items: center; border: 1px solid rgba(255,255,255,.13); border-radius: .9rem; color: #8d9ab5; background: rgba(5,9,20,.3); font-size: .8rem; font-weight: 850; } -.is-playable .arcade-cabinet-action { color: #14152a; border-color: transparent; background: linear-gradient(135deg, #a89dff, #8170f5); box-shadow: 0 10px 28px rgba(126,103,255,.28); } - -.connections-visual { display: grid; place-items: center; background: radial-gradient(circle at 50% 20%, rgba(180,162,255,.35), transparent 55%), linear-gradient(145deg,#342c64,#1d2042); } -.connections-visual-grid { display: grid; width: 9.5rem; grid-template-columns: repeat(4, 1fr); gap: .38rem; transform: perspective(400px) rotateX(8deg) rotateZ(-2deg); } -.connections-visual-grid i { aspect-ratio: 1; border-radius: .5rem; box-shadow: inset 0 1px rgba(255,255,255,.45), 0 5px 10px rgba(0,0,0,.22); } -.connections-visual-grid i:nth-child(-n+4) { background: #ffd166; } -.connections-visual-grid i:nth-child(n+5):nth-child(-n+8) { background: #5ee0a0; } -.connections-visual-grid i:nth-child(n+9):nth-child(-n+12) { background: #5ac8fa; } -.connections-visual-grid i:nth-child(n+13) { background: #b69cff; } -.connections-visual-label { position: absolute; right: 1rem; bottom: .75rem; color: rgba(255,255,255,.6); font-size: .62rem; font-weight: 900; letter-spacing: .16em; text-transform: uppercase; } - -.crossword-visual { display: grid; place-items: center; background: linear-gradient(150deg,#f4dca1,#c98c45); } -.crossword-visual::before { content: ""; position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#412911 1px,transparent 1px),linear-gradient(90deg,#412911 1px,transparent 1px); background-size: 18px 18px; } -.crossword-grid { display: grid; width: 10rem; grid-template-columns: repeat(8,1fr); gap: 2px; transform: rotate(-3deg); filter: drop-shadow(0 12px 12px rgba(71,38,10,.28)); } -.crossword-grid i { display: grid; aspect-ratio: 1; place-items: center; background: #312419; color: transparent; font-size: .62rem; font-style: normal; font-weight: 950; } -.crossword-grid i.has-letter { color: #322112; background: #fff9e9; } -.crossword-pencil { position: absolute; right: 1.2rem; bottom: .55rem; color: #6d3e17; font-size: 3rem; transform: rotate(-24deg); text-shadow: 0 5px 8px rgba(64,35,10,.2); } -.arcade-cabinet-crossword .arcade-cabinet-status { color: #f3bb62; } - -.blocks-visual { background: linear-gradient(#071a20,#0a3436); } -.blocks-visual::before { content: ""; position: absolute; inset: 0; opacity: .18; background-image: linear-gradient(rgba(74,222,128,.45) 1px,transparent 1px),linear-gradient(90deg,rgba(74,222,128,.45) 1px,transparent 1px); background-size: 24px 24px; } -.blocks-piece { position: absolute; display: grid; gap: 3px; } -.blocks-piece i,.blocks-floor i { border: 1px solid rgba(255,255,255,.35); border-radius: 4px; box-shadow: inset 0 0 8px rgba(255,255,255,.25),0 0 13px currentColor; } -.blocks-piece-a { left: 32%; top: 18%; grid-template-columns: repeat(2,1.8rem); color: #38e6c5; animation: arcadeBlockHover 2s ease-in-out infinite; } -.blocks-piece-a i { width: 1.8rem; height: 1.8rem; background: #0fcbaa; } -.blocks-piece-b { right: 18%; top: 42%; grid-template-columns: repeat(3,1.55rem); color: #ff4f91; transform: rotate(90deg); } -.blocks-piece-b i { width: 1.55rem; height: 1.55rem; background: #ed397e; } -.blocks-floor { position: absolute; right: 12%; bottom: 1rem; left: 12%; display: grid; grid-template-columns: repeat(6,1fr); gap: 3px; } -.blocks-floor i { aspect-ratio: 1; color: #ffc857; background: #eaaa2e; } -.blocks-floor i:nth-child(3n) { color: #6c7cff; background: #5868ee; transform: translateY(-1.5rem); } -.blocks-arrow { position: absolute; left: 19%; top: 20%; color: rgba(103,232,211,.5); font-size: 2.2rem; animation: arcadeArrowDown 1.3s ease-in-out infinite; } -.arcade-cabinet-falling-blocks .arcade-cabinet-status { color: #49ddb6; } - -.asteroid-visual { background: radial-gradient(circle at 72% 28%,#253c74,#101735 45%,#070b19); } -.arcade-star { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: white; box-shadow: 0 0 8px white; } -.star-a { left: 16%; top: 18%; }.star-b { right: 18%; top: 12%; }.star-c { left: 48%; top: 39%; } -.asteroid { position: absolute; border: 2px solid #8c91a6; border-radius: 47% 53% 42% 58%; background: radial-gradient(circle at 32% 28%,#9299ac,#4c5268 62%,#292e41); box-shadow: inset -7px -8px 12px rgba(0,0,0,.35),0 8px 18px rgba(0,0,0,.35); } -.asteroid::after { content: ""; position: absolute; width: 28%; height: 24%; left: 18%; top: 22%; border-radius: 50%; background: rgba(32,37,55,.45); } -.asteroid-a { width: 3.2rem; height: 3rem; right: 14%; top: 18%; transform: rotate(18deg); }.asteroid-b { width: 2rem; height: 2rem; left: 16%; top: 27%; }.asteroid-c { width: 1.4rem; height: 1.35rem; right: 32%; bottom: 18%; } -.space-station { position: absolute; left: 20%; bottom: 18%; width: 4rem; height: 1.4rem; border-radius: 65% 20% 35% 65%; background: linear-gradient(90deg,#b9c8ef,#6f83bd); transform: rotate(-12deg); box-shadow: 0 0 20px rgba(105,151,255,.35); } -.space-station::before { content: ""; position: absolute; left: 1.2rem; bottom: 1rem; border-right: 1rem solid transparent; border-bottom: 1.5rem solid #53699f; border-left: .25rem solid transparent; } -.space-station i { position: absolute; right: -.75rem; top: .3rem; width: 1rem; height: .8rem; border-radius: 50%; background: #50e6ff; box-shadow: 0 0 16px #4fdff8; } -.laser-beam { position: absolute; width: 38%; height: 2px; left: 42%; top: 52%; background: linear-gradient(90deg,#70f4ff,transparent); transform: rotate(-20deg); transform-origin: left; box-shadow: 0 0 8px #52e7ff; } -.shield-ring { position: absolute; left: 8%; bottom: 4%; width: 7.5rem; height: 6rem; border: 2px solid rgba(90,221,255,.25); border-radius: 50%; transform: rotate(-15deg); } -.arcade-cabinet-asteroid-defense .arcade-cabinet-status { color: #65c9ff; } - -@keyframes arcadeBlockHover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } } -@keyframes arcadeArrowDown { 0%,100% { opacity: .3; transform: translateY(-5px); } 50% { opacity: .8; transform: translateY(6px); } } - -/* Connections has its own playful game-show visual system, independent of the desk UI. */ -.connections-world { - --theme-bg-surface: #182443; - --theme-bg-surface-alt: #223154; - --theme-bg-callout: #2c3d68; - --theme-primary: #ffd166; - --theme-primary-hover: #ffe29a; - --theme-text-heading: #f8fbff; - --theme-text-body: #dbe7ff; - --theme-text-secondary: #afc0df; - --theme-text-muted: #8497bd; - --theme-border: #52668d; - --theme-border-light: #33486f; - position: relative; - isolation: isolate; - overflow: hidden; - color: var(--theme-text-body); - background: - radial-gradient(circle at 10% 0%, rgba(88, 101, 242, .45), transparent 28rem), - radial-gradient(circle at 95% 25%, rgba(239, 71, 111, .2), transparent 25rem), - linear-gradient(145deg, #101a35 0%, #111d39 48%, #172846 100%); - box-shadow: 0 28px 80px rgba(8, 15, 34, .28); -} - -.connections-hub.connections-world, -.connections-stage.connections-world { - overflow: visible; - background: transparent; - box-shadow: none; -} -.connections-hub.connections-world::before, -.connections-stage.connections-world::before { display: none; } - -.connections-world::before { - content: ""; - position: absolute; - inset: 0; - z-index: -1; - opacity: .13; - background-image: - linear-gradient(rgba(255,255,255,.18) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,.18) 1px, transparent 1px); - background-size: 42px 42px; - mask-image: linear-gradient(to bottom, black, transparent 78%); -} - -.connections-world .editorial-title { - font-family: var(--font-manrope), ui-sans-serif, system-ui, sans-serif; - font-weight: 850; - letter-spacing: -.045em; -} - -.connections-hub-hero, -.connections-topbar, -.connections-status, -.connections-setup, -.connections-import { - backdrop-filter: blur(18px); - box-shadow: inset 0 1px rgba(255,255,255,.08), 0 18px 45px rgba(2, 8, 23, .22); -} - -.connections-hub-hero { - background: linear-gradient(120deg, rgba(34,49,84,.94), rgba(24,36,67,.72)); - border: 1px solid rgba(151, 174, 221, .18); -} - -.connections-pack-card { - border-color: rgba(117, 141, 187, .28); - background: rgba(24, 36, 67, .74); - box-shadow: inset 0 1px rgba(255,255,255,.05), 0 12px 30px rgba(3, 9, 24, .16); -} - -.connections-pack-card:hover { - transform: translateY(-2px); - border-color: rgba(255, 209, 102, .48); -} - -.connections-pack-card.is-active { - border-color: #ffd166; - background: linear-gradient(120deg, rgba(65, 72, 132, .9), rgba(38, 55, 96, .9)); - box-shadow: 0 0 0 1px rgba(255,209,102,.25), 0 18px 45px rgba(3,9,24,.25); -} - -.connections-primary-button { - color: #172033; - background: linear-gradient(135deg, #ffd166, #ffb84d); - box-shadow: 0 10px 26px rgba(255, 184, 77, .24), inset 0 1px rgba(255,255,255,.5); - transition: transform .18s var(--ease-spring), filter .18s ease, box-shadow .18s ease; -} - -.connections-primary-button:not(:disabled):hover { - transform: translateY(-2px) scale(1.01); - filter: brightness(1.06); - box-shadow: 0 14px 34px rgba(255, 184, 77, .32), inset 0 1px rgba(255,255,255,.6); -} - -.connections-secondary-button { - color: #dbe7ff; - border: 1px solid #52668d; - background: rgba(24, 36, 67, .82); - transition: transform .18s var(--ease-spring), background .18s ease; -} - -.connections-secondary-button:not(:disabled):hover { transform: translateY(-2px); background: #2c3d68; } - -.connections-tile { - color: #15213a; - border: 1px solid rgba(255,255,255,.75); - background: linear-gradient(145deg, #fffaf0, #e9effa); - box-shadow: 0 7px 0 #aebbd2, 0 12px 24px rgba(2,8,23,.24), inset 0 1px #fff; - animation: connectionsTileIn .36s var(--ease-spring) both; - transition: transform .16s var(--ease-spring), box-shadow .16s ease, color .16s ease, background .16s ease; -} - -.connections-tile:hover { transform: translateY(-3px); box-shadow: 0 9px 0 #aebbd2, 0 16px 30px rgba(2,8,23,.28), inset 0 1px #fff; } -.connections-tile.is-selected { - color: #fff; - border-color: #8c7cf7; - background: linear-gradient(145deg, #735df2, #5540ca); - box-shadow: 0 5px 0 #30228e, 0 12px 28px rgba(84,64,202,.4), inset 0 1px rgba(255,255,255,.28); - transform: translateY(2px) scale(.975); - animation: connectionsTileSelect .25s var(--ease-spring); -} - -.connections-group, -.connections-review-card { - color: #172033; - box-shadow: inset 0 1px rgba(255,255,255,.55), 0 10px 26px rgba(2,8,23,.2); -} -.connections-group .text-text-heading, -.connections-group .text-text-secondary, -.connections-review-card .text-text-heading, -.connections-review-card .text-text-secondary { color: #172033; } -.connections-group { animation: connectionsGroupReveal .56s var(--ease-spring) both; } -.connections-group-0 { background: linear-gradient(135deg, #ffd166, #f7b944); } -.connections-group-1 { background: linear-gradient(135deg, #70e1a1, #36bd7c); } -.connections-group-2 { background: linear-gradient(135deg, #72d5f7, #4aa8e8); } -.connections-group-3 { background: linear-gradient(135deg, #b9a2ff, #8d73ed); } - -.connections-results-hero { - background: linear-gradient(130deg, #5b46d8, #283d77 58%, #164e63); - box-shadow: inset 0 1px rgba(255,255,255,.18), 0 22px 50px rgba(2,8,23,.3); -} - -.connections-completion { - position: relative; - display: grid; - min-height: 32rem; - place-content: center; - overflow: hidden; - border-radius: 1.75rem; - text-align: center; - background: radial-gradient(circle, rgba(94,234,212,.2), transparent 42%), rgba(10,18,39,.72); - animation: connectionsCompletionIn .55s var(--ease-spring) both; -} -.connections-completion-mark { - display: grid; - width: 6rem; - height: 6rem; - margin: 0 auto 1.5rem; - place-items: center; - border-radius: 999px; - color: #172033; - background: #ffd166; - box-shadow: 0 0 0 12px rgba(255,209,102,.12), 0 0 65px rgba(255,209,102,.46); - font-size: 3rem; - font-weight: 900; - animation: connectionsWinMark .8s .15s var(--ease-spring) both; -} -.connections-completion p { color: #8ee7d1; font-size: .75rem; font-weight: 850; letter-spacing: .2em; text-transform: uppercase; } -.connections-completion h2 { margin-top: .35rem; color: white; font-size: clamp(2.5rem, 8vw, 5.5rem); font-weight: 900; letter-spacing: -.065em; line-height: .95; } -.connections-completion span { margin-top: 1rem; color: #b9c8e5; font-weight: 700; } - -.connections-confetti { position: absolute; inset: 0; pointer-events: none; } -.connections-confetti i { - position: absolute; - top: -8%; - left: var(--confetti-x); - width: 9px; - height: 17px; - border-radius: 2px; - background: var(--confetti-color); - animation: connectionsConfettiFall 1.45s var(--confetti-delay) cubic-bezier(.2,.7,.3,1) both; -} - -@keyframes connectionsTileIn { from { opacity: 0; transform: translateY(16px) scale(.94); } to { opacity: 1; transform: translateY(0) scale(1); } } -@keyframes connectionsTileSelect { 0% { transform: scale(1); } 55% { transform: translateY(3px) scale(.94); } 100% { transform: translateY(2px) scale(.975); } } -@keyframes connectionsShake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-9px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(3px); } } -@keyframes connectionsGroupReveal { from { opacity: 0; transform: scale(.9) translateY(18px); filter: brightness(1.6); } 65% { transform: scale(1.025) translateY(-2px); } to { opacity: 1; transform: scale(1) translateY(0); filter: brightness(1); } } -@keyframes connectionsCompletionIn { from { opacity: 0; transform: scale(.94); } to { opacity: 1; transform: scale(1); } } -@keyframes connectionsWinMark { from { opacity: 0; transform: scale(.2) rotate(-25deg); } 70% { transform: scale(1.12) rotate(4deg); } to { opacity: 1; transform: scale(1) rotate(0); } } -@keyframes connectionsConfettiFall { 0% { opacity: 0; transform: translateY(-1rem) rotate(0); } 10% { opacity: 1; } 100% { opacity: 1; transform: translateY(38rem) rotate(var(--confetti-spin)); } } -@keyframes connectionsResultsIn { from { opacity: 0; transform: translateY(22px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } -.animate-connections-shake { animation: connectionsShake .42s ease-in-out; } -.animate-connections-results-in { animation: connectionsResultsIn .52s var(--ease-spring) both; } - -/* Crossword turns the full arcade shell into a warm, ink-and-paper newsroom. */ -body:has(.crossword-world) { - --theme-bg-base: #20170f; - --theme-bg-surface: #f6ecd2; - --theme-bg-surface-alt: #ead9b5; - --theme-bg-callout: #dfc795; - --theme-primary: #9a5a24; - --theme-primary-hover: #7b431b; - --theme-text-heading: #2d2117; - --theme-text-body: #493725; - --theme-text-secondary: #68523a; - --theme-text-muted: #8a7052; - --theme-border: #a9865d; - --theme-border-light: #cfb88e; - background: - radial-gradient(circle at 18% 8%, rgba(230, 181, 88, .18), transparent 28rem), - linear-gradient(135deg, #1d140d, #3c2818 55%, #21160e); -} -body:has(.crossword-world)::before { - opacity: .16; - background-image: repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,236,195,.16) 4px); -} -body:has(.crossword-world) .app-sidebar, -body:has(.crossword-world) .app-mobile-header { - --theme-bg-surface-alt: #3a2819; - --theme-bg-callout: #e4cc98; - --theme-primary: #8a4f20; - --theme-text-heading: #fff3d8; - --theme-text-secondary: #d7c3a2; - --theme-text-muted: #aa9170; - --theme-border-light: #5b4028; - color: #f8ead0; - border-color: rgba(224, 190, 126, .22); - background: rgba(35, 23, 14, .94); -} - -.crossword-world { - position: relative; - color: var(--theme-text-body); -} -.crossword-hub.crossword-world, -.crossword-stage.crossword-world { overflow: visible; } -.crossword-hero, -.crossword-setup, -.crossword-active-clue, -.crossword-clues, -.crossword-import, -.crossword-paper { - border: 1px solid rgba(100, 68, 35, .24); - background: - linear-gradient(rgba(255,255,255,.18), rgba(255,255,255,0)), - repeating-linear-gradient(0deg, rgba(83,54,24,.025) 0 1px, transparent 1px 4px), - #f4e7c9; - box-shadow: inset 0 1px rgba(255,255,255,.7), 0 18px 48px rgba(15,9,4,.25); -} -.crossword-hero { position: relative; overflow: hidden; } -.crossword-hero > * { position: relative; z-index: 1; } -.crossword-hero::after { - content: "DAILY STUDY"; - position: absolute; - right: 1rem; - top: .35rem; - color: rgba(77,51,28,.07); - font-family: Georgia, serif; - font-size: clamp(2.75rem, 5vw, 5rem); - font-weight: 900; - letter-spacing: -.06em; - line-height: 1; - pointer-events: none; -} -.crossword-kicker, -.crossword-section-label { - color: #925623; - font-size: .68rem; - font-weight: 900; - letter-spacing: .18em; - text-transform: uppercase; -} -.crossword-section-label { display: block; margin-bottom: .7rem; color: var(--theme-text-muted); } -.crossword-primary { - color: #fff8e7; - background: linear-gradient(135deg, #a86429, #744018); - box-shadow: inset 0 1px rgba(255,255,255,.24), 0 7px 0 #4a2912, 0 12px 24px rgba(49,27,12,.22); - transition: transform .16s var(--ease-spring), filter .16s ease; -} -.crossword-primary:hover { transform: translateY(-2px); filter: brightness(1.08); } -.crossword-primary:active { transform: translateY(4px); box-shadow: inset 0 1px rgba(255,255,255,.2), 0 3px 0 #4a2912; } -.crossword-pack { - border-color: rgba(111,76,43,.25); - background: rgba(244,231,201,.92); - box-shadow: 0 10px 26px rgba(20,12,6,.16), inset 0 1px rgba(255,255,255,.65); - transition: transform .18s ease, border-color .18s ease; -} -.crossword-pack:hover { transform: translateY(-2px); border-color: #a86429; } -.crossword-pack.is-active { border-color: #8d511f; box-shadow: 0 0 0 2px rgba(168,100,41,.2), 0 14px 30px rgba(20,12,6,.2); } -.crossword-paper > div i { aspect-ratio: 1; border: 1px solid rgba(45,33,23,.3); } -.crossword-size { - color: var(--theme-text-secondary); - border: 1px solid var(--theme-border-light); - background: rgba(255,250,235,.48); -} -.crossword-size.is-active { color: #2d2117; border-color: #955720; background: #e4c993; box-shadow: inset 0 0 0 2px rgba(255,255,255,.35); } -.crossword-board-preview { overflow: hidden; border: 1px solid #c9aa78; border-radius: .9rem; background: rgba(255,250,235,.48); } -.crossword-preview-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .65rem .8rem; border-bottom: 1px solid #d5bd93; color: #5d4630; } -.crossword-preview-heading span { font-size: .68rem; font-weight: 900; letter-spacing: .14em; text-transform: uppercase; } -.crossword-preview-heading small { color: #8a7052; font-size: .68rem; } -.crossword-preview-canvas { display: grid; min-height: 9rem; place-items: center; overflow: hidden; padding: .75rem; background: #322116; } -.crossword-preview-grid { display: grid; filter: drop-shadow(0 6px 9px rgba(0,0,0,.35)); } -.crossword-preview-grid i { border: .5px solid #9e8058; background: #fff6de; } -.crossword-preview-loading,.crossword-preview-canvas > p { color: #d7c3a2; font-size: .75rem; font-weight: 800; } -.crossword-option { display: flex; align-items: flex-start; gap: .7rem; padding: .75rem; border-radius: .75rem; background: rgba(224,202,157,.38); } -.crossword-option input { margin-top: .2rem; accent-color: #925623; } -.crossword-option strong,.crossword-option small { display: block; } -.crossword-option small { margin-top: .12rem; color: var(--theme-text-muted); } -.crossword-active-clue { display: grid; gap: .2rem; } -.crossword-active-clue span { color: #925623; font-size: .68rem; font-weight: 900; letter-spacing: .15em; text-transform: uppercase; } -.crossword-active-clue strong { color: #2d2117; font-family: Georgia, serif; font-size: 1.05rem; } -.crossword-active-clue small { color: var(--theme-text-muted); } -.crossword-toolbar { color: #f4e7c9; background: rgba(35,23,14,.9); box-shadow: 0 8px 24px rgba(13,8,4,.24); } -.crossword-toolbar > div { display: flex; align-items: center; gap: .35rem; } -.crossword-toolbar button { display: grid; width: 2rem; height: 2rem; place-items: center; border: 1px solid rgba(245,222,179,.25); border-radius: .45rem; font-weight: 900; } -.crossword-toolbar span { min-width: 3rem; text-align: center; font-size: .75rem; font-weight: 800; } -.crossword-board-viewport { - max-height: min(68vh, 46rem); - overflow: auto; - padding: 1rem; - overscroll-behavior: contain; - background: #2a1d13; - box-shadow: inset 0 0 0 5px #4c321e, inset 0 0 28px rgba(0,0,0,.7), 0 16px 38px rgba(13,8,4,.3); -} -.crossword-board { display: grid; width: max-content; min-width: 100%; min-height: 20rem; place-content: center; } -.crossword-cell { - position: relative; - display: grid; - width: var(--crossword-cell); - height: var(--crossword-cell); - place-items: center; - color: #25190f; - border: 1px solid #6f5335; - background: #fff8e6; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - transition: background .12s ease, box-shadow .12s ease; -} -.crossword-cell small { position: absolute; left: 2px; top: 0; font-size: max(7px, calc(var(--crossword-cell) * .22)); line-height: 1; } -.crossword-cell strong { font-size: max(12px, calc(var(--crossword-cell) * .56)); } -.crossword-cell.is-word { background: #f2d99f; } -.crossword-cell.is-active { z-index: 2; background: #ffe27e; box-shadow: inset 0 0 0 3px #925623; } -.crossword-cell.is-wrong::after { content: ""; position: absolute; right: 3px; bottom: 3px; width: 7px; height: 7px; border: 2px solid #a62e25; border-radius: 50%; } -.crossword-cell.is-revealed strong { color: #8a5a24; text-decoration: underline dotted; } -.crossword-secondary { - min-height: 2.75rem; - padding: .6rem .9rem; - border: 1px solid #9f7a50; - border-radius: .7rem; - color: #392718; - background: #ead6ae; - font-size: .78rem; - font-weight: 850; - box-shadow: 0 3px 0 #aa895f; -} -.crossword-secondary:hover { background: #f5e5c4; } -.crossword-clues { max-height: 72vh; overflow: hidden; } -.crossword-clues > div button { min-height: 2.5rem; border-bottom: 2px solid transparent; color: var(--theme-text-muted); font-size: .75rem; font-weight: 900; text-transform: uppercase; } -.crossword-clues > div button.is-active { color: #7d461d; border-color: #9a5a24; } -.crossword-clues ol { max-height: calc(72vh - 5rem); overflow-y: auto; padding-right: .25rem; } -.crossword-clues li button { display: grid; width: 100%; grid-template-columns: 2rem 1fr; gap: .4rem; padding: .55rem; border-radius: .5rem; text-align: left; color: #4b3826; } -.crossword-clues li button:hover,.crossword-clues li button.is-active { background: #dec38c; } -.crossword-clues li b { color: #8a4f20; } -.crossword-mobile-input { position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; } -.crossword-results-hero { color: #f8ead0; background: linear-gradient(135deg, #3b2819, #6d421f); box-shadow: 0 18px 45px rgba(15,9,4,.3); } -.crossword-results-hero > div > div { background: rgba(255,244,220,.1); } -.crossword-results-hero small,.crossword-results-hero strong { display: block; } -.crossword-results-hero small { color: rgba(255,244,220,.6); font-size: .65rem; font-weight: 900; text-transform: uppercase; } -.crossword-results-hero strong { margin-top: .2rem; font-size: 1.25rem; } -.crossword-final-board { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 12px 32px rgba(20,12,6,.18); } -.crossword-final-legend { display: flex; flex-wrap: wrap; gap: .8rem; color: #6a5139; font-size: .72rem; font-weight: 800; } -.crossword-final-legend span { display: inline-flex; align-items: center; gap: .35rem; } -.crossword-final-legend i { width: .8rem; height: .8rem; border: 1px solid currentColor; border-radius: .15rem; } -.crossword-final-legend i.is-correct { color: #356b3b; background: #bfe0b9; } -.crossword-final-legend i.is-assisted { color: #8a631c; background: #f2d47e; } -.crossword-final-legend i.is-incorrect { color: #923a30; background: #efb9af; } -.crossword-final-viewport { overflow: auto; padding: 1rem; border-radius: 1rem; background: #2a1d13; box-shadow: inset 0 0 22px rgba(0,0,0,.55); } -.crossword-final-grid { display: grid; width: max-content; min-width: 100%; place-content: center; } -.crossword-final-cell { position: relative; display: grid; place-items: center; color: #25190f; border: 1px solid #62482f; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.crossword-final-cell.is-correct { background: #c7e6bf; box-shadow: inset 0 0 0 1px rgba(47,108,58,.35); } -.crossword-final-cell.is-assisted { background: #f2d47e; box-shadow: inset 0 0 0 2px rgba(138,99,28,.42); } -.crossword-final-cell.is-incorrect { background: #efbbb1; box-shadow: inset 0 0 0 2px rgba(145,47,39,.45); } -.crossword-final-cell small { position: absolute; left: 2px; top: 1px; font-size: 7px; line-height: 1; } -.crossword-final-cell strong { font-size: clamp(10px, 2vw, 15px); } -.crossword-review { border: 1px solid #c8aa79; color: #392718; background: #f4e7c9; box-shadow: 0 8px 22px rgba(20,12,6,.15); } -.crossword-review.is-incorrect { border-left: 6px solid #a63a2e; } -.crossword-review.is-correct { border-left: 6px solid #4f7a4f; } -.crossword-review.is-assisted { border-left: 6px solid #b58627; } -.crossword-review.is-omitted { opacity: .75; border-left: 6px solid #8c765a; } - -@media (max-width: 639px) { - .crossword-board-viewport { margin-inline: -.25rem; padding: .6rem; } - .crossword-board { place-content: start; min-height: 24rem; } - .crossword-clues { max-height: 28rem; } - .crossword-hero::after { top: .75rem; right: .75rem; font-size: 2.75rem; } - .crossword-final-viewport { margin-inline: -.25rem; padding: .65rem; } -} - .markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); } .markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); } .markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); } diff --git a/src/components/activity/ActivityBanner.tsx b/src/components/activity/ActivityBanner.tsx index b2e2f7e..a058535 100644 --- a/src/components/activity/ActivityBanner.tsx +++ b/src/components/activity/ActivityBanner.tsx @@ -6,7 +6,6 @@ interface DailyActivity { date: string; flashcards: number; questions: number; - arcade: number; total: number; level: 0 | 1 | 2 | 3 | 4; } @@ -154,7 +153,7 @@ function DayTooltip({ day }: { day: DailyActivity }) { return (
{formatted} · {day.total} total
-
{day.flashcards} flashcards · {day.questions} questions · {day.arcade} arcade groups
+
{day.flashcards} flashcards · {day.questions} questions
); } @@ -185,5 +184,5 @@ function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) { } function getDayAriaLabel(day: DailyActivity) { - return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, and ${day.arcade} arcade groups, ${day.total} total activities`; + return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, ${day.total} total activities`; } diff --git a/src/components/arcade/ArcadeGameShell.tsx b/src/components/arcade/ArcadeGameShell.tsx deleted file mode 100644 index bc63241..0000000 --- a/src/components/arcade/ArcadeGameShell.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { useRouter } from "next/navigation"; - -export function ArcadeGameShell({ - title, - exitHref, - elapsedSeconds, - complete, - worldClassName = "connections-world connections-stage", - children, -}: { - title: string; - exitHref: string; - elapsedSeconds: number; - complete: boolean; - worldClassName?: string; - children: ReactNode; -}) { - const router = useRouter(); - function exitGame() { - if (!complete && !window.confirm("Leave this round? Your progress will not be saved.")) return; - router.push(exitHref); - } - const minutes = Math.floor(elapsedSeconds / 60); - const seconds = String(elapsedSeconds % 60).padStart(2, "0"); - return ( -
-
- -

{title}

-
{minutes}:{seconds}
-
- {children} -
- ); -} diff --git a/src/components/arcade/ArcadeImportModal.tsx b/src/components/arcade/ArcadeImportModal.tsx deleted file mode 100644 index 6378d34..0000000 --- a/src/components/arcade/ArcadeImportModal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { GenerateTab } from "@/components/import/GenerateTab"; -import type { ArcadeGameKey, ArcadeImportBatchPreview } from "@/types/arcade"; - -export function ArcadeImportModal({ - classId, - gameType, - onClose, - onImported, -}: { - classId: string; - gameType: ArcadeGameKey; - onClose: () => void; - onImported: () => void; -}) { - const [tab, setTab] = useState<"generate" | "import">("import"); - const [rawJson, setRawJson] = useState(""); - const [names, setNames] = useState([]); - const [preview, setPreview] = useState(null); - const [error, setError] = useState(""); - const [details, setDetails] = useState([]); - const [acknowledged, setAcknowledged] = useState(false); - const [busy, setBusy] = useState(false); - - async function previewImport() { - setBusy(true); - setError(""); - setDetails([]); - setPreview(null); - try { - const response = await fetch("/api/arcade/import/preview", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ gameType, rawJson }), - }); - const data = await response.json(); - if (!response.ok) { - setError(data.error ?? "Import preview failed"); - setDetails(data.details ?? []); - return; - } - setPreview(data); - setNames(data.packs.map((pack: { name: string }) => pack.name)); - } finally { - setBusy(false); - } - } - - async function saveImport() { - if (!preview || names.some((name) => !name.trim())) return; - setBusy(true); - setError(""); - try { - const response = await fetch("/api/arcade/packs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - classId, - gameType, - rawJson, - ...(gameType === "connections" ? { names: names.map((name) => name.trim()) } : { name: names[0]?.trim() }), - warningsAcknowledged: acknowledged, - }), - }); - const data = await response.json(); - if (!response.ok) { - setError(data.error ?? "Import failed"); - setDetails(data.details ?? []); - return; - } - onImported(); - } finally { - setBusy(false); - } - } - - return ( -
- -
-
- {(["generate", "import"] as const).map((item) => ( - - ))} -
-
- {tab === "generate" ? : ( -
-

{gameType === "connections" ? "Paste one pack object or an array of up to ten packs." : "Paste one Crossword pack containing exactly 80 entries."} Every board is validated before anything is saved.

-