124 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5.5 6f0a297345 merge: v1.1 navigation motion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:59:26 +01:00
Lucas WintherandClaude Opus 5.5 89db7f88b7 docs: record loading-line motion exception (R20)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:59:26 +01:00
Lucas WintherandClaude Opus 5.5 bc267c6562 fix(web): keep focus on the dock or outline control that navigated; announce only
Navigations started from the dock (Previous/Next) or the course outline leave focus on the control used and only
announce the new title; other navigations (content links, library, back/forward) still focus the new h1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:56:37 +01:00
Lucas WintherandClaude Opus 5.5 4379d77b9c test(web): deterministic motion e2e (fetch gate, condition waits) and new frame capture
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:49:49 +01:00
Lucas WintherandClaude Opus 5.5 b5e00af4f7 fix(web,learner): navigation swaps at once; content-only entrance, loading line, focus (R19)
Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen
while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the
content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over
150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1
and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops.
Scroll restore stops on user input; pdf.js never starts a load after cleanup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:49:49 +01:00
Lucas WintherandClaude Opus 5.5 305c262ff1 docs: record motion plan deviations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:26:15 +01:00
Lucas WintherandClaude Opus 5.5 89ff72cc28 fix(web): keep the sidebar through route transitions; end them within 120 ms
Router updates now run in a React transition, so a route whose code is still loading keeps the current page up
and the view transition captures the new page with its sidebar (it used to drop out and pop back on the first
course-to-lesson visit). The root group's 250 ms UA morph is off: pointer input goes to the document while a
transition runs, so it stays as short as the old page's 120 ms fade. The pointer-events rule had no effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:24:43 +01:00
Lucas WintherandClaude Opus 5.5 44f1b1d53d merge: v1.1 edit imported courses in the studio
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:01:40 +01:00
Lucas WintherandClaude Opus 5.5 f02e30450c fix(studio): review round 2 - a conversion that fails after its commit recovers without a restart
- The convert job reloads the course on success and on failure, so learners
  get the intact import (rolled back) or the published studio course, never a
  course whose folder moved away.
- POST /convert on a course with an incomplete conversion (.converting-<id>,
  staging or retired folders) queues a job that recovers and finishes it
  instead of answering 409; the studio page shows "Conversion incomplete" with
  Retry for the owner.
- The 409 for pending import jobs names their kinds and ids; the dead withLock
  in git.ts is gone; the dot-folder discovery rule is documented in
  docs/operations.md and spec section 5.
- Route tests (SQLite and Postgres) inject a failed cross-filesystem copy, a
  checksum mismatch and a pre-commit failure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:00:45 +01:00
Lucas WintherandClaude Opus 5.5 57fe83f5de chore: motion frame capture script
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:59:50 +01:00
Lucas WintherandClaude Opus 5.5 6bb7681306 test(web): reduced motion disables all motion
Also turns off the fold height transition, which the universal selector does not reach (::details-content).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:55:32 +01:00
Lucas WintherandClaude Opus 5.5 771d755da4 fix(studio): review round 1 for imported-course editing
- Media exclusions move from the editable root .gitignore to the repo's
  info/exclude, and dirtyFiles never counts files listed in media.json, so an
  author's or remote's .gitignore cannot block publish or Sync. Publish and
  Sync refuse commits that track a file over referenced media.
- Importer jobs (code-repo, pdf-deck, transcribe) and the cover route run
  under the course lock now shared with the studio (core withCourseLock) and
  re-check the course inside it; after conversion they fail with "this course
  is now edited in the studio". Convert answers 409 while an import job for
  the course is queued or running, and the job re-checks.
- Crash-safe conversion: the import folder is hidden (.converting-<id>) before
  the published worktree exists; cross-filesystem moves copy into a staging
  dir, fsync, verify size and sha256, then rename; the source is retired as
  .converted-<id>.<n>. Recovery runs at server start and before every convert;
  a re-run of a finished conversion reports success. The loader skips
  dot-folders.
- Minor: no raw NUL bytes in source, link fallback only on EXDEV/EPERM/EMLINK,
  referenced media are not read whole by the editor, missing media are logged,
  mediaDir JSDoc, docs on per-author copies across filesystems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:53:50 +01:00
Lucas WintherandClaude Opus 5.5 370d381ff4 feat(learner): progress, toast and menu motion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:53:34 +01:00
Lucas WintherandClaude Opus 5.5 13393e10ba merge: v1.1 AI schema work (tool calls, mentor table, citations, sealed MCP secrets)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:51:35 +01:00
Lucas WintherandClaude Opus 5.5 d5e836cc79 feat(learner): cross-fade deck slides
The deck viewer draws the next slide into a second canvas and fades it in over the current one, 4 px from the
direction of travel; the stage never blanks while pdf.js draws. A newer draw into a canvas cancels the older one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:50:54 +01:00
Lucas WintherandClaude Opus 5.5 f2169a1235 fix(ai): bind sealed secrets to purpose and row; never drop unreadable secrets
Review fix round 1.
- Secret box v2 tokens: one HKDF key per purpose (byok, server-api-key,
  mcp) and AES-GCM AAD bound to the row (user id + provider; server id +
  field). MCP env and headers are sealed per field. open() refuses v1
  tokens; the data upgrade re-seals v1 keys, v1 plaintext MCP values and
  the draft secretCipher, and marks ai_secrets_v2 only when nothing
  legacy is left (retried until the previous secret is back).
- Unreadable secrets are reported (admin: unreadableSecrets with server
  and field; user: byok.keyUnreadable), logged at startup, and kept on
  save unless replaced or cleared (clearApiKey, clearSecrets). The admin
  and account pages show the warnings.
- allowedOrigins and provider resolution no longer open MCP secrets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:04 +01:00
Lucas WintherandClaude Opus 5.5 a83ac02a90 feat(learner): module lists and folds open with motion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:47:12 +01:00
Lucas WintherandClaude Opus 5.5 a953e35178 feat(learner): hold the lesson until the next one loads, swap with a view transition
The sidebar highlight moves at once; the lesson column keeps the current lesson until the next has loaded, then
swaps inside a view transition, and the route scroll (top, or the saved position on back) is applied then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:42:15 +01:00
Lucas WintherandClaude Opus 5.5 3988addaed fix(learner): destroy decks through the pdf.js loading task
pdf.js 6 documents have no destroy(); leaving a lesson whose deck had loaded threw in the effect cleanup and
unmounted the app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:41:23 +01:00
Lucas WintherandClaude Opus 5.5 08e62ff683 test(e2e): narrow the Postgres URL for the typechecker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:38:49 +01:00
Lucas WintherandClaude Opus 5.5 260f7b52c8 docs(ai): contract requests 3-5 settled in v1.1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:37:33 +01:00
Lucas WintherandClaude Opus 5.5 d607938630 feat(web): view transitions on route changes; content enter animation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:36:45 +01:00
Lucas WintherandClaude Opus 5.5 5c63f17cff feat(ai): structured tool calls on ChatMessage (contract request 3)
ChatMessage gains toolCalls and an opaque providerData; providers may
yield providerData once per turn. The assistant loop appends one
assistant message per model turn. Anthropic replays its own raw blocks
from providerData (no in-memory map) or maps text + tool_use blocks;
OpenAI maps toolCalls to tool_calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:36:38 +01:00
Lucas WintherandClaude Opus 5.5 f834088588 docs: imported courses in the studio (media kept out of git, moved to <data>/media/<id>)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:35:36 +01:00
Lucas WintherandClaude Opus 5.5 4a49e28fcc test(e2e): import, make editable, edit, publish; learner keeps progress and notes, media keep Range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:35:10 +01:00
Lucas WintherandClaude Opus 5.5 ddc66f5be5 feat(ai): encrypt MCP env and headers at rest and mask them in the admin API
env/headers are sealed into secretCipher with the BYOK AES-GCM/HKDF box.
The admin GET returns masked values; a mask posted back for the same
server and key keeps the stored value, any other mask is a 400. The chat
and the test button use the stored values. upgradeAiData seals plaintext
v1 rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:33:56 +01:00
Lucas WintherandClaude Opus 5.5 20246b442f feat(studio): "Make editable in the studio" panel for imported courses
Owner sees what conversion does, starts it, follows progress and lands in the
workbench; other authors are told only the owner can. Existing tokens only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:33:55 +01:00
Lucas WintherandClaude Opus 5.5 635fd66d04 feat(ui): scroll keeper with deferred settle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:33:44 +01:00
Lucas WintherandClaude Opus 5.5 afa2338ad3 feat(studio): owner-only "Make editable in the studio" route and conversion job
POST /api/studio/courses/:id/convert (owner only, CSRF-guarded by core) queues
studio.convert-import; GET /api/studio/jobs/:id reports progress. Read-only
responses say whether the caller may convert. Resolves TODO(studio) (ruling R9).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:32:59 +01:00
Lucas WintherandClaude Opus 5.5 b1d17956f4 feat(ui): view-transition helper and held-value hook
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:32:57 +01:00
Lucas WintherandClaude Opus 5.5 17b6ae3828 feat(ui): motion tokens, keyframes and reduced-motion switch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:32:21 +01:00
Lucas WintherandClaude Opus 5.5 eed59d4ef2 docs: v1.1 M navigation motion plan
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:31:36 +01:00
Lucas WintherandClaude Opus 5.5 df6d235070 feat(studio): convert an imported course into a studio repo, media kept outside git
Small files are committed unchanged (visibility and source.kind stay); large media
are referenced from .stcourses/media.json, ignored, and hard-linked into every
worktree from <dataDir>/media/<id>, where the import folder is moved. Studio writes
to referenced media are refused and never write through a shared inode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:31:19 +01:00
Lucas WintherandClaude Opus 5.5 a995900fba feat(ai): per-course mentor preferences table (contract request 4)
0002_ai_mentor_prefs.sql adds ai_mentor_prefs(user_id, course_id, off).
upgradeAiData copies v1 config_json.mentorOff toggles into it once
(marked ai_data_v11 in schema_migrations); the settings repo awaits the
upgrade before first use and aiFeature starts it at construction.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:30:59 +01:00
Lucas WintherandClaude Opus 5.5 60179407f4 feat(studio): media references for imported courses (path, size, sha256, .gitignore)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:28:39 +01:00
Lucas WintherandClaude Opus 5.5 d34a5c1f0c feat(ai): citations column on ai_messages (contract request 5)
New packages/feature-ai/migrations/0001_ai_message_citations.sql adds
ai_messages.citations_json and folds v1 'sources' rows into the assistant
row before them. The feature writes and reads the column directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:28:23 +01:00
Lucas WintherandClaude Opus 5.5 5b8ba24b00 docs: v1.1 S plan (edit imported courses in the studio)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:27:40 +01:00
Lucas WintherandClaude Opus 5.5 fbfe16534a docs: v1.1 work package A plan (AI contract requests 3-5, MCP secrets)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:26:09 +01:00
Lucas WintherandClaude Opus 5.5 fd5c72afc2 docs: v1.1 plan (motion, studio import editing, perf, AI schema)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:21:39 +01:00
Lucas WintherandClaude Opus 5.5 58e0b20c95 merge: Phase 1 feature packages and Phase 2 integration
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:51:19 +01:00
Lucas WintherandClaude Opus 5.5 db5a604647 fix(ui): cover tiles keep the monogram for landscape images; quiet pill header nav
Landscape images (imported video frames, slide pages) would lose most of the
picture in a 3:4 crop, so the tile keeps its monogram; portrait and
near-square images (w/h <= 1.05) fade in over it, cropped from the centre.
The header nav follows the mockup's segmented-control style: muted text, no
underline, the current area raised in accent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:47:01 +01:00
Lucas WintherandClaude Opus 5.5 8045c80d93 fix: final-review wave (CSRF guard, fail-closed owner bootstrap, streamed zips, e2e visibility suite)
- Critical 1 (CSRF): core wrap refuses every POST/PUT/PATCH/DELETE whose Origin
  (Referer as fallback) is not publicUrl's origin or STC_ALLOWED_ORIGINS, and
  any body that is not application/json (multipart only on declared upload
  routes). Better Auth routes go through the same guard; only declared
  identity-provider callbacks (SAML ACS/SLO, OAuth callbacks) are exempt.
  Better Auth trustedOrigins use the same list. Session cookie stays Lax
  (Strict breaks OAuth/OIDC/SAML returns); documented.
- Important 4 / learner minor 4: the first-owner claim and the role row are
  one transaction guarded by "no owner yet"; only a unique violation means
  learner; any other error fails the sign-up with 500 and removes the
  half-created user. Serialised per DB handle (Bun's SQLite connection).
- Important 2 / learner minor 12: code zips stream entry by entry (large
  files stored in 256 KB chunks with a data descriptor and incremental
  CRC-32), so memory stays at one chunk; the cap is now 1 GiB (ZIP32).
- Important 3 / learner minor 10: the e2e runs the production wiring
  (productFeatures order, AI drafts through the real sink) against a
  restricted course and asserts 404/401 for learner and anonymous on
  course, lesson, media, cover, code, zip, notes, progress, attempts, AI
  chat, library, search and AI status; a real Range request (206, 416);
  forged writes (foreign Origin, text/plain, no Origin) are 403 and the MCP
  test command never runs.
- Minors 7-9: compose passes every documented variable; the health check
  uses STC_PORT; productFeatures checks the studio-before-AI order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:42:17 +01:00
Lucas WintherandClaude Opus 5.5 69aaedcb43 build: docker image and compose
Dockerfile on oven/bun:1.3 with ffmpeg, git, unzip and python3-pymupdf, a
production install, non-root user, /data volume and a health check;
docker-compose.yml (project stc) with an optional postgres:17 profile and
read-only course and import mounts; docs/operations.md with configuration,
mounts, security headers and the measured idle memory (101.4 MiB for app plus
Postgres after 5 minutes idle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:17:58 +01:00
Lucas WintherandClaude Opus 5.5 d71a95dbc1 fix(web): styled role-filtered nav, hidden on phones; clearer studio preview label
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:07:45 +01:00
Lucas WintherandClaude Opus 5.5 777ae2cc37 feat: resolve Phase 1 contract requests and compose every feature
Contracts and core:
- Feature gains auth and stop; FeatureFactory; core composeServer builds the
  context, runs the factories, then the job queue from the union of their jobs
  (R8/R14). startServer uses it; main.ts composes learner, import, studio, AI.
- AppConfig/loadConfig own the layout and settings: courseDirs include
  <dataDir>/courses and <dataDir>/worktrees, studioReposDir, cacheDir,
  importRoots, uploadMaxMb, aiAllowedBaseUrls, transcribeUrl, python.
  resolveConfig derives the same for tests; cachePaths shares the importer's
  cache layout.
- canView: the global author role views every course (R14); the learner,
  studio and AI packages all call it.
- migrate(db, packages): core first, then packages in name order.
- Media: nosniff on every response, a sandbox CSP on svg/html/js; a Range on
  an empty file is 416.
- courseUrl/lessonUrl and the lesson route patterns live in @stc/contracts.

Packages:
- import, studio and AI read AppConfig instead of process.env; the importer
  refuses ids used by studio/repos.
- learner migrations run through core migrate; library covers fall back to
  the importer's cached candidate behind a canView-checked route.
- studio preview renders through the learner LessonView (anchors honoured,
  draft URLs); AI drafts go to the studio draft branch for studio courses (as
  the requesting user) and to <dataDir>/drafts otherwise, and the generator
  page says which.

Web: tsconfig paths for @stc/<feature>/web browser entries; features.ts
imports them by package name; nav.roles hides links by role.

Tests: compose, config, canView, media headers, migrations order, covers,
preview, nav, SPA bundle without server code, and an end-to-end flow on
SQLite and Postgres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:02:46 +01:00
Lucas WintherandClaude Opus 5.5 427eda6e93 merge: ai
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:44:06 +01:00
Lucas WintherandClaude Opus 5.5 2854c26a7d merge: studio
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:42:51 +01:00
Lucas WintherandClaude Opus 5.5 627ebe0029 merge: import
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:41:36 +01:00
Lucas WintherandClaude Opus 5.5 d8d62696da merge: learner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:40:29 +01:00
Lucas WintherandClaude Opus 5.5 0da35ec6f8 fix(learner): remove the SPA proxy; CSP as a meta tag in index.html (ruling R16)
The proxy, its internal server, Unix socket and NO_PROXY changes are gone; main.ts serves the HTML
import directly again. apps/web/index.html carries the SPA policy as <meta http-equiv=CSP> ahead of every
script (script-src 'self' plus the hash of Bun's dev-server helper, no unsafe-inline/eval; no
frame-ancestors, which meta cannot carry). Dev HMR connects under it. The runner frame keeps its header CSP.

Tests: the served index.html (prod and dev, in a subprocess) has the meta policy and every inline script
is hash-covered; the XSS browser test fails with the sanitizer disabled and passes with it, and the meta
CSP alone blocks unsanitized handlers (with a no-CSP control); no source assigns proxy env vars, and the
feature leaves them untouched at runtime. Contract request 5 asks core for frame-ancestors/X-Frame-Options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:38:04 +01:00