diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..b50a304 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "mission-control", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--prefix", "mission-control"], + "port": 3000 + }, + { + "name": "yard-satellite", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-c", + "set -a && source mission-control/.env && cd yard/satellite && python3 web_server.py" + ], + "port": 3001 + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..664cb3d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +name: CI + +# Build + test gate. Set these jobs as REQUIRED status checks on main +# (Settings → Branches → main → Require status checks to pass) so a red +# build can never be merged again. + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Public Firebase web config is required at build time. These are placeholders +# so `next build` is deterministic in CI; they are NOT secrets (real values live +# in each app's .env, which is git-ignored). +env: + NEXT_PUBLIC_FIREBASE_API_KEY: ci-placeholder + NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ci-placeholder.firebaseapp.com + NEXT_PUBLIC_FIREBASE_PROJECT_ID: ci-placeholder + NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ci-placeholder.appspot.com + NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: "0000000000" + NEXT_PUBLIC_FIREBASE_APP_ID: "1:0000000000:web:0000000000000000000000" + +jobs: + mission-control: + name: mission-control (build + test) + runs-on: ubuntu-latest + defaults: + run: + working-directory: mission-control + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: mission-control/package-lock.json + - run: npm ci + - run: npm run lint + # The yard satellite serves a compiled copy of the rover simulator so its + # monitor page can animate a fake rover with the same physics and + # renderer the learner's mission page uses. That copy is committed (the + # Pi has no Node toolchain), so this fails the build if it no longer + # matches the TypeScript it was built from. + - run: npm run check:roversim + - run: npx next build + - run: npx jest --ci + + yard-rover: + name: yard rover (python tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install rover deps + run: pip install -r yard/rover/requirements.txt + - name: Run rover tests + working-directory: yard/rover + run: python -m pytest -q + + # The satellite is where mission locking, the offline sync worker, the + # operator console and the rover stop-control all live, and none of it was + # gated: this job did not exist, so ~200 tests covering the most + # safety-relevant code in the repo could go red without failing a build. + yard-satellite: + name: yard satellite (python tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install satellite test deps + run: pip install -r yard/satellite/requirements-test.txt + - name: Run satellite tests + working-directory: yard/satellite + run: python -m pytest tests -q + + # The two browser-driven files (test_blockly_codegen, test_status_page) are + # skipped by tests/conftest.py whenever playwright is absent, and + # requirements-test.txt deliberately leaves it out so a plain `pytest tests` + # works anywhere. The consequence was that they ran on nobody's machine + # unless someone installed playwright by hand: the yard editor's Blockly + # generator, the one thing a learner's program passes through before it + # reaches a rover, had no gate at all. + # + # Caught for real. Moving the block definitions into a shared module turned + # code.html's script into an ES module, module scope is not global, and all + # seven codegen tests broke. Nothing in CI would have said so. + # + # A separate job because it is the slow one: it downloads a browser (cached + # below) and needs network access for Blockly from unpkg. Keeping it out of + # yard-satellite leaves that job's ~2s feedback intact. + yard-browser: + name: yard browser tests (blockly codegen) + runs-on: ubuntu-latest + env: + # Pinned rather than floating, for the same reason terraform_version is: + # a new release should not change what CI does under the team, and the + # browser cache below is keyed on this exact value. + PLAYWRIGHT_VERSION: "1.62.0" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install satellite test deps + run: | + pip install -r yard/satellite/requirements-test.txt + pip install "playwright==${PLAYWRIGHT_VERSION}" pytest-playwright + + # Chromium is ~95MB. Keyed on the pinned version so bumping it fetches a + # matching browser instead of silently reusing an incompatible one. + - uses: actions/cache@v4 + id: pw-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_VERSION }} + - name: Install chromium + if: steps.pw-cache.outputs.cache-hit != 'true' + run: python -m playwright install --with-deps chromium + + - name: Run browser tests + working-directory: yard/satellite + run: python -m pytest tests/test_blockly_codegen.py tests/test_status_page.py -q diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 0000000..fb7496d --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,73 @@ +name: Deploy prod + +# Prod promotion (Werner's deploy guide, Phase 3). +# +# Manually triggered, gated by the GitHub "production" Environment (configure +# required reviewers there: Werner and/or Gavin approve). Promotes the image +# digest CURRENTLY SERVING on staging to the prod service: the exact bytes +# that were smoke-checked, no rebuild. +# +# Rollback is the same mechanism in reverse: rerun this workflow after +# pointing staging at the previous digest, or +# gcloud run services update-traffic / gcloud run deploy --image +# +# Variables (from Terraform outputs): GCP_WIF_PROVIDER, GCP_DEPLOY_SA, +# GCP_PROJECT_ID, GCP_REGION, STAGING_SERVICE, PROD_SERVICE, PROD_URL. +# PROD_URL is the load-balancer URL (service_urls.prod), not *.run.app. + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write # OIDC token for WIF + +concurrency: + group: deploy-prod + cancel-in-progress: false + +jobs: + promote: + name: Promote staging digest to prod + runs-on: ubuntu-latest + environment: production + steps: + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: ${{ vars.GCP_DEPLOY_SA }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Read the digest serving on staging + run: | + IMAGE=$(gcloud run services describe "${{ vars.STAGING_SERVICE }}" \ + --project "${{ vars.GCP_PROJECT_ID }}" --region "${{ vars.GCP_REGION }}" \ + --format='value(spec.template.spec.containers[0].image)') + echo "Promoting: $IMAGE" + case "$IMAGE" in + *@sha256:*) ;; + *) echo "Staging is not pinned to a digest; refusing to promote a mutable tag" >&2; exit 1 ;; + esac + echo "IMAGE=$IMAGE" >> "$GITHUB_ENV" + + - name: Deploy Cloud Run prod + run: | + gcloud run deploy "${{ vars.PROD_SERVICE }}" \ + --project "${{ vars.GCP_PROJECT_ID }}" \ + --region "${{ vars.GCP_REGION }}" \ + --image "$IMAGE" \ + --quiet + + - name: Smoke check + run: | + URL="${{ vars.PROD_URL }}" + if [ -z "$URL" ]; then + echo "PROD_URL GitHub variable is unset; copy terraform output service_urls.prod" >&2 + exit 1 + fi + echo "Prod URL: $URL" + curl -sSf -o /dev/null "$URL/" + test "$(curl -s -o /dev/null -w '%{http_code}' "$URL/operator")" = "404" + echo "Smoke check passed" diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..5b9474a --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,102 @@ +name: Deploy staging + +# CD for mission-control (Werner's deploy guide, Phase 2). +# +# Runs only after the CI workflow finishes GREEN on main: build the Docker +# image, push it to Artifact Registry tagged with the git SHA (immutable +# artifact), deploy Cloud Run staging to that exact digest, then smoke-check. +# +# Auth is Workload Identity Federation (GitHub OIDC -> deploy service +# account): no downloaded JSON keys anywhere. All environment-specific values +# come from GitHub *variables* (Settings -> Secrets and variables -> Actions +# -> Variables), which are filled from Terraform outputs once Phase 1 lands: +# +# GCP_WIF_PROVIDER projects/N/locations/global/workloadIdentityPools/github/providers/github +# GCP_DEPLOY_SA deploy SA email (push image + update Cloud Run) +# GCP_PROJECT_ID bt-impact-academy +# GCP_REGION e.g. africa-south1 +# GCP_AR_REPO Artifact Registry docker repo name +# STAGING_SERVICE e.g. mission-control-staging +# STAGING_URL load-balancer URL from terraform output service_urls.staging +# (NOT the Cloud Run *.run.app URI — that 403s without allUsers) +# NEXT_PUBLIC_FIREBASE_* public web config for the staging Firebase app +# NEXT_PUBLIC_APP_URL same as STAGING_URL (baked into the image at build time) +# +# Server secrets (FIREBASE_* admin credentials) are NOT here: Terraform wires +# them from Secret Manager onto the Cloud Run service itself. + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +permissions: + contents: read + id-token: write # OIDC token for WIF + +concurrency: + group: deploy-staging + cancel-in-progress: false + +jobs: + deploy: + name: Build, push, deploy staging + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: ${{ vars.GCP_DEPLOY_SA }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Configure docker for Artifact Registry + run: gcloud auth configure-docker ${{ vars.GCP_REGION }}-docker.pkg.dev --quiet + + - name: Build image (tag = git SHA) + working-directory: mission-control + run: | + IMAGE="${{ vars.GCP_REGION }}-docker.pkg.dev/${{ vars.GCP_PROJECT_ID }}/${{ vars.GCP_AR_REPO }}/mission-control:${{ github.event.workflow_run.head_sha }}" + echo "IMAGE=$IMAGE" >> "$GITHUB_ENV" + docker build \ + --build-arg NEXT_PUBLIC_APP_URL='${{ vars.NEXT_PUBLIC_APP_URL }}' \ + --build-arg NEXT_PUBLIC_FIREBASE_API_KEY='${{ vars.NEXT_PUBLIC_FIREBASE_API_KEY }}' \ + --build-arg NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN='${{ vars.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}' \ + --build-arg NEXT_PUBLIC_FIREBASE_PROJECT_ID='${{ vars.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}' \ + --build-arg NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET='${{ vars.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}' \ + --build-arg NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID='${{ vars.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}' \ + --build-arg NEXT_PUBLIC_FIREBASE_APP_ID='${{ vars.NEXT_PUBLIC_FIREBASE_APP_ID }}' \ + --build-arg NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID='${{ vars.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}' \ + -t "$IMAGE" . + + - name: Push image + run: docker push "$IMAGE" + + - name: Deploy Cloud Run staging (by digest) + run: | + DIGEST=$(gcloud artifacts docker images describe "$IMAGE" --format='value(image_summary.digest)') + gcloud run deploy "${{ vars.STAGING_SERVICE }}" \ + --project "${{ vars.GCP_PROJECT_ID }}" \ + --region "${{ vars.GCP_REGION }}" \ + --image "${IMAGE%%:*}@${DIGEST}" \ + --quiet + + - name: Smoke check + run: | + URL="${{ vars.STAGING_URL }}" + if [ -z "$URL" ]; then + echo "STAGING_URL GitHub variable is unset; copy terraform output service_urls.staging" >&2 + exit 1 + fi + echo "Staging URL: $URL" + # Learner home must serve via the load balancer; the operator surface must NOT exist. + curl -sSf -o /dev/null "$URL/" + test "$(curl -s -o /dev/null -w '%{http_code}' "$URL/operator")" = "404" + echo "Smoke check passed" diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml new file mode 100644 index 0000000..b42d806 --- /dev/null +++ b/.github/workflows/terraform-plan.yml @@ -0,0 +1,145 @@ +name: Terraform plan + +# Werner's deploy guide item 5: infrastructure changes are reviewed as code. +# Any PR touching infra/ gets a `terraform plan` posted as a comment, so the +# reviewer sees exactly what would change before approving the merge. This is +# what makes the 2026-07-23 standup decision ("infrastructure deployments via +# pull requests") real rather than aspirational. +# +# PLAN ONLY. This workflow never applies. Applying stays a deliberate human +# action until the team decides otherwise, because a bad auto-apply on merge +# can take prod down with no approval gate in front of it. +# +# Auth is Workload Identity Federation (GitHub OIDC -> service account), same +# as the deploy workflows: no downloaded JSON keys anywhere. +# +# Variables (Settings -> Secrets and variables -> Actions -> Variables), all +# from `terraform output`: +# GCP_WIF_PROVIDER projects/N/locations/global/workloadIdentityPools/github/providers/github +# GCP_TF_PLAN_SA terraform-plan@.iam.gserviceaccount.com (READ-ONLY, not the deploy SA) +# +# GCP_TF_PLAN_SA is a different identity from GCP_DEPLOY_SA on purpose: the +# deploy SA only holds push-image and update-Cloud-Run, which cannot refresh +# state, and widening it would give CD permissions it should not have. + +on: + pull_request: + branches: [main] + paths: + - 'infra/**' + - '.github/workflows/terraform-plan.yml' + +permissions: + contents: read + id-token: write # OIDC token for WIF + pull-requests: write # post the plan as a PR comment + +concurrency: + group: terraform-plan-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + plan: + name: fmt, validate, plan + runs-on: ubuntu-latest + defaults: + run: + working-directory: infra + + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + # Pinned rather than "latest" so a new Terraform release cannot + # change plan output under the team. + # + # Must be >= whatever version last wrote the remote state: Terraform + # upgrades state formats forward but refuses to read state created by + # a NEWER version. 1.15.8 is what the first apply was run with, so + # anything older here fails at init. Bump this when the team bumps + # their local CLI, not the other way around. + terraform_version: 1.15.8 + terraform_wrapper: false + + # Runs before auth: formatting needs no GCP access, and failing here + # gives a fast, obvious signal instead of an opaque credentials error. + - name: Check formatting + run: terraform fmt -check -recursive + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: ${{ vars.GCP_TF_PLAN_SA }} + + - name: Init + run: terraform init -input=false + + - name: Validate + run: terraform validate -no-color + + - name: Plan + id: plan + # Do not fail the step on a non-zero exit: we want the error text in + # the PR comment too, and the explicit check at the end of the job + # decides pass/fail. + continue-on-error: true + # + # -var-file is NOT optional. A bare plan falls back to variable + # defaults, and since the deployment moved to Impact those no longer + # describe the live infrastructure: `domains` defaults to empty, so the + # plan reads "6 to destroy" and proposes tearing down both managed + # certs and the HTTPS listeners. Plan with the same file the apply uses + # or the PR comment is worse than no comment. + run: | + terraform plan -no-color -input=false -var-file=impact.tfvars -out=tfplan 2>&1 | tee plan.txt + echo "exitcode=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Comment plan on the PR + uses: actions/github-script@v7 + env: + PLAN_EXIT: ${{ steps.plan.outputs.exitcode }} + with: + script: | + const fs = require('fs'); + const raw = fs.readFileSync('infra/plan.txt', 'utf8'); + const ok = process.env.PLAN_EXIT === '0'; + + // GitHub caps comments at 65536 chars. Keep the TAIL, because the + // plan summary ("Plan: N to add...") and any error land there. + const LIMIT = 60000; + const body = raw.length > LIMIT + ? '_(truncated, see the workflow log for the full plan)_\n\n...' + raw.slice(-LIMIT) + : raw; + + const marker = ''; + const comment = [ + marker, + `### Terraform plan ${ok ? 'succeeded' : 'FAILED'}`, + '', + '```terraform', + body, + '```', + ].join('\n'); + + // Sticky comment: update in place so a PR with several pushes does + // not accumulate a wall of stale plans. + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const existing = await github.paginate( + github.rest.issues.listComments, { owner, repo, issue_number } + ); + const mine = existing.find(c => c.body && c.body.includes(marker)); + + if (mine) { + await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body: comment }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body: comment }); + } + + - name: Fail the check if the plan failed + if: steps.plan.outputs.exitcode != '0' + run: | + echo "terraform plan exited ${{ steps.plan.outputs.exitcode }}" + exit 1 diff --git a/.gitignore b/.gitignore index 942fb16..eb398b6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,10 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +# Anchored to repo root so these Python build dirs do NOT match the Next apps' +# source dirs (e.g. mission-control/src/lib/). +/lib/ +/lib64/ parts/ sdist/ var/ @@ -162,3 +164,42 @@ cython_debug/ # Yard satellite runtime config (rover URL edited via /status page) yard/satellite/satellite_config.json .DS_Store + +# Node (root workspace tooling + the two Next apps) +node_modules/ +npm-debug.log* + +# Generated simulation videos +*.mp4 + +# Root lockfile — dev-only tooling (concurrently); apps own their lockfiles +/package-lock.json + +# Terraform (infra/): state and caches never get committed; the .terraform.lock.hcl DOES +.terraform/ +*.tfstate +*.tfstate.* +crash.log + +# Youtube API IDs +client_secret.json + + +# Satellite mission mirror (local SQLite state, never shared) +missions.db +missions.db-shm +missions.db-wal +yard/satellite/missions.db +yard/satellite/missions.db-shm +yard/satellite/missions.db-wal +yard/satellite/satellite_config.json + +# Console-spawned camera output (development only) +yard/satellite/camera_server.log + +# Terraform: saved plans are point-in-time binaries containing resolved +# variable values, and state must never be committed. +*.tfplan +*.tfstate +*.tfstate.* +.terraform/ diff --git a/INF4027W_Team06_Iteration2_ReadMe2026.md b/INF4027W_Team06_Iteration2_ReadMe2026.md new file mode 100644 index 0000000..7bcfdea --- /dev/null +++ b/INF4027W_Team06_Iteration2_ReadMe2026.md @@ -0,0 +1,231 @@ +# INF4027W — Mission Control +## Iteration 2 ReadMe, 2026 + +**Team:** Team06 +**Repository:** https://github.com/HlalanathiMashimbye/4tronix-rover-simulator +**Live system:** https://mission-control-staging-cp4cyuy7ga-bq.a.run.app + +--- + +## 1. What this is + +Mission Control is an educational robotics platform. Learners write a rover +mission in Python or in Blockly blocks, submit it from a web browser, and an +operator at a science centre runs it on a physical 4tronix M.A.R.S. rover. The +run is filmed, and the recording is linked back to the mission so the learner +can watch their own code drive a real robot. + +The system spans three environments, split along network boundaries: + +| Band | Where it runs | What it does | +|---|---|---| +| **A. Cloud** | Google Cloud Run | Learner-facing web app, mission storage, email | +| **B. Venue LAN** | Raspberry Pi (`mro.local`) | Operator console, local mission queue, offline operation | +| **C. Rover** | Raspberry Pi Zero (`marspi.local`) | Mission execution, motor and servo control | + +The defining constraint is that **the venue is frequently offline**. Band B +therefore keeps its own SQLite copy of everything it needs and reconciles with +the cloud later, so a dropped internet connection delays synchronisation but +never stops an operator running the rover. + +--- + +## 2. Running the system + +### Prerequisites + +- Node.js 20 or later, and npm +- Python 3.11 or later +- A Firebase project (Firestore in Native mode, Email/Password auth enabled) + +No rover hardware is required. The rover service falls back to a fake driver +automatically when it cannot find the hardware libraries, and the whole stack +runs on one laptop. + +### Quick start + +```bash +npm install # first time only; also installs mission-control/ +npm run dev # starts all three services and opens the browser +``` + +| Service | URL | Purpose | +|---|---|---| +| Mission Control hub | http://localhost:3000 | Learner web app | +| Yard satellite | http://localhost:3001 | Operator console, tablet editor, TV monitor | +| Rover server | http://localhost:8523 | Mission execution API | + +Individual services: `npm run dev:control`, `npm run dev:satellite`, +`npm run dev:yard`. + +### Python environment + +Only needed to run the Python test suites, or to start the yard services by +hand. `npm run dev` installs the satellite's Python dependencies on first run +by itself. + +```bash +python3 -m venv .venv +source .venv/bin/activate # macOS and Linux +# .venv\Scripts\activate # Windows PowerShell + +pip install -r yard/satellite/requirements-test.txt -r yard/rover/requirements.txt +``` + +Activating puts `(.venv)` in your prompt, after which plain `python` works. +The root `requirements.txt` belongs to the original desktop simulator (PyQt6, +OpenCV) and is **not** needed for Mission Control. + +### Configuration + +The hub reads `mission-control/.env`. **That file is excluded from this +submission because it contains live credentials.** To run the system you will +need to create it with your own Firebase project values(you can use the hosted site to skip the hassle): + +``` +NEXT_PUBLIC_FIREBASE_API_KEY=... +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=... +NEXT_PUBLIC_FIREBASE_PROJECT_ID=... +NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=... +NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=... +NEXT_PUBLIC_FIREBASE_APP_ID=... + +FIREBASE_PROJECT_ID=... +FIREBASE_CLIENT_EMAIL=... # omit both to use Application Default Credentials +FIREBASE_PRIVATE_KEY=... + +RESEND_API_KEY=... # learner status emails +RESEND_SANDBOX_RECIPIENT=... # redirects ALL mail to one inbox while testing +``` + +The yard satellite takes `ROVER_URL` (default `http://marspi.local:8523`) and +`SATELLITE_PORT` (default `3001`). The rover URL is also editable at runtime +from the satellite's Settings page, which persists it across restarts. + +--- + +## 3. Running the tests + +| Suite | Command | Count | +|---|---|---| +| Hub (Jest) | `npm test --prefix mission-control` | 233 | +| Yard satellite (pytest) | `cd yard/satellite && python -m pytest tests -q` | 205 | +| Rover service (pytest) | `cd yard/rover && python -m pytest -q` | 103 | + +Python dependencies for the test suites come from +`yard/satellite/requirements-test.txt` and `yard/rover/requirements.txt`. + +Two satellite test files drive a real browser through Playwright and are +skipped automatically when Playwright is not installed, so `pytest tests` runs +everything it can on any machine and says plainly what it left out. + +All three suites also run in CI on every push (`.github/workflows/ci.yml`). + +--- + +## 4. Repository layout + +``` +mission-control/ Next.js 16 / React 19 learner-facing app (Band A) + src/app/ Routes and API endpoints + src/core/ Domain entities, services and ports (no framework code) + src/infrastructure/ Firestore, Firebase Admin, Resend, validation + src/components/ UI, Blockly editor, Monaco editor, 2D simulator + +yard/satellite/ Flask operator console and local mirror (Band B) + operator_console.py Operator actions: dispatch, complete, cancel, video + mission_store.py SQLite mirror, outbox, mission leases + sync_worker.py The only component that talks to Firestore + mission_watcher.py Confirms completion against the rover + recovery.py Repairs runs interrupted by a restart + +yard/rover/ Rover execution service (Band C) + rover_server.py HTTP and SSE API + service.py FIFO queue, sandboxed execution, watchdog + drivers.py RoverDriver abstraction: fake and real + +infra/ Terraform: Cloud Run, Artifact Registry, Secret + Manager, IAM, Workload Identity Federation +docs/architecture/ Architecture, network and deployment documentation +``` + +--- + +## 5. Deployment + +The cloud application is deployed to Google Cloud Run and is publicly +accessible at the URL at the top of this document. + +**Automated path.** A push to `main` triggers CI (lint, type-check, tests). On +success the image is built and tagged with the git SHA, pushed to Artifact +Registry, and deployed to Cloud Run staging. Promotion to production reads the +digest currently serving on staging and deploys those exact bytes, so both +environments run byte-identical artefacts and rollback is the same action +aimed at an older digest. GitHub authenticates to Google Cloud through +Workload Identity Federation, so no long-lived service account key exists. + +**Manual path.** `scripts/deploy-demo.sh` builds and deploys directly, for +getting a URL up without waiting on CI. + +**Venue path.** The two Raspberry Pis are provisioned once by cloning the +repository and installing systemd units from `yard/deploy/`. Updates are +`git pull` and a service restart over SSH on the venue network. There is no +build artefact for the venue and no removable media involved: CI cannot reach +a Pi on a private network, so venue deployment is deliberately manual and +verified step by step. + +--- + +## 6. Notes for the marker + +**Data protection.** Mission documents are world-readable, so they never carry +a learner's identity. They store two SHA-256 hashes instead: one of the learner +id and one of the email address. Actual email addresses live only on the +learner record, which can be fetched by exact id but never listed. The hash of +a learner id is genuine pseudonymisation (a 21-character nanoid cannot be +brute-forced); the email hash is deliberately weaker and documented as such, +since an address is low entropy and a guess can be confirmed. What it prevents +is bulk harvesting, which was the real exposure. + +**Learner code safety.** Learner Python runs on the rover inside a restricted +namespace with a reduced builtins set, under a wall-clock watchdog, with a +trace hook that allows the operator's stop button to interrupt an infinite +loop. A mission that never terminates cannot occupy the rover indefinitely. + +**Operator authentication.** The operator console uses Firebase sign-in with +custom claims. An `OPERATOR_AUTH=off` environment flag disables the login for +event days. This is a deliberate, documented exception rather than an +oversight: the console runs on a private venue network behind physical access +control, and a login failure in front of a queue of children is a worse outcome +than an unauthenticated console. + +**Current hosting.** The live deployment runs in a personal Google Cloud +project. The intended host is the partner organisation's project, where the +account currently lacks the IAM permissions needed to create the deployment +identity. The Terraform is unchanged between the two: moving across is a +variable file and a re-initialisation, not a rewrite. + +**Known limitations.** +- Learner status emails are redirected to a single test inbox while the + sending domain is unverified, so no learner currently receives mail. +- Production traffic on the partner project is pending an infrastructure + permission change outside the team's control. +- The rover physics module in `yard/rover/rover_physics.py` is retained for + reference only; the simulation shown to learners runs in TypeScript. + +--- + +## 7. Iteration 2 scope + +Delivered in this iteration: + +- Offline-first venue operation: local SQLite mirror, outbox, mission leases + and restart recovery, so the yard runs with no internet connection +- Operator console rebuilt around the mission queue, with a mission execution + view, a stop control, and live line-by-line progress during a run +- Firestore read cost reduced to roughly a tenth of the previous figure, well + inside the free tier +- Cloud deployment through Terraform and GitHub Actions with no stored + credentials, and the platform publicly hosted +- Architecture, network and deployment documentation with generated diagrams + (`docs/architecture/`) diff --git a/README.md b/README.md index 29fb11c..5901d9e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,30 @@ Simulator library to allow dev and test of code for the Raspberry Pi-based 4tronix M.A.R.S. Rover without being connected to the actual Rover. +## Mission Control platform (hub + yard) + +This repo also hosts the Mission Control web platform built on top of the simulator. +From the repo root, `npm run dev` starts the full stack, waits for the web ports, +and opens the two browser pages you actually use: + +| Service | URL | What it is | +|---------|-----|------------| +| Hub (`mission-control`) | http://localhost:3000 | Learner-facing Mission Control app | +| Yard satellite UI | http://localhost:3001 | Operator/run surface — Blockly editor + TV monitor | +| Rover backend | http://localhost:8523 | Yard rover server (`yard/rover`) | + +```bash +npm install # first time only; a postinstall hook also installs mission-control/ +npm run dev # start hub + satellite + rover, then open 3000 and 3001 +``` + +Run a single service with `npm run dev:control`, `npm run dev:satellite`, or +`npm run dev:yard`. The satellite port can be overridden with the `SATELLITE_PORT` +env var. + +Useful terminal output is kept to the essentials: which driver is active and +which ports are serving. + ## Getting things set up We normally use the [Python](https://www.python.org) programming language to program the M.A.R.S. Rover, so this simulator is also all written in Python. The simulator uses a few Python libraries, so the first time you use this on a particular computer you'll need to take some steps to download those libraries. @@ -20,13 +44,16 @@ cd \dev\4tronix-rover-simulator 4. Next, we create something called a Python _virtual environment_. That's essentially a place to put all the libraries this code uses. Run this command: ``` -py -m venv env +python -m venv .venv ``` 5. Next, you need to activate this environment. (Activating it means telling the command window to use this Python environment.): ``` -.\env\Scripts\activate +# Windows: +.\.venv\Scripts\activate +# macOS / Linux: +source .venv/bin/activate ``` Once you've done this, the terminal or command window should change its prompt to show that the environment is activated by showing `(env)` at the start @@ -45,6 +72,12 @@ So the next thing you'll probably want to do is run the simulator. ## Simulator User Interface (UI) +> **Note:** This standalone Qt simulator is the original, still-working way to +> dev and test rover code locally. The current platform is **Mission Control** +> (see the section at the top, `npm run dev`), which runs its own browser-based +> simulator. Use this desktop simulator for quick, offline Python experiments; +> use Mission Control for the full learner experience. + The [roversimui.py](roversimui.py) displays a simple representation of the Rover. This lets us see: * Where the Rover is @@ -68,10 +101,13 @@ cd \dev\4tronix-rover-simulator 4. Activate the environment by running this command: ``` -.\env\Scripts\activate +# Windows: +.\.venv\Scripts\activate +# macOS / Linux: +source .venv/bin/activate ``` -The terminal or command window should change its prompt to show that the environment is activated by showing `(env)` at the start. +The terminal or command window should change its prompt to show that the environment is activated by showing `(.venv)` at the start. 6. Start the emulator UI with this command: @@ -99,7 +135,7 @@ import roversimulator as rover this tells Python that anything in the code that uses the `rover` module should use the simulator instead. Here's a simple example using the forward and spin functions to start drawing a square. -Complete square code is in [square.py](square.py), Which you can run by opening and then running it (e.g. by pressing F5 in Visual Studio). +Complete square code is in [examples/square.py](examples/square.py), Which you can run by opening and then running it (e.g. by pressing F5 in Visual Studio). ```py import roversimulator as rover import time @@ -117,7 +153,7 @@ time.sleep(0.5) # take a short break ``` -Here's a simple example of direct servo control which is in the [very-simple-example.py](very-simple-example.py) file. Which you can run by opening and then running it (e.g. by pressing F5 in Visual Studio). +Here's a simple example of direct servo control which is in the [examples/very-simple-example.py](examples/very-simple-example.py) file. Which you can run by opening and then running it (e.g. by pressing F5 in Visual Studio). ```py import roversimulator as rover @@ -185,6 +221,7 @@ rover.forward(0) | Folder | Description | |--------|-------------| +| [examples/](examples/) | Ready-to-run sample programs for the simulator (`square.py`, `very-simple-example.py`, `move-rover.py`) | | [real-rover/](real-rover/README.md) | Controlling the real M.A.R.S. Rover hardware | | [web_interface/](web_interface/README.md) | Browser-based control interface (experimental) | | [yard/](yard/README.md) | Classroom setup with tablets and TV monitor | diff --git a/docs/architecture/deployment-diagram.md b/docs/architecture/deployment-diagram.md new file mode 100644 index 0000000..4a45a78 --- /dev/null +++ b/docs/architecture/deployment-diagram.md @@ -0,0 +1,91 @@ +# Deployment Diagram + +Companion writeup for `Mission Control: Deployment Path` (August 2026). + +Two halves of this system ship on completely different clocks, so they get +completely different paths. The diagram splits them because trying to force one +pipeline over both would compromise each. + +## A. Cloud path: Mission Control Hub + +Fully automated, no human in the loop until production. A push to a feature +branch triggers GitHub Actions: install, lint, test, build the container image. +On green `main`, the image goes to Artifact Registry tagged with the git SHA, +which makes it an immutable artifact rather than a moving tag. Cloud Run staging +deploys that exact digest and health-checks the new revision. + +Promotion to production is a separate manual step. It reads the digest +**currently serving on staging** and deploys those same bytes, with no rebuild. +Staging and production therefore run byte-identical artifacts, and rollback is +the same action aimed at an older digest. There is no build step between +"verified" and "live" that could introduce drift. + +Two supporting boxes hang off this path rather than sitting in it: + +- **Workload Identity Federation** authenticates GitHub to Google Cloud over + OIDC, so no long-lived JSON service account key exists in the repo or in + Actions secrets. The only credential CI ever holds is a short-lived token. +- **Secret Manager** injects admin credentials and runtime config as environment + variables at deploy time, so nothing sensitive is baked into the image and + rotating a secret does not require a rebuild. + +Cloud Logging, Error Reporting and uptime checks watch the deployed revisions. + +## B. Venue path: yard satellite and rover + +Manual, and that is a decision rather than a shortfall. GitHub Actions cannot +reach a Raspberry Pi on a private venue network, so the venue path runs over SSH +on the venue LAN. The row is split because provisioning and updating are +genuinely different operations. + +### B1, once per Pi + +Image the SD card, clone the repo to `/home/mars/4tronix-rover-simulator`, build +a virtualenv with `--system-site-packages` (required, because `picamera2` comes +from apt and not pip), then install the systemd units from `yard/deploy/` and +enable them. From that point the services start on boot and restart after a +crash with no operator present, which matters at a venue where nobody is +watching a terminal. Configuration lands last: the rover URL is set per venue +and persisted to `satellite_config.json`, so a field fix survives reboots. + +### B2, every release + +SSH in, `git pull`, restart the two satellite services, then verify before +moving on. `curl http://localhost:3001/api/health` must return `status: ok`, +with `rover_status: connected` once the rover is up. Only then does the rover +get the same pull and restart, and only after its queue is confirmed to accept a +mission is the system called operational. The order is deliberate: verify the +satellite before touching the rover, so a failure has one obvious cause. + +The fallback annotation is there because it has been needed. If mDNS fails, SSH +by IP. If the network is unusable, an HDMI monitor and keyboard plugged directly +into the Pi. Neither is the intended path, but neither requires the internet. + +Once operational, the venue is offline-first: missions execute against the local +SQLite mirror with no connectivity at all, and `sync_worker.py` reconciles with +Firestore whenever the internet returns. + +## Reading the lines + +Solid arrows are automatic and online. Dashed arrows are manual steps over the +venue LAN. The blue dashed lines are identity and trust relationships rather +than deployment steps, which is why they enter the flow from below instead of +sitting in it. + +## Why it is shaped like this + +A bad cloud deploy is recoverable in a minute by promoting an older digest. A +bad venue deploy leaves children standing around a dead rover with no remote +rollback and, quite possibly, no internet. So the cloud path optimises for speed +and automation, and the venue path optimises for verification: every step is +checked before the next, and every step works with the internet down. + +## Known caveats + +- **Production is not serving traffic yet.** External ingress is blocked at the + org policy level, waiting on elevated privileges. The pipeline is built and + green through staging; the production box is proven mechanically but has not + carried live traffic. +- **There is no venue build artifact.** The cloud path builds a container image; + the venue path clones a repo and installs in place. Any diagram or writeup + that shows a package being carried to the venue is wrong. diff --git a/docs/architecture/design-decisions.md b/docs/architecture/design-decisions.md new file mode 100644 index 0000000..7e23c93 --- /dev/null +++ b/docs/architecture/design-decisions.md @@ -0,0 +1,445 @@ +# Design decisions: what we chose, why, and what we rejected + +Companion to [diagram-spec.md](diagram-spec.md). Written to be defended out loud. +Each entry follows the same shape: the forcing constraint, the decision, the +alternative we turned down, where it lives in the code, and the honest limitation. +The limitation matters. A reviewer who finds a weakness you did not name will +discount everything else you said. + +--- + +## 0. The four constraints everything follows from + +Almost every non-obvious choice in this codebase traces back to one of these. If +you can only remember one slide, remember this one. + +1. **Actions are physical and not replayable.** A rover crossing a room cannot be + undone, retried idempotently, or rolled back. Anything that would be a cheap + retry in a normal web system is a hazard here. +2. **The venue is frequently offline.** On Mandela Day the science centre wifi + could not sustain Firebase sign-in. The system that fails at the door on a bad + network is not a system that ran 45 missions that day. +3. **The users are children, and the data is theirs.** Learner email addresses on + world-readable documents is a POPIA problem, not a code-style problem. A + learner seeing "Failed" on their own work is a product failure even when it is + technically accurate. +4. **We are running on free tiers with hard quotas.** Firestore allows 50,000 + document reads a day, shared between every learner loading the public feed and + every satellite polling for work. Architecture that ignores read cost stops + working in production and nowhere else. + +Everything below is one of these four constraints, made concrete. + +--- + +## 1. Ports and Adapters at the hardware edge + +**Constraint.** Constraint 1. We cannot run a test suite against a real rover. +There is one rover, it is at a science centre, and it moves. + +**Decision.** The rover backend is built as hexagonal architecture with two +explicit abstract boundaries: + +- `RoverQueuePort` (abstract base) implemented by `RoverQueueService`, in + [yard/rover/service.py](../../yard/rover/service.py). The Flask layer depends + on the port, not the service. +- `RoverDriver` (abstract base) with `FakeRoverDriver` and `RealRoverDriver`, in + [yard/rover/drivers.py](../../yard/rover/drivers.py). The driver is injected. + +**Why this and not a mock or a flag.** A boolean like `if SIMULATION_MODE:` +sprinkled through the service means the tested path and the production path are +different code. Injecting a driver means the queue logic, the worker thread, the +history, the SSE broadcast and the sandbox are byte-for-byte the same in both +modes; only the last inch differs. That is the difference between a test that +proves something and a test that proves the test harness works. + +**The detail worth pointing at.** `RoverDriver` carries a `hardware` class +attribute, and the `/status` page shows an amber badge when it is `False`. The +abstraction is not hidden from the operator. Someone standing in the yard can see +that the rover is faked, which is exactly the failure mode a clean abstraction +would otherwise conceal. + +**Rejected.** Mocking `rover.py` in tests only. It would have left the fake path +untested in real use, and the fake path is what every developer runs every day. + +**Limitation.** `RealRoverDriver` imports the `rover` module at construction, so +it is only constructible on a Pi. The abstraction is honest but it is not +uniform, and there is no integration test that runs the real driver in CI. + +--- + +## 2. Sandboxing learner code, and why static analysis is not enough + +**Constraint.** Constraints 1 and 3. Learner Python executes on a Raspberry Pi +that is wired to motors. + +**Decision, three layers, each assuming the previous one failed.** + +1. **Client-side allowlist** in + [ast-allowlist-analyzer.ts](../../mission-control/src/infrastructure/sandbox/ast-allowlist-analyzer.ts) + and + [rover-command-allowlist.ts](../../mission-control/src/infrastructure/sandbox/rover-command-allowlist.ts). + This layer exists for *feedback*, not security. A learner finds out at line 4 + that `import os` is not available, in the editor, immediately. +2. **Server-side validation** at the API edge: Zod schema, then the same + allowlist re-run through `AllowlistService`, in + [schemas.ts](../../mission-control/src/infrastructure/validation/schemas.ts). + This layer exists because the client is attacker-controlled. +3. **Bounded execution** in `RoverQueueService.run_python`. Code is `compile()`d + with the filename `` so the trace function can tell student + frames from rover internals, then run under a `sys.settrace` hook that raises + `StudentCodeInterrupted` when either the stop button is pressed or a 120 second + wall-clock deadline passes. `time.sleep` is swapped for an interruptible + version so a sleeping program is still stoppable. + +**Why the allowlist is a pattern matcher and not a real AST.** Real Python AST +parsing in the browser needs Pyodide or Skulpt, which is megabytes of WASM +shipped to a tablet on a science centre network, to gain precision on a layer +whose job is fast feedback. The security decision is made at layer 3, where the +code actually runs, so layer 1 is allowed to be approximate. This is stated in the +file header rather than left as an inference. Naming the weakness at the point +where the weakness lives is the difference between a shortcut and a decision. + +**Rejected.** A denylist. Denylists are unbounded and each new Python feature is a +new hole. The allowlist starts restrictive and widens on demand, and a rejection +produces a message aimed at a learner rather than a stack trace. + +**Limitation.** The trace-based watchdog stops Python-level execution; it does not +contain a process. A C-level call that blocks without returning to the +interpreter is not interruptible by `settrace`. Real containment would need a +subprocess with `rlimit` and a seccomp profile, which is the honest next step. + +--- + +## 3. Clean architecture in the hub, and where we deliberately stopped + +**Constraint.** The persistence layer was expected to change (Firestore was and +is a migration risk, see the blocked DB migration), and business rules had to be +unit-testable without a database emulator. + +**Decision.** Dependency inversion around the storage and email boundaries only. +`MissionService` depends on `IMissionRepository`, not on Firestore; +`MissionNotificationService` depends on `IEmailSender`, not on Resend. The +concrete `FirestoreMissionRepository` and `ResendEmailSender` live in +`infrastructure/` and are wired at the API route. + +**The payoff is provable, not theoretical.** Two things happened that this bought: + +- Resend was swapped in after a SendGrid detour without touching a single + application service. +- `MissionService` and `MissionNotificationService` have real unit tests with a + fake repository, in `src/__tests__/unit/`, which is only possible because + neither one can reach a network. + +**Where we stopped, on purpose.** There is no repository abstraction over the +learner records, no CQRS, no domain events, no dependency injection container. +`missionQueryService` reads Firestore directly from the client for the public +feed, because that page is a read of world-readable data by an unauthenticated +browser and routing it through a server layer would add a Cloud Run hop and a +second read for no gain. + +**Say this if challenged on "why isn't it consistent".** Abstraction is a cost +paid in indirection and repaid only when the thing behind it changes or needs +faking. We paid it exactly where we had a live migration risk (persistence) and a +live vendor risk (email). We did not pay it for the public feed, where neither +risk exists. Uniform abstraction is not a virtue; matched abstraction is. + +**Limitation.** `IMissionRepository` has leaked slightly toward Firestore: the +cursor type is `{ submittedAt, id }`, which is a Firestore composite-cursor shape. +A different store would honour it, but the interface is not perfectly neutral. + +--- + +## 4. Pseudonymisation: two hashes that are not the same decision + +**Constraint.** Constraint 3, plus a structural fact: mission documents are +world-readable. The public discovery feed lists them, the Firebase web config +ships in the browser bundle, and Firestore rules cannot filter fields on read. +Anything stored on a mission is public. Full stop. + +**Decision.** Nothing identifying goes on a mission document. Two separate +one-way SHA-256 hashes, for two different reasons, in +[learnerRef.ts](../../mission-control/src/core/domain/services/learnerRef.ts) and +[learnerEmailHash.ts](../../mission-control/src/core/domain/services/learnerEmailHash.ts). + +**`learnerRef` is genuine pseudonymisation.** A learner id is a 21-character +nanoid, roughly 124 bits of entropy. Its hash cannot be reversed or brute forced. +The raw id never leaves `localStorage`. This restored a security property we had +lost: when raw ids were printed on every public card, possession of an id proved +nothing, so `POST /api/learners/[id]/email` could not authenticate its caller and +anyone could write an address onto anyone's record. + +**`learnerEmailHash` is damage limitation, and we say so.** Email addresses are +low entropy. Someone who already suspects an address can confirm it by hashing +their guess. What the hash removes is **bulk harvesting**, which was the actual +exposure: read the feed, collect ids, fetch each learner document by exact id, +read a child's address in plaintext. The hash exists so a learner can find their +own missions from a second device by hashing the address they already know. + +**Being able to articulate that difference is the whole point.** Two hashes that +look identical in the code have different threat models and different strengths, +and both file headers state which is which. A reviewer asking "isn't hashing an +email pointless?" is asking a good question, and the answer is "yes against a +targeted guess, no against enumeration, and enumeration was the real risk". + +**The address itself** lives in `learners/{id}/private/contact`, a subcollection +browsers are denied entirely and only the Admin SDK reaches. + +**Limitation, and it is in the rules file as a comment.** These rules stop the +bulk read. They cannot stop a forged write, because learner ids are still public +on older documents. Closing that needs ids to stop being published at all. + +--- + +## 5. Firestore rules as least privilege, not as the security model + +**Decision.** Every write that creates or advances a mission goes through the +Admin SDK, which bypasses rules entirely. The browser therefore needs almost no +access, and [firestore.rules](../../firestore.rules) is written to grant almost +none. Anything not matched is denied. + +The browser has exactly **one** write in the entire system: stamping +`learnerEmailHash` onto a mission submitted before the learner supplied an +address. That single rule is pinned four ways: + +``` +allow update: if touchedKeys().hasOnly(['learnerEmailHash']) + && !('learnerEmailHash' in resource.data) // fill a blank, never overwrite + && request.resource.data.learnerEmailHash is string + && request.resource.data.learnerEmailHash.size() == 64 + && request.resource.data.learnerEmailHash.matches('^[0-9a-f]{64}$'); +``` + +**Why the regex matters.** Without the shape check, that one permitted write is a +channel for smuggling a plaintext address onto a world-readable document. The +64-character hex constraint pins the field to a SHA-256 digest and closes it. + +**Rejected.** Letting the client write mission status directly. It would have +removed a server hop, and it would have let any browser mark any mission +complete. + +--- + +## 6. Mission locking with leases, and why the satellite is the lock owner + +**Constraint.** Constraint 1. Two operators on two tablets tapping Send on the +same mission means the rover runs it twice, in front of a room of children. + +**Decision.** A lease-based lock, taken inside a real transaction, in +`acquire_mission` in [yard/satellite/mission_store.py](../../yard/satellite/mission_store.py): + +```python +conn.execute('BEGIN IMMEDIATE') # write lock taken at statement one, not at commit +... +lease_live = bool(lease) and lease > now_iso +if holder and holder != owner and lease_live: + return False, 'locked-by-other', None +``` + +**Three details worth defending.** + +- **`BEGIN IMMEDIATE`, not `BEGIN`.** SQLite's default deferred transaction takes + its write lock lazily, which leaves a read-then-write check-and-set open to a + race under a threaded Flask server. `IMMEDIATE` takes the write lock up front, + which is what makes the claim atomic. +- **A lease, not a lock.** A plain lock held by a process that loses power is held + forever, and the mission is stuck. An expired lease on a `processing` mission is + reclaimable, and reclaiming it is the entire point of having an expiry. +- **A missing lease is deliberately NOT reclaimable.** That is legacy data, not a + dead holder, and guessing about legacy data would be guessing about a physical + action. + +**The subtle one, and the best story in this section.** The lock owner is the +**satellite**, not the operator, and there is a specific reason recorded in +[satellite_identity.py](../../yard/satellite/satellite_identity.py). The event-day +escape hatch `OPERATOR_AUTH=off` makes `current_operator()` return one shared stub +whose uid is the literal string `'offline'`. If the operator uid were the lock +principal, then in that mode every operator is the same principal, `holder != +owner` never fires, both tablets acquire, and the rover runs the mission twice. +The lock would have been disabled in precisely the conditions that created the +need for it. + +The lock is about which *machine* owns the rover, not which human is tapping. One +satellite owns one rover, so the satellite is the correct principal, and its +identity is a UUID persisted to disk so a restart reclaims its own leases instead +of looking like a different box. + +**This is the strongest single answer to "what was technically difficult".** It is +a real distributed-systems bug, found by reasoning about an interaction between +two features, in the mode where it would have hurt most. + +--- + +## 7. Offline-first: local SQLite, an outbox, and push before pull + +**Constraint.** Constraint 2. + +**Decision.** Every Flask request handler on the satellite reads and writes +**SQLite only**. A single background worker, +[sync_worker.py](../../yard/satellite/sync_worker.py), is the only component that +talks to Firestore. The console therefore has no network in its request path, so +losing the uplink degrades freshness instead of breaking the console. + +**The ordering rule, which is the part to defend.** Flush the outbox **before** +pulling. Not a preference, a correctness requirement: + +> A local write records a physical event. The rover actually moved across the +> yard. The Firestore copy is stale by definition, because it never heard about +> that run. Pulling first would overwrite ground truth with staleness and silently +> erase the fact that a mission ran. + +**How it holds together.** A local change sets `local_dirty = 1` on the mirror row +and appends to `outbox`. The pull's `UPSERT` carries `WHERE local_dirty = 0`, so a +pull physically cannot clobber an unsynced local change. `local_dirty` is cleared +only once nothing is queued for that row. + +**Conflict resolution without coordination.** Status is **monotonic**: it only +moves up the ladder `queued < processing < cancelled < failed < completed`, never +back down. So the merge rule is "higher rank wins, later timestamp breaks ties", +in `should_local_win`, and most reconnect conflicts resolve themselves with no +coordination at all. Every resolution is written to a `conflict_log` table and +surfaced in the console, so the automation is auditable rather than silent. + +**Rejected.** Firestore's own offline persistence. It is a client SDK feature, it +does not span the satellite's Python process and its background threads, and it +gives no control over the push/pull ordering that correctness here depends on. + +**Limitation, and volunteer it.** Multi-site is still open. Two satellites on +different networks both holding a stale view can still conflict in ways the rank +rule resolves plausibly rather than correctly. + +--- + +## 8. Read-cost budget as an architectural constraint + +**Constraint.** Constraint 4. This one is usually invisible in student projects +and it is worth showing precisely because it is. + +**The arithmetic we started from.** The naive sync worker pulled 200 documents +every 30 seconds: + +``` +2,880 cycles/day x 200 docs = 576,000 reads/day +``` + +against a 50,000/day free-tier quota shared with every learner loading the public +feed. That is eleven times the entire daily budget, from one satellite, before a +single learner opens the site. + +**Decision, three mechanisms, same freshness, roughly one hundredth the cost.** + +1. **Incremental pull.** New missions only, via `submittedAt > cursor`. A quiet + cycle reads nothing (an empty result bills as one read), so the floor is about + 2,880 reads a day. +2. **Active reconcile.** Missions can also change remotely, which an + incremental-by-`submittedAt` query cannot see. So every Nth cycle re-reads + **only** the missions that can still change: `queued` and `processing`. + Terminal missions are never re-read, because they do not move. +3. **Cursor pagination, not offset**, in `IMissionRepository`. Firestore bills + every document an offset skips over, so page 5 of an offset scheme costs five + pages' worth of reads. The cursor carries both ordering fields + (`submittedAt`, `id`) so ties cannot skip or repeat a row. + +**And it is tunable at runtime** via `SYNC_INTERVAL` and `SYNC_RECONCILE_EVERY`, +because the right trade-off differs by day: during an event freshness matters and +there is an operator watching; on a quiet day the same settings burn quota for +nobody. + +--- + +## 9. Human-in-the-loop as an invariant, not a policy + +**Constraint.** Constraints 1 and 3. Three rules are enforced in code, not in a +runbook. + +**Never move the robot without a human.** No component auto-dispatches. The +`mission_watcher` polls the rover and is deliberately one-directional: it only +ever *reads* `/queue/status`. It can complete a mission, never fail one, never +send one. The distinction it rests on is worth stating out loud: recording an +outcome the rover already reported moves nothing, whereas dispatching is a +physical action that cannot be replayed. + +**"I could not tell" must never be read as "it finished".** Both +[recovery.py](../../yard/satellite/recovery.py) and the watcher return an empty +set on any failure: unreachable, malformed JSON, non-200, all of it. A silent +rover is not a completed mission. This is fail-safe defaulting applied to a case +where the unsafe default would mark a mission complete that never ran. + +**Crash recovery refuses to guess.** If the satellite loses power mid-mission, the +mirror holds a `processing` row that this satellite owns, and that state is +genuinely ambiguous. Recovery resolves it only when the **rover itself** confirms +the outcome. Everything else is flagged `needs_review` for an operator. It +specifically does not re-dispatch, and it specifically does not mark the mission +failed, because "failed" asserts an outcome nobody established. + +**And the learner never sees "Failed".** +[discoveryStatus.ts](../../mission-control/src/lib/discoveryStatus.ts) collapses +five internal statuses into two learner-facing ones, Completed or Pending. The +operator console shows the full accurate status. This is not the system lying; it +is two audiences with different needs, and the code names the reason: a learner +should not be made to feel bad by seeing their own work marked "Failed". + +**Say this if asked why the automation is so timid.** In a normal web system the +safe default under uncertainty is to retry. Here the safe default under +uncertainty is to stop and ask a human, because the failure mode is a machine +moving in a room with children in it. The invariants are asymmetric on purpose. + +--- + +## 10. Deployment: no long-lived credentials, and prod runs staging's bytes + +**Decision.** Infrastructure is Terraform with remote state in GCS +([infra/](../../infra)). CI authenticates to GCP with **Workload Identity +Federation** over GitHub's OIDC token, so there is no downloaded JSON service +account key anywhere in the pipeline, which is the most common way a student +project leaks production access. + +Staging builds an image, tags it with the git SHA, and deploys that exact digest. +Prod promotion is `workflow_dispatch`, gated by a GitHub Environment with required +reviewers, and it promotes **the digest currently serving on staging**. No +rebuild. Prod runs the same bytes that were smoke-checked, and rollback is the +same mechanism pointed at the previous digest. + +`terraform-plan.yml` runs a plan on any PR touching `infra/`, so an infrastructure +change is reviewable as a diff rather than as a description of a diff. + +**Limitation, state it before someone finds it.** Firebase itself (the Firestore +database, Auth, the web app) is provisioned through the console by the migration +workstream, not by Terraform. Infrastructure as code coverage is real but partial, +and the diagram marks this. + +--- + +## 11. Anticipated challenges, with the short answer + +| Challenge | Short answer | +|---|---| +| "Why SQLite instead of just using Firestore offline persistence?" | It is a client SDK feature that does not span a Python process and its threads, and it gives no control over push-before-pull ordering, which is where correctness lives here. | +| "Isn't a client-side allowlist security theatre?" | It is not the security layer; it is the feedback layer. Security is the server allowlist plus the bounded interpreter, and the file header says so rather than implying otherwise. | +| "Hashing an email is weak." | Correct against a targeted guess, which we state in the source. It defeats bulk harvesting, which was the actual exposure, and the address itself is not on a public document at all. | +| "Why not a message queue instead of Firestore-as-queue?" | One rover, tens of missions a day, and a hard requirement that the same store be readable by an unauthenticated public feed. A broker adds an operational component with no capability we need at this scale. | +| "Why is `OPERATOR_AUTH=off` allowed to exist?" | Because it is what got 45 missions run on an event day when the venue wifi could not sustain Firebase sign-in. It is scoped, documented, marked in the UI, and the locking model was deliberately designed to keep working with it on, which is the interesting part. | +| "The architecture is not uniformly layered." | Correct and intended. We inverted the dependencies where we had live change risk (persistence, email) and did not where we had none (public feed reads). | +| "How do you know the read-cost design works?" | The arithmetic is in the source comments with the before figure, the after figure, and three tuning presets. It is a measured constraint, not an intuition. | + +--- + +## 12. One-paragraph version + +The system is a cloud authoring app, an offline-capable field satellite, and a +physical rover, and every hard decision in it comes from one of four constraints: +physical actions cannot be replayed, the venue is often offline, the users are +children whose data is public by default, and the free-tier quotas are real. So +the rover backend is hexagonal with an injected driver, learner code runs under a +time-bounded interruptible interpreter behind three independent validation layers, +identifiers on world-readable documents are one-way hashes with two different and +explicitly stated threat models, the browser holds exactly one narrowly shaped +write permission, mission dispatch is guarded by an expiring lease owned by the +satellite rather than the operator (because the event-day auth bypass would +otherwise have collapsed every operator into one principal), the satellite is +offline-first with an outbox that always pushes before pulling so a witnessed +physical event can never be overwritten by a stale cloud read, the sync worker was +rebuilt around a read-cost budget after the naive version came to eleven times the +daily quota, no component ever moves the robot or asserts an unobserved outcome +without a human, and the whole thing deploys through keyless OIDC with prod +running the exact image digest staging smoke-tested. diff --git a/docs/architecture/diagram-prompt.md b/docs/architecture/diagram-prompt.md new file mode 100644 index 0000000..48a9379 --- /dev/null +++ b/docs/architecture/diagram-prompt.md @@ -0,0 +1,204 @@ +# Paste-ready prompt + +Copy everything between the rules into Claude. Self-contained: assumes no repo +access. + +--- + +Produce a **system architecture diagram** as a single self-contained HTML page +containing one inline ``. No external fonts, scripts, stylesheets or images. +It must be legible projected in a room and printed on A3. + +**The most important requirement is that it does not feel congested.** Prefer +white space over completeness. If something does not fit, cut it rather than +shrink it. + +## The system + +"Mission Control" is an educational robotics platform. School learners write or +block-build Python that drives a physical 4tronix M.A.R.S. rover at a science +centre. A cloud web app collects missions, an operator at the venue dispatches +them to the real rover, the run is filmed and published back to the learner. The +venue network is frequently offline, so the yard runs offline-first. + +## Layout + +Landscape, 1600 x 1000 viewBox. Four horizontal bands separated by full-width +labelled rules, organised by network boundary rather than by technology. The two +boundary rules are the heaviest lines on the page. + +``` +TITLE +BAND A PUBLIC INTERNET / GOOGLE CLOUD +════ TRUST BOUNDARY: internet | venue LAN, frequently offline ════ +BAND B VENUE LAN: yard satellite (mro.local) +──── DEVICE BOUNDARY: physical actuation ──── +BAND C ROVER (marspi.local) + hardware +BAND D PLATFORM & DELIVERY (one slim strip) +``` + +## Density rules (enforce these) + +- **No node may contain more than four lines of body text.** Where a list would + run longer, name the group and give three representative members, not all of + them. +- Minimum 32px of empty space between any two nodes, 56px between bands. +- No legend, no key, no index table, no numbered walkthrough, no scope note. + Anything a reader needs must be readable off the node or the edge label itself. +- Nothing below 12px. If type would need to shrink, remove content instead. + +## Band A: cloud + +**A1 Learner devices** (small, far left). Desktop browser, tablet browser, TV +monitor. Icons with one-word labels, no body text. + +**A2 Mission Control Hub**, Next.js 16 / React 19 / TypeScript, on Cloud Run. +One container holding **four stacked strata**, with a single arrow down the left +edge labelled "dependencies point inward". One line per stratum, no more: + +1. **Presentation** `src/app`, `src/components` : Blockly and Monaco editors, + 2D canvas simulator, public mission feed +2. **API routes** `src/app/api` : `/api/missions`, `/api/learners`, Zod + validation at the edge +3. **Core domain** `src/core` : Mission and Learner entities, `IMissionRepository` + and `IEmailSender` ports, MissionService, AllowlistService +4. **Infrastructure** `src/infrastructure` : FirestoreMissionRepository, + firebase-admin, ResendEmailSender, code allowlist analyzer + +**A3 Managed services** (right column, lighter fill and a dashed left edge to +read as external). One line each: +- **Firestore** : `missions` (world readable), `learners`, + `learners/{id}/private` (Admin SDK only), `users` +- **Firebase Auth** : operator sign-in, custom claims +- **Resend** : learner status email +- **YouTube Data API** : links a published clip to its mission + +## Band B: yard satellite, Flask on mro.local:3001 + +Four columns. + +**B1 Surfaces.** `/code/` tablet Blockly editor, `/monitor/` TV display, +`/status` config and health, `/operator/` console. Four labels, no body text. + +**B2 Operator console** `operator_console.py`. Flask blueprint, Firebase sign-in +with custom claims. Actions: send to rover, mark complete, cancel, attach video. +Add one small amber tag reading `OPERATOR_AUTH=off event-day bypass`. + +**B3 SQLite mirror** `mission_store.py`. Draw as a database cylinder. Four table +names only, with the two fields that matter shown inside the first: +`mission_mirror (lock_owner, lease_expires_at)`, `outbox`, `sync_meta`, +`conflict_log`. + +**B4 Background threads** (marked "async, no user waiting"). Five rows, one short +clause each: +- `sync_worker.py` : the only component that reaches Firestore from the yard +- `mission_watcher.py` : polls the rover, completes only what it confirms +- `recovery.py` : resolves missions interrupted by a restart +- `satellite_identity.py` : holds the mission lease for this yard +- `camera_control.py` : starts and restarts the camera stream + +## Band C: rover, marspi.local:8523 + +Left to right, as a pipeline: + +- **`rover_server.py`** Flask : `/queue/add`, `/queue/status`, `/queue/events` + (SSE), `/photo` +- **`RoverQueueService`** implementing the abstract `RoverQueuePort` : FIFO + queue, single worker thread, sandboxed `run_python` with a wall-clock watchdog +- **`RoverDriver`** (abstract) drawn above its two implementations, + `FakeRoverDriver` and `RealRoverDriver`, as a small explicit inheritance fork. + Keep this fork visually clean, it is the one place the drawing should show a + class relationship. +- **Hardware** : 4tronix M.A.R.S. rover on a Raspberry Pi Zero (motors, 16 servo + channels, LEDs, ultrasonic). Separate node: Pi AI Camera IMX500 on a Raspberry + Pi 5, WebSocket on 8890. + +## Band D: platform and delivery + +One slim horizontal strip, drawn as a left-to-right chain, small type: +Terraform (GCS remote state) to GitHub Actions to Artifact Registry to Cloud Run +(staging and prod) to Secret Manager, with Workload Identity Federation tagged +"OIDC, no JSON keys" at the end. + +At the right of the same strip, a small dashed group labelled "dev and simulation, +same code paths, no hardware": `roversimui.py` (PyQt6 viewer), +`roversimulator.py` (drop-in for the real rover module), `rover_physics.py` +(deprecated four-wheel steering model, kept for reference only), `dev-launcher.js`, +Jest, pytest. Greyscale, roughly 60% opacity. + +Do not place any physics model inside the rover band. The rover service runs no +physics module of its own; the simulation is rendered by compiled TypeScript in +the satellite's static assets. + +## Edges + +Three types only, distinguished by dash pattern so the drawing survives greyscale. +**Do not draw a legend for them.** Instead label the handful of edges that cross a +band with their protocol, set in a small paper-coloured pill. + +- **Solid 2px, filled arrowhead** : synchronous request, caller waits +- **Dashed 6-4** : background or scheduled work, nobody waiting +- **Two thin parallel lines** : SSE or WebSocket stream, long lived + +Plus one exception: the driver-to-hardware edge is a **thick 4px green arrow** +labelled "physical actuation". + +Draw roughly these edges and no more. Extra edges are the main cause of clutter: + +- learner devices to hub API (solid, "HTTPS") +- learner devices to Firestore (solid, "direct read, public feed") +- hub infrastructure to Firestore and to Resend (solid) +- `sync_worker` to Firestore, as **two** dashed arrows labelled + "1. push outbox" then "2. pull incremental", with the ordering visible +- operator console to `mission_store` (solid), and `mission_store` to + `sync_worker` (dashed) +- operator console to rover `/queue/add` (solid, "dispatch") +- rover `/queue/events` to satellite to `/monitor/` browser (stream, one + continuous run labelled "SSE") +- camera to `/monitor/` (stream, "WS 8890") +- `mission_watcher` to rover `/queue/status` (dashed, "read only") +- operator console to hub `/api/missions/[id]/notify` (dashed, "best effort") +- `RoverQueueService` to `RoverDriver` to hardware (physical actuation) + +Never cross two edges without an arc hop. Draw bidirectional relationships as two +separate arrows. + +## Visual system + +- Warm paper ground `#FAF7F2`, ink `#1A1D21` for all text and structure. +- Band accents only as a thin top rule and a small header chip, never as a large + fill: cloud indigo `#3B4CCA`, yard teal `#0E7C7B`, rover forest `#2F6B3A`, + platform slate `#475569`. +- Node fills are paper or 4% ink. No gradients, no shadows, no 3D, no emoji. + Depth comes from border weight and whitespace. +- One typeface (system UI stack). Band headers 20px bold uppercase with letter + spacing, node titles 15px semibold, body 12px regular, edge labels 11px in a + paper-coloured rounded pill. +- At most one small monochrome line icon per node, as inline SVG paths. +- 8px grid, 8px node radius, 12px container radius. +- Support both colour schemes via CSS custom properties on the SVG: light by + default, plus `@media (prefers-color-scheme: dark)` and + `:root[data-theme="dark"]` / `:root[data-theme="light"]` overrides using ground + `#12100E` and ink `#F2EFE9`. +- Wrap the SVG in a container with `overflow-x: auto` and `max-width: 100%` so the + page never scrolls horizontally. + +## Deliverable + +One HTML file, one inline SVG, one `

hi

' + ); + + expect(sendMock).toHaveBeenCalledTimes(1); + const payload = sendMock.mock.calls[0][0]; + expect(payload.to).toBe('konke@example.com'); + expect(payload.subject).toBe('[to: learner@school.edu] 🛰️ Mission Queued - Red Rock Run'); + expect(payload.html).toBe('

hi

'); + + // The redirect must be loud: the service layer logs the intended recipient, + // so without this the logs would claim a learner was mailed when they weren't. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('learner@school.edu -> konke@example.com') + ); + warnSpy.mockRestore(); + }); + + it('sends to the real recipient untouched when no sandbox recipient is set', async () => { + const ResendEmailSender = await loadSender(); + + await new ResendEmailSender().send('learner@school.edu', 'Mission Queued', '

hi

'); + + const payload = sendMock.mock.calls[0][0]; + expect(payload.to).toBe('learner@school.edu'); + expect(payload.subject).toBe('Mission Queued'); + }); + + it('throws when Resend rejects the send', async () => { + sendMock.mockResolvedValue({ + data: null, + error: { message: 'You can only send testing emails to your own email address' }, + }); + const ResendEmailSender = await loadSender(); + + await expect( + new ResendEmailSender().send('learner@school.edu', 'Mission Queued', '

hi

') + ).rejects.toThrow('You can only send testing emails to your own email address'); + }); + + it('names every missing variable when Resend is not configured', async () => { + delete process.env.RESEND_API_KEY; + delete process.env.RESEND_FROM_EMAIL; + const ResendEmailSender = await loadSender(); + + await expect( + new ResendEmailSender().send('learner@school.edu', 'Mission Queued', '

hi

') + ).rejects.toThrow('RESEND_API_KEY, RESEND_FROM_EMAIL'); + }); +}); diff --git a/mission-control/src/__tests__/unit/rover-movement.test.ts b/mission-control/src/__tests__/unit/rover-movement.test.ts new file mode 100644 index 0000000..260db8e --- /dev/null +++ b/mission-control/src/__tests__/unit/rover-movement.test.ts @@ -0,0 +1,3 @@ +describe('rover movement (planned)', () => { + it.todo('Task 31: rover movement calculations are correct'); +}); diff --git a/mission-control/src/__tests__/unit/roverBlockly.test.ts b/mission-control/src/__tests__/unit/roverBlockly.test.ts new file mode 100644 index 0000000..98e5413 --- /dev/null +++ b/mission-control/src/__tests__/unit/roverBlockly.test.ts @@ -0,0 +1,232 @@ +/** + * Unit tests for the shared rover Blockly generators (AB#254). + * + * Validates that workspaceToPython emits the same low-level rover program the + * yard runs, and that workspaceToCommands maps movement blocks for the local + * simulator. Uses lightweight mock blocks so no browser/Blockly is required. + */ + +import { mergeUplinkHats, workspaceToPython, workspaceToCommands } from '@/lib/roverBlockly'; + +type Fields = Record; + +interface MockBlock { + type: string; + _next: MockBlock | null; + getFieldValue(name: string): string | number | undefined; + getInputTargetBlock(name: string): MockBlock | null; + getNextBlock(): MockBlock | null; +} + +function block(type: string, fields: Fields = {}, inputs: Record = {}): MockBlock { + const b: MockBlock = { + type, + _next: null, + getFieldValue: (n) => fields[n], + getInputTargetBlock: (n) => inputs[n] ?? null, + getNextBlock: () => b._next, + }; + return b; +} + +/** Link blocks into a next-chain and return the head. */ +function chain(...blocks: MockBlock[]): MockBlock { + for (let i = 0; i < blocks.length - 1; i++) blocks[i]._next = blocks[i + 1]; + return blocks[0]; +} + +function workspace(...top: MockBlock[]): { getTopBlocks: () => MockBlock[] } { + return { getTopBlocks: () => top }; +} + +function onReceive(body: MockBlock): MockBlock { + return block('rover_on_receive', {}, { DO: body }); +} + +/** + * A richer mock than MockBlock above: mergeUplinkHats actually rewires + * connections and disposes blocks, where the codegen tests only ever walk a + * read-only chain. previousConnection/nextConnection model just enough of + * Blockly's real connection objects (an `_owner` back to the block, and a + * `connect` that records the link) for mergeUplinkHats's reconnect logic to + * exercise the same call shape it uses against a real workspace. + */ +interface MergeConnection { + _owner: MergeMockBlock; + connect?: (other: MergeConnection) => void; +} + +interface MergeMockBlock { + type: string; + _next: MergeMockBlock | null; + _body: MergeMockBlock | null; + _disposed: boolean; + previousConnection: MergeConnection | null; + nextConnection: MergeConnection | null; + getFieldValue: () => undefined; + getInputTargetBlock: (name: string) => MergeMockBlock | null; + getNextBlock: () => MergeMockBlock | null; + getInput: (name: string) => { connection: MergeConnection } | null; + dispose: () => void; +} + +function mergeBlock(type: string): MergeMockBlock { + const merge: MergeMockBlock = { + type, + _next: null, + _body: null, + _disposed: false, + previousConnection: null, + nextConnection: null, + getFieldValue: () => undefined, + getInputTargetBlock: (name) => (name === 'DO' ? merge._body : null), + getNextBlock: () => merge._next, + getInput: (name) => { + if (name !== 'DO') return null; + return { + connection: { + _owner: merge, + connect(other) { + merge._body = other?._owner ?? null; + }, + }, + }; + }, + dispose: () => { + merge._disposed = true; + }, + }; + + merge.previousConnection = { _owner: merge }; + merge.nextConnection = { + _owner: merge, + connect(other) { + merge._next = other?._owner ?? null; + }, + }; + + return merge; +} + +function mergeWorkspace(...top: MergeMockBlock[]): { getTopBlocks: () => MergeMockBlock[] } { + return { getTopBlocks: () => top }; +} + +describe('workspaceToPython', () => { + it('emits the rover servo + forward + sleep + stop sequence', () => { + const ws = workspace(onReceive(block('rover_forward', { TIME: 2 }))); + expect(workspaceToPython(ws)).toBe( + [ + 'rover.setServo(9, 0)', + 'rover.setServo(11, 0)', + 'rover.setServo(13, 0)', + 'rover.setServo(15, 0)', + 'rover.forward(60)', + 'time.sleep(2)', + 'rover.stop()', + ].join('\n') + '\n' + ); + }); + + it('indents a repeat loop body with range()', () => { + const ws = workspace( + onReceive(block('rover_repeat', { TIMES: 3 }, { DO: block('rover_stop') })) + ); + expect(workspaceToPython(ws)).toBe( + ['for _ in range(3):', ' rover.stop()'].join('\n') + '\n' + ); + }); + + it('emits mast/LED/photo actions', () => { + const ws = workspace( + onReceive( + chain( + block('rover_leds_all', { COLOUR: '255, 0, 0' }), + block('rover_take_photo'), + block('rover_read_distance') + ) + ) + ); + expect(workspaceToPython(ws)).toBe( + [ + 'rover.setColor(rover.fromRGB(255, 0, 0))', + 'rover.show()', + 'take_photo()', + "print('Distance: ' + str(round(rover.getDistance())) + ' cm')", + ].join('\n') + '\n' + ); + }); + + it('only generates code inside an On uplink hat (loose blocks ignored)', () => { + const ws = workspace(block('rover_forward', { TIME: 1 })); // not inside on_receive + expect(workspaceToPython(ws)).toBe('\n'); + }); +}); + +describe('mergeUplinkHats', () => { + it('merges extra uplink hats into the first one in canvas order', () => { + const firstBody = mergeBlock('rover_forward'); + const secondBody = mergeBlock('rover_spin_left'); + const firstHat = mergeBlock('rover_on_receive'); + const secondHat = mergeBlock('rover_on_receive'); + firstHat._body = firstBody; + secondHat._body = secondBody; + + const ws = mergeWorkspace(firstHat, secondHat); + + expect(mergeUplinkHats(ws)).toBe(true); + expect(firstHat._body).toBe(firstBody); + expect(firstBody._next).toBe(secondBody); + expect(secondHat._disposed).toBe(true); + }); + + it('reports a change and disposes an empty spare hat, even though nothing needed relocating', () => { + // The likely real case: a learner drags out a second uplink, never puts + // anything in it, and leaves. There is no body to move, but the spare + // hat still needs to disappear - and the caller still needs to know a + // save is due, or the disposal never survives past this session. + const firstBody = mergeBlock('rover_forward'); + const firstHat = mergeBlock('rover_on_receive'); + const emptyHat = mergeBlock('rover_on_receive'); + firstHat._body = firstBody; + + const ws = mergeWorkspace(firstHat, emptyHat); + + expect(mergeUplinkHats(ws)).toBe(true); + expect(emptyHat._disposed).toBe(true); + expect(firstHat._body).toBe(firstBody); + }); + + it('does nothing to a workspace with a single uplink hat', () => { + const onlyHat = mergeBlock('rover_on_receive'); + const ws = mergeWorkspace(onlyHat); + + expect(mergeUplinkHats(ws)).toBe(false); + expect(onlyHat._disposed).toBe(false); + }); +}); + +describe('workspaceToCommands', () => { + it('maps movement blocks to simulator commands at fixed speed 60', () => { + const ws = workspace( + onReceive( + chain( + block('rover_forward', { TIME: 2 }), + block('rover_steer_left', { DEGREES: 20, TIME: 1 }), + block('rover_take_photo') // non-movement → skipped + ) + ) + ); + expect(workspaceToCommands(ws)).toEqual([ + { command: 'forward', speed: 60, duration: 2 }, + { command: 'steerLeft', degrees: 20, speed: 60, duration: 1 }, + ]); + }); + + it('expands repeat loops', () => { + const ws = workspace( + onReceive(block('rover_repeat', { TIMES: 2 }, { DO: block('rover_stop') })) + ); + expect(workspaceToCommands(ws)).toEqual([{ command: 'stop' }, { command: 'stop' }]); + }); +}); diff --git a/mission-control/src/__tests__/unit/sandbox-disallowed-commands.test.ts b/mission-control/src/__tests__/unit/sandbox-disallowed-commands.test.ts new file mode 100644 index 0000000..23818cc --- /dev/null +++ b/mission-control/src/__tests__/unit/sandbox-disallowed-commands.test.ts @@ -0,0 +1,3 @@ +describe('sandbox disallowed commands (planned)', () => { + it.todo('Task 76: disallowed commands raise exceptions in the sandbox'); +}); diff --git a/mission-control/src/__tests__/unit/validation.test.ts b/mission-control/src/__tests__/unit/validation.test.ts new file mode 100644 index 0000000..348d859 --- /dev/null +++ b/mission-control/src/__tests__/unit/validation.test.ts @@ -0,0 +1,199 @@ +/** + * Unit Tests for Mission Schema Validation (Task 39) + * + * Tests validation rules for mission submission. + * Ensures data integrity before persistence. + */ + +import { validateMission, createMissionSchema } from '@/infrastructure/validation/schemas'; + +describe('Mission Schema Validation', () => { + describe('validateMission', () => { + it('should accept valid mission data', () => { + const validData = { + yardId: 'uct-rover-1', + learnerId: 'learner-123', + sessionId: 'test-session-123', + name: 'Test Mission', + code: 'rover.forward(100)\nrover.wait(2)', + }; + + const result = validateMission(validData); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validData); + expect(result.errors).toBeUndefined(); + }); + + it('should accept a minimal valid mission', () => { + const validData = { + yardId: 'yard-1', + learnerId: 'learner-456', + sessionId: 'session-456', + name: 'Turn Left Mission', + code: 'rover.turn_left(50)', // Updated to use approved command + }; + + const result = validateMission(validData); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validData); + }); + + it('should reject empty yardId', () => { + const invalidData = { + yardId: '', + sessionId: 'session-123', + code: 'rover.forward(100)', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors).toContain('yardId: Yard ID is required'); + }); + + it('should reject yardId with invalid characters', () => { + const invalidData = { + yardId: 'yard@#$%', + sessionId: 'session-123', + code: 'rover.forward(100)', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors?.[0]).toContain('Yard ID must contain only alphanumeric characters'); + }); + + it('should reject empty sessionId', () => { + const invalidData = { + yardId: 'yard-1', + sessionId: '', + code: 'rover.forward(100)', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors).toContain('sessionId: Session ID is required'); + }); + + it('should reject empty code', () => { + const invalidData = { + yardId: 'yard-1', + sessionId: 'session-123', + code: '', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors).toContain('code: Code cannot be empty'); + }); + + it('should reject code with only whitespace', () => { + const invalidData = { + yardId: 'yard-1', + sessionId: 'session-123', + code: ' \n\t ', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors).toContain('code: Code cannot be only whitespace'); + }); + + it('should reject code exceeding maximum length', () => { + const invalidData = { + yardId: 'yard-1', + learnerId: 'learner-123', + sessionId: 'session-123', + name: 'Too Long Mission', + code: 'a'.repeat(10001), + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors?.[0]).toContain('Code exceeds maximum length'); + }); + + it('should reject yardId exceeding maximum length', () => { + const invalidData = { + yardId: 'a'.repeat(51), + sessionId: 'session-123', + code: 'rover.forward(100)', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors).toContain('yardId: Yard ID too long'); + }); + + it('should reject sessionId exceeding maximum length', () => { + const invalidData = { + yardId: 'yard-1', + sessionId: 'a'.repeat(101), + code: 'rover.forward(100)', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors).toContain('sessionId: Session ID too long'); + }); + + it('should reject missing required fields', () => { + const invalidData = { + yardId: 'yard-1', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors?.length).toBeGreaterThan(0); + }); + + it('should return multiple errors for multiple invalid fields', () => { + const invalidData = { + yardId: '', + sessionId: '', + code: '', + }; + + const result = validateMission(invalidData); + + expect(result.success).toBe(false); + expect(result.errors?.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe('createMissionSchema', () => { + it('should parse valid data with Zod', () => { + const validData = { + yardId: 'rover-yard-1', + learnerId: 'learner_abc123', + sessionId: 'sess_abc123', + name: 'Zod Parse Mission', + code: 'rover.forward(100)\nrover.stop()', + }; + + const parsed = createMissionSchema.parse(validData); + + expect(parsed).toEqual(validData); + }); + + it('should throw ZodError for invalid data', () => { + const invalidData = { + yardId: 123, + sessionId: null, + code: '', + }; + + expect(() => createMissionSchema.parse(invalidData)).toThrow(); + }); + }); +}); diff --git a/mission-control/src/__tests__/unit/youtube-embed.test.tsx b/mission-control/src/__tests__/unit/youtube-embed.test.tsx new file mode 100644 index 0000000..f776783 --- /dev/null +++ b/mission-control/src/__tests__/unit/youtube-embed.test.tsx @@ -0,0 +1,55 @@ +/** + * @jest-environment jsdom + * + * Unit tests for the click-to-play YouTube facade. + * + * The iframe must not exist until the learner taps play (eager embeds from a + * shared venue IP trigger YouTube's bot detection), the player must use the + * privacy-enhanced youtube-nocookie.com domain, and the Watch-on-YouTube + * escape hatch must always be present since a blocked embed cannot be + * detected or recovered from inside the iframe. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { YouTubeEmbed } from '@/components/mission/YouTubeEmbed'; + +const ID = 'dQw4w9WgXcQ'; + +describe('YouTubeEmbed', () => { + it('renders the thumbnail facade with no iframe until played', () => { + const { container } = render(); + + expect(container.querySelector('iframe')).not.toBeInTheDocument(); + + const thumb = container.querySelector('img'); + expect(thumb).toHaveAttribute('src', `https://img.youtube.com/vi/${ID}/hqdefault.jpg`); + + expect(screen.getByRole('button', { name: /play video: sand observer run/i })).toBeInTheDocument(); + }); + + it('creates the privacy-enhanced iframe with autoplay after tapping play', () => { + const { container } = render(); + + fireEvent.click(screen.getByRole('button', { name: /play video/i })); + + const iframe = container.querySelector('iframe'); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute( + 'src', + `https://www.youtube-nocookie.com/embed/${ID}?rel=0&autoplay=1` + ); + // The facade button is gone once the player is live + expect(screen.queryByRole('button', { name: /play video/i })).not.toBeInTheDocument(); + }); + + it('always offers the Watch on YouTube escape hatch in a new tab', () => { + render(); + + const link = screen.getByRole('link', { name: /watch on youtube/i }); + expect(link).toHaveAttribute('href', `https://www.youtube.com/watch?v=${ID}`); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')); + }); +}); diff --git a/mission-control/src/app/api/learners/[id]/email/route.ts b/mission-control/src/app/api/learners/[id]/email/route.ts new file mode 100644 index 0000000..7f3f42e --- /dev/null +++ b/mission-control/src/app/api/learners/[id]/email/route.ts @@ -0,0 +1,115 @@ +/** + * POST /api/learners/[id]/email + * + * Sets (or clears) a learner's contact address. + * + * Why this route exists at all, rather than the browser writing the field + * itself as it used to: + * + * Mission documents are world-readable and carry `learnerId`, and learner + * documents are readable by exact id. So anyone could read the public feed, + * collect learner ids from it, fetch each learner document and read + * `learnerEmail` in plaintext - harvesting a list of school children's email + * addresses from public data, with no credentials. `list: false` on the + * collection did not help, because the ids were already being published. + * + * Firestore rules cannot hide a single field on read, so the address moves to + * a subcollection that browsers are denied entirely, and only the Admin SDK + * (which bypasses rules) can reach it. The public learner document keeps the + * harmless profile fields. + * + * KNOWN LIMITATION, deliberately not solved here: `learnerId` is still + * published on public mission documents, so it is not a secret and this route + * cannot prove the caller owns the learner it names. That means someone can + * still WRITE an address onto another learner's record - exactly as they + * could before this change, since the old browser-side rule was equally + * unauthenticated. This change closes the bulk-read exposure only. The root + * fix is to stop publishing learnerId (carry a one-way hash on missions, the + * same trick already used for the address itself) so possession of the id + * means something; that needs a backfill of existing mission documents and is + * tracked separately. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getFirestoreInstance } from '@/infrastructure/persistence/firebase-admin'; +import { + LEARNER_PRIVATE_COLLECTION, + LEARNER_CONTACT_DOC, +} from '@/core/domain/services/learnerContact'; + +const bodySchema = z.object({ + // null clears the address (the learner removing it). + email: z.string().email('Must be a valid email address').nullable(), +}); + +export async function POST( + request: NextRequest, + ctx: { params: Promise<{ id: string }> } +) { + const { id } = await ctx.params; + + if (!id || id.length > 64) { + return NextResponse.json( + { success: false, error: 'Invalid learner id' }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { success: false, error: 'Invalid JSON body' }, + { status: 400 } + ); + } + + const validation = bodySchema.safeParse(body); + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: validation.error.errors.map((e) => e.message).join(', '), + }, + { status: 400 } + ); + } + + const { email } = validation.data; + + try { + const firestore = getFirestoreInstance(); + const contactRef = firestore + .collection('learners') + .doc(id) + .collection(LEARNER_PRIVATE_COLLECTION) + .doc(LEARNER_CONTACT_DOC); + + if (email === null) { + await contactRef.delete(); + } else { + await contactRef.set( + { learnerEmail: email, updatedAt: new Date().toISOString() }, + { merge: true } + ); + } + + // Clear any address left on the publicly readable parent document by the + // old client-side write. Without this, existing learners stay exposed + // even though nothing writes there any more. + await firestore + .collection('learners') + .doc(id) + .set({ learnerEmail: null }, { merge: true }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[learners/email] Failed to persist contact address:', error); + return NextResponse.json( + { success: false, error: 'Failed to save email' }, + { status: 500 } + ); + } +} diff --git a/mission-control/src/app/api/missions/[id]/notify/route.ts b/mission-control/src/app/api/missions/[id]/notify/route.ts new file mode 100644 index 0000000..17b4e09 --- /dev/null +++ b/mission-control/src/app/api/missions/[id]/notify/route.ts @@ -0,0 +1,88 @@ +/** + * POST /api/missions/[id]/notify + * + * Best-effort status-change email trigger for callers that update mission + * status directly in Firestore instead of through PATCH /api/missions/[id] + * (the yard operator console, which must keep working even if this app is + * unreachable). This route sends the notification only - it never touches + * persistence, since the caller has already written the new status itself. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { getFirestoreInstance } from '@/infrastructure/persistence/firebase-admin'; +import { FirestoreMissionRepository } from '@/infrastructure/persistence/FirestoreMissionRepository'; +import { MissionService } from '@/core/application/services/MissionService'; +import { MissionNotificationService } from '@/core/application/services/MissionNotificationService'; +import { ResendEmailSender } from '@/infrastructure/email/resend-client'; +import { resolveAppUrl } from '@/infrastructure/config/appUrl'; + +const notifyRequestSchema = z.object({ + status: z.enum(['queued', 'processing', 'completed', 'failed', 'cancelled']), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { success: false, error: 'Invalid JSON body' }, + { status: 400 } + ); + } + + const validation = notifyRequestSchema.safeParse(body); + if (!validation.success) { + const errors = validation.error.errors.map((err) => { + const path = err.path.join('.'); + return `${path}: ${err.message}`; + }); + + return NextResponse.json( + { success: false, error: 'Validation failed', details: errors }, + { status: 400 } + ); + } + + try { + const firestore = getFirestoreInstance(); + const repository = new FirestoreMissionRepository(firestore); + const service = new MissionService(repository); + const mission = await service.getMissionById(id); + + if (!mission) { + return NextResponse.json( + { success: false, error: 'Mission not found' }, + { status: 404 } + ); + } + + const appUrl = resolveAppUrl(); + const notificationService = new MissionNotificationService( + new ResendEmailSender(), + firestore, + appUrl + ); + + const outcome = await notificationService.notifyStatusChange(mission, validation.data.status); + + // Surfaced rather than discarded. Sending stays best-effort - a provider + // outage must not fail the caller, which is the yard console - but the + // caller can no longer tell "sent" apart from "silently skipped because + // the learner has no address" or "Resend rejected it". That ambiguity cost + // an afternoon of testing a domain-verification failure as if it were a + // broken template. Always HTTP 200; the detail is in the body. + return NextResponse.json({ success: true, notification: outcome }, { status: 200 }); + } catch (error) { + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/mission-control/src/app/api/missions/route.ts b/mission-control/src/app/api/missions/route.ts new file mode 100644 index 0000000..c0785a3 --- /dev/null +++ b/mission-control/src/app/api/missions/route.ts @@ -0,0 +1,66 @@ +/** + * POST /api/missions - server-side mission submission (Tasks 40 & 41). + * + * Validates the payload (schema + command allowlist) before persisting, so + * submission can be trusted even if it bypasses the client UI. The client may + * still submit directly via the repository, but this endpoint is the safe path. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { validateMission } from '@/infrastructure/validation/schemas'; +import { getFirestoreInstance } from '@/infrastructure/persistence/firebase-admin'; +import { FirestoreMissionRepository } from '@/infrastructure/persistence/FirestoreMissionRepository'; +import { MissionService } from '@/core/application/services/MissionService'; +import { MissionNotificationService } from '@/core/application/services/MissionNotificationService'; +import { ResendEmailSender } from '@/infrastructure/email/resend-client'; +import { resolveAppUrl } from '@/infrastructure/config/appUrl'; + +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { success: false, error: 'Invalid JSON body' }, + { status: 500 } + ); + } + + // Phase 1 + 2: schema and command-allowlist validation + const validation = validateMission(body); + if (!validation.success || !validation.data) { + return NextResponse.json( + { success: false, error: 'Validation failed', details: validation.errors ?? [] }, + { status: 400 } + ); + } + + try { + const firestore = getFirestoreInstance(); + const repository = new FirestoreMissionRepository(firestore); + const service = new MissionService(repository); + const result = await service.submitMission(validation.data); + + if (!result.success || !result.mission) { + return NextResponse.json( + { success: false, error: result.error ?? 'Failed to submit mission' }, + { status: 500 } + ); + } + + const appUrl = resolveAppUrl(); + const notificationService = new MissionNotificationService( + new ResendEmailSender(), + firestore, + appUrl + ); + await notificationService.notifyStatusChange(result.mission, 'queued'); + + return NextResponse.json({ success: true, mission: result.mission }, { status: 201 }); + } catch (error) { + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/mission-control/src/app/apple-icon.png b/mission-control/src/app/apple-icon.png new file mode 100644 index 0000000..d191e0c Binary files /dev/null and b/mission-control/src/app/apple-icon.png differ diff --git a/mission-control/src/app/favicon.ico b/mission-control/src/app/favicon.ico new file mode 100644 index 0000000..cf51d24 Binary files /dev/null and b/mission-control/src/app/favicon.ico differ diff --git a/mission-control/src/app/globals.css b/mission-control/src/app/globals.css new file mode 100644 index 0000000..9c7a574 --- /dev/null +++ b/mission-control/src/app/globals.css @@ -0,0 +1,670 @@ +@import "tailwindcss"; + +/* + * Theme tokens, dark and light. + * + * Historically this whole block was named ".light"/[data-theme="light"] but + * held dark-space colours - there was no light theme, just a misleadingly + * named default and html{color-scheme:dark} forcing it everywhere. Now + * :root is the pre-hydration fallback (used only for the instant before the + * theme-init script in layout.tsx sets data-theme), and [data-theme="dark"] + * / [data-theme="light"] are each complete, independent token sets - nothing + * in the light block is inherited by omission from the dark one. + */ +:root { + /* 0.9rem came with the Paper & Ink palette. It lives here rather than in the + light block because radius is not a colour-scheme concern, so this + tightens the corners in BOTH themes by 1.6px at the base step. */ + --radius: 0.9rem; + + --background: oklch(0.16 0.04 270); + --foreground: oklch(0.97 0.02 260); + + --card: oklch(0.21 0.05 270); + --card-foreground: oklch(0.97 0.02 260); + --popover: oklch(0.21 0.05 270); + --popover-foreground: oklch(0.97 0.02 260); + + --primary: oklch(0.72 0.19 45); + --primary-foreground: oklch(0.16 0.04 270); + + --secondary: oklch(0.28 0.06 270); + --secondary-foreground: oklch(0.97 0.02 260); + --muted: oklch(0.26 0.04 270); + --muted-foreground: oklch(0.75 0.04 260); + --accent: oklch(0.74 0.18 175); + --accent-foreground: oklch(0.16 0.04 270); + + --destructive: oklch(0.65 0.24 27); + --destructive-foreground: oklch(0.99 0 0); + --border: oklch(0.32 0.05 270); + --input: oklch(0.32 0.05 270); + --ring: oklch(0.72 0.19 45); + + /* Mission tokens - vivid gradient/glow colours, same in both themes. + They read as accent chips and gradients (bg-gradient-mars etc.), not + bare text on the page background, so they don't need a light variant. */ + --mars: oklch(0.68 0.21 35); + --mars-glow: oklch(0.78 0.18 55); + --buzz: oklch(0.72 0.21 145); /* Buzz Lightyear green */ + --buzz-glow: oklch(0.85 0.18 155); + --cosmic: oklch(0.55 0.22 285); /* deep purple nebula */ + --stardust: oklch(0.92 0.06 95); + + /* Rover block colours. Defined here rather than as literals in @theme inline + because light mode needs deeper versions - these are vivid enough to sit + on a dark page but several fail contrast as text or as a chip fill on + paper. [data-theme="light"] overrides each one. */ + --block-move: #2196f3; /* Move Forward / Backward */ + --block-spin: #9c27b0; /* Spin Left / Right */ + --block-steer: #00bcd4; /* Steer Left / Right */ + --block-stop: #f44336; /* Stop */ + --block-hat: #ff6d00; /* uplink / hat */ + --block-foreground: oklch(0.99 0 0); /* text on a filled block */ + + --gradient-mars: linear-gradient(135deg, var(--mars) 0%, var(--mars-glow) 100%); + --gradient-cosmic: linear-gradient(135deg, var(--cosmic) 0%, var(--mars) 100%); + --gradient-launch: linear-gradient(135deg, var(--primary) 0%, oklch(0.65 0.24 12) 100%); + + --shadow-glow-mars: 0 20px 60px -15px color-mix(in oklab, var(--mars) 55%, transparent); + --shadow-glow-buzz: 0 20px 60px -15px color-mix(in oklab, var(--buzz) 55%, transparent); + --shadow-card: 0 30px 80px -30px color-mix(in oklab, var(--cosmic) 60%, transparent); + + /* Named easings, theme-independent (neither [data-theme] block overrides + these). The built-in CSS easings read as weak/generic - not using one + literal value in .clay-press and a different one everywhere else that + wants a "confident settle" curve. Motion can't read a CSS custom + property at runtime, so src/lib/easings.ts exports the same curve as a + plain array - keep the two in sync if this value ever changes. */ + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); + --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* reserved for a future sheet/drawer */ +} + +[data-theme="dark"] { + --background: oklch(0.13 0.04 270); + --foreground: oklch(0.97 0.02 260); + + --card: oklch(0.21 0.05 270); + --card-foreground: oklch(0.97 0.02 260); + --popover: oklch(0.21 0.05 270); + --popover-foreground: oklch(0.97 0.02 260); + + --primary: oklch(0.72 0.19 45); + --primary-foreground: oklch(0.16 0.04 270); + + --secondary: oklch(0.28 0.06 270); + --secondary-foreground: oklch(0.97 0.02 260); + --muted: oklch(0.26 0.04 270); + --muted-foreground: oklch(0.75 0.04 260); + --accent: oklch(0.74 0.18 175); + --accent-foreground: oklch(0.16 0.04 270); + + --destructive: oklch(0.65 0.24 27); + --destructive-foreground: oklch(0.99 0 0); + --border: oklch(0.32 0.05 270); + --input: oklch(0.32 0.05 270); + --ring: oklch(0.72 0.19 45); +} + +/* + * Light mode: "Paper & Ink". + * + * Paper and ink, fully monochrome. Values supplied by the user from a Lovable + * palette; transcribed here rather than pasted into :root, because in this + * codebase :root is the pre-hydration DARK fallback - dropping a light palette + * there would have made every first paint, and every browser that never runs + * the theme script, render light-on-light. + * + * The hue is WARM (60-85), not the 270 the dark theme uses. That is the point: + * this is paper, not a lightened space theme. An earlier pass carried 270 + * across at low chroma and read as cold grey. + * + * --primary is INK, not the mission orange. Every CTA and the active tab pill + * paint with bg-gradient-mars, so --gradient-mars is overridden below to the + * ink gradient - without that the buttons stay orange and the palette is only + * half applied. The mission orange survives in dark mode untouched. + */ +[data-theme="light"] { + --background: oklch(0.966 0.006 85); /* warm off-white paper */ + --foreground: oklch(0.18 0.005 60); /* near-black ink */ + --card: oklch(0.99 0.004 85); + --card-foreground: oklch(0.18 0.005 60); + --popover: oklch(0.99 0.004 85); + --popover-foreground: oklch(0.18 0.005 60); + + --primary: oklch(0.22 0.005 60); /* ink as the accent */ + --primary-foreground: oklch(0.97 0.005 85); + --secondary: oklch(0.925 0.008 85); + --secondary-foreground: oklch(0.22 0.005 60); + --muted: oklch(0.925 0.008 85); + --muted-foreground: oklch(0.48 0.008 70); + --accent: oklch(0.89 0.01 85); + --accent-foreground: oklch(0.2 0.005 60); + --destructive: oklch(0.52 0.2 27); + --destructive-foreground: oklch(0.97 0.005 85); + + --border: oklch(0.88 0.008 85); + --input: oklch(0.88 0.008 85); + --ring: oklch(0.22 0.005 60); + + /* Deepened so white text stays readable on a light page. The dark-theme + values are vivid enough against near-black but several drop under 4.5:1 + as a chip fill or as bare text here. */ + --block-move: oklch(0.5 0.16 248); + --block-spin: oklch(0.46 0.19 318); + --block-steer: oklch(0.52 0.11 200); + --block-stop: oklch(0.52 0.2 27); + --block-hat: oklch(0.52 0.15 55); + --block-foreground: oklch(0.99 0.005 85); + + /* Surfaces and effects */ + --sand: oklch(0.93 0.008 85); /* subtle inset panels */ + --clay: oklch(0.82 0.03 80); /* simulator terrain */ + --gradient-ink: linear-gradient(135deg, oklch(0.28 0.005 60), oklch(0.14 0.005 60)); + --shadow-soft: + 0 1px 2px oklch(0.2 0.01 60 / 0.07), + 0 12px 30px -18px oklch(0.2 0.01 60 / 0.4); + + /* Every CTA, the active nav item and both tab pills paint with these. Left + as mars gradients they would stay orange on a monochrome page, so they + resolve to ink here. Dark mode keeps the mission orange. */ + --gradient-mars: var(--gradient-ink); + --gradient-launch: var(--gradient-ink); + --shadow-card: var(--shadow-soft); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 8px); + --radius-3xl: calc(var(--radius) + 12px); + --radius-4xl: calc(var(--radius) + 16px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-ring-offset-background: var(--background); + + /* Mission palette */ + --color-mars: var(--mars); + --color-mars-glow: var(--mars-glow); + --color-buzz: var(--buzz); + --color-buzz-glow: var(--buzz-glow); + --color-cosmic: var(--cosmic); + --color-stardust: var(--stardust); + + /* Blockly category colours (echo the real blocks the kids touch) */ + /* Bound to the vars above rather than literal hex, so light mode can deepen + them. The literals used to live here, which meant every theme got the same + saturated colour and several were unreadable on paper. */ + --color-block-move: var(--block-move); + --color-block-spin: var(--block-spin); + --color-block-steer: var(--block-steer); + --color-block-stop: var(--block-stop); + --color-block-hat: var(--block-hat); + --color-block-foreground: var(--block-foreground); + + /* Page-content ceiling. One token instead of max-w-6xl/7xl repeated + across five files - a monitor wider than 1280px was leaving dead + margins on every page with no way to retune it in one place. */ + --container-page: 112.5rem; /* 1800px */ + + --font-display: "Fredoka", "Baloo 2", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; + --font-sans: "Inter", system-ui, sans-serif; + + /* Custom Mars Rover Animations */ + --animate-float: float 6s ease-in-out infinite; + --animate-pulse-slow: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-twinkle: twinkle 2.6s ease-in-out infinite; + --animate-blast: blast-off 1.6s ease-in-out infinite; + --animate-drift: drift 240s linear infinite; + --animate-shoot: shoot 6s ease-in infinite; +} + +@layer base { + * { border-color: var(--color-border); } + + html, body { font-family: var(--font-sans); } + + body { + background-color: var(--color-background); + color: var(--color-foreground); + } + + body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -20; + pointer-events: none; + background-image: + radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklab, var(--cosmic) 40%, transparent), transparent 60%), + radial-gradient(ellipse 60% 40% at 100% 100%, color-mix(in oklab, var(--mars) 35%, transparent), transparent 60%); + } + + /* Same wash, dimmed - full strength was tuned against a near-black page and + reads as a heavy tint rather than a hint once the background is near-white. */ + [data-theme="light"] body::before { + opacity: 0.35; + } + + h1, h2, h3, .font-display { font-family: var(--font-display); letter-spacing: -0.01em; } +} + +@layer utilities { + .text-gradient-mars { + background: var(--gradient-mars); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + .text-gradient-launch { + background: var(--gradient-launch); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + .bg-gradient-mars { background: var(--gradient-mars); } + .bg-gradient-cosmic { background: var(--gradient-cosmic); } + .shadow-glow-mars { box-shadow: var(--shadow-glow-mars); } + .shadow-glow-buzz { box-shadow: var(--shadow-glow-buzz); } + .shadow-card { box-shadow: var(--shadow-card); } + + .starfield { + background-image: + /* tiny distant stars */ + radial-gradient(1px 1px at 20% 30%, oklch(1 0 0 / 0.95), transparent 60%), + radial-gradient(1px 1px at 70% 60%, oklch(0.95 0.06 95 / 0.9), transparent 60%), + radial-gradient(1px 1px at 85% 20%, oklch(1 0 0 / 0.85), transparent 60%), + radial-gradient(1px 1px at 60% 15%, oklch(1 0 0 / 0.8), transparent 60%), + radial-gradient(1px 1px at 33% 88%, oklch(1 0 0 / 0.8), transparent 60%), + radial-gradient(1px 1px at 92% 78%, oklch(0.9 0.08 220 / 0.85), transparent 60%), + radial-gradient(1px 1px at 5% 50%, oklch(1 0 0 / 0.7), transparent 60%), + radial-gradient(1px 1px at 50% 50%, oklch(1 0 0 / 0.7), transparent 60%), + radial-gradient(1px 1px at 15% 12%, oklch(1 0 0 / 0.75), transparent 60%), + radial-gradient(1px 1px at 78% 42%, oklch(1 0 0 / 0.7), transparent 60%), + /* brighter mid stars */ + radial-gradient(1.5px 1.5px at 10% 70%, oklch(0.95 0.1 60 / 0.95), transparent 55%), + radial-gradient(1.5px 1.5px at 88% 35%, oklch(0.95 0.08 220 / 0.9), transparent 55%), + radial-gradient(1.5px 1.5px at 25% 55%, oklch(1 0 0 / 0.9), transparent 55%), + radial-gradient(1.5px 1.5px at 65% 82%, oklch(0.92 0.1 320 / 0.9), transparent 55%), + /* large glowing stars */ + radial-gradient(2px 2px at 40% 80%, oklch(0.9 0.12 220 / 0.95), transparent 55%), + radial-gradient(2px 2px at 72% 25%, oklch(0.95 0.1 60 / 0.95), transparent 55%), + radial-gradient(2.5px 2.5px at 18% 38%, oklch(0.95 0.14 320 / 0.95), transparent 55%); + background-size: 600px 600px, 600px 600px, 600px 600px, 600px 600px, 600px 600px, 600px 600px, 600px 600px, 600px 600px, 600px 600px, 600px 600px, 900px 900px, 900px 900px, 900px 900px, 900px 900px, 1200px 1200px, 1200px 1200px, 1200px 1200px; + background-repeat: repeat; + } + + /* Point-of-light stars are drawn for a near-black backdrop and either + vanish or turn into a muddy smear against a near-white one - there's no + sensible light-mode equivalent within this pass, so it's suppressed + rather than retuned. */ + [data-theme="light"] .starfield { + display: none; + } + + .nebula { + background-image: + radial-gradient(ellipse 50% 35% at 22% 28%, color-mix(in oklab, var(--cosmic) 55%, transparent), transparent 70%), + radial-gradient(ellipse 45% 30% at 78% 65%, color-mix(in oklab, var(--mars) 45%, transparent), transparent 70%), + radial-gradient(ellipse 30% 25% at 55% 85%, color-mix(in oklab, oklch(0.6 0.22 320) 50%, transparent), transparent 70%), + radial-gradient(ellipse 25% 20% at 90% 10%, color-mix(in oklab, oklch(0.7 0.18 200) 45%, transparent), transparent 70%); + filter: blur(40px); + will-change: transform; + transform: translateZ(0); + } + + .milky-way { + background: + linear-gradient(115deg, + transparent 30%, + color-mix(in oklab, oklch(0.85 0.08 280) 18%, transparent) 45%, + color-mix(in oklab, oklch(0.9 0.1 320) 22%, transparent) 50%, + color-mix(in oklab, oklch(0.85 0.08 220) 18%, transparent) 55%, + transparent 70%); + filter: blur(20px); + will-change: transform; + transform: translateZ(0); + } + + .shooting-star { + position: absolute; + width: 140px; + height: 1px; + background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.95), transparent); + border-radius: 9999px; + transform: rotate(-18deg); + opacity: 0; + } + + /* Chunky, toy-like depth for kid-friendly cards and buttons (claymorphic): + a soft outer lift plus an inner bottom bevel, no neon glow. */ + .clay { + box-shadow: + 0 10px 24px -12px rgba(0, 0, 0, 0.55), + inset 0 -3px 0 rgba(0, 0, 0, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.06); + } + .clay-press { + /* The pressed-state transform gets a custom curve - the built-in + easings are too weak to read as an intentional press. Hover/filter + stays on plain ease, which is the right curve for a colour change. */ + transition: + transform 120ms var(--ease-out), + filter 150ms ease, + box-shadow 150ms ease; + } + .clay-press:hover { filter: brightness(1.06); } + .clay-press:active { transform: translateY(1px); } + + /* Thin, themed scrollbar for panels that scroll internally. */ + .scroll-panel { scrollbar-width: thin; scrollbar-color: var(--color-border) transparent; } + .scroll-panel::-webkit-scrollbar { width: 10px; height: 10px; } + .scroll-panel::-webkit-scrollbar-track { background: transparent; } + .scroll-panel::-webkit-scrollbar-thumb { + background: color-mix(in oklab, var(--color-border) 90%, transparent); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: content-box; + } + .scroll-panel::-webkit-scrollbar-thumb:hover { + background: color-mix(in oklab, var(--primary) 60%, var(--color-border)); + background-clip: content-box; + } + + /* input[type=search] draws its own clear button in WebKit, so the navbar + showed TWO of them: the browser's, and ours - which is the one that is + positioned with the filter chips, themed, and carries a real accessible + name. Suppress the native pair rather than dropping type=search, which is + what gives the field its search semantics and Escape-to-clear. */ + input[type='search']::-webkit-search-cancel-button, + input[type='search']::-webkit-search-decoration, + input[type='search']::-webkit-search-results-button, + input[type='search']::-webkit-search-results-decoration { + -webkit-appearance: none; + appearance: none; + } +} + +/* Respect users who prefer less motion: drop the ambient space animations and + keep transitions near-instant. */ +@media (prefers-reduced-motion: reduce) { + .animate-float, + .animate-drift, + .animate-shoot, + .animate-twinkle, + .animate-blast, + .page-transition-enter { + animation: none !important; + } + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-12px); } +} + +@keyframes drift { + from { transform: translate3d(0, 0, 0); } + to { transform: translate3d(-1200px, -1200px, 0); } +} + +@keyframes shoot { + 0% { transform: translate(0, 0) rotate(-18deg); opacity: 0; } + 10% { opacity: 1; } + 100% { transform: translate(520px, 170px) rotate(-18deg); opacity: 0; } +} + +@keyframes orbit { + from { transform: rotate(0deg) translateX(140px) rotate(0deg); } + to { transform: rotate(360deg) translateX(140px) rotate(-360deg); } +} + +@keyframes twinkle { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } +} + +@keyframes blast-off { + 0% { transform: translateY(0) scale(1); } + 50% { transform: translateY(-6px) scale(1.02); } + 100% { transform: translateY(0) scale(1); } +} + +@keyframes page-enter { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-float { animation: float 6s ease-in-out infinite; } +.animate-drift { animation: drift 240s linear infinite; } +.animate-shoot { animation: shoot 6s ease-in infinite; } +.animate-twinkle { animation: twinkle 2.6s ease-in-out infinite; } +.animate-blast { animation: blast-off 1.6s ease-in-out infinite; } + +/* Planets and decorative objects */ +.planet { + border-radius: 9999px; + filter: blur(18px); + will-change: transform; +} +.planet--small { + width: 120px; + height: 120px; + background: radial-gradient(circle at 30% 25%, color-mix(in oklab, var(--cosmic) 55%, transparent), var(--cosmic)); + opacity: 0.22; +} +.planet--large { + width: 240px; + height: 240px; + background: radial-gradient(circle at 25% 20%, color-mix(in oklab, var(--mars) 55%, transparent), var(--mars)); + opacity: 0.12; +} + +/* Slight orbital motion for a planet */ +.animate-orbit { animation: orbit 40s linear infinite; } + +.page-transition-enter { + animation: page-enter 220ms ease-out both; + will-change: transform, opacity; +} + +.workspaceSplitGrid { + display: grid; + gap: 0.5rem; + /* minmax(0, 1fr), never a bare 1fr. A bare `1fr` is minmax(AUTO, 1fr), and + that auto minimum lets the track grow to its content's min-content width - + so Blockly's canvas and the simulator pushed the column wider than the + phone, and everything to the right of the fold was simply unreachable. */ + grid-template-columns: minmax(0, 1fr); + /* Create Mission owns the viewport and sizes to it. The mission view puts + this inside an already-sized flex parent instead, and passes 100%. */ + max-height: var(--workspace-height, calc(100vh - 122px)); + height: var(--workspace-height, calc(100vh - 122px)); + /* No transition on grid-template-columns, deliberately, and this has now + been established the hard way twice: + 1. It is a layout property on a container that both Blockly and the + simulator canvas watch with a ResizeObserver, so interpolating it + fires a full svgResize + canvas redraw on every frame of the ease. + 2. Worse, and the reason it cannot simply be tuned: retargeting the + value rapidly (which a drag does, via the --workspace-* variables) + leaves Chrome's interpolation stuck. Measured directly - vars set + to 47fr/53fr rendered as 655.9px/573.1px and STAYED there after + settling, instead of the correct 577.6px/651.4px. The panels end up + at the wrong sizes, permanently, until the next change. + The split is dragged, and a drag wants the layout pinned to the cursor + anyway - there is no easing to miss here. */ +} + +/* + * The draggable divider between the build and simulator panels. + * + * This exists because a divider is the right mouse control for this job: it + * sits under the cursor and moves exactly with it, 1:1, at any container + * width. The keyboard path lives on the divider itself for precise stepping. + */ +.workspaceSplitDivider { + display: none; +} + +.workspaceSplitDivider::before { + content: ""; + width: 3px; + height: 42px; + border-radius: 999px; + background: var(--border); + transition: background-color 120ms var(--ease-out); +} + +.workspaceSplitDivider:hover::before, +.workspaceSplitDivider[data-dragging="true"]::before { + background: var(--primary); +} + +.workspaceSplitDivider:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-radius: 999px; +} + +/* Phones only. The grid is sized to one viewport height, which is right when + the panels sit side by side and fine on a tablet, but on a phone they stack + - so two panels shared ~690px, leaving Blockly and the simulator about 340px + each. Let the column grow and the page scroll instead: a phone scrolls + anyway, and half a screen of Blockly is not usable. + + Deliberately max-width 767px and not the lg breakpoint the columns use: + tablets are the device the yard runs on, and their layout is already good. */ +@media (max-width: 767px) { + .workspaceSplitGrid { + height: auto; + max-height: none; + } + + .workspaceSplitGrid > * { + min-height: 70vh; + } +} + +@media (min-width: 1024px) { + .workspaceSplitGrid { + align-items: stretch; + /* The middle track is the drag divider. Below this breakpoint the grid + collapses to one column and the divider is display:none, so there is + nothing to split and no third track to reserve. */ + grid-template-columns: + minmax(0, var(--workspace-left, 60fr)) + auto + minmax(320px, var(--workspace-right, 40fr)); + } + + .workspaceSplitDivider { + display: flex; + align-items: center; + justify-content: center; + /* Narrow visually (the 3px pill in ::before) but a comfortably wide + target - a 3px-wide grab area would be a precision test, not a + control. */ + width: 11px; + cursor: col-resize; + /* The pointer belongs to the divider for the whole drag, including the + frames where the cursor outruns the layout and leaves the element. */ + touch-action: none; + background: none; + border: none; + padding: 0; + } +} + +/* Blockly Light Theme Overrides */ + +/* + * The workspace container is clipped to a 20px corner radius, and the first + * toolbox category sits 4px from the top - so the arc ate roughly 7px of the + * 8px coloured border-left that identifies the category ("Uplink" lost most of + * its orange bar). Starting the list below where the arc straightens keeps the + * full bar visible on every category. + */ +.blocklyToolbox { + padding-top: 18px !important; +} +.blocklySvg { + background-color: #ffffff !important; +} + +.blocklyMainBackground { + fill: #ffffff !important; +} + +.blocklyToolboxDiv { + background-color: #f8fafc !important; + border-right: 1px solid #e2e8f0 !important; + min-width: 140px !important; + width: min(28vw, 176px) !important; +} + +.blocklyToolboxCategory { + background-color: transparent !important; + color: #1e293b !important; + padding: 5px 7px !important; + font-size: 11px !important; + font-weight: 600 !important; +} + +.blocklyFlyoutBackground { + fill: #f1f5f9 !important; + fill-opacity: 1 !important; +} + +.blocklyTreeRow { + color: #334155 !important; + cursor: pointer; + padding: 1px 6px !important; +} + +.blocklyTreeLabel { + color: #1e293b !important; + font-size: 11px !important; +} + +.blocklyText { + font-size: 11px !important; +} + +.blocklyHtmlInput, +.blocklyDropdownText { + font-size: 11px !important; +} diff --git a/mission-control/src/app/history/page.tsx b/mission-control/src/app/history/page.tsx new file mode 100644 index 0000000..39d917f --- /dev/null +++ b/mission-control/src/app/history/page.tsx @@ -0,0 +1,20 @@ +import { MissionHistory } from '@/components/mission/MissionHistory'; + +export default function HistoryPage() { + return ( +
+
+

+ My Missions +

+

+ Every rover run you have sent, newest first. +

+
+ +
+ +
+
+ ); +} diff --git a/mission-control/src/app/icon.png b/mission-control/src/app/icon.png new file mode 100644 index 0000000..d191e0c Binary files /dev/null and b/mission-control/src/app/icon.png differ diff --git a/mission-control/src/app/layout.tsx b/mission-control/src/app/layout.tsx new file mode 100644 index 0000000..37969de --- /dev/null +++ b/mission-control/src/app/layout.tsx @@ -0,0 +1,137 @@ +import type { Metadata } from "next"; +import { Inter, Fredoka } from "next/font/google"; +import Script from "next/script"; +import "./globals.css"; +import { Navbar } from "@/components/layout/Navbar"; +import { EnvironmentBanner } from "@/components/layout/EnvironmentBanner"; +import { LearnerProvider } from "@/contexts/LearnerContext"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import { SearchProvider } from "@/contexts/SearchContext"; +import { PageTransition } from "@/components/layout/PageTransition"; +import { resolveAppUrl } from "@/infrastructure/config/appUrl"; + +// Runs before hydration (next/script's beforeInteractive: "injected into the +// initial HTML from the server, downloaded before any Next.js module" - +// exactly what avoids a flash of the wrong theme). Decides once, synchronously, +// what data-theme starts as: a saved choice if the learner picked one, else +// the OS preference. ThemeContext reads this same attribute back on mount +// rather than re-deriving it, so the two can't disagree. +const THEME_INIT_SCRIPT = ` +(function() { + try { + var stored = localStorage.getItem('theme'); + var theme = stored === 'light' || stored === 'dark' + ? stored + : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + document.documentElement.setAttribute('data-theme', theme); + document.documentElement.style.colorScheme = theme; + } catch (e) {} +})(); +`; + +const inter = Inter({ + variable: "--font-sans", + subsets: ["latin"], +}); + +const fredoka = Fredoka({ + variable: "--font-display", + subsets: ["latin"], + weight: ["300", "400", "500", "600", "700"], +}); + +const APP_TITLE = "Mission Control · Mars Mission Platform"; +// Describes what the platform actually does. The previous line promised a +// rover called Sparky and mission patches to earn; neither exists anywhere in +// the codebase, and this string is what a shared link shows to someone who has +// never seen the site. +const APP_DESCRIPTION = + "Write a rover mission in blocks or Python, send it to a real Mars rover at the yard, and watch the video of your code driving it."; + +export const metadata: Metadata = { + // Absolute base for the og:image URL. Open Graph requires an absolute URL, + // and a crawler resolving a relative one against its own host gets nothing, + // so a shared link shows no image at all. Falls back to localhost for dev. + metadataBase: new URL(resolveAppUrl()), + title: APP_TITLE, + description: APP_DESCRIPTION, + + // The tab icon and the link preview both come from app/ file conventions: + // icon.png, apple-icon.png, favicon.ico and opengraph-image.jpg. All four + // are the SAME centre crop of public/rover-hero.jpg that the navbar renders + // top-left, so the rover in the tab is the rover on the page. + openGraph: { + title: APP_TITLE, + description: APP_DESCRIPTION, + type: "website", + siteName: "Mission Control", + }, + twitter: { + card: "summary_large_image", + title: APP_TITLE, + description: APP_DESCRIPTION, + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {/* Per next/script's own docs: beforeInteractive scripts are placed + in the component tree (body is the documented location for the + App Router - Next hoists it into at build time regardless + of where it's written; there's no hand-authored in the App + Router the way Pages Router's _document.js has one). */} + + + {/* Clean starfield backdrop: a single drifting layer of distant stars, + kept subtle so the UI reads like a punchy video feed, not a glow. + Hidden under light mode - see [data-theme="light"] .starfield in + globals.css. */} +
+ + {/* Planets and shooting stars for a livelier backdrop */} +
+
+
+
+
+
+
+ + {/* One restrained Mars glow anchored in a corner for warmth (no neon). */} +
+ + + + {/* Wraps Navbar AND the page: the navbar renders the search UI + while each page publishes what is searchable. */} + + + + {/* pb on mobile keeps content clear of the fixed bottom tab bar */} +
+ {children} +
+
+
+
+ + + ); +} diff --git a/mission-control/src/app/mission/page.tsx b/mission-control/src/app/mission/page.tsx new file mode 100644 index 0000000..7f9ad96 --- /dev/null +++ b/mission-control/src/app/mission/page.tsx @@ -0,0 +1,26 @@ +import { MissionWorkspace } from '@/components/mission/MissionWorkspace'; +import { Suspense } from 'react'; + +export default function MissionPage() { + return ( + // See MissionVideoClient for the full reasoning: pinned to the viewport + // from md up, free to grow on a phone where the panels stack and a fixed + // 100vh clips the simulator out of reach. +
+
+
+

+ Build your Mission +

+

+ Drive it, snap blocks together, or write Python, then send it to a real rover. +

+
+ + Loading workspace...
}> + + +
+ + ); +} diff --git a/mission-control/src/app/missions/[missionId]/MissionVideoClient.tsx b/mission-control/src/app/missions/[missionId]/MissionVideoClient.tsx new file mode 100644 index 0000000..a6ec73b --- /dev/null +++ b/mission-control/src/app/missions/[missionId]/MissionVideoClient.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useEffect, useMemo, useState } from 'react'; +import { ArrowLeft, Rocket, Star, Zap } from 'lucide-react'; +import { Mission } from '@/core/domain/entities/Mission'; +import Link from 'next/link'; +import { getFirestoreClient } from '@/lib/firebase'; +import { FirestoreMissionRepository } from '@/infrastructure/persistence/FirestoreMissionRepository'; +import { RoverSimulator } from '@/components/mission/RoverSimulator'; +import { YouTubeEmbed } from '@/components/mission/YouTubeEmbed'; +import { BlocklyViewer } from '@/components/mission/BlocklyViewer'; +import { parseRoverCode } from '@/lib/parseRoverCode'; +import { simulateCommands } from '@/lib/simulateCommands'; +import { getDiscoveryStatus, DISCOVERY_BADGE_CLASS } from '@/lib/discoveryStatus'; +import { useFavorites } from '@/lib/useFavorites'; +import { SplitPane } from '@/components/ui/SplitPane'; + +function getYouTubeId(url: string | undefined): string | null { + if (!url) return null; + const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; + const match = url.match(regExp); + return (match && match[2].length === 11) ? match[2] : null; +} + +type RunOption = { id: string; label: string; kind: 'sim' | 'real'; youtubeId?: string }; + +export default function MissionVideoClient({ missionId }: { missionId: string }) { + const [mission, setMission] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [selectedRunId, setSelectedRunId] = useState('sim'); + const [codeView, setCodeView] = useState<'blocks' | 'python'>('blocks'); + const [copied, setCopied] = useState(false); + const { isFavorite, toggleFavorite } = useFavorites(); + + // The simulated run is reproducible from the mission's code, so it is computed + // on demand rather than stored. Keeps hosting cheap and always in sync. + const simTrajectory = useMemo( + () => (mission ? simulateCommands(parseRoverCode(mission.code)) : []), + [mission] + ); + + // Run selector entries: the simulated run is always present; real yard runs + // are added as they are attached. A dropdown handles any number of runs. + const runs = useMemo(() => { + const list: RunOption[] = [{ id: 'sim', label: 'Simulated run', kind: 'sim' }]; + const realId = getYouTubeId(mission?.youtubeUrl || mission?.videoUrl); + if (realId) list.push({ id: 'real-1', label: 'Real run', kind: 'real', youtubeId: realId }); + return list; + }, [mission]); + + useEffect(() => { + const fetchMission = async () => { + try { + const repository = new FirestoreMissionRepository(getFirestoreClient()); + const loadedMission = await repository.findById(missionId); + if (loadedMission) setMission(loadedMission); + else setError('Mission not found'); + } catch (err) { + console.error('Fetch mission error:', err); + setError('Failed to load mission'); + } finally { + setLoading(false); + } + }; + void fetchMission(); + }, [missionId]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !mission) { + return ( +
+
+ +
+

Mission not found

+

{error || 'We could not load this mission.'}

+ + Back to the feed + +
+ ); + } + + const missionName = mission.name || `Mission ${mission.id.slice(0, 8)}`; + const starred = isFavorite(mission.id); + const discoveryStatus = getDiscoveryStatus(mission.status); + const selectedRun = runs.find((r) => r.id === selectedRunId) ?? runs[0]; + const durationMs = mission.executionMetadata?.duration_ms; + const durationLabel = durationMs ? `${Math.round(durationMs / 1000)}s` : 'Not yet'; + const dateLabel = new Date(mission.completedAt || mission.submittedAt).toLocaleDateString(); + const hasBlocks = !!mission.blocklyState; + const showBlocks = hasBlocks && codeView === 'blocks'; + + const copyCode = async () => { + try { + await navigator.clipboard.writeText(mission.code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* clipboard unavailable */ + } + }; + + return ( + // Pinned to the viewport from md up, where the panels sit side by side and + // a page that never scrolls is the point. On a phone they stack, so a + // fixed 100vh with overflow-hidden CLIPPED the second panel entirely - the + // blocks and the code were rendered, just unreachable, with no scrollbar to + // hint that anything was below. +
+
+ {/* Header */} +
+
+ + + +

{missionName}

+ + {discoveryStatus} + + + + {mission.yardId} · {dateLabel} + +
+ +
+ + {/* Body fills the remaining viewport height; nothing scrolls except the + code. Same draggable divider as Create Mission - a fixed 2/5 - 3/5 + split meant a long mission's code and its footage both stayed + cramped with no way to trade space between them. height="100%" + because this sits inside an already-sized flex parent, unlike + Create Mission which owns the viewport. */} + +
+ {selectedRun.kind === 'real' && selectedRun.youtubeId ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + + +
+
+ } + /* Code (scrolls internally) + remix */ + right={ +
+
+
+ {hasBlocks ? ( +
+ + +
+ ) : ( +
+ + + + + mission.py + +
+ )} + +
+ {showBlocks ? ( +
+ +
+ ) : ( +
+                  {mission.code.trim() || '# No code'}
+                
+ )} +
+ +
+
+

Like this mission?

+

Remix it: tweak the code and run your own version.

+
+ +
+
+ } + /> +
+ + ); +} + +function Stat({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/mission-control/src/app/missions/[missionId]/page.tsx b/mission-control/src/app/missions/[missionId]/page.tsx new file mode 100644 index 0000000..58f7975 --- /dev/null +++ b/mission-control/src/app/missions/[missionId]/page.tsx @@ -0,0 +1,11 @@ +import MissionVideoClient from './MissionVideoClient'; + +export async function generateStaticParams() { + // Required for output: export to work in Next.js when there are dynamic routes + return [{ missionId: 'default' }]; +} + +export default async function MissionVideoPage({ params }: { params: Promise<{ missionId: string }> }) { + const { missionId } = await params; + return ; +} diff --git a/mission-control/src/app/opengraph-image.jpg b/mission-control/src/app/opengraph-image.jpg new file mode 100644 index 0000000..fc5951c Binary files /dev/null and b/mission-control/src/app/opengraph-image.jpg differ diff --git a/mission-control/src/app/page.tsx b/mission-control/src/app/page.tsx new file mode 100644 index 0000000..f4dae8a --- /dev/null +++ b/mission-control/src/app/page.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useReducedMotion } from 'motion/react'; +import { Plus, Rocket, Star, Grid2x2, CircleCheckBig, Hourglass } from 'lucide-react'; +import { Mission } from '@/core/domain/entities/Mission'; +import { MissionCursor } from '@/core/domain/repositories/IMissionRepository'; +import { getFirestoreClient } from '@/lib/firebase'; +import { FirestoreMissionRepository } from '@/infrastructure/persistence/FirestoreMissionRepository'; +import { getDiscoveryStatus, type DiscoveryStatus } from '@/lib/discoveryStatus'; +import { useFavorites } from '@/lib/useFavorites'; +import { MissionCard } from '@/components/MissionCard/MissionCard'; +import { StaggeredEntrance } from '@/components/ui/StaggeredEntrance'; +import { useSearch, useRegisterSearchFilters } from '@/contexts/SearchContext'; + +type StatusFilter = 'all' | 'favorites' | DiscoveryStatus; + +/** + * Missions per page. Each page costs FEED_SIZE + 1 Firestore reads (the extra + * one detects whether another page exists without a separate count query), so + * this is pay-as-you-scroll rather than paying up front for rows nobody sees. + */ +const FEED_SIZE = 24; + +export default function LandingPage() { + const [missions, setMissions] = useState([]); + const [cursor, setCursor] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // Search and filter state lives in SearchContext because the controls now + // live in the navbar; this page still owns the DATA they filter. + const { query, setQuery, activeFilter, setActiveFilter, lastChange } = useSearch(); + const statusFilter = activeFilter as StatusFilter; + const { favorites, isFavorite } = useFavorites(); + const reduceMotion = useReducedMotion(); + // Cards remounting purely because a live search narrowed the list should not + // replay the entrance stagger - that fires on every keystroke, well past the + // "occasional" tier the effect is meant for. A filter click SHOULD replay it. + // Derived from which control was last touched rather than set by hand, since + // the controls now live in the navbar and no longer share a handler here. + const skipEntrance = lastChange === 'query'; + + useEffect(() => { + const loadMissions = async () => { + const timeoutId = setTimeout(() => { + console.warn('[Landing] Mission loading is taking longer than expected (>10s)'); + }, 10000); + + try { + const repository = new FirestoreMissionRepository(getFirestoreClient()); + // findRecent reads one page. findAll fetched 100 documents to render 24 + // and then ran a COUNT aggregation per queued mission for positions + // this page never displays - roughly 125 reads per view, and why the + // feed sat on a spinner for ~30 seconds. + const page = await repository.findRecent(FEED_SIZE); + + setMissions(page.missions); + setCursor(page.nextCursor); + setError(null); + } catch (err) { + console.error('[Landing] Failed to load missions:', err); + let errorMessage = 'Failed to load missions. '; + + if (err instanceof Error) { + errorMessage += err.message; + if (err.message.includes('Missing or insufficient permissions')) { + errorMessage = 'Database permissions error. Please check Firestore security rules.'; + } else if (err.message.includes('projectId')) { + errorMessage = 'Firebase is not configured. Please set up environment variables.'; + } else if (err.message.includes('network') || err.message.includes('fetch')) { + errorMessage = 'Network error. Please check your internet connection.'; + } + } else { + errorMessage += 'Unknown error occurred.'; + } + + setError(errorMessage); + } finally { + clearTimeout(timeoutId); + setLoading(false); + } + }; + + loadMissions(); + }, []); + + const loadMore = async () => { + if (!cursor || loadingMore) return; + + setLoadingMore(true); + try { + const repository = new FirestoreMissionRepository(getFirestoreClient()); + const page = await repository.findRecent(FEED_SIZE, cursor); + + // Guard against a mission appearing twice if one was inserted between + // pages: the cursor is stable, but a re-render could still double up. + setMissions((current) => { + const seen = new Set(current.map((m) => m.id)); + return [...current, ...page.missions.filter((m) => !seen.has(m.id))]; + }); + setCursor(page.nextCursor); + } catch (err) { + console.error('[Landing] Failed to load more missions:', err); + setError('Could not load more missions. Check your connection and try again.'); + } finally { + setLoadingMore(false); + } + }; + + const counts = useMemo(() => { + let completed = 0; + for (const m of missions) { + if (getDiscoveryStatus(m.status) === 'Completed') completed += 1; + } + return { all: missions.length, Completed: completed, Pending: missions.length - completed }; + }, [missions]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return missions.filter((m) => { + if (statusFilter === 'favorites' && !isFavorite(m.id)) return false; + if (statusFilter !== 'all' && statusFilter !== 'favorites' && getDiscoveryStatus(m.status) !== statusFilter) return false; + if (!q) return true; + return (m.name ?? '').toLowerCase().includes(q) || m.code.toLowerCase().includes(q); + }); + }, [missions, query, statusFilter, isFavorite]); + + const filters: { key: StatusFilter; label: string; count: number; icon: typeof Star }[] = [ + { key: 'all', label: 'All missions', count: counts.all, icon: Grid2x2 }, + { key: 'favorites', label: 'Favorite missions', count: favorites.length, icon: Star }, + { key: 'Completed', label: 'Completed missions', count: counts.Completed, icon: CircleCheckBig }, + { key: 'Pending', label: 'Pending missions', count: counts.Pending, icon: Hourglass }, + ]; + + // Published to the navbar, which renders the search field and these chips. + // Withdrawn on unmount, so the bar disappears on pages without a feed. + useRegisterSearchFilters(filters); + + return ( +
+ {/* Feed: the only thing that scrolls */} +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+

Unable to load missions

+

{error}

+ +
+ ) : missions.length === 0 ? ( + + ) : filtered.length === 0 ? ( + { + // Clears the navbar's controls; setActiveFilter marks this as a + // filter change, so the stagger replays on the restored list. + setQuery(''); + setActiveFilter('all'); + }} + /> + ) : ( +
+ {filtered.map((mission, index) => ( + + + + ))} +
+ )} + + {/* Only when another page exists, and never while a filter or search is + narrowing the view - "load more" there would look like it failed, + since the next page may contain nothing matching. */} + {cursor && !query && statusFilter === 'all' && ( +
+ +
+ )} +
+
+ ); +} + +function EmptyState({ + title, + subtitle, + cta, + onClear, +}: { + title: string; + subtitle: string; + cta?: boolean; + onClear?: () => void; +}) { + return ( +
+
+ +
+

{title}

+

{subtitle}

+ {cta && ( + + + Create Mission + + )} + {onClear && ( + + )} +
+ ); +} diff --git a/mission-control/src/components/MissionCard/MissionCard.tsx b/mission-control/src/components/MissionCard/MissionCard.tsx new file mode 100644 index 0000000..0d7346b --- /dev/null +++ b/mission-control/src/components/MissionCard/MissionCard.tsx @@ -0,0 +1,161 @@ +'use client'; + +import Link from 'next/link'; +import { Play, Rocket } from 'lucide-react'; +import { Mission } from '@/core/domain/entities/Mission'; +import { getDiscoveryStatus, DISCOVERY_BADGE_CLASS } from '@/lib/discoveryStatus'; + +function getYouTubeId(url: string | undefined): string | null { + if (!url) return null; + const patterns = [ + /youtube\.com\/watch\?v=([^&]+)/, + /youtu\.be\/([^?]+)/, + /youtube\.com\/embed\/([^?]+)/, + ]; + for (const p of patterns) { + const m = url.match(p); + if (m?.[1]) return m[1]; + } + return null; +} + +/** Human-friendly run time: "8s" or "1:23". */ +function formatDuration(ms: number): string { + const total = Math.max(0, Math.round(ms / 1000)); + if (total < 60) return `${total}s`; + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +/** "2 Aug 2026" - shorter and less ambiguous than 02/08/2026. */ +function formatDate(value: string | Date): string { + return new Date(value).toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} + +const PEEK_LINES = 4; + +/** + * The first few meaningful lines, always padded to PEEK_LINES. + * + * The padding is what keeps every card the same height: a two-line mission and + * a twenty-line one both render a four-line block, so the grid stays even + * without a magic pixel height that would have to be retuned alongside the + * font size. + */ +function codePeek(code: string): string { + const lines = code + .split('\n') + .map((l) => l.trimEnd()) + .filter((l) => l.trim().length > 0); + const peek = lines.length > 0 ? lines.slice(0, PEEK_LINES) : ['# No code']; + // A non-breaking space, not an empty string: a trailing "\n" at the end of a + //
 renders no line box at all, so empty padding lines silently did
+  // nothing and short missions came out one line shorter than the rest.
+  while (peek.length < PEEK_LINES) peek.push(' ');
+  return peek.join('\n');
+}
+
+interface MissionCardProps {
+  mission: Mission;
+  /** Show the learner identifier - intended for operator views */
+  showLearnerId?: boolean;
+}
+
+/**
+ * Learner-facing mission card, laid out like a video listing: a 16:9 tile with
+ * the status and run time over it, then the title and details underneath.
+ *
+ * The tile is always there, generic art when the mission has no recording yet,
+ * so every card in the grid is exactly the same size.
+ *
+ * Always uses the discovery status (Completed / Pending) so a learner never
+ * sees their mission as "Failed"; links through to the full mission detail
+ * page.
+ */
+export function MissionCard({ mission }: MissionCardProps) {
+  const discoveryStatus = getDiscoveryStatus(mission.status);
+  const videoUrl = mission.youtubeUrl || mission.videoUrl;
+  const youtubeId = getYouTubeId(videoUrl);
+  const thumbnailUrl = youtubeId ? `https://img.youtube.com/vi/${youtubeId}/hqdefault.jpg` : null;
+  const durationMs = mission.executionMetadata?.duration_ms;
+
+  return (
+    
+      
+ {thumbnailUrl ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element -- thumbnail hosts vary per mission record; next/image would need remotePatterns per host */} + +
+ + + +
+ + ) : ( + // Generic placeholder art. Deliberately built from theme tokens and a + // single icon rather than the illustrated rover that used to sit here + // - that was hardcoded browns and oranges on a monochrome palette, + // and it drew far more attention than an absent video deserves. + // Mixed from --foreground rather than --accent: accent is a saturated + // teal in dark mode, which turned every video-less tile into the + // brightest thing on the page. +
+ +

+ {discoveryStatus === 'Pending' ? 'Recording on its way' : 'No video for this run'} +

+
+ )} + + + {discoveryStatus} + + + {durationMs ? ( + + {formatDuration(durationMs)} + + ) : null} +
+ +
+
+

+ {mission.name ?? `Mission-${mission.id.slice(0, 8)}`} +

+ {/* The yard id used to sit here ("uct-rover-1"). It is internal + plumbing - a learner has no idea which yard they are on and it was + the same string on every card. */} +

{formatDate(mission.submittedAt)}

+
+ + {/* Code peek - no fake window chrome. The traffic-light dots were three + more saturated colours competing with the status badge, on a surface + whose whole point is to be quiet. */} +
+          {codePeek(mission.code)}
+        
+
+ + ); +} diff --git a/mission-control/src/components/layout/EnvironmentBanner.tsx b/mission-control/src/components/layout/EnvironmentBanner.tsx new file mode 100644 index 0000000..aabab3d --- /dev/null +++ b/mission-control/src/components/layout/EnvironmentBanner.tsx @@ -0,0 +1,49 @@ +import { connection } from 'next/server'; + +import { resolveEnvironment } from '@/infrastructure/config/environment'; + +/** + * A strip across the top of every non-production page. + * + * Server component, so the environment is read at request time and never baked + * into the bundle. Renders nothing at all in production rather than rendering + * a hidden element, so there is no prod markup to leak or mis-style. + * + * `await connection()` is load-bearing, not ceremony. Without it this sits in a + * statically prerendered layout, so resolveEnvironment() runs at BUILD time - + * when APP_ENV does not exist yet - and the answer is baked into the HTML. The + * live staging site said "LOCAL DEVELOPMENT" for exactly that reason: moving + * the value to a runtime variable achieves nothing while the RENDER is still + * build-time. connection() opts this into dynamic rendering so the variable is + * read per request. See next/docs 01-app/02-guides/environment-variables.md. + */ +export async function EnvironmentBanner() { + await connection(); + + const environment = resolveEnvironment(); + + if (environment === 'prod') { + return null; + } + + const label = + environment === 'staging' + ? 'STAGING — not the live site. Missions submitted here are test data.' + : 'LOCAL DEVELOPMENT'; + + return ( +
+ {label} +
+ ); +} diff --git a/mission-control/src/components/layout/Navbar.tsx b/mission-control/src/components/layout/Navbar.tsx new file mode 100644 index 0000000..ac50dde --- /dev/null +++ b/mission-control/src/components/layout/Navbar.tsx @@ -0,0 +1,248 @@ +/** + * Global Navigation Bar + * + * Desktop (md+): top bar with explicit Home and My History links, + * a prominent "Create Mission" button, and the notification bell. + * Mobile (< md): top bar shows logo + bell; the destinations move to a fixed + * bottom tab bar (kid-friendly, always visible, no hidden hamburger menu). + */ + +'use client'; + +import Link from 'next/link'; +import Image from 'next/image'; +import { usePathname } from 'next/navigation'; +import { + Bell, + Home, + History as HistoryIcon, + Plus, + Sun, + Moon, +} from 'lucide-react'; +import { useCallback, useState, type ComponentProps } from 'react'; +import { NotificationModal } from './NotificationModal'; +import { NavbarSearch } from './NavbarSearch'; +import { EmailPrompt } from '@/components/learner/EmailPrompt'; +import { useTheme } from '@/contexts/ThemeContext'; +import { useCompletionNotifications } from '@/lib/useCompletionNotifications'; + +const NAV_ITEMS = [ + { href: '/', label: 'Home', mobileLabel: 'Home', icon: Home }, + { + href: '/history', + label: 'My History', + mobileLabel: 'History', + // Not a plain Clock: the Pending filter chip sits a few pixels away in the + // same bar and was using the same clock face. + icon: HistoryIcon, + }, +]; + +export function Navbar() { + const pathname = usePathname(); + const [isNotificationOpen, setIsNotificationOpen] = useState(false); + const { theme, toggleTheme } = useTheme(); + + const { unread, hasUnread, markAllSeen, dismiss } = useCompletionNotifications(); + + // What the open panel shows is captured when it opens, not read live. + // Opening marks everything seen, so a live list would empty itself in front + // of the learner as they looked at it. + const [viewing, setViewing] = useState< + ComponentProps['notifications'] + >([]); + + const openNotifications = useCallback(() => { + setViewing(unread.map((n) => ({ type: 'completed' as const, ...n }))); + setIsNotificationOpen(true); + markAllSeen(); + }, [unread, markAllSeen]); + + const dismissNotification = useCallback( + (id: string) => { + dismiss(id); + setViewing((prev) => (prev ?? []).filter((n) => n.id !== id)); + }, + [dismiss] + ); + + const isActive = (path: string): boolean => { + if (path === '/') return pathname === '/'; + return pathname === path || pathname.startsWith(path + '/'); + }; + + // Each destination is a segment inside a single pill-shaped nav group. + const desktopLinkClass = (path: string): string => { + // Deliberately smaller than the Create Mission button beside them: these + // are wayfinding, that is the action, and at equal weight they competed. + const base = + 'flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors'; + const active = 'bg-gradient-mars text-primary-foreground clay'; + const inactive = + 'text-muted-foreground hover:text-foreground hover:bg-card/60'; + + return `${base} ${isActive(path) ? active : inactive}`; + }; + + return ( + <> + {/* Divider is an inset shadow (not border-b) so the bar stays exactly 64px + tall, matching the h-[calc(100vh-64px)] page mains (no 1px overflow). */} + {/* The fill alone cannot separate this from the page: in Paper & Ink the + card and the background are ~2% apart in lightness (0.99 vs 0.966), + which measured 1.13:1 - not a band, just a smudge. A hairline plus a + soft shadow underneath is what actually reads as a raised bar, and it + works in both themes without touching the palette. The shadow is an + OUTER one so the bar stays exactly 64px and the page mains below + (h-[calc(100vh-64px)]) do not overflow by a pixel. */} + + + {/* Mobile bottom tab bar */} + + + setIsNotificationOpen(false)} + notifications={viewing} + onDismiss={dismissNotification} + /> + + + + ); +} \ No newline at end of file diff --git a/mission-control/src/components/layout/NavbarSearch.tsx b/mission-control/src/components/layout/NavbarSearch.tsx new file mode 100644 index 0000000..78d1e78 --- /dev/null +++ b/mission-control/src/components/layout/NavbarSearch.tsx @@ -0,0 +1,99 @@ +'use client'; + +/** + * The search field and its inline filter chips, living in the navbar. + * + * Renders nothing at all when no page has registered filters, which is how it + * disappears on Create Mission and the mission detail pages - see + * SearchContext for why that is a registry rather than a route check. + */ + +import { useReducedMotion } from 'motion/react'; +import { Search, X } from 'lucide-react'; +import { useSearch } from '@/contexts/SearchContext'; +import { ActivePillBackground } from '@/components/ui/ActivePillBackground'; + +export function NavbarSearch() { + const { query, setQuery, activeFilter, setActiveFilter, filters } = useSearch(); + const reduceMotion = useReducedMotion(); + + // No registered filters means this page has nothing to search. The wrapper + // still renders: it holds the navbar grid's middle column, and returning + // null here would let the action cluster slide into the centre. + const hasSearch = filters.length > 0; + + return ( + // Keep the search centered and only adjust the input's own width. +
+ {!hasSearch ? null : ( + // Nudged left of true centre to balance the busier right-hand cluster, but + // only a little: at 15% the filter chips slid underneath the nav pill and + // sat on top of the "Home" link. +
+ + setQuery(e.target.value)} + placeholder="Search missions" + aria-label="Search missions by name or code" + // Right padding clears the chips, which are absolutely positioned over + // the field so the whole thing reads as one control rather than an + // input with a toolbar bolted on. + className="h-10 w-full min-w-[20rem] rounded-full border border-border/60 bg-card/60 pl-10 pr-40 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary lg:pr-44" + /> + +
+ {query && ( + + )} + + + + {filters.map((f) => { + const active = activeFilter === f.key; + const Icon = f.icon; + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/mission-control/src/components/layout/NotificationModal.tsx b/mission-control/src/components/layout/NotificationModal.tsx new file mode 100644 index 0000000..8dd4a4c --- /dev/null +++ b/mission-control/src/components/layout/NotificationModal.tsx @@ -0,0 +1,187 @@ +/** + * Notification Modal Component + * + * Shows notifications for: + * - Completed missions (green) + * - New missions to explore (orange) + * + * Notifications arrive via props; the Navbar currently passes an empty list + * until the backend feed is wired up, so learners see the empty state. + */ + +'use client'; + +import { useEffect, useState } from 'react'; +import { X } from 'lucide-react'; + +// Kept in sync with the transition durations below - see EmailPrompt for +// why the exit needs this rather than an instant unmount. +// +// This is deliberately plain CSS, not Motion's AnimatePresence: tried it +// (three different structures - a keyed array of siblings, a nested +// motion.div wrapper, two independent AnimatePresence blocks) and all three +// exhibited the same bug in this exact React 19 / Next 16 / motion@12.43.0 +// combination, verified in a clean production build with real clicks: the +// exit animation completes correctly (opacity/scale reach their exact target +// values), but the component never actually unmounts - leaving an invisible, +// still-interactive layer sitting over the page, capable of eating clicks +// meant for whatever's underneath. Shipping that would be worse than the +// plain conditional render this replaced. Revisit if a newer `motion` +// release fixes it. +const EXIT_MS = 200; + +interface CompletedNotification { + type: 'completed'; + /** Mission id, so a single notification can be dismissed by hand. */ + id: string; + missionName: string; + completedAt: string; // ISO timestamp; rendered as "2 hours ago" +} + +interface NewMissionNotification { + type: 'new-mission'; + id: string; + missionName: string; + message: string; +} + +type Notification = CompletedNotification | NewMissionNotification; + +interface NotificationModalProps { + isOpen: boolean; + onClose: () => void; + notifications?: Notification[]; + /** Remove one notification. Opening the panel already clears the dot; this + is the manual way out when something stays put anyway. */ + onDismiss?: (id: string) => void; +} + +/** "2 hours ago" from an ISO timestamp. Kept local: the only other relative + time in the app formats mission cards and carries their wording. */ +function relativeTime(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return 'recently'; + const secs = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (secs < 60) return 'just now'; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'} ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? '' : 's'} ago`; +} + +export function NotificationModal({ + isOpen, + onClose, + notifications = [], + onDismiss, +}: NotificationModalProps) { + const [mounted, setMounted] = useState(isOpen); + const [visible, setVisible] = useState(false); + + // Mount immediately, flip visible a frame later so the transition has a + // "before" state to run from, and hold the unmount until the exit + // animation has actually played. + useEffect(() => { + if (isOpen) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- opening the panel + setMounted(true); + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + } + setVisible(false); + const timer = setTimeout(() => setMounted(false), EXIT_MS); + return () => clearTimeout(timer); + }, [isOpen]); + + if (!mounted) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Modal - anchored to the bell in the top-right, so it scales in from + that corner rather than its own centre (the default origin is + wrong for anything anchored to a trigger; a centered scale would + read as materializing out of nowhere instead of opening from the + bell). */} +
+ {/* Header. + A theme toggle used to sit here so mobile could reach one at all - + the bottom tab bar is a tight 4-slot layout. It was the wrong home: + opening notifications is not asking to change appearance, and a + control that switches the whole page's look has no business hiding + behind a bell. It now lives in the mobile top bar, which had space + all along. */} +
+

Notifications

+ +
+ + {/* Notifications List */} +
+ {notifications.length === 0 ? ( +
+ No new notifications +
+ ) : ( + notifications.map((notification) => ( +
+ {notification.type === 'completed' && ( +
+

+ A mission finished on the rover +

+

+ {notification.missionName} was + completed {relativeTime(notification.completedAt)} +

+
+ )} + + {notification.type === 'new-mission' && ( +
+

+ New Missions to Explore! +

+

+ {notification.message} +

+
+ )} + + {onDismiss && ( + + )} +
+ )) + )} +
+
+ + ); +} diff --git a/mission-control/src/components/layout/PageTransition.tsx b/mission-control/src/components/layout/PageTransition.tsx new file mode 100644 index 0000000..93a1667 --- /dev/null +++ b/mission-control/src/components/layout/PageTransition.tsx @@ -0,0 +1,14 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { usePathname } from 'next/navigation'; + +export function PageTransition({ children }: Readonly<{ children: ReactNode }>) { + const pathname = usePathname(); + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/mission-control/src/components/learner/EmailPrompt.tsx b/mission-control/src/components/learner/EmailPrompt.tsx new file mode 100644 index 0000000..14b1641 --- /dev/null +++ b/mission-control/src/components/learner/EmailPrompt.tsx @@ -0,0 +1,126 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useLearner } from '@/contexts/LearnerContext'; + +/** + * Non-blocking email capture prompt, opened after a mission is submitted (to + * offer notifications) and from the history page - never on landing. Saving + * persists via LearnerContext (localStorage + Firestore); skipping just closes + * it, email is optional. + */ +// Kept in sync with the transition durations below - the panel stays +// mounted exactly as long as its exit animation takes, so it can fade and +// scale out instead of vanishing, then unmounts the instant that finishes. +// +// This is deliberately plain CSS, not Motion's AnimatePresence - see +// NotificationModal for why: verified in a clean production build that +// AnimatePresence's exit animation completes correctly here but the +// component never actually unmounts, in this exact library/framework +// combination. Not shipping that. +const EXIT_MS = 200; + +export function EmailPrompt() { + const { learnerEmail, setLearnerEmail, showEmailPrompt, closeEmailPrompt } = useLearner(); + const [email, setEmail] = useState(learnerEmail ?? ''); + const [error, setError] = useState(null); + const [mounted, setMounted] = useState(showEmailPrompt); + const [visible, setVisible] = useState(false); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- reset the draft field whenever the prompt (re)opens or the saved email changes + setEmail(learnerEmail ?? ''); + }, [learnerEmail, showEmailPrompt]); + + // This is a modal - occasional enough to earn a real entrance and exit + // rather than the instant cut `if (!open) return null` used to give it. + // Opening mounts immediately (so autoFocus still lands on the input) and + // flips to visible a frame later, so the "hidden" state actually paints + // first. Closing reverses: fade out, then unmount once the CSS transition + // below has had time to finish. + useEffect(() => { + if (showEmailPrompt) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- opening the prompt + setMounted(true); + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + } + setVisible(false); + const timer = setTimeout(() => setMounted(false), EXIT_MS); + return () => clearTimeout(timer); + }, [showEmailPrompt]); + + if (!mounted) return null; + + const isValid = (value: string) => /^[^\s@]+@([^\s@]+\.)+[A-Za-z]{2,}$/.test(value.trim()); + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!email || !isValid(email)) { + setError('Please enter a valid email address'); + return; + } + await setLearnerEmail(email.trim()); + }; + + return ( +
+
+
e.stopPropagation()} + > +

Stay in touch

+

+ Enter your email so we can send mission updates and completion notices. This is optional - you can skip it. +

+ + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} + +export default EmailPrompt; diff --git a/mission-control/src/components/mission/BlocklyEditor.tsx b/mission-control/src/components/mission/BlocklyEditor.tsx new file mode 100644 index 0000000..da85cab --- /dev/null +++ b/mission-control/src/components/mission/BlocklyEditor.tsx @@ -0,0 +1,352 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Play, CheckCircle2, AlertTriangle } from 'lucide-react'; +import { loadBlockly } from '@/lib/loadBlockly'; +import { + defineRoverBlocks, + ROVER_TOOLBOX, + ROVER_MAX_INSTANCES, + mergeUplinkHats, + workspaceToPython, + workspaceToCommands, + type SimulationCommand, +} from '@/lib/roverBlockly'; + +interface BlocklyEditorProps { + onGenerateCommands: (commands: SimulationCommand[]) => void; + onCodeChange?: (code: string) => void; + onBlocklyStateChange?: (state: string) => void; +} + +// Hub-local storage of the serialized workspace. Separate origin from the yard, +// so the key name need not match - but the JSON format does (Blockly.serialization). +const STORAGE_KEY = 'roverWorkspace'; + +export function BlocklyEditor({ onGenerateCommands, onCodeChange, onBlocklyStateChange }: BlocklyEditorProps) { + const blocklyDivRef = useRef(null); + const containerRef = useRef(null); + // Holds the Blockly workspace instance (untyped CDN global). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const workspaceRef = useRef(null); + const flyoutObserverRef = useRef(null); + const mergedNoticeTimerRef = useRef | null>(null); + const [isInitialized, setIsInitialized] = useState(false); + const [blocklyLoaded, setBlocklyLoaded] = useState(false); + // Unset means still loading; the CDN fetch previously had no failure path + // at all, so a network hiccup or ad-blocker left this stuck on the loading + // spinner forever with no way out and nothing in the console to explain it. + const [loadError, setLoadError] = useState(false); + // Tells the learner their workspace just changed for a reason they didn't + // cause - a leftover duplicate uplink from before the cap existed got + // merged away on load. Without this, blocks they'd placed just vanish + // from under them with no explanation, on a page that never even asked. + const [mergedNotice, setMergedNotice] = useState(false); + const [retryToken, setRetryToken] = useState(0); + + // Loading (and the Monaco/AMD conflict that used to make this silently + // render an empty canvas) is handled in lib/loadBlockly. + useEffect(() => { + let cancelled = false; + loadBlockly() + .then(() => { + if (!cancelled) setBlocklyLoaded(true); + }) + .catch((err) => { + console.error('[BlocklyEditor] Blockly failed to load:', err); + if (!cancelled) setLoadError(true); + }); + return () => { + cancelled = true; + }; + }, [retryToken]); + + useEffect(() => { + if (!blocklyLoaded || !blocklyDivRef.current || !window.Blockly) return; + if (workspaceRef.current) return; // Already initialized + + const timer = setTimeout(() => { + if (!blocklyDivRef.current || !window.Blockly || workspaceRef.current) return; + + const Blockly = window.Blockly; + + // Register the shared rover blocks (same defs the yard uses). + defineRoverBlocks(Blockly); + + // Initialize workspace with the shared category toolbox. + const workspace = Blockly.inject(blocklyDivRef.current, { + toolbox: ROVER_TOOLBOX, + // The actual cap - Blockly reads maxInstances only from here, never + // from a toolbox content entry, so this must live on inject() itself. + maxInstances: ROVER_MAX_INSTANCES, + renderer: 'zelos', + zoom: { + controls: true, + wheel: true, + startScale: 1.0, + maxScale: 2.5, + minScale: 0.35, + scaleSpeed: 1.15, + }, + grid: { + spacing: 20, + length: 3, + colour: '#ccc', + snap: true, + }, + trashcan: true, + move: { + drag: true, + scrollbars: true, + wheel: true, + }, + }); + + workspaceRef.current = workspace; + setIsInitialized(true); + + // Resize after paint so Blockly measures the final container dimensions. + requestAnimationFrame(() => { + Blockly.svgResize(workspace); + }); + + // Restore the saved workspace (JSON via Blockly.serialization), or start + // with a fresh "On uplink" hat block - mirrors the yard's bootstrap. + const startWithHat = () => { + const block = workspace.newBlock('rover_on_receive'); + block.initSvg(); + block.render(); + block.moveBy(40, 40); + }; + + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + try { + Blockly.serialization.workspaces.load(JSON.parse(saved), workspace); + if (mergeUplinkHats(workspace)) { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(Blockly.serialization.workspaces.save(workspace)) + ); + setMergedNotice(true); + mergedNoticeTimerRef.current = setTimeout(() => setMergedNotice(false), 5000); + } + } catch (e) { + console.warn('Failed to load saved workspace, starting fresh', e); + workspace.clear(); + startWithHat(); + } + } else { + startWithHat(); + } + + // Centre whatever we just put on the canvas. Saved workspaces keep the + // coordinates they were dragged to, and a remix carries the coordinates + // of whoever built it, so opening the editor could land on empty canvas + // with the program off-screen. Deliberately scrollCenter and not + // zoomToFit: the learner's zoom level is theirs, and rescaling on open + // is the behaviour the recenter button was removed for (see below). + // After a frame, so it measures the container at its final size. + requestAnimationFrame(() => { + if (workspaceRef.current) workspaceRef.current.scrollCenter(); + }); + + // Blockly hides a flyout but leaves its scrollbar behind. Closing a + // category left a 15x322 scrollbar sitting over the workspace, still + // display:block with a visible handle, covering blocks underneath and + // swallowing clicks meant for them. + // + // Each flyout is immediately followed in the DOM by its own scrollbar + // (toolbox and trashcan each have a pair), so the fix is to mirror the + // flyout's display onto the scrollbar whenever Blockly changes it. + const syncFlyoutScrollbars = () => { + blocklyDivRef.current + ?.querySelectorAll('.blocklyFlyout') + .forEach((flyout) => { + const scrollbar = flyout.nextElementSibling; + if (!scrollbar?.classList.contains('blocklyFlyoutScrollbar')) return; + const hidden = getComputedStyle(flyout).display === 'none'; + (scrollbar as SVGElement).style.display = hidden ? 'none' : ''; + }); + }; + + // Runs once for the initial state too: the scrollbar ships visible even + // before any category has been opened. + syncFlyoutScrollbars(); + + const flyoutObserver = new MutationObserver(syncFlyoutScrollbars); + blocklyDivRef.current + ?.querySelectorAll('.blocklyFlyout') + .forEach((flyout) => + flyoutObserver.observe(flyout, { attributes: true, attributeFilter: ['style', 'class'] }) + ); + flyoutObserverRef.current = flyoutObserver; + + // Auto-save serialized state on every change. + workspace.addChangeListener(() => { + try { + const state = Blockly.serialization.workspaces.save(workspace); + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch { + // Non-fatal - a transient change event during load can race; ignore. + } + }); + }, 200); + + return () => { + clearTimeout(timer); + flyoutObserverRef.current?.disconnect(); + flyoutObserverRef.current = null; + if (mergedNoticeTimerRef.current) clearTimeout(mergedNoticeTimerRef.current); + }; + }, [blocklyLoaded]); + + useEffect(() => { + if (!isInitialized || !workspaceRef.current || !window.Blockly) return; + + const workspace = workspaceRef.current; + // Coalesced to at most once per animation frame - the panel-split slider + // (MissionWorkspace.tsx) now animates its CSS grid track with a + // transition, which fires this ResizeObserver on every intermediate + // frame of that transition, not just once per onChange. Without this, + // a full Blockly svgResize (workspace metrics + toolbox/flyout layout) + // ran on every one of those frames for the whole drag. + let rafId: number | null = null; + const handleResize = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + if (workspaceRef.current) { + window.Blockly.svgResize(workspaceRef.current); + } + }); + }; + + window.addEventListener('resize', handleResize); + + let resizeObserver: ResizeObserver | null = null; + if (containerRef.current && typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(containerRef.current); + } + + handleResize(); + + return () => { + window.removeEventListener('resize', handleResize); + resizeObserver?.disconnect(); + if (rafId !== null) cancelAnimationFrame(rafId); + + if (workspaceRef.current === workspace) { + workspace.dispose(); + workspaceRef.current = null; + setIsInitialized(false); + } + }; + }, [isInitialized]); + + const handleRun = () => { + if (!workspaceRef.current) return; + + const commands = workspaceToCommands(workspaceRef.current); + if (commands.length === 0) { + alert('Add some movement blocks inside "On uplink" first!'); + return; + } + + onGenerateCommands(commands); + }; + + // There is deliberately no custom recenter button. Blockly's own zoom-reset + // control (the target icon above the +/- buttons, enabled by zoom.controls + // below) already does the job: measured, it returns the scale to 1.0 AND + // re-centers the blocks in the viewport. A second button that called + // zoomToFit() used to sit at the bottom of the canvas, which meant two + // recenter controls with different behaviour - zoomToFit re-scales to fit + // the content, so it could leave the blocks tiny or oversized rather than + // back at a normal size. + + // Listen for workspace changes and push the generated Python (and the + // serialized Blockly state) up to the parent. + useEffect(() => { + if (!isInitialized || !workspaceRef.current) return; + + const workspace = workspaceRef.current; + const listener = () => { + onCodeChange?.(workspaceToPython(workspace)); + if (onBlocklyStateChange && window.Blockly) { + onBlocklyStateChange( + JSON.stringify(window.Blockly.serialization.workspaces.save(workspace)) + ); + } + }; + + workspace.addChangeListener(listener); + + // Initial generation + listener(); + + return () => { + workspace.removeChangeListener(listener); + }; + }, [isInitialized, onCodeChange, onBlocklyStateChange]); + + if (loadError) { + return ( +
+ +

Couldn't load the block editor. Check your connection and try again.

+ +
+ ); + } + + if (!blocklyLoaded) { + return ( +
+ + Loading blocks... +
+ ); + } + + return ( +
+
+

+ Stack blocks inside “On uplink”, tune the numbers, then run it. +

+ +
+ + {mergedNotice && ( +
+ +

Merged an extra uplink into one mission.

+
+ )} + +
+
+
+
+ ); +} diff --git a/mission-control/src/components/mission/BlocklyViewer.tsx b/mission-control/src/components/mission/BlocklyViewer.tsx new file mode 100644 index 0000000..30db8e8 --- /dev/null +++ b/mission-control/src/components/mission/BlocklyViewer.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { loadBlockly } from '@/lib/loadBlockly'; +import { defineRoverBlocks } from '@/lib/roverBlockly'; + +/** + * Read-only Blockly rendering of a saved workspace (mission.blocklyState). + * + * Shares lib/loadBlockly with the editor - one script, one cache - and renders + * the program without a toolbox, so learners can see the blocks they will + * remix. Pan/zoom stay on (scrollbars + wheel) but editing is off. + */ +export function BlocklyViewer({ state }: { state: string }) { + const divRef = useRef(null); + const [loaded, setLoaded] = useState(false); + const [loadError, setLoadError] = useState(false); + const [retryToken, setRetryToken] = useState(0); + + useEffect(() => { + let cancelled = false; + loadBlockly() + .then(() => { + if (!cancelled) setLoaded(true); + }) + .catch((err) => { + console.error('[BlocklyViewer] Blockly failed to load:', err); + if (!cancelled) setLoadError(true); + }); + return () => { + cancelled = true; + }; + }, [retryToken]); + + useEffect(() => { + if (!loaded || !divRef.current || !window.Blockly) return; + + const Blockly = window.Blockly; + defineRoverBlocks(Blockly); + + const workspace = Blockly.inject(divRef.current, { + readOnly: true, + renderer: 'zelos', + move: { drag: true, scrollbars: true, wheel: true }, + zoom: { controls: true, wheel: true, startScale: 0.9, maxScale: 2.5, minScale: 0.3 }, + }); + + try { + Blockly.serialization.workspaces.load(JSON.parse(state), workspace); + } catch { + // Ignore malformed state; an empty read-only canvas is an acceptable fallback. + } + + requestAnimationFrame(() => { + Blockly.svgResize(workspace); + // Blocks carry the coordinates they were authored at, so a mission built + // off to one side opened showing empty canvas and the learner had to + // hunt for it. scrollCenter (not zoomToFit) keeps the scale the viewer + // was configured with and only moves the viewport. + workspace.scrollCenter(); + }); + + return () => workspace.dispose(); + }, [loaded, state]); + + if (loadError) { + return ( +
+ +

Couldn't load the block viewer.

+ +
+ ); + } + + if (!loaded) { + return ( +
+ Loading blocks... +
+ ); + } + + return
; +} diff --git a/mission-control/src/components/mission/EditorPanel.tsx b/mission-control/src/components/mission/EditorPanel.tsx new file mode 100644 index 0000000..74f3e1e --- /dev/null +++ b/mission-control/src/components/mission/EditorPanel.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useReducedMotion } from 'motion/react'; +import { Gamepad2, Blocks, Code2, AlertTriangle } from 'lucide-react'; +import { ManualControlRealtime } from '@/components/mission/ManualControlRealtime'; +import { BlocklyEditor } from '@/components/mission/BlocklyEditor'; +import { MonacoCodeEditor } from '@/components/mission/MonacoCodeEditor'; +import { ActivePillBackground } from '@/components/ui/ActivePillBackground'; +import type { RoverState } from '@/lib/rover-physics'; + +export type EditorMode = 'manual' | 'blockly' | 'code'; + +type SimulationCommand = { + command: string; + speed?: number; + duration?: number; + degrees?: number; +}; + +// Blocks-first ordering: tap-to-drive on-ramp, then the block editor (the hero), +// then Python for those ready for it. +const MODES: { mode: EditorMode; label: string; Icon: typeof Gamepad2 }[] = [ + { mode: 'manual', label: 'Drive', Icon: Gamepad2 }, + { mode: 'blockly', label: 'Blocks', Icon: Blocks }, + { mode: 'code', label: 'Python', Icon: Code2 }, +]; + +interface EditorPanelProps { + editorMode: EditorMode; + onEditorModeChange: (mode: EditorMode) => void; + error: string | null; + + onManualTrajectory: (trajectory: RoverState[]) => void; + onResetSimulation: () => void; + manualResetVersion: number; + onGenerateCommands: (commands: SimulationCommand[]) => void; + onCodeChange: (code: string) => void; + onBlocklyStateChange?: (state: string) => void; +} + +export function EditorPanel({ + editorMode, + onEditorModeChange, + error, + onManualTrajectory, + onResetSimulation, + manualResetVersion, + onGenerateCommands, + onCodeChange, + onBlocklyStateChange, +}: EditorPanelProps) { + const reduceMotion = useReducedMotion(); + + return ( +
+ {/* Editor mode tabs */} +
+ {MODES.map(({ mode, label, Icon }) => { + const active = editorMode === mode; + return ( + + ); + })} +
+ + {error && ( +
+ +

{error}

+
+ )} + + {/* Editor content */} +
+ {editorMode === 'manual' && ( + + )} + {editorMode === 'blockly' && } + {editorMode === 'code' && } +
+ +
+ ); +} diff --git a/mission-control/src/components/mission/ManualControlRealtime.module.css b/mission-control/src/components/mission/ManualControlRealtime.module.css new file mode 100644 index 0000000..7918ba5 --- /dev/null +++ b/mission-control/src/components/mission/ManualControlRealtime.module.css @@ -0,0 +1,47 @@ +/* Tap buttons styled to read as Blockly blocks: real category colour, chunky + rounded body, a connector tab on the bottom edge, and bottom shading for + depth. Colour comes from --c set per block. */ +.block { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + border: none; + border-radius: 16px; + background: var(--c); + padding: 20px 22px; + text-align: left; + font-weight: 800; + font-size: 18px; + color: #fff; + cursor: pointer; + box-shadow: inset 0 -6px 0 rgba(0, 0, 0, 0.22); + transition: transform 0.08s ease, filter 0.12s ease; +} + +.block:hover { + filter: brightness(1.08); +} + +.block:active { + transform: translateY(1px); +} + +/* Blockly-style next-connector tab on the bottom edge. */ +.block::after { + content: ''; + position: absolute; + bottom: -8px; + left: 24px; + width: 36px; + height: 8px; + border-radius: 0 0 9px 9px; + background: var(--c); + box-shadow: inset 0 -4px 0 rgba(0, 0, 0, 0.22); +} + +.active { + outline: 2px solid rgba(255, 255, 255, 0.85); + outline-offset: 1px; +} diff --git a/mission-control/src/components/mission/ManualControlRealtime.tsx b/mission-control/src/components/mission/ManualControlRealtime.tsx new file mode 100644 index 0000000..7daac4a --- /dev/null +++ b/mission-control/src/components/mission/ManualControlRealtime.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback, type CSSProperties } from 'react'; +import styles from './ManualControlRealtime.module.css'; +import { RoverPhysics, RoverState } from '@/lib/rover-physics'; + +interface ManualControlRealtimeProps { + onTrajectoryUpdate: (trajectory: RoverState[]) => void; + onReset?: () => void; + resetVersion?: number; +} + +/** + * The manual palette mirrors the Blockly movement blocks: tap one and the rover + * runs that instruction for a beat. It is the on-ramp David asked for, so a + * learner who has never coded sees that blocks drive the rover before they open + * the full Blockly editor. + */ +type DriveBlock = { command: string; label: string; speed: number; ms: number; colour: string }; + +// Labels and colours mirror the real Blockly movement blocks, so a learner sees +// the same thing in both places. The colours are CSS VARIABLES rather than the +// hex literals they used to be: these buttons are large filled surfaces, and +// the dark-theme values are too vivid to carry white text on a light page. +// globals.css deepens each one under [data-theme="light"]. +// the same Lego pieces here as in the editor (movement blue, spin purple, +// steer cyan, stop red). +const BLOCKS: DriveBlock[] = [ + { command: 'forward', label: 'Move Forward', speed: 80, ms: 1000, colour: 'var(--block-move)' }, + { command: 'reverse', label: 'Move Backward', speed: 80, ms: 1000, colour: 'var(--block-move)' }, + { command: 'spinLeft', label: 'Spin Left', speed: 60, ms: 500, colour: 'var(--block-spin)' }, + { command: 'spinRight', label: 'Spin Right', speed: 60, ms: 500, colour: 'var(--block-spin)' }, + { command: 'steerLeft', label: 'Steer Left', speed: 60, ms: 1000, colour: 'var(--block-steer)' }, + { command: 'steerRight', label: 'Steer Right', speed: 60, ms: 1000, colour: 'var(--block-steer)' }, +]; + +const KEY_MAP: Record = { + w: BLOCKS[0], + s: BLOCKS[1], + a: BLOCKS[2], + d: BLOCKS[3], + q: BLOCKS[4], + e: BLOCKS[5], +}; + +export function ManualControlRealtime({ onTrajectoryUpdate, onReset, resetVersion = 0 }: ManualControlRealtimeProps) { + const roverRef = useRef(new RoverPhysics()); + const trajectoryRef = useRef([]); + const animationFrameRef = useRef(null); + const runTimeoutRef = useRef | null>(null); + const [isActive, setIsActive] = useState(false); + const [activeCommand, setActiveCommand] = useState(null); + // True once a block has been tapped this run, so chaining more blocks keeps + // the existing trail. Reset clears it. + const startedRef = useRef(false); + + const resetController = useCallback(() => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (runTimeoutRef.current) { + clearTimeout(runTimeoutRef.current); + runTimeoutRef.current = null; + } + setIsActive(false); + setActiveCommand(null); + roverRef.current.reset(); + trajectoryRef.current = []; + startedRef.current = false; + }, []); + + // Listen for external reset from the shared simulator controls. + useEffect(() => { + if (resetVersion > 0) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- react to the shared reset signal from the workspace controls + resetController(); + } + }, [resetVersion, resetController]); + + useEffect(() => { + const updateLoop = () => { + const newState = roverRef.current.update(); + trajectoryRef.current.push(newState); + + if (trajectoryRef.current.length > 1000) { + trajectoryRef.current = trajectoryRef.current.slice(-1000); + } + + onTrajectoryUpdate([...trajectoryRef.current]); + animationFrameRef.current = requestAnimationFrame(updateLoop); + }; + + if (isActive) { + updateLoop(); + } + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [isActive, onTrajectoryUpdate]); + + // Tap a block: run that instruction for a beat, then stop. Tapping more blocks + // extends the path, one block at a time. + const runBlock = useCallback((block: DriveBlock) => { + if (!startedRef.current) { + startedRef.current = true; + trajectoryRef.current = [roverRef.current.getState()]; + } + if (runTimeoutRef.current) clearTimeout(runTimeoutRef.current); + roverRef.current.setCommand(block.command, block.speed); + setActiveCommand(block.command); + setIsActive(true); // run the render loop while this instruction plays + runTimeoutRef.current = setTimeout(() => { + roverRef.current.setCommand('stop'); + setActiveCommand(null); + runTimeoutRef.current = null; + setIsActive(false); // halt the loop; the rover holds its place and trail + }, block.ms); + }, []); + + const stopNow = useCallback(() => { + if (runTimeoutRef.current) { + clearTimeout(runTimeoutRef.current); + runTimeoutRef.current = null; + } + roverRef.current.setCommand('stop'); + setActiveCommand(null); + setIsActive(false); // halt the loop when the learner stops + }, []); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (e.repeat) return; + const block = KEY_MAP[e.key.toLowerCase()]; + if (block) { + e.preventDefault(); + runBlock(block); + } else if (e.key === ' ') { + e.preventDefault(); + stopNow(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [runBlock, stopNow]); + + const handleReset = () => { + resetController(); + onReset?.(); + }; + + return ( +
+
+

Tap a block to drive

+

+ These are the same blocks you code with. Tap one to run it. +

+
+ +
+ {BLOCKS.map((block) => ( + + ))} + +
+ +
+ + Keys: W A S D, Q E, space to stop +
+
+ ); +} diff --git a/mission-control/src/components/mission/MissionHistory.tsx b/mission-control/src/components/mission/MissionHistory.tsx new file mode 100644 index 0000000..bef80fc --- /dev/null +++ b/mission-control/src/components/mission/MissionHistory.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useReducedMotion } from 'motion/react'; +import { Rocket, Plus, Star, Grid2x2, CircleCheckBig, Hourglass } from 'lucide-react'; +import { getLearnerID } from '@/lib/getLearnerID'; +import { + subscribeMissionsByLearnerId, + subscribeMissionsByLearnerEmail, +} from '@/lib/services/missionQueryService'; +import { Mission } from '@/core/domain/entities/Mission'; +import { MissionCard } from '@/components/MissionCard/MissionCard'; +import { StaggeredEntrance } from '@/components/ui/StaggeredEntrance'; +import { useLearner } from '@/contexts/LearnerContext'; +import { useSearch, useRegisterSearchFilters } from '@/contexts/SearchContext'; +import { getDiscoveryStatus } from '@/lib/discoveryStatus'; +import { useFavorites } from '@/lib/useFavorites'; + +export function MissionHistory() { + const { learnerEmail, openEmailPrompt } = useLearner(); + const reduceMotion = useReducedMotion(); + // Same navbar control as the feed, deliberately: identical chips in the same + // place doing the same thing beats a second, subtly different filter set. + const { query, activeFilter, lastChange } = useSearch(); + const { favorites, isFavorite } = useFavorites(); + const skipEntrance = lastChange === 'query'; + + // Missions for this browser (by learner id) and, if an email is set, missions + // submitted under that email on any device. We keep them separate and merge + // so a learner sees their full history regardless of which one a mission was + // stamped with. + const [byId, setById] = useState([]); + const [byEmail, setByEmail] = useState([]); + const [idLoaded, setIdLoaded] = useState(false); + const [emailLoaded, setEmailLoaded] = useState(false); + + useEffect(() => { + // Async now: the id is hashed before querying, because missions carry only + // learnerRef. Same teardown guard as the email subscription below - the + // effect can be torn down before the hash resolves, which would otherwise + // leak a live listener. + let cancelled = false; + let unsubscribe: (() => void) | undefined; + + subscribeMissionsByLearnerId(getLearnerID(), (missions) => { + setById(missions); + setIdLoaded(true); + }) + .then((unsub) => { + if (cancelled) { + unsub(); + return; + } + unsubscribe = unsub; + }) + .catch((error) => { + console.error('Failed to initialize mission history:', error); + setIdLoaded(true); + }); + + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, []); + + useEffect(() => { + if (!learnerEmail) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- no email set, so this source is trivially settled + setByEmail([]); + setEmailLoaded(true); + return; + } + setEmailLoaded(false); + + // Hashing the address is async, so the subscription is established after an + // await. Guard against the effect being torn down (or the email changing) + // before it resolves, which would otherwise leak a live listener. + let cancelled = false; + let unsubscribe: (() => void) | undefined; + + subscribeMissionsByLearnerEmail(learnerEmail, (missions) => { + setByEmail(missions); + setEmailLoaded(true); + }) + .then((unsub) => { + if (cancelled) { + unsub(); + return; + } + unsubscribe = unsub; + }) + .catch((error) => { + console.error('Failed to subscribe to missions by email:', error); + setByEmail([]); + setEmailLoaded(true); + }); + + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [learnerEmail]); + + const missions = useMemo(() => { + const merged = new Map(); + for (const mission of [...byId, ...byEmail]) { + merged.set(mission.id, mission); + } + return Array.from(merged.values()).sort( + (a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime() + ); + }, [byId, byEmail]); + + const counts = useMemo(() => { + let completed = 0; + for (const m of missions) { + if (getDiscoveryStatus(m.status) === 'Completed') completed += 1; + } + return { all: missions.length, Completed: completed, Pending: missions.length - completed }; + }, [missions]); + + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + return missions.filter((m) => { + if (activeFilter === 'favorites' && !isFavorite(m.id)) return false; + if (activeFilter !== 'all' && activeFilter !== 'favorites' + && getDiscoveryStatus(m.status) !== activeFilter) return false; + if (!q) return true; + return (m.name ?? '').toLowerCase().includes(q) || m.code.toLowerCase().includes(q); + }); + }, [missions, query, activeFilter, isFavorite]); + + // Counts are this page's own, so the chips describe the learner's history + // rather than the public feed. Withdrawn on unmount by the hook. + useRegisterSearchFilters( + useMemo( + () => [ + { key: 'all', label: 'All missions', count: counts.all, icon: Grid2x2 }, + { key: 'favorites', label: 'Favorite missions', count: favorites.length, icon: Star }, + { key: 'Completed', label: 'Completed missions', count: counts.Completed, icon: CircleCheckBig }, + { key: 'Pending', label: 'Pending missions', count: counts.Pending, icon: Hourglass }, + ], + [counts, favorites.length], + ), + ); + + const isLoading = !idLoaded || !emailLoaded; + + // Banner: prompt for an email when none is set, or show which email is in use. + const emailBanner = learnerEmail ? ( +
+

+ Showing missions for{' '} + {learnerEmail} (synced across your devices). +

+ +
+ ) : ( +
+

+ Add your email to see your missions on any device. +

+ +
+ ); + + if (isLoading) { + return ( +
+ {emailBanner} +
+
+

Loading your missions...

+
+
+ ); + } + + return ( +
+ {emailBanner} + + {missions.length === 0 ? ( +
+
+ +
+

No missions yet

+

Build your first mission and it will show up here.

+ + + Create Mission + +
+ ) : visible.length === 0 ? ( + // Distinct from "No missions yet" above: the learner HAS missions, the + // navbar's search or filter just excluded all of them. Telling them to + // build their first mission here would be wrong and a bit insulting. +
+
+ +
+

No missions match

+

Try a different name, code, or filter.

+
+ ) : ( +
+
+ {visible.map((mission, index) => ( + + + + ))} +
+
+ )} +
+ ); +} diff --git a/mission-control/src/components/mission/MissionNameInput.tsx b/mission-control/src/components/mission/MissionNameInput.tsx new file mode 100644 index 0000000..7696d04 --- /dev/null +++ b/mission-control/src/components/mission/MissionNameInput.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { Dices } from 'lucide-react'; +import { generateRandomMissionName } from '@/lib/missionNameGenerator'; + +interface MissionNameInputProps { + value: string; + onChange: (value: string) => void; +} + +export function MissionNameInput({ value, onChange }: MissionNameInputProps) { + const handleGenerateRandom = () => { + onChange(generateRandomMissionName()); + }; + + return ( +
+
+ + Mission name + + {/* Generated, not typed: a learner can re-roll this but not edit it, + so a mission can never be created unnamed. */} + + {value} + + +
+
+ ); +} diff --git a/mission-control/src/components/mission/MissionSentDialog.tsx b/mission-control/src/components/mission/MissionSentDialog.tsx new file mode 100644 index 0000000..7290312 --- /dev/null +++ b/mission-control/src/components/mission/MissionSentDialog.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { CheckCircle2 } from 'lucide-react'; + +/** + * Confirmation shown after a mission reaches the queue. + * + * There was already a small "Mission sent!" banner in the submit bar, but on a + * learner's FIRST submit it was never seen: the email prompt opens in the same + * tick behind a full-screen backdrop that covers the banner, and the banner + * cleared itself on a 5-second timer that ran while the modal was still up. Read + * the prompt, press Skip, and the confirmation had already expired - so the one + * moment that needs to feel like an achievement said nothing at all. + * + * This waits for the email decision instead of racing it, and dismisses on a + * tap rather than a timer, because a timer is what caused the problem. + * + * Plain CSS transitions, not Motion's AnimatePresence - see NotificationModal + * for the verified reason that library combination leaves an invisible, + * click-eating layer behind on exit. + */ +const EXIT_MS = 200; + +interface MissionSentDialogProps { + open: boolean; + onClose: () => void; + /** Set when the learner has an email saved: changes what we promise them. */ + email: string | null; +} + +export function MissionSentDialog({ open, onClose, email }: MissionSentDialogProps) { + const [mounted, setMounted] = useState(open); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (open) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- opening the dialog + setMounted(true); + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + } + setVisible(false); + const timer = setTimeout(() => setMounted(false), EXIT_MS); + return () => clearTimeout(timer); + }, [open]); + + if (!mounted) return null; + + return ( +
+
+ +
+
+ +
+ +

+ Mission sent! +

+ + {email ? ( +

+ It is in the queue for the rover. We will email{' '} + {/* The address is shown back deliberately: it is the only chance to + notice a typo before the one notification goes to nobody. */} + {email} once it has run. +

+ ) : ( +

+ It is in the queue for the rover. No email needed - find it again any + time under My History. +

+ )} + + +
+
+ ); +} diff --git a/mission-control/src/components/mission/MissionSubmitBar.tsx b/mission-control/src/components/mission/MissionSubmitBar.tsx new file mode 100644 index 0000000..3e4a07f --- /dev/null +++ b/mission-control/src/components/mission/MissionSubmitBar.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Rocket, CheckCircle2 } from 'lucide-react'; +import { MissionNameInput } from '@/components/mission/MissionNameInput'; + +// Deliberately plain CSS, not Motion's AnimatePresence - see NotificationModal +// for why: verified in a clean production build that AnimatePresence's exit +// animation completes correctly here but the component never actually +// unmounts, in this exact library/framework combination. Not shipping that. +const EXIT_MS = 200; + +/** + * Name-and-launch controls, rendered as the footer of the simulator column. + * + * These used to be stacked under the block canvas, where they cost 147px of a + * workspace locked to the viewport - against 311px for the canvas itself. The + * simulator's arena is drawn letterboxed with vertical slack to spare, so the + * space is cheaper on that side. + * + * Only mounted in Blocks and Python modes. Drive mode has no code to send, so + * it would otherwise show a permanently disabled button. + * + * Sizing responds to the CONTAINER, not the viewport: the split slider can + * squeeze this column to 320px while the window stays wide, so viewport + * breakpoints would not see it coming. Below 24rem the button takes its own + * line rather than crushing the name field to a few characters. + */ +interface MissionSubmitBarProps { + missionName: string; + onMissionNameChange: (name: string) => void; + onSubmit: () => void; + submitting: boolean; + submitSuccess: boolean; + currentCode: string; +} + +export function MissionSubmitBar({ + missionName, + onMissionNameChange, + onSubmit, + submitting, + submitSuccess, + currentCode, +}: MissionSubmitBarProps) { + const [mounted, setMounted] = useState(submitSuccess); + const [visible, setVisible] = useState(false); + + // Mount immediately, flip visible a frame later so the transition has a + // "before" state to run from, and hold the unmount until the exit + // animation has actually played. Mirrors NotificationModal/EmailPrompt. + useEffect(() => { + if (submitSuccess) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- the banner is appearing + setMounted(true); + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + } + setVisible(false); + const timer = setTimeout(() => setMounted(false), EXIT_MS); + return () => clearTimeout(timer); + }, [submitSuccess]); + + return ( +
+
+ + + +
+ + {/* Confirmation belongs next to the button that earned it, not back in + the editor column the controls just left. + A submit is rare and high-emotion (the delight tier, not just + feedback) - it earns a real entrance rather than the plain + conditional render this used to be. */} + {mounted && ( +
+ +
+

Mission sent!

+

It is in the queue for the rover to run.

+
+
+ )} +
+ ); +} diff --git a/mission-control/src/components/mission/MissionWorkspace.tsx b/mission-control/src/components/mission/MissionWorkspace.tsx new file mode 100644 index 0000000..44bab31 --- /dev/null +++ b/mission-control/src/components/mission/MissionWorkspace.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useState, useRef, useCallback, useEffect } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { RoverState } from '@/lib/rover-physics'; +import { getLearnerID } from '@/lib/getLearnerID'; +import { useLearner } from '@/contexts/LearnerContext'; +import { validateMission } from '@/infrastructure/validation/schemas'; +import { generateRandomMissionName } from '@/lib/missionNameGenerator'; +import { EditorPanel, type EditorMode } from '@/components/mission/EditorPanel'; +import { SimulationPanel } from '@/components/mission/SimulationPanel'; +import { MissionSubmitBar } from '@/components/mission/MissionSubmitBar'; +import { MissionSentDialog } from '@/components/mission/MissionSentDialog'; +import { SplitPane } from '@/components/ui/SplitPane'; +import { simulateCommands } from '@/lib/simulateCommands'; +import { resolveYardId } from '@/infrastructure/config/yard'; + +interface TrajectoryPoint { + x: number; + y: number; + heading: number; + speedL: number; + speedR: number; + servos: Record; +} + +type SimulationCommand = { + command: string; + speed?: number; + duration?: number; + degrees?: number; +}; + +// Bounds of the build/simulator split, as a percentage given to the build +// side. Owned here rather than in EditorPanel so the divider clamps to the +// same range as the values used to size the grid tracks. +const SPLIT_MIN = 35; +const SPLIT_MAX = 75; +const SPLIT_DEFAULT = 60; + +export function MissionWorkspace() { + const { learnerEmail, openEmailPrompt, showEmailPrompt } = useLearner(); + const searchParams = useSearchParams(); + const initialMode = (searchParams.get('mode') as EditorMode) || 'manual'; + const initialCode = searchParams.get('code') ?? ''; + + const [trajectory, setTrajectory] = useState([]); + const [isPlaying, setIsPlaying] = useState(false); + const [error, setError] = useState(null); + const [editorMode, setEditorMode] = useState(initialMode); + const [currentCode, setCurrentCode] = useState(initialCode); + const [blocklyState, setBlocklyState] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [submitSuccess, setSubmitSuccess] = useState(false); + const [missionSentOpen, setMissionSentOpen] = useState(false); + // True between opening the email prompt and the learner answering it either + // way. A ref, not state: nothing renders from it, and it must be readable by + // the effect below in the same tick the prompt closes. + const awaitingEmailChoiceRef = useRef(false); + // A name is generated up front so the learner never faces a blank, + // unnamed mission — they can only re-roll it, not type their own, so it is + // always present and always valid. + const [missionName, setMissionName] = useState(() => generateRandomMissionName()); + const abortControllerRef = useRef(null); + const manualTrajectoryLengthRef = useRef(0); + const [manualResetVersion, setManualResetVersion] = useState(0); + + // Run the commands through the client-side physics model and play the + // trajectory in the simulator. + const runSimulation = (commands: SimulationCommand[]) => { + setError(null); + const simulated = simulateCommands(commands); + setTrajectory(simulated); + setIsPlaying(true); + }; + + // Switching editor mode starts a clean simulator: clear the previous run's + // trajectory so, e.g., Manual starts from an empty canvas. + const handleEditorModeChange = useCallback((mode: EditorMode) => { + setEditorMode(mode); + setTrajectory([]); + setIsPlaying(false); + setError(null); + manualTrajectoryLengthRef.current = 0; + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + const handleManualTrajectory = useCallback((realtimeTrajectory: RoverState[]) => { + const converted: TrajectoryPoint[] = realtimeTrajectory.map((state) => ({ + x: state.x, + y: state.y, + heading: state.heading, + speedL: state.speedL, + speedR: state.speedR, + servos: { + '9': state.servos[9], + '15': state.servos[15], + '11': state.servos[11], + '13': state.servos[13], + }, + })); + setTrajectory((previousTrajectory) => { + const startIndex = manualTrajectoryLengthRef.current; + if (converted.length <= startIndex) { + return previousTrajectory; + } + + manualTrajectoryLengthRef.current = converted.length; + return [...previousTrajectory, ...converted.slice(startIndex)]; + }); + setIsPlaying(true); + }, []); + + const handleResetSimulation = useCallback(() => { + if (editorMode === 'manual') { + // Clear the drawn path and park the rover back at the start. The reset + // version bump tells ManualControlRealtime to reset its physics too, so + // the next tap drives from the centre again. + manualTrajectoryLengthRef.current = 0; + setTrajectory([]); + setManualResetVersion((version) => version + 1); + setIsPlaying(false); + return; + } + + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + setTrajectory([]); + setIsPlaying(false); + manualTrajectoryLengthRef.current = 0; + }, [editorMode]); + + const handleSubmitToQueue = async () => { + if (!currentCode.trim()) { + setError('Please write some code first!'); + return; + } + + setSubmitting(true); + setError(null); + setSubmitSuccess(false); + + try { + const learnerId = getLearnerID(); + let sessionId = localStorage.getItem('rover-session-id'); + if (!sessionId) { + sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + localStorage.setItem('rover-session-id', sessionId); + } + + const validation = validateMission({ + code: currentCode, + yardId: resolveYardId(), + learnerId, + sessionId, + // Stamp the email when the learner has provided one so this mission + // shows up in their cross-device history. + ...(learnerEmail ? { learnerEmail } : {}), + ...(editorMode === 'blockly' && blocklyState ? { blocklyState } : {}), + name: missionName, + }); + + if (!validation.success) { + setError(validation.errors?.join(' | ') || 'Validation failed'); + return; + } + + const response = await fetch('/api/missions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validation.data), + }); + const result = await response.json(); + + if (!response.ok || !result.success || !result.mission) { + throw new Error(result.error || 'Failed to submit mission'); + } + + localStorage.setItem('rover-latest-mission-id', result.mission.id); + + setSubmitSuccess(true); + setMissionName(generateRandomMissionName()); + // Offer notifications once the mission is in (never on landing), and only + // if the learner has not already saved an email. The confirmation waits + // for that answer rather than racing it: the prompt covers the whole + // screen, so anything shown underneath now is read by nobody. + if (!learnerEmail) { + awaitingEmailChoiceRef.current = true; + openEmailPrompt(); + } else { + setMissionSentOpen(true); + } + setTimeout(() => setSubmitSuccess(false), 5000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to submit mission'); + console.error('Submit error:', err); + } finally { + setSubmitting(false); + } + }; + + // Both Skip and Save close the prompt (LearnerContext.setLearnerEmail clears + // it too), so watching it close covers either answer with one path. By the + // time this runs, learnerEmail already holds a just-saved address, which is + // what lets the dialog promise an email to the right place. + useEffect(() => { + if (!showEmailPrompt && awaitingEmailChoiceRef.current) { + awaitingEmailChoiceRef.current = false; + setMissionSentOpen(true); + } + }, [showEmailPrompt]); + + return ( +
+ + } + right={ + + ) + } + /> + } + /> + + setMissionSentOpen(false)} + email={learnerEmail} + /> +
+ ); +} diff --git a/mission-control/src/components/mission/MonacoCodeEditor.tsx b/mission-control/src/components/mission/MonacoCodeEditor.tsx new file mode 100644 index 0000000..90ad50b --- /dev/null +++ b/mission-control/src/components/mission/MonacoCodeEditor.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import Editor from '@monaco-editor/react'; +import { Play, AlertTriangle } from 'lucide-react'; +import { type SimulationCommand } from '@/lib/roverBlockly'; +import { parseRoverCode } from '@/lib/parseRoverCode'; + +interface MonacoCodeEditorProps { + onGenerateCommands: (commands: SimulationCommand[]) => void; + onCodeChange?: (code: string) => void; +} + +// The real rover API: speed is 0-100, and you control how long a move lasts +// with time.sleep() then rover.stop() - exactly what the blocks generate. +const DEFAULT_CODE = `# Drive your rover. Speed is 0-100, time is in seconds. +rover.forward(60) +time.sleep(1.5) +rover.stop() + +rover.spinRight(60) +time.sleep(0.5) +rover.stop() + +rover.forward(60) +time.sleep(1.5) +rover.stop() +`; + +// Snippets the palette inserts. Colours echo the Blockly categories so the +// Python tab reads as "the same blocks, written out". +const SNIPPETS: { label: string; colour: string; code: string }[] = [ + { label: 'Forward', colour: '#2196F3', code: 'rover.forward(60)\ntime.sleep(1)\nrover.stop()\n' }, + { label: 'Backward', colour: '#2196F3', code: 'rover.reverse(60)\ntime.sleep(1)\nrover.stop()\n' }, + { label: 'Spin left', colour: '#9C27B0', code: 'rover.spinLeft(60)\ntime.sleep(0.5)\nrover.stop()\n' }, + { label: 'Spin right', colour: '#9C27B0', code: 'rover.spinRight(60)\ntime.sleep(0.5)\nrover.stop()\n' }, + { + label: 'Steer left', + colour: '#00BCD4', + code: + 'rover.setServo(9, -20)\nrover.setServo(15, -20)\nrover.setServo(11, 20)\nrover.setServo(13, 20)\n' + + 'rover.forward(60)\ntime.sleep(1)\nrover.stop()\n' + + 'rover.setServo(9, 0)\nrover.setServo(11, 0)\nrover.setServo(13, 0)\nrover.setServo(15, 0)\n', + }, + { + label: 'Steer right', + colour: '#00BCD4', + code: + 'rover.setServo(9, 20)\nrover.setServo(15, 20)\nrover.setServo(11, -20)\nrover.setServo(13, -20)\n' + + 'rover.forward(60)\ntime.sleep(1)\nrover.stop()\n' + + 'rover.setServo(9, 0)\nrover.setServo(11, 0)\nrover.setServo(13, 0)\nrover.setServo(15, 0)\n', + }, + { label: 'Stop', colour: '#f44336', code: 'rover.stop()\n' }, + { label: 'Wait', colour: '#FF9800', code: 'time.sleep(1)\n' }, + { label: 'Repeat', colour: '#FF9800', code: 'for _ in range(3):\n rover.forward(60)\n time.sleep(1)\n rover.stop()\n' }, + { label: 'Lights', colour: '#673AB7', code: 'rover.setColor(rover.fromRGB(255, 0, 0))\nrover.show()\n' }, +]; + +export function MonacoCodeEditor({ onGenerateCommands, onCodeChange }: MonacoCodeEditorProps) { + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [validationErrors, setValidationErrors] = useState>([]); + // Monaco editor + namespace instances (provided untyped by the editor lib). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const editorRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const monacoRef = useRef(null); + + useEffect(() => { + // Load saved code from localStorage + const saved = localStorage.getItem('rover_monaco_code'); + const initialCode = saved || DEFAULT_CODE; + // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time hydration of editor contents from localStorage + setCode(initialCode); + + if (onCodeChange) { + onCodeChange(initialCode); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount to hydrate from storage + }, []); + + // Monaco's own internal services (tokenization, model disposal, etc.) use + // a "Canceled" sentinel error for work that gets interrupted - normally + // swallowed internally, but disposing the editor externally (switching + // away from this tab, which unmounts it) races one of those in-flight + // operations often enough to leak an unhandled rejection. This is + // Monaco/vscode's own long-documented pattern, not application code we can + // add a try/catch around - narrowly matches on the exact message so it + // can't mask an unrelated real rejection. + useEffect(() => { + const handleRejection = (event: PromiseRejectionEvent) => { + if (event.reason?.message === 'Canceled') { + event.preventDefault(); + } + }; + window.addEventListener('unhandledrejection', handleRejection); + return () => window.removeEventListener('unhandledrejection', handleRejection); + }, []); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Monaco editor/namespace instances from @monaco-editor/react onMount + const handleEditorDidMount = (editor: any, monaco: any) => { + editorRef.current = editor; + monacoRef.current = monaco; + + // Validate on mount + validateCode(code); + }; + + const validateCode = (codeToValidate: string) => { + const errors: Array<{ line: number; message: string }> = []; + + // Only flag genuinely unsafe lines (mirrors the server-side allowlist). + // Real rover code uses time.sleep(), for-loops, print() and rover.setServo() + // alongside the movement calls, so we do not require every line to be a + // rover command. + const dangerousPatterns = [ + { pattern: /\bimport\b/, message: 'Import statements are not allowed' }, + { pattern: /\bopen\(/, message: 'File operations are not allowed' }, + { pattern: /\beval\(/, message: 'eval() is not allowed' }, + { pattern: /\bexec\(/, message: 'exec() is not allowed' }, + { pattern: /\b__import__\b/, message: '__import__ is not allowed' }, + { pattern: /\bos\./, message: 'The os module is not allowed' }, + { pattern: /\bsys\./, message: 'The sys module is not allowed' }, + ]; + + codeToValidate.split('\n').forEach((line, index) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return; + for (const { pattern, message } of dangerousPatterns) { + if (pattern.test(line)) { + errors.push({ line: index + 1, message }); + return; + } + } + }); + + setValidationErrors(errors); + + // Update Monaco editor markers + if (editorRef.current && monacoRef.current) { + const model = editorRef.current.getModel(); + if (model) { + const markers = errors.map(err => ({ + startLineNumber: err.line, + startColumn: 1, + endLineNumber: err.line, + endColumn: model.getLineMaxColumn(err.line), + message: err.message, + severity: monacoRef.current.MarkerSeverity.Error, + })); + monacoRef.current.editor.setModelMarkers(model, 'rover-validator', markers); + } + } + }; + + const handleCodeChange = (value: string | undefined) => { + const newCode = value || ''; + setCode(newCode); + localStorage.setItem('rover_monaco_code', newCode); + setError(null); + + // Validate code in real-time + validateCode(newCode); + + // Notify parent of code change + if (onCodeChange) { + onCodeChange(newCode); + } + }; + + const insertSnippet = (snippet: string) => { + const editor = editorRef.current; + if (!editor) return; + const selection = editor.getSelection(); + editor.executeEdits('palette', [{ range: selection, text: snippet, forceMoveMarkers: true }]); + editor.focus(); + }; + + const handleRun = () => { + try { + const commands = parseRoverCode(code); + + if (commands.length === 0) { + setError('No rover moves found yet. Try a move, then time.sleep() for how long, then rover.stop().'); + return; + } + + setError(null); + onGenerateCommands(commands); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to parse code'); + } + }; + + return ( +
+
+

+ Write Python using rover commands. +

+ +
+ + {/* Insert-on-click command palette (doubles as the cheat sheet). Tap a + chip to drop the real rover code at the cursor. */} +
+ {SNIPPETS.map((item) => ( + + ))} +
+ + {error && ( +
+ +

Error: {error}

+
+ )} + + {validationErrors.length > 0 && ( +
+

+ + {validationErrors.length} thing{validationErrors.length === 1 ? '' : 's'} to fix +

+
    + {validationErrors.map((err, idx) => ( +
  • + Line {err.line}: {err.message} +
  • + ))} +
+
+ )} + +
+ +
+
+ ); +} + +// parseRoverCode lives in @/lib/parseRoverCode so the mission detail page can +// re-simulate a stored mission from its code too. diff --git a/mission-control/src/components/mission/RoverSimulator.tsx b/mission-control/src/components/mission/RoverSimulator.tsx new file mode 100644 index 0000000..6a434c6 --- /dev/null +++ b/mission-control/src/components/mission/RoverSimulator.tsx @@ -0,0 +1,352 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTheme } from '@/contexts/ThemeContext'; +import { + computeLayout, + drawSimFrame, + interpolate, + SIM_FPS, + DARK_SIM_PALETTE, + LIGHT_SIM_PALETTE, + type SimLayout, +} from '@/lib/roverSimRender'; + +interface TrajectoryPoint { + x: number; + y: number; + heading: number; + speedL: number; + speedR: number; + servos: Record; +} + +interface RoverSimulatorProps { + trajectory: TrajectoryPoint[]; + isPlaying: boolean; + onReset?: () => void; + editorMode?: 'manual' | 'blockly' | 'code'; + resetVersion?: number; + /** + * Rendered inside this card, below the playback controls. A slot rather than + * anything simulator-specific: the arena is drawn letterboxed with vertical + * slack, which makes this the cheapest place on the page to spend height. + * The simulator does not need to know what goes in it. + */ + footer?: React.ReactNode; +} + +export function RoverSimulator({ + trajectory = [], + isPlaying = false, + onReset, + editorMode, + resetVersion = 0, + footer, +}: RoverSimulatorProps) { + const canvasRef = useRef(null); + const wrapRef = useRef(null); + const trajRef = useRef(trajectory); + const playheadRef = useRef(0); + const rafRef = useRef(null); + const lastTsRef = useRef(null); + const sizeRef = useRef({ w: 0, h: 0, s: 1, ox: 0, oy: 0, dpr: 1 }); + + const { theme } = useTheme(); + const isManual = editorMode === 'manual'; + const [isPaused, setIsPaused] = useState(false); + const [hud, setHud] = useState({ x: 0, y: 0, heading: 0, frame: 0, total: 0 }); + + // Keep the latest trajectory available to the rAF loop (which reads it live) + // without re-subscribing every frame. Runs before the draw effects below. + useEffect(() => { + trajRef.current = trajectory; + }); + + // The canvas cannot read CSS custom properties, so the terrain palette is + // chosen here and passed in. Listed as a dependency so toggling the theme + // repaints the yard - without it the arena keeps the old ground until the + // next resize or playback frame happens to redraw it. + const simPalette = theme === 'light' ? LIGHT_SIM_PALETTE : DARK_SIM_PALETTE; + + const drawScene = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const L = sizeRef.current; + if (L.w === 0) return; + ctx.setTransform(L.dpr, 0, 0, L.dpr, 0, 0); + const traj = trajRef.current; + const playhead = isManual ? Math.max(0, traj.length - 1) : playheadRef.current; + drawSimFrame(ctx, L, traj, playhead, simPalette); + }, [isManual, simPalette]); + + // --- Sizing (crisp on HiDPI) -------------------------------------------- + + const resize = useCallback(() => { + const canvas = canvasRef.current; + const wrap = wrapRef.current; + if (!canvas || !wrap) return; + const rect = wrap.getBoundingClientRect(); + const w = Math.max(0, rect.width); + const h = Math.max(0, rect.height); + const dpr = Math.min(2.5, window.devicePixelRatio || 1); + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + sizeRef.current = { ...computeLayout(w, h), dpr }; + drawScene(); + }, [drawScene]); + + useEffect(() => { + resize(); + const wrap = wrapRef.current; + if (!wrap || typeof ResizeObserver === 'undefined') return; + // Coalesced to at most once per animation frame - the panel-split slider + // (MissionWorkspace.tsx) animates its CSS grid track with a transition, + // which fires this ResizeObserver on every intermediate frame of that + // transition. Without this, the full canvas resize + scene redraw ran on + // every one of those frames for the whole drag, not just once per step. + let rafId: number | null = null; + const throttledResize = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + resize(); + }); + }; + const ro = new ResizeObserver(throttledResize); + ro.observe(wrap); + return () => { + ro.disconnect(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, [resize]); + + // --- HUD + playback ------------------------------------------------------ + + const syncHud = useCallback(() => { + const traj = trajRef.current; + if (traj.length === 0) { + setHud({ x: 0, y: 0, heading: 0, frame: 0, total: 0 }); + return; + } + const playhead = isManual ? traj.length - 1 : playheadRef.current; + const st = interpolate(traj, playhead); + setHud({ + x: st.x, + y: st.y, + heading: ((st.heading % 360) + 360) % 360, + frame: Math.round(playhead) + 1, + total: traj.length, + }); + }, [isManual]); + + // Continuous rAF only while a non-manual run is actively playing. + useEffect(() => { + if (isManual || isPaused || !isPlaying) return; + if (trajectory.length === 0) return; + + let lastHudFrame = -1; + const tick = (ts: number) => { + const last = lastTsRef.current ?? ts; + const dt = (ts - last) / 1000; + lastTsRef.current = ts; + + const len = trajRef.current.length; + let p = playheadRef.current + dt * SIM_FPS; + if (p >= len - 1) p = len - 1; + playheadRef.current = p; + + drawScene(); + const f = Math.round(p); + if (f !== lastHudFrame) { + lastHudFrame = f; + syncHud(); + } + + if (p < len - 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + rafRef.current = null; + } + }; + + lastTsRef.current = null; + rafRef.current = requestAnimationFrame(tick); + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + }; + }, [isManual, isPaused, isPlaying, trajectory.length, drawScene, syncHud]); + + // A fresh non-manual run starts from the beginning and plays. + useEffect(() => { + if (isManual) return; + playheadRef.current = 0; + // eslint-disable-next-line react-hooks/set-state-in-effect -- restart playback when a new trajectory arrives (external event) + setIsPaused(false); + drawScene(); + syncHud(); + }, [trajectory, isManual, drawScene, syncHud]); + + // Manual mode is live: keep the rover on the newest point as it streams in. + useEffect(() => { + if (!isManual) return; + playheadRef.current = Math.max(0, trajectory.length - 1); + drawScene(); + syncHud(); + }, [isManual, trajectory, drawScene, syncHud]); + + // External reset (shared simulator controls) parks the view at the start. + useEffect(() => { + if (resetVersion === 0) return; + playheadRef.current = 0; + // eslint-disable-next-line react-hooks/set-state-in-effect -- react to the shared reset signal from the workspace controls + setIsPaused(true); + drawScene(); + syncHud(); + }, [resetVersion, drawScene, syncHud]); + + const handleScrub = (value: number) => { + setIsPaused(true); + playheadRef.current = value; + drawScene(); + syncHud(); + }; + + const handlePlayPause = () => { + const len = trajRef.current.length; + if (isPaused && playheadRef.current >= len - 1) { + playheadRef.current = 0; // restart if parked at the end + } + lastTsRef.current = null; + setIsPaused((p) => !p); + }; + + const handleReset = () => { + onReset?.(); + playheadRef.current = 0; + setIsPaused(true); + drawScene(); + syncHud(); + }; + + const hasTrajectory = trajectory.length > 0; + + return ( +
+
+
+ +

Simulator

+
+ {hasTrajectory && ( +
+ + + +
+ )} +
+ +
+ + {!hasTrajectory && ( +
+

+ Tap a block or press Run to move your rover +

+
+ )} +
+ + {hasTrajectory && ( +
+ handleScrub(parseInt(e.target.value))} + className="h-1.5 w-full cursor-pointer accent-primary" + aria-label="Scrub simulation frame" + /> +
+ {!isManual && ( + + )} + +
+
+ )} + + {footer} +
+ ); +} + +function Chip({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function PlayIcon() { + return ( + + + + ); +} +function PauseIcon() { + return ( + + + + ); +} +function ResetIcon() { + return ( + + + + + ); +} diff --git a/mission-control/src/components/mission/SimulationPanel.tsx b/mission-control/src/components/mission/SimulationPanel.tsx new file mode 100644 index 0000000..7aeee55 --- /dev/null +++ b/mission-control/src/components/mission/SimulationPanel.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { RoverSimulator } from '@/components/mission/RoverSimulator'; + +/** + * Right-hand simulation column. Thin layout wrapper around RoverSimulator; + * props are derived from it so they stay in sync automatically. + */ +type SimulationPanelProps = React.ComponentProps; + +export function SimulationPanel(props: SimulationPanelProps) { + return ( +
+ +
+ ); +} diff --git a/mission-control/src/components/mission/YouTubeEmbed.tsx b/mission-control/src/components/mission/YouTubeEmbed.tsx new file mode 100644 index 0000000..11b251c --- /dev/null +++ b/mission-control/src/components/mission/YouTubeEmbed.tsx @@ -0,0 +1,77 @@ +'use client'; + +/** + * Click-to-play YouTube embed. + * + * The iframe is only created after the learner taps play. Three reliability + * wins over an eager embed: + * - Pages full of eager embeds from one venue IP (500 kids on shared wifi) + * look like bot traffic to Google and trigger "unusual traffic" blocks. + * Click-to-play means embeds load only on intent. + * - The preview thumbnail comes from img.youtube.com, a separate pipeline + * that is practically never bot-gated, so the page always renders. + * - The block page cannot be detected from our side (it loads "successfully" + * cross-origin and a captcha cannot be solved inside an embed), so a + * visible Watch-on-YouTube link is the escape hatch: the real site or app + * can pass the check. + * + * Uses the youtube-nocookie.com privacy-enhanced player, the right default + * for an audience of minors. + */ + +import React, { useState } from 'react'; +import { Play, ExternalLink } from 'lucide-react'; + +interface YouTubeEmbedProps { + youtubeId: string; + title: string; +} + +export function YouTubeEmbed({ youtubeId, title }: YouTubeEmbedProps) { + const [playing, setPlaying] = useState(false); + const watchUrl = `https://www.youtube.com/watch?v=${youtubeId}`; + + return ( +
+
+ {playing ? ( +