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]>
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]>
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]>
- 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]>
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]>
- 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
- 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]>
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]>
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]>
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]>