diff --git a/apps/web/src/features.ts b/apps/web/src/features.ts
index 8b077c9..cc95268 100644
--- a/apps/web/src/features.ts
+++ b/apps/web/src/features.ts
@@ -2,3 +2,4 @@ import type { WebFeature } from "@stc/contracts";
// Phase 1 packages append one import line each and push into this list.
export const webFeatures: WebFeature[] = [];
+import { studioWeb } from "../../../packages/feature-studio/src/web"; webFeatures.push(studioWeb);
diff --git a/packages/feature-studio/dev/serve.ts b/packages/feature-studio/dev/serve.ts
new file mode 100644
index 0000000..ef20f0e
--- /dev/null
+++ b/packages/feature-studio/dev/serve.ts
@@ -0,0 +1,35 @@
+/**
+ * Dev server for the studio until Phase 2 lets apps/server compose ctx-dependent features
+ * (docs/contract-requests/studio.md). Mirrors startServer, adds the studio feature and a dev-only user.
+ *
+ * STC_PORT=3463 STC_DATA_DIR=/tmp/stc-studio-data STC_DEV_USER=author bun packages/feature-studio/dev/serve.ts
+ *
+ * STC_DEV_USER: "author" | "owner" | "learner" | unset (anonymous). Never use this entry in production.
+ */
+import type { AuthUser, ServerContext } from "@stc/contracts";
+import { createApp, createJobQueue, loadConfig } from "@stc/core";
+import { createCourseRepository } from "@stc/course-format";
+import { connect, migrate } from "@stc/db";
+import indexHtml from "../../../apps/web/index.html";
+import { studioCourseDirs, studioFeature } from "../src";
+
+const config = loadConfig();
+if (process.env.NODE_ENV === "production") throw new Error("dev/serve.ts is for development only");
+const role = process.env.STC_DEV_USER as AuthUser["roles"][number] | undefined;
+const devUser: AuthUser | null = role ? { id: `dev-${role}`, name: `Dev ${role[0]!.toUpperCase()}${role.slice(1)}`, email: `${role}@dev.invalid`, roles: [role], groups: [] } : null;
+
+const db = await connect(config.databaseUrl);
+await migrate(db);
+const courses = await createCourseRepository(studioCourseDirs(config));
+const jobs = createJobQueue(db, {});
+const ctx: ServerContext = { config, db, courses, jobs, auth: { userFromRequest: async () => devUser } };
+const studio = studioFeature(ctx);
+const app = createApp(ctx, [studio]);
+jobs.start();
+const server = Bun.serve({
+ port: config.port,
+ routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
+ fetch: (req) => app.fetch(req),
+ development: { hmr: false, console: true },
+});
+console.log(`STCourses studio dev server on ${server.url} as ${devUser ? devUser.name : "anonymous"}`);
diff --git a/packages/feature-studio/src/ui/CourseStudio.tsx b/packages/feature-studio/src/ui/CourseStudio.tsx
new file mode 100644
index 0000000..c5b1cbb
--- /dev/null
+++ b/packages/feature-studio/src/ui/CourseStudio.tsx
@@ -0,0 +1,163 @@
+import { useState, type FormEvent } from "react";
+import { Link, useParams, useSearchParams } from "react-router-dom";
+import { CourseCover, Pill } from "@stc/ui";
+import { api, coursePath, uniqueId, type Diagnostic, type ManifestLesson } from "./api";
+import { AccessMessage } from "./StudioHome";
+import { clone, useWorkspace, type Workspace } from "./workspace";
+import { CourseSettings } from "./editors/CourseSettings";
+import { LessonEditor } from "./editors/LessonEditor";
+import { AttachmentsEditor, DecksEditor } from "./editors/Media";
+import { ConflictView, RawManifest, SyncPanel } from "./editors/GitPanels";
+import { Field } from "./editors/common";
+import "./studio.css";
+
+type Notice = { kind: "ok" | "warn" | "bad"; text: string; files?: string[]; diagnostics?: Diagnostic[]; offerUpdate?: boolean };
+
+export default function CourseStudio() {
+ const { courseId = "" } = useParams();
+ const { ws, readOnly, error } = useWorkspace(courseId);
+
+ if (error) return
Studio
{error.status === 404 ?
No such course.
:
}
;
+ if (readOnly) {
+ return (
+
+
Studio
+
{readOnly.title}
+
{readOnly.message}. It is read-only here; edit it in its own repository at {readOnly.root}.
+
+ );
+ }
+ if (!ws) return ;
+ return ;
+}
+
+function Workbench({ ws }: { ws: Workspace }) {
+ const [params, setParams] = useSearchParams();
+ const view = ws.state.status.merging ? "conflicts" : params.get("view") ?? "settings";
+ const lessonId = params.get("lesson");
+ const go = (v: string, lesson?: string) => {
+ if (ws.docDirty && !window.confirm("You have unsaved changes to the course structure. Leave them unsaved for now?")) return;
+ setParams(lesson ? { view: v, lesson } : { view: v });
+ };
+ const [notice, setNotice] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const st = ws.state.status;
+ const hasErrors = ws.state.manifestErrors.length > 0 || ws.state.diagnostics.some((d) => d.level === "error");
+
+ const publish = async () => {
+ setBusy(true);
+ const { status, data } = await api("POST", coursePath(ws.courseId, "/publish"));
+ setBusy(false);
+ if (status === 200) setNotice(data.published ? { kind: "ok", text: "Published. Learners now see this version." } : { kind: "ok", text: "Nothing new to publish." });
+ else if (data?.reason === "conflict") setNotice({ kind: "warn", text: "Not published: the published version was changed outside your draft, in the same places you edited. Nothing was overwritten. Update your draft, resolve the conflicts, then publish.", files: data.conflicts, offerUpdate: true });
+ else if (data?.reason === "dirty") setNotice({ kind: "warn", text: "Not published: someone has unsaved edits in the published folder on the server. Ask them to commit or discard them first.", files: data.files });
+ else if (data?.reason === "invalid") setNotice({ kind: "bad", text: "Not published: the draft has errors.", diagnostics: data.diagnostics });
+ else setNotice({ kind: "bad", text: data?.error ?? `Publish failed (${status})` });
+ await ws.reload();
+ };
+ const updateDraft = async () => {
+ setBusy(true);
+ const { status, data } = await api("POST", coursePath(ws.courseId, "/update-draft"), { from: "published" });
+ setBusy(false);
+ setNotice(status === 200 ? { kind: "ok", text: "Your draft now includes the latest published changes." } : status === 409 && data?.reason === "conflict" ? null : { kind: "bad", text: data?.message ?? data?.error });
+ await ws.reload();
+ };
+
+ return (
+
+
+
+
+
+
{ws.doc.title}
+
Studio · {ws.courseId}
+
+
+
+ {[["settings", "Course settings"], ["decks", "Deck links"], ["attachments", "Attachments"], ["manifest", "stcourse.yaml"], ["sync", "Sync"]].map(([v, label]) => (
+ go(v!)}>{label}{v === "sync" && st.remote ? remote : null}
+ ))}
+ {st.merging ? Conflicts {st.conflicts.length} : null}
+
+ {ws.doc.modules.map((m) => (
+
+ {m.title}
+ {m.lessons.map((l) => (
+
+ go("lesson", l.id)}>
+ {l.title} {l.type === "text" ? "" : l.type}
+
+
+ ))}
+ setParams({ view: "lesson", lesson: id })} />
+
+ ))}
+
+
+
+
+
+ Editing your draft {st.branch}
+ {" · "}{st.ahead ? {st.ahead} unpublished {st.ahead === 1 ? "change" : "changes"} : "nothing unpublished"}
+ {st.behind ? <> · {st.behind} new on published since your draft> : null}
+ {ws.docDirty ? <> · unsaved structure changes > : null}
+
+ {st.behind && !st.merging ?
Update draft : null}
+
Publish
+
+
+ {notice ? (
+
+ {notice.text}
+ {notice.files?.length ?
{notice.files.map((f) => {f} )} : null}
+ {notice.diagnostics?.length ?
{notice.diagnostics.map((d, i) => {d.lessonId ? `${d.lessonId}: ` : ""}{d.message} )} : null}
+ {notice.offerUpdate ?
: null}
+
+ ) : null}
+
+ {view === "conflicts" ?
+ : view === "lesson" && lessonId ?
setParams({ view: "settings" })} />
+ : view === "decks" ?
+ : view === "attachments" ?
+ : view === "manifest" ?
+ : view === "sync" ?
+ : }
+
+
+ );
+}
+
+/** Adds a lesson: manifest entry plus its Markdown file, saved in one commit. */
+function AddLesson({ ws, moduleId, onAdded }: { ws: Workspace; moduleId: string; onAdded: (id: string) => void }) {
+ const [open, setOpen] = useState(false);
+ const [title, setTitle] = useState("");
+ const [type, setType] = useState("text");
+ const [err, setErr] = useState(null);
+ if (!open) return setOpen(true)}>+ Add lesson ;
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ const taken = new Set(ws.doc.modules.flatMap((m) => m.lessons.map((l) => l.id)));
+ const id = uniqueId(title, taken);
+ const doc = clone(ws.doc);
+ const lesson: ManifestLesson = type === "video"
+ ? { id, title: title.trim(), type, file: `media/${id}.mp4`, transcript: `lessons/${id}.md` }
+ : { id, title: title.trim(), type, file: `lessons/${id}.md` };
+ doc.modules.find((m) => m.id === moduleId)!.lessons.push(lesson);
+ const r = await ws.save({ manifest: doc, files: [{ path: `lessons/${id}.md`, content: `# ${title.trim()}\n\n`, baseHash: null }], message: `Add lesson ${id}` });
+ if (!r.ok) return setErr(r.error ?? "A file with that name already exists");
+ setOpen(false); setTitle("");
+ onAdded(id);
+ };
+ return (
+
+ );
+}
diff --git a/packages/feature-studio/src/ui/StudioHome.tsx b/packages/feature-studio/src/ui/StudioHome.tsx
new file mode 100644
index 0000000..0a68b8c
--- /dev/null
+++ b/packages/feature-studio/src/ui/StudioHome.tsx
@@ -0,0 +1,119 @@
+import { useEffect, useState, type FormEvent } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { CourseCover, Pill } from "@stc/ui";
+import { api, slug } from "./api";
+import "./studio.css";
+
+interface CourseRow {
+ id: string; title: string; pitch: string | null; accent: string | null; managed: boolean; publishedStatus: "ok" | "error";
+ ahead?: number; behind?: number; merging?: boolean; remote?: string | null; message?: string;
+}
+
+export function AccessMessage({ status, error }: { status: number; error?: string }) {
+ if (status === 401) return Sign in to use the studio. The studio is for course authors and the owner.
;
+ if (status === 403) return The studio is for authors. Ask the owner for author access.
;
+ return Something went wrong. {error}
;
+}
+
+export default function StudioHome() {
+ const [rows, setRows] = useState(null);
+ const [canCreate, setCanCreate] = useState(false);
+ const [err, setErr] = useState<{ status: number; error?: string } | null>(null);
+ const [title, setTitle] = useState("");
+ const [id, setId] = useState("");
+ const [idTouched, setIdTouched] = useState(false);
+ const [createErr, setCreateErr] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const nav = useNavigate();
+
+ useEffect(() => {
+ api<{ courses: CourseRow[]; canCreate: boolean; error?: string }>("GET", "/api/studio/courses").then(({ status, data }) => {
+ if (status !== 200) return setErr({ status, error: data?.error });
+ setRows(data.courses);
+ setCanCreate(data.canCreate);
+ });
+ }, []);
+
+ const create = async (e: FormEvent) => {
+ e.preventDefault();
+ setBusy(true);
+ setCreateErr(null);
+ const { status, data } = await api("POST", "/api/studio/courses", { id, title });
+ setBusy(false);
+ if (status === 201) nav(`/studio/${encodeURIComponent(data.id)}`);
+ else setCreateErr(data?.error ?? `Could not create the course (${status})`);
+ };
+
+ const managed = rows?.filter((r) => r.managed) ?? [];
+ const outside = rows?.filter((r) => !r.managed) ?? [];
+
+ return (
+
+
Studio
+
Your courses
+
Edit on your own draft, save as you go, and publish when it is ready. Learners only see what you publish.
+ {err ?
: null}
+
+ {canCreate ? (
+
+ New course
+
+ {createErr ? {createErr}
: null}
+
+ ) : null}
+
+ {rows ? (
+
+ Studio courses
+ {managed.length === 0 ? No studio courses yet.
: (
+
+ )}
+
+ ) : null}
+
+ {outside.length ? (
+
+ Managed outside STCourses
+ These courses come from repositories the platform does not own. Edit them in their own repository.
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/feature-studio/src/ui/api.ts b/packages/feature-studio/src/ui/api.ts
new file mode 100644
index 0000000..8efb89f
--- /dev/null
+++ b/packages/feature-studio/src/ui/api.ts
@@ -0,0 +1,58 @@
+// Browser client for /api/studio. Types mirror the server responses (kept here so no server module enters the bundle).
+
+export interface ApiError { status: number; body: any }
+
+export async function api(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> {
+ const init: RequestInit = { method, headers: {} };
+ if (body instanceof FormData) init.body = body;
+ else if (body !== undefined) {
+ (init.headers as Record)["content-type"] = "application/json";
+ init.body = JSON.stringify(body);
+ }
+ const r = await fetch(path, init);
+ const text = await r.text();
+ let data: any = null;
+ try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
+ return { status: r.status, data };
+}
+
+export const coursePath = (id: string, rest = "") => `/api/studio/courses/${encodeURIComponent(id)}${rest}`;
+export const rawUrl = (id: string, rel: string) => coursePath(id, `/raw/${rel.split("/").map(encodeURIComponent).join("/")}`);
+
+export interface ManifestLesson {
+ id: string; title: string; type: "text" | "video" | "exercise"; file?: string; anchor?: string; duration?: number; transcript?: string;
+ hints?: { file: string; match: string };
+ browser?: { language: "js" | "ts"; starter: string; tests: string; solution?: string };
+ localCommand?: string | Record;
+ [k: string]: unknown;
+}
+export interface ManifestModule { id: string; title: string; lessons: ManifestLesson[]; [k: string]: unknown }
+export interface ManifestDeck { id: string; file: string; title?: string; attachTo: string }
+export interface ManifestAttachment { file: string; title?: string; module?: string }
+export interface ManifestDoc {
+ schema: 1; id: string; title: string; pitch?: string; authors?: string[]; cover?: string; accent?: string; collections?: string[];
+ visibility?: "public" | "signed-in" | "restricted"; gating?: "none" | "sequential"; mentor?: string;
+ decks?: ManifestDeck[]; attachments?: ManifestAttachment[]; modules: ManifestModule[]; [k: string]: unknown;
+}
+
+export interface DraftStatus {
+ branch: string; publishedBranch: string; draftHead: string; publishedHead: string; ahead: number; behind: number;
+ merging: boolean; conflicts: string[]; remote: string | null; publishedDirty: string[];
+}
+export interface Diagnostic { level: "error" | "warning"; lessonId?: string; path?: string; message: string }
+
+export interface CourseState {
+ readOnly: false; course: { id: string; title: string }; status: DraftStatus; manifestText: string; manifestHash: string | null;
+ manifestRaw: ManifestDoc | null; manifestErrors: string[]; diagnostics: Diagnostic[]; files: string[];
+}
+export interface ReadOnlyState { readOnly: true; message: string; course: { id: string; title: string; root: string }; diagnostics: Diagnostic[] }
+
+export interface FileWrite { path: string; content?: string; baseHash?: string | null; delete?: boolean }
+export interface FileConflict { path: string; current: string | null; currentHash: string | null }
+
+export const slug = (s: string) => s.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item";
+export const uniqueId = (base: string, taken: Set) => {
+ let id = slug(base);
+ for (let n = 2; taken.has(id); n++) id = `${slug(base)}-${n}`;
+ return id;
+};
diff --git a/packages/feature-studio/src/ui/editors/CourseSettings.tsx b/packages/feature-studio/src/ui/editors/CourseSettings.tsx
new file mode 100644
index 0000000..6aae94b
--- /dev/null
+++ b/packages/feature-studio/src/ui/editors/CourseSettings.tsx
@@ -0,0 +1,85 @@
+import { useState } from "react";
+import { Pill } from "@stc/ui";
+import { uniqueId } from "../api";
+import type { Workspace } from "../workspace";
+import { DiagnosticsList, Errors, Field, FilePicker, SaveBar, isImage, isMarkdown, useManifestValidation } from "./common";
+
+const list = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean);
+
+/** Manifest fields editor (structured), with live validation from @stc/course-format. */
+export function CourseSettings({ ws }: { ws: Workspace }) {
+ const { doc, setDoc, state } = ws;
+ const errors = useManifestValidation(ws, { manifest: doc });
+ const [newModule, setNewModule] = useState("");
+ const set = (k: K, v: (typeof doc)[K] | undefined) => setDoc((d) => {
+ if (v === undefined || v === "") delete d[k]; else d[k] = v;
+ });
+
+ return (
+
+
Course settings
+
{doc.title || "Untitled course"}
+
These fields live in stcourse.yaml. Changes go to your draft when you save.
+
+
+
set("title", e.target.value)} />
+
+
+
+ set("authors", list(e.target.value))} />
+ set("collections", list(e.target.value))} />
+
+ set("visibility", e.target.value as never)}>
+ Restricted: named users or groups
+ Signed-in: any user
+ Public: anyone
+
+
+
+ set("gating", e.target.value as never)}>
+ Off
+ Sequential: a module unlocks when the previous one is done
+
+
+ set("accent", e.target.value)} />
+ set("cover", v)} />
+ set("mentor", v)} />
+
+
+
+
+
+
Validation
+ {errors.length === 0 && !state.diagnostics.length ?
The manifest is valid.
: null}
+
+
+
+
+
[]} message={() => "Edit course settings"} />
+
+
+ );
+}
diff --git a/packages/feature-studio/src/ui/editors/GitPanels.tsx b/packages/feature-studio/src/ui/editors/GitPanels.tsx
new file mode 100644
index 0000000..7663319
--- /dev/null
+++ b/packages/feature-studio/src/ui/editors/GitPanels.tsx
@@ -0,0 +1,123 @@
+import { useEffect, useState } from "react";
+import { Pill } from "@stc/ui";
+import { api, coursePath } from "../api";
+import type { Workspace } from "../workspace";
+import { Errors, Field, SaveBar, useManifestValidation } from "./common";
+import { useFile } from "../workspace";
+
+interface ConflictFile { path: string; base: string | null; ours: string | null; theirs: string | null; working: string | null }
+
+/** Spec 9.1/12: conflicts are shown, and the author decides every file. Nothing is resolved automatically. */
+export function ConflictView({ ws }: { ws: Workspace }) {
+ const [files, setFiles] = useState(null);
+ const [text, setText] = useState>({});
+ const [msg, setMsg] = useState(null);
+ useEffect(() => {
+ api<{ files: ConflictFile[] }>("GET", coursePath(ws.courseId, "/conflicts")).then(({ data }) => {
+ setFiles(data.files);
+ setText(Object.fromEntries(data.files.map((f) => [f.path, f.working ?? ""])));
+ });
+ }, [ws.courseId, ws.state.status.draftHead, ws.state.status.conflicts.join("\0")]);
+
+ const resolve = async (path: string, content: string | null) => {
+ const { status, data } = await api("POST", coursePath(ws.courseId, "/resolve"), { files: [{ path, content }] });
+ setMsg(status === 200 ? (data.remaining.length ? null : "All conflicts resolved. Your draft now includes the published changes; publish when ready.") : data?.error);
+ await ws.reload();
+ };
+
+ return (
+
+
Conflicts
+
Resolve conflicts
+
The published version and your draft changed the same lines. For each file, choose a version or edit the result, then mark it resolved. Your draft is not published until you publish it.
+ {msg ?
{msg}
: null}
+ {files?.map((f) => (
+
+ {f.path}
+
+
Your draft {f.ours ?? "(deleted in your draft)"}
+
Published (changed outside your draft) {f.theirs ?? "(deleted on published)"}
+
+
+
+
+
setText((t) => ({ ...t, [f.path]: f.ours ?? "" }))}>Use my draft
+
setText((t) => ({ ...t, [f.path]: f.theirs ?? "" }))}>Use published
+
{7})/m.test(text[f.path] ?? "")} onClick={() => resolve(f.path, text[f.path] ?? "")}>Mark resolved
+ {f.ours === null || f.theirs === null ?
resolve(f.path, null)}>Delete the file : null}
+
+
+ ))}
+
+
{ await api("POST", coursePath(ws.courseId, "/abort")); await ws.reload(); }}>Cancel the update
+
Returns your draft to how it was before the update.
+
+
+ );
+}
+
+export function SyncPanel({ ws }: { ws: Workspace }) {
+ const [url, setUrl] = useState(ws.state.status.remote ?? "");
+ const [msg, setMsg] = useState<{ kind: "ok" | "bad" | "warn"; text: string } | null>(null);
+ const [busy, setBusy] = useState(false);
+ const remote = ws.state.status.remote;
+
+ const saveRemote = async () => {
+ const { status, data } = await api("PUT", coursePath(ws.courseId, "/remote"), { url: url.trim() || null });
+ setMsg(status === 200 ? { kind: "ok", text: data.remote ? "Remote saved." : "Remote removed." } : { kind: "bad", text: data?.error });
+ await ws.reload();
+ };
+ const sync = async () => {
+ setBusy(true);
+ const { status, data } = await api("POST", coursePath(ws.courseId, "/sync"));
+ setBusy(false);
+ if (status === 200) setMsg({ kind: "ok", text: `Synced. ${data.pulled ? "Pulled changes from the remote. " : ""}${data.pushed ? "Pushed the published version." : ""}${!data.pulled && !data.pushed ? "Already up to date." : ""}` });
+ else if (data?.reason === "conflict") setMsg({ kind: "warn", text: `The remote changed the same lines as the published version (${data.conflicts.join(", ")}). Nothing was changed. Update your draft from the remote, resolve the conflicts, publish, then sync again.` });
+ else setMsg({ kind: "bad", text: data?.message ?? data?.error ?? `Sync failed (${status})` });
+ await ws.reload();
+ };
+ const fromRemote = async () => {
+ const { status, data } = await api("POST", coursePath(ws.courseId, "/update-draft"), { from: "remote" });
+ setMsg(status === 200 ? { kind: "ok", text: "Your draft now includes the remote's changes." } : status === 409 ? { kind: "warn", text: "Conflicts to resolve: see Conflicts." } : { kind: "bad", text: data?.message ?? data?.error });
+ await ws.reload();
+ };
+
+ return (
+
+
Sync
+
Remote repository
+
Nothing syncs on its own. Sync pulls the remote's published branch and pushes yours, only when you click it.
+
+
+ {msg ?
{msg.text}
: null}
+
+
{busy ? "Syncing…" : "Sync now"}
+
Update my draft from the remote
+ {!remote ?
Add a remote to enable Sync. : null}
+
+
+
+ );
+}
+
+/** The whole manifest as YAML, for anything the structured editors do not cover. */
+export function RawManifest({ ws }: { ws: Workspace }) {
+ const f = useFile(ws.courseId, "stcourse.yaml", ws.version);
+ const errors = useManifestValidation(ws, { manifestText: f.content });
+ return (
+
+
Manifest
+
stcourse.yaml
+
The source of truth for the course structure. Unsaved changes in the other editors are not shown here.
+
+
+ );
+}
diff --git a/packages/feature-studio/src/ui/editors/LessonEditor.tsx b/packages/feature-studio/src/ui/editors/LessonEditor.tsx
new file mode 100644
index 0000000..0c630a6
--- /dev/null
+++ b/packages/feature-studio/src/ui/editors/LessonEditor.tsx
@@ -0,0 +1,168 @@
+import { Pill } from "@stc/ui";
+import type { FileWrite, ManifestLesson } from "../api";
+import { LessonPreview } from "../preview";
+import { useFile, type OpenFile, type Workspace } from "../workspace";
+import { DiagnosticsList, Field, FilePicker, SaveBar, UploadButton, isMarkdown, isVideo, useManifestValidation } from "./common";
+
+const STARTER = (lang: string) => `// Starter code for the learner.\nexport function solve(input${lang === "ts" ? ": unknown" : ""}) {\n // TODO\n}\n`;
+const TESTS = `// export const tests: { name, run(mod, assert) }[]\nexport const tests = [\n {\n name: "solves the example",\n run(mod, assert) {\n assert.equal(mod.solve(1), 1);\n },\n },\n];\n`;
+const HINT = (match: string, label: string) => `\n${match} — ${label} \n\nWrite the hint here.\n\n \n`;
+
+function CodeFile({ label, file, rows = 10 }: { label: string; file: OpenFile; rows?: number }) {
+ if (!file.path) return null;
+ return (
+
+
+ );
+}
+
+export function LessonEditor({ ws, lessonId, onGone }: { ws: Workspace; lessonId: string; onGone: () => void }) {
+ const { doc, setDoc, state } = ws;
+ let mi = -1, li = -1;
+ doc.modules.forEach((m, i) => m.lessons.forEach((l, j) => { if (l.id === lessonId) { mi = i; li = j; } }));
+ const lesson: ManifestLesson | undefined = mi >= 0 ? doc.modules[mi]!.lessons[li] : undefined;
+
+ // the Markdown body: the lesson file for text/exercise lessons, the transcript for videos
+ const mdPath = lesson ? (lesson.type === "video" ? lesson.transcript : lesson.file && isMarkdown(lesson.file) ? lesson.file : undefined) : undefined;
+ const lang = lesson?.browser?.language ?? "js";
+ const body = useFile(ws.courseId, mdPath, ws.version, lesson ? `# ${lesson.title}\n\n` : "");
+ const starter = useFile(ws.courseId, lesson?.browser?.starter, ws.version, STARTER(lang));
+ const tests = useFile(ws.courseId, lesson?.browser?.tests, ws.version, TESTS);
+ const solution = useFile(ws.courseId, lesson?.browser?.solution, ws.version, STARTER(lang).replace("// TODO", "return input;"));
+ const hints = useFile(ws.courseId, lesson?.hints?.file !== mdPath ? lesson?.hints?.file : undefined, ws.version);
+ const errors = useManifestValidation(ws, { manifest: doc });
+
+ if (!lesson) return This lesson is no longer in the manifest.
Back to course settings ;
+
+ const upd = (f: (l: ManifestLesson) => void) => setDoc((d) => f(d.modules[mi]!.lessons[li]!));
+ const setOpt = (k: K, v: ManifestLesson[K] | undefined) => upd((l) => { if (v === undefined || v === "") delete l[k]; else l[k] = v; });
+ const files = [body, starter, tests, solution, hints];
+ const dirty = ws.docDirty || files.some((f) => f.dirty);
+ const collect = (): FileWrite[] => files.filter((f) => f.path && (f.dirty || (!f.exists && f.loaded))).map((f) => f.write()!).filter((w, i, a) => a.findIndex((x) => x.path === w.path) === i);
+ const lessonErrors = errors.filter((e) => e.startsWith(`modules.${mi}.lessons.${li}.`));
+ const exDir = `exercises/${lesson.id}`;
+
+ return (
+
+
{doc.modules[mi]!.title} · {lesson.type === "text" ? "Text lesson" : lesson.type === "video" ? "Video lesson" : "Exercise"}
+
+
+
upd((l) => { l.title = e.target.value; })} />
+
+ upd((l) => { l.type = e.target.value as ManifestLesson["type"]; })}>
+ Text Video Exercise
+
+
+
+ setOpt("duration", e.target.value ? Number(e.target.value) * 60 : undefined)} />
+
+ {lesson.type === "video" ? (
+ <>
+
setOpt("file", v)} />
+
setOpt("transcript", v)} />
+
Upload { setOpt("file", p); }} />
+ >
+ ) : (
+ <>
+
setOpt("file", v)} />
+
setOpt("anchor", e.target.value)} />
+ >
+ )}
+
+ {lesson.type === "video" && !lesson.transcript ? (
+
setOpt("transcript", `lessons/${lesson.id}.md`)}>Add a transcript file
+ ) : null}
+
+
+ {mdPath ? (
+
+
+
+
+
Preview
+
ws.resolveUrl(new URL(rel, `http://x/${mdPath}`).pathname.slice(1))} />
+
+
+ ) : null}
+
+ {lesson.type === "exercise" ? (
+
+
Exercise
+
+
+ setOpt("localCommand", e.target.value)} />
+
+
+ {
+ const lang = e.target.value as "js" | "ts" | "";
+ upd((l) => {
+ if (!lang) { delete l.browser; return; }
+ l.browser = l.browser ? { ...l.browser, language: lang } : { language: lang, starter: `${exDir}/starter.${lang}`, tests: `${exDir}/tests.${lang}`, solution: `${exDir}/solution.${lang}` };
+ });
+ }}>
+ None (runs locally) JavaScript TypeScript
+
+
+
+ {lesson.browser ? (
+ <>
+
+
+
+ >
+ ) : null}
+
+
Hints
+ {lesson.hints ? (
+ <>
+
+ upd((l) => { if (v) l.hints = { ...l.hints!, file: v }; })} />
+
+ upd((l) => { l.hints = { ...l.hints!, match: e.target.value }; })} />
+
+
+ {hints.path ?
:
The hints live in the lesson file above.
}
+
+ {(["nudge", "stronger hint", "near-solution"] as const).map((tier) => (
+
{
+ const target = hints.path ? hints : body;
+ target.setContent((target.content.trimEnd() ? target.content.trimEnd() + "\n\n" : "") + HINT(lesson.hints!.match, tier));
+ }}>Add {tier}
+ ))}
+
+ >
+ ) : (
+
{
+ const match = lesson.title;
+ upd((l) => { l.hints = { file: `${exDir}/hints.md`, match }; });
+ }}>Add hint tiers
+ )}
+
+ ) : null}
+
+
`Edit lesson ${lesson.id}`} extra={
+ <>
+ setDoc((d) => { const ls = d.modules[mi]!.lessons; const [x] = ls.splice(li, 1); ls.splice(li - 1, 0, x!); })}>Move up
+ {
+ if (!window.confirm(`Remove "${lesson.title}" from the course? Its files stay in the repository.`)) return;
+ setDoc((d) => { d.modules[mi]!.lessons.splice(li, 1); });
+ onGone();
+ }}>Remove lesson
+ >
+ } />
+ Saved changes stay on your draft. Learners see them after you publish.
+
+
+ );
+}
+
+function Errorsish({ errors, ws, lessonId }: { errors: string[]; ws: Workspace; lessonId: string }) {
+ return (
+ <>
+ {errors.length ? : null}
+ d.lessonId === lessonId} />
+ >
+ );
+}
diff --git a/packages/feature-studio/src/ui/editors/Media.tsx b/packages/feature-studio/src/ui/editors/Media.tsx
new file mode 100644
index 0000000..c705295
--- /dev/null
+++ b/packages/feature-studio/src/ui/editors/Media.tsx
@@ -0,0 +1,87 @@
+import { Pill } from "@stc/ui";
+import { slug, uniqueId, type ManifestDoc } from "../api";
+import type { Workspace } from "../workspace";
+import { DiagnosticsList, Field, FilePicker, SaveBar, UploadButton, isPdf } from "./common";
+
+const targets = (doc: ManifestDoc) => [
+ ...doc.modules.map((m) => ({ id: m.id, label: `Module: ${m.title}` })),
+ ...doc.modules.flatMap((m) => m.lessons.map((l) => ({ id: l.id, label: `${m.title} · ${l.title}` }))),
+];
+
+/** Deck links: a PDF deck attached to a lesson (for example its video) or a module. */
+export function DecksEditor({ ws }: { ws: Workspace }) {
+ const { doc, setDoc, state } = ws;
+ const decks = doc.decks ?? [];
+ const opts = targets(doc);
+ const add = (file: string) => setDoc((d) => {
+ const ids = new Set((d.decks ?? []).map((x) => x.id));
+ const name = file.split("/").pop()!.replace(/\.pdf$/i, "");
+ (d.decks ??= []).push({ id: uniqueId(slug(name), ids), file, title: name, attachTo: opts[0]?.id ?? "" });
+ });
+ return (
+
+
Deck links
+
Slide decks
+
A deck is a PDF. Link it to the lesson or module it belongs to, and learners see it next to that content.
+
+
+
+ {state.files.some(isPdf) ?
add(state.files.find(isPdf)!)}>Link an existing PDF : null}
+
+
/deck/.test(d.message)} />
+ []} message={() => "Edit deck links"} />
+
+ );
+}
+
+/** Course attachments (PDFs, images, code archives), optionally scoped to a module. */
+export function AttachmentsEditor({ ws }: { ws: Workspace }) {
+ const { doc, setDoc, state } = ws;
+ const list = doc.attachments ?? [];
+ return (
+
+
Attachments
+
Attachments
+
Files learners can download. Uploads are committed to your draft at once; the list below is saved with the manifest.
+
+ {list.length === 0 ? No attachments yet. : null}
+ {list.map((a, i) => (
+
+
+
{a.file}
+
setDoc((d) => { d.attachments![i]!.title = e.target.value || undefined; })} />
+
+ setDoc((d) => { if (e.target.value) d.attachments![i]!.module = e.target.value; else delete d.attachments![i]!.module; })}>
+ Whole course
+ {doc.modules.map((m) => {m.title} )}
+
+
+
+ setDoc((d) => { d.attachments!.splice(i, 1); if (!d.attachments!.length) delete d.attachments; })}>Remove
+
+ ))}
+
+
+ setDoc((d) => { (d.attachments ??= []).push({ file: p, title: p.split("/").pop() }); })} />
+
+
/attachment/.test(d.message)} />
+ []} message={() => "Edit attachments"} />
+
+ );
+}
diff --git a/packages/feature-studio/src/ui/editors/common.tsx b/packages/feature-studio/src/ui/editors/common.tsx
new file mode 100644
index 0000000..b660ee4
--- /dev/null
+++ b/packages/feature-studio/src/ui/editors/common.tsx
@@ -0,0 +1,131 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { Pill } from "@stc/ui";
+import { api, coursePath, type Diagnostic, type FileConflict, type FileWrite } from "../api";
+import type { SaveOutcome, Workspace } from "../workspace";
+
+export function Field({ label, hint, children, wide }: { label: string; hint?: ReactNode; children: ReactNode; wide?: boolean }) {
+ return (
+
+ {label}
+ {children}
+ {hint ? {hint} : null}
+
+ );
+}
+
+export function Errors({ errors }: { errors: string[] }) {
+ if (!errors.length) return null;
+ return {errors.map((e, i) => {e} )} ;
+}
+
+export function DiagnosticsList({ diagnostics, filter }: { diagnostics: Diagnostic[]; filter?: (d: Diagnostic) => boolean }) {
+ const list = filter ? diagnostics.filter(filter) : diagnostics;
+ if (!list.length) return null;
+ return `${d.level === "warning" ? "warning: " : ""}${d.lessonId ? `${d.lessonId}: ` : ""}${d.message}`)} />;
+}
+
+/** Live manifest validation through @stc/course-format on the server, debounced. */
+export function useManifestValidation(ws: Workspace, input: { manifest?: unknown; manifestText?: string }) {
+ const [errors, setErrors] = useState(ws.state.manifestErrors);
+ const key = JSON.stringify(input);
+ useEffect(() => {
+ const t = setTimeout(async () => {
+ const { status, data } = await api("POST", coursePath(ws.courseId, "/validate"), input);
+ if (status === 200) setErrors(data.errors);
+ }, 350);
+ return () => clearTimeout(t);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, ws.courseId]);
+ return errors;
+}
+
+/**
+ * Save button with the "changed outside the studio" flow: on a 409 it lists the files and lets the author
+ * either reload the outside version or explicitly keep theirs. Nothing is overwritten without that choice.
+ */
+export function SaveBar({ ws, dirty, collect, message, label = "Save draft", onSaved, extra, withManifest }: {
+ ws: Workspace; dirty: boolean; withManifest?: boolean; collect: () => FileWrite[]; message: () => string; label?: string; onSaved?: () => void; extra?: ReactNode;
+}) {
+ const [busy, setBusy] = useState(false);
+ const [conflicts, setConflicts] = useState(null);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+ const pending = useRef([]);
+
+ const run = async (files: FileWrite[]) => {
+ setBusy(true); setError(null); setSaved(false);
+ const r: SaveOutcome = await ws.save({ files, message: message(), withManifest });
+ setBusy(false);
+ if (r.ok) { setConflicts(null); setSaved(true); onSaved?.(); return; }
+ if (r.conflicts) { pending.current = files; setConflicts(r.conflicts); }
+ else setError(r.error ?? "Save failed");
+ };
+
+ return (
+
+
+
run(collect())}>{busy ? "Saving…" : label}
+ {extra}
+ {saved && !dirty ?
Saved to your draft : null}
+ {dirty ?
Unsaved changes : null}
+
+ {error ?
{error}
: null}
+ {conflicts ? (
+
+
Changed outside the studio since you opened it: {conflicts.map((c) => c.path).join(", ")}.
+
+ {conflicts.map((c) => (
+
{c.path}, current version on disk {c.current ?? "(deleted)"}
+ ))}
+
+
+
{ setConflicts(null); ws.reload().then(() => onSaved?.()); }}>Discard mine, load theirs
+
run(pending.current.map((f) => {
+ const c = conflicts.find((x) => x.path === f.path);
+ return c ? { ...f, baseHash: c.currentHash } : f;
+ }))}>Keep mine and save over theirs
+
+
+ ) : null}
+
+ );
+}
+
+export function FilePicker({ files, value, onChange, filter, allowEmpty, id }: {
+ files: string[]; value: string | undefined; onChange: (v: string | undefined) => void; filter?: (f: string) => boolean; allowEmpty?: boolean; id?: string;
+}) {
+ const list = filter ? files.filter(filter) : files;
+ const opts = value && !list.includes(value) ? [value, ...list] : list;
+ return (
+ onChange(e.target.value || undefined)}>
+ {allowEmpty || !value ? None : null}
+ {opts.map((f) => {f} )}
+
+ );
+}
+
+export function UploadButton({ ws, dir, accept, label, onUploaded }: { ws: Workspace; dir: string; accept?: string; label: string; onUploaded: (path: string) => void }) {
+ const ref = useRef(null);
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState(null);
+ return (
+ <>
+ {
+ const f = e.target.files?.[0];
+ e.target.value = "";
+ if (!f) return;
+ setBusy(true); setErr(null);
+ const r = await ws.upload(f, dir);
+ setBusy(false);
+ if (r.ok) onUploaded(r.path); else setErr(r.error);
+ }} />
+ ref.current?.click()} disabled={busy}>{busy ? "Uploading…" : label}
+ {err ? {err} : null}
+ >
+ );
+}
+
+export const isMarkdown = (f: string) => /\.(md|markdown)$/i.test(f);
+export const isPdf = (f: string) => /\.pdf$/i.test(f);
+export const isVideo = (f: string) => /\.(mp4|webm|mov|m4v)$/i.test(f);
+export const isImage = (f: string) => /\.(png|jpe?g|svg|webp|gif)$/i.test(f);
diff --git a/packages/feature-studio/src/ui/markdown.ts b/packages/feature-studio/src/ui/markdown.ts
new file mode 100644
index 0000000..799aa33
--- /dev/null
+++ b/packages/feature-studio/src/ui/markdown.ts
@@ -0,0 +1,129 @@
+/**
+ * A small, safe Markdown renderer for the studio's live preview (v1 adapter; Phase 2 swaps in the learner's LessonView).
+ * Everything is HTML-escaped; the only raw HTML let through is the bare ``/`` used for hint tiers.
+ */
+
+export interface RenderOptions {
+ /** Maps a relative image/link URL in the lesson to something the browser can load (the draft raw endpoint). */
+ resolveUrl?: (url: string) => string;
+}
+
+const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+const unesc = (s: string) => s.replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
+
+/** Same rule as @stc/course-format's slugify (GitHub style), duplicated to keep node-only modules out of the browser bundle. */
+export function slugify(heading: string): string {
+ return heading
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
+ .replace(/[`*~]/g, "")
+ .replace(/<[^>]+>/g, "")
+ .trim()
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "")
+ .replace(/\s/g, "-");
+}
+
+const SAFE_SCHEME = /^(https?:|mailto:|#|\/|\.{0,2}\/|[^:]*$)/i;
+const URL_PART = String.raw`((?:[^()]|\([^()]*\))*)`;
+
+function inline(text: string, o: RenderOptions): string {
+ const codes: string[] = [];
+ let s = esc(text).replace(/`([^`]+)`/g, (_, c: string) => `\u0000${codes.push(`${c}`) - 1}\u0000`);
+ const url = (raw: string) => {
+ const u = unesc(raw.trim());
+ if (!SAFE_SCHEME.test(u)) return null;
+ const isRelative = !/^([a-z]+:|\/|#)/i.test(u);
+ return esc(isRelative && o.resolveUrl ? o.resolveUrl(u) : u);
+ };
+ s = s.replace(new RegExp(String.raw`!\[([^\]]*)\]\(` + URL_PART + String.raw`\)`, "g"), (_, alt: string, u: string) => {
+ const href = url(u);
+ return href ? ` ` : alt;
+ });
+ s = s.replace(new RegExp(String.raw`\[([^\]]+)\]\(` + URL_PART + String.raw`\)`, "g"), (_, label: string, u: string) => {
+ const href = url(u);
+ return href ? `${label} ` : label;
+ });
+ s = s.replace(/\*\*(.+?)\*\*/g, "$1 ").replace(/__(.+?)__/g, "$1 ");
+ s = s.replace(/(^|[^*\w])\*(?!\s)(.+?)\*(?!\w)/g, "$1$2 ").replace(/(^|[^_\w])_(?!\s)(.+?)_(?!\w)/g, "$1$2 ");
+ return s.replace(/\u0000(\d+)\u0000/g, (_, i: string) => codes[Number(i)]!);
+}
+
+export function renderMarkdown(md: string, o: RenderOptions = {}): string {
+ let lines = md.replace(/\r\n?/g, "\n").split("\n");
+ if (lines[0] === "---") {
+ const end = lines.indexOf("---", 1);
+ if (end > 0) lines = lines.slice(end + 1);
+ }
+ const out: string[] = [];
+ const slugs = new Map();
+ let i = 0;
+ const isBlockStart = (l: string) =>
+ /^ {0,3}(#{1,6}\s|```|~~~|>|[-*+]\s|\d+[.)]\s|(-{3,}|\*{3,}|_{3,})\s*$)/.test(l) || /^\s*<\/?(details|summary)>/.test(l);
+
+ while (i < lines.length) {
+ const line = lines[i]!;
+ if (!line.trim()) { i++; continue; }
+
+ const fence = /^ {0,3}(`{3,}|~{3,})\s*([\w+-]*)/.exec(line);
+ if (fence) {
+ const body: string[] = [];
+ i++;
+ while (i < lines.length && !lines[i]!.trimStart().startsWith(fence[1]!)) body.push(lines[i++]!);
+ i++;
+ const lang = fence[2] ? ` data-lang="${esc(fence[2])}"` : "";
+ out.push(`${esc(body.join("\n"))} `);
+ continue;
+ }
+
+ const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
+ if (h) {
+ const base = slugify(h[2]!);
+ const n = slugs.get(base) ?? 0;
+ slugs.set(base, n + 1);
+ const lvl = h[1]!.length;
+ out.push(`${inline(h[2]!, o)} `);
+ i++;
+ continue;
+ }
+
+ if (/^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push(" "); i++; continue; }
+
+ // hint blocks: , label ,
+ const det = /^\s*<(\/?)details>\s*$/.exec(line);
+ if (det) { out.push(det[1] ? " " : ""); i++; continue; }
+ const sum = /^\s*()?\s*(.*?)<\/summary>\s*$/.exec(line);
+ if (sum) {
+ out.push(`${sum[1] ? "" : ""}${inline(sum[2]!, o)} `);
+ i++;
+ continue;
+ }
+
+ if (/^ {0,3}>/.test(line)) {
+ const body: string[] = [];
+ while (i < lines.length && /^ {0,3}>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^ {0,3}> ?/, ""));
+ out.push(`${renderMarkdown(body.join("\n"), o)} `);
+ continue;
+ }
+
+ const list = /^ {0,3}([-*+]|\d+[.)])\s+/.exec(line);
+ if (list) {
+ const ordered = /\d/.test(list[1]!);
+ const re = ordered ? /^ {0,3}\d+[.)]\s+(.*)$/ : /^ {0,3}[-*+]\s+(.*)$/;
+ const items: string[] = [];
+ while (i < lines.length) {
+ const m = re.exec(lines[i]!);
+ if (m) { items.push(m[1]!); i++; continue; }
+ if (lines[i]!.trim() && /^\s{2,}/.test(lines[i]!) && items.length) { items[items.length - 1] += "\n" + lines[i]!.trim(); i++; continue; }
+ break;
+ }
+ const tag = ordered ? "ol" : "ul";
+ out.push(`<${tag}>${items.map((it) => `${inline(it, o)} `).join("")}${tag}>`);
+ continue;
+ }
+
+ const para: string[] = [];
+ while (i < lines.length && lines[i]!.trim() && (para.length === 0 || !isBlockStart(lines[i]!))) para.push(lines[i++]!);
+ out.push(`${inline(para.join("\n"), o)}
`);
+ }
+ return out.join("\n");
+}
diff --git a/packages/feature-studio/src/ui/preview.tsx b/packages/feature-studio/src/ui/preview.tsx
new file mode 100644
index 0000000..dfc4faf
--- /dev/null
+++ b/packages/feature-studio/src/ui/preview.tsx
@@ -0,0 +1,28 @@
+import type { ComponentType } from "react";
+import { renderMarkdown } from "./markdown";
+
+/**
+ * Live-preview adapter. v1 renders with a small built-in Markdown renderer; Phase 2 swaps in the learner
+ * package's `LessonView` via `setLessonPreview` (docs/contract-requests/studio.md).
+ */
+export interface LessonPreviewProps {
+ courseId: string;
+ markdown: string;
+ lesson?: { id: string; title: string; type: string; anchor?: string } | null;
+ /** Maps a course-relative path (images, attachments) to a URL that serves the draft file. */
+ resolveUrl: (rel: string) => string;
+}
+export type LessonPreviewComponent = ComponentType;
+
+export function SimpleMarkdownPreview({ markdown, resolveUrl }: LessonPreviewProps) {
+ return
;
+}
+
+let current: LessonPreviewComponent = SimpleMarkdownPreview;
+export function setLessonPreview(c: LessonPreviewComponent): void {
+ current = c;
+}
+export function LessonPreview(props: LessonPreviewProps) {
+ const C = current;
+ return ;
+}
diff --git a/packages/feature-studio/src/ui/studio.css b/packages/feature-studio/src/ui/studio.css
new file mode 100644
index 0000000..c1ab02a
--- /dev/null
+++ b/packages/feature-studio/src/ui/studio.css
@@ -0,0 +1,98 @@
+/* Studio: the denser "Work" look of the mockup. Tokens only; no new colours. */
+.studio { font-family: var(--sans); font-size: 14px; line-height: 1.55; color: var(--text); }
+.studio-page { max-width: 1180px; margin: 0 auto; padding-bottom: 64px; }
+.studio h1 { font: 600 28px/1.12 var(--sans); color: var(--head); margin: 0; letter-spacing: -.012em; }
+.studio h2 { font: 600 16px/1.3 var(--sans); color: var(--head); margin: 0 0 10px; }
+.studio h3 { font: 600 14px/1.3 var(--sans); color: var(--head); margin: 18px 0 8px; }
+.studio .chap { font-size: 13px; font-weight: 500; color: var(--muted); margin: 0 0 10px; }
+.studio .muted { color: var(--muted); }
+.studio .faint { color: var(--faint); font-size: 12.5px; }
+.studio code { font-size: 12.5px; }
+.studio-lede { color: var(--muted); margin: 8px 0 0; max-width: 760px; }
+
+/* rows separated by thin rules */
+.studio-rows { list-style: none; margin: 18px 0 0; padding: 0; border-top: 1px solid var(--line); }
+.studio-row { display: flex; align-items: center; gap: 14px; padding: 12px 2px; border-bottom: 1px solid var(--line); }
+.studio-row .t { flex: 1; min-width: 0; }
+.studio-row .t a, .studio-row .t strong { color: var(--head); font-weight: 500; text-decoration: none; font-size: 14.5px; }
+.studio-row .t a:hover { text-decoration: underline; }
+.studio-row .s { display: block; color: var(--faint); font-size: 12.5px; margin-top: 2px; }
+.studio-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 9px; border: 1px solid var(--line2); border-radius: 11px; font-size: 11.5px; color: var(--muted); white-space: nowrap; }
+.studio-tag--accent { border-color: var(--accent-dim); color: var(--accent); }
+
+/* forms */
+.studio-form { display: grid; gap: 14px; max-width: 760px; }
+.studio-form--inline { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; max-width: none; }
+.studio-field { display: grid; gap: 5px; min-width: 0; align-content: start; }
+.studio-field > span { font-size: 12px; color: var(--faint); }
+.studio-field small { font-size: 12px; color: var(--faint); }
+.studio input[type="text"], .studio input[type="url"], .studio input[type="number"], .studio select, .studio textarea {
+ width: 100%; min-width: 0; height: 34px; padding: 0 12px; border: 1px solid var(--line2); border-radius: 8px; background: var(--side); color: var(--text); font: inherit; font-size: 13.5px;
+}
+.studio select { padding-right: 8px; }
+.studio textarea { height: auto; padding: 10px 12px; line-height: 1.55; resize: vertical; }
+.studio textarea.code, .studio input.code { font-family: var(--mono); font-size: 13px; tab-size: 2; }
+.studio input:focus, .studio select:focus, .studio textarea:focus { outline: none; border-color: var(--faint); }
+.studio .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+.studio .grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
+.studio-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+
+/* messages */
+.studio-note { border-left: 2px solid var(--line2); padding: 8px 0 8px 14px; color: var(--muted); margin: 14px 0; }
+.studio-note strong { color: var(--text); font-weight: 500; }
+.studio-note--warn { border-left-color: var(--accent); }
+.studio-note--bad { border-left-color: var(--bad); }
+.studio-note--ok { border-left-color: var(--ok); }
+.studio-errors { list-style: none; margin: 6px 0 0; padding: 0; font-size: 12.5px; color: var(--bad); }
+.studio-errors li { padding: 2px 0; font-family: var(--mono); }
+
+/* course workspace */
+.studio-side-head { padding: 18px 16px 14px; border-bottom: 1px solid var(--line); }
+.studio-side-head h2 { font-size: 14.5px; margin: 0; }
+.studio-side-head .src { font-size: 12px; color: var(--faint); margin-top: 2px; }
+.studio-nav { list-style: none; margin: 0; padding: 6px 0; border-bottom: 1px solid var(--line); }
+.studio-nav button, .studio-nav a { display: grid; grid-template-columns: 1fr auto; gap: 6px; width: 100%; align-items: center; padding: 6px 16px; border: 0; background: none; text-align: left; font-size: 13px; color: var(--muted); text-decoration: none; }
+.studio-nav button:hover { background: color-mix(in srgb, var(--raised) 60%, transparent); color: var(--text); }
+.studio-nav button[aria-current="page"] { background: var(--raised); color: var(--head); box-shadow: inset 2px 0 0 var(--accent); }
+.studio-nav .mod { padding: 11px 16px 4px; font-size: 13.5px; color: var(--text); font-weight: 500; }
+.studio-nav .k { font-size: 11.5px; color: var(--faint); }
+.studio-nav .add { color: var(--faint); font-size: 12.5px; }
+
+.studio-ws { display: flex; align-items: flex-start; margin: -44px -40px 0; }
+.studio-aside { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; }
+.studio-aside .src a { color: var(--muted); }
+.studio-content { flex: 1; min-width: 0; padding: 0 40px 64px; }
+.studio-bar { position: sticky; top: var(--top); z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 0 -40px 26px; padding: 10px 40px; background: var(--ground); border-bottom: 1px solid var(--line); }
+.studio-bar .where { flex: 1; min-width: 200px; font-size: 12.5px; color: var(--muted); }
+.studio-bar .where b { color: var(--text); font-weight: 500; }
+
+.studio-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
+.studio-split textarea { min-height: 520px; }
+.studio-preview-wrap { border-left: 1px solid var(--line); padding-left: 24px; min-width: 0; }
+.studio-preview { --pf: var(--serif); font-size: 17px; }
+.studio-preview :where(h1, h2, h3) { font-family: var(--serif); color: var(--head); }
+.studio-preview h1 { font-size: 26px; line-height: 1.2; margin: 0 0 .6em; }
+.studio-preview pre.code { font-size: 13px; line-height: 1.6; background: var(--side); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; overflow-x: auto; color: var(--text); }
+.studio-preview :not(pre) > code { font-size: .8em; padding: .08em .32em; background: color-mix(in srgb, var(--raised) 75%, transparent); border-radius: 4px; color: var(--text); }
+.studio-preview blockquote { border-left: 2px solid var(--line2); margin: 0 0 1em; padding-left: 16px; color: var(--muted); }
+.studio-preview details { border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); margin: 0 0 1.2em; padding: 0 2px; }
+.studio-preview summary { padding: 10px 0; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
+.studio-preview img { max-width: 100%; }
+.studio-preview hr { border: 0; border-top: 1px solid var(--line); margin: 2em 0; }
+
+.studio-section { padding: 18px 0; border-top: 1px solid var(--line); }
+.studio-section:first-of-type { border-top: 0; padding-top: 0; }
+
+/* conflicts */
+.studio-conflict { border-top: 1px solid var(--line); padding: 16px 0; }
+.studio-sides { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 10px 0; }
+.studio-sides pre { margin: 0; max-height: 280px; overflow: auto; font-size: 12.5px; line-height: 1.55; background: var(--side); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; white-space: pre-wrap; }
+
+@media (max-width: 900px) {
+ .studio-split, .studio-sides, .studio .grid2, .studio .grid3 { grid-template-columns: 1fr; }
+ .studio-preview-wrap { border-left: 0; padding-left: 0; border-top: 1px solid var(--line); padding-top: 18px; }
+ .studio-ws { flex-direction: column; margin: -44px -40px 0; }
+ .studio-aside { position: static; width: 100%; height: auto; max-height: 50vh; border-right: 0; border-bottom: 1px solid var(--line); }
+ .studio-content { padding: 0 16px 48px; width: 100%; }
+ .studio-bar { margin: 0 -16px 20px; padding: 10px 16px; }
+}
diff --git a/packages/feature-studio/src/ui/workspace.ts b/packages/feature-studio/src/ui/workspace.ts
new file mode 100644
index 0000000..ec80f89
--- /dev/null
+++ b/packages/feature-studio/src/ui/workspace.ts
@@ -0,0 +1,110 @@
+import { useCallback, useEffect, useState } from "react";
+import { api, coursePath, rawUrl, type CourseState, type FileConflict, type FileWrite, type ManifestDoc } from "./api";
+
+export type SaveOutcome = { ok: true } | { ok: false; conflicts?: FileConflict[]; error?: string };
+
+/** What every editor gets: the loaded draft, a local manifest copy shared across editors, and save helpers. */
+export interface Workspace {
+ courseId: string;
+ state: CourseState;
+ doc: ManifestDoc;
+ docDirty: boolean;
+ setDoc(update: (d: ManifestDoc) => void): void;
+ /** One commit: optional files plus the local manifest when it has changes (or when `withManifest`). */
+ save(opts: { files?: FileWrite[]; message: string; withManifest?: boolean; manifest?: ManifestDoc }): Promise;
+ upload(file: File, dir: string): Promise<{ ok: true; path: string } | { ok: false; error: string }>;
+ reload(): Promise;
+ /** Bumped after every save so open file editors refetch their base hash. */
+ version: number;
+ resolveUrl(rel: string): string;
+}
+
+export function clone(x: T): T {
+ return JSON.parse(JSON.stringify(x)) as T;
+}
+
+/** A course file opened for editing, with the hash it was loaded at (for conflict detection on save). */
+export function useFile(courseId: string, path: string | undefined, version: number, initialIfMissing = "") {
+ const [s, setS] = useState({ path: undefined as string | undefined, content: "", base: "", hash: null as string | null, loaded: false });
+ useEffect(() => {
+ let live = true;
+ if (!path) { setS({ path, content: "", base: "", hash: null, loaded: true }); return; }
+ setS((p) => ({ ...p, loaded: false }));
+ api("GET", coursePath(courseId, `/file?path=${encodeURIComponent(path)}`)).then(({ status, data }) => {
+ if (!live) return;
+ if (status === 200) setS({ path, content: data.content, base: data.content, hash: data.hash, loaded: true });
+ else setS({ path, content: initialIfMissing, base: "", hash: null, loaded: true });
+ });
+ return () => { live = false; };
+ }, [courseId, path, version]);
+ return {
+ path,
+ content: s.content,
+ loaded: s.loaded && s.path === path,
+ exists: s.hash !== null,
+ dirty: s.content !== s.base,
+ setContent: (c: string) => setS((p) => ({ ...p, content: c })),
+ write: (): FileWrite | null => (path ? { path, content: s.content, baseHash: s.hash } : null),
+ };
+}
+export type OpenFile = ReturnType;
+
+export function useWorkspace(courseId: string) {
+ const [state, setState] = useState(null);
+ const [readOnly, setReadOnly] = useState<{ message: string; title: string; root: string } | null>(null);
+ const [error, setError] = useState<{ status: number; error?: string } | null>(null);
+ const [doc, setDocState] = useState(null);
+ const [docDirty, setDocDirty] = useState(false);
+ const [version, setVersion] = useState(0);
+
+ const reload = useCallback(async () => {
+ const { status, data } = await api("GET", coursePath(courseId));
+ if (status !== 200) { setError({ status, error: data?.error }); return; }
+ setError(null);
+ if (data.readOnly) { setReadOnly({ message: data.message, title: data.course.title, root: data.course.root }); return; }
+ setState(data);
+ setDocState(data.manifestRaw ? clone(data.manifestRaw) : null);
+ setDocDirty(false);
+ }, [courseId]);
+
+ useEffect(() => { reload(); }, [reload]);
+
+ const ws: Workspace | null = state && doc ? {
+ courseId, state, doc, docDirty, version,
+ setDoc(update) {
+ setDocState((d) => {
+ const n = clone(d!);
+ update(n);
+ return n;
+ });
+ setDocDirty(true);
+ },
+ async save({ files = [], message, withManifest, manifest }) {
+ const body: Record = { message, files };
+ if (manifest || withManifest === true || (withManifest !== false && docDirty)) {
+ body.manifest = manifest ?? doc;
+ body.manifestBaseHash = state.manifestHash;
+ }
+ const { status, data } = await api("PUT", coursePath(courseId, "/files"), body);
+ if (status === 200) {
+ setVersion((v) => v + 1);
+ await reload();
+ return { ok: true };
+ }
+ if (status === 409 && data?.conflicts) return { ok: false, conflicts: data.conflicts };
+ return { ok: false, error: data?.error ?? `Save failed (${status})` };
+ },
+ async upload(file, dir) {
+ const fd = new FormData();
+ fd.set("file", file);
+ fd.set("dir", dir);
+ const { status, data } = await api("POST", coursePath(courseId, "/upload"), fd);
+ if (status !== 200) return { ok: false, error: data?.error ?? `Upload failed (${status})` };
+ return { ok: true, path: data.path };
+ },
+ reload,
+ resolveUrl: (rel) => rawUrl(courseId, rel),
+ } : null;
+
+ return { ws, state, readOnly, error, reload, setVersion };
+}
diff --git a/packages/feature-studio/src/web.ts b/packages/feature-studio/src/web.ts
new file mode 100644
index 0000000..d60a5a4
--- /dev/null
+++ b/packages/feature-studio/src/web.ts
@@ -0,0 +1,12 @@
+import type { WebFeature } from "@stc/contracts";
+
+/** Studio web routes. The pages call /api/studio, which allows only owners, authors and per-course authors. */
+export const studioWeb: WebFeature = {
+ name: "studio",
+ routes: [
+ { path: "/studio", element: () => import("./ui/StudioHome"), nav: { label: "Studio", area: "header" } },
+ { path: "/studio/:courseId", element: () => import("./ui/CourseStudio") },
+ ],
+};
+
+export { setLessonPreview, type LessonPreviewProps, type LessonPreviewComponent } from "./ui/preview";
diff --git a/packages/feature-studio/test/app-helpers.ts b/packages/feature-studio/test/app-helpers.ts
index 0c46da1..43b1d11 100644
--- a/packages/feature-studio/test/app-helpers.ts
+++ b/packages/feature-studio/test/app-helpers.ts
@@ -15,9 +15,20 @@ export const users: Record = {
};
/** A server context like main.ts builds, with header-selected users (`x-user: ada`). */
-export async function setupApp(extraCourseDirs: string[] = []) {
+export async function setupApp(extraCourseDirs: string[] = [], dbUrl = "sqlite://:memory:") {
const dataDir = tempDir();
- const db = await connect("sqlite://:memory:");
+ let dropSchema = async () => {};
+ if (dbUrl.startsWith("postgres")) {
+ // a private schema per test, like packages/db's forEachDb
+ const schema = `t_${Bun.randomUUIDv7().replaceAll("-", "")}`;
+ const admin = await connect(dbUrl);
+ await admin.sql.unsafe(`CREATE SCHEMA ${schema}`);
+ const u = new URL(dbUrl);
+ u.searchParams.set("options", `-c search_path=${schema}`);
+ dbUrl = u.toString();
+ dropSchema = async () => { await admin.sql.unsafe(`DROP SCHEMA ${schema} CASCADE`); await admin.close(); };
+ }
+ const db = await connect(dbUrl);
await migrate(db);
const config = { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: extraCourseDirs, secret: "t", publicUrl: "http://x" };
const courses = await createCourseRepository(studioCourseDirs(config));
@@ -39,5 +50,5 @@ export async function setupApp(extraCourseDirs: string[] = []) {
try { json = JSON.parse(text); } catch { json = text; }
return { status: r.status, json };
};
- return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); } };
+ return { ctx, db, dataDir, feature, app, call, stop: async () => { feature.stop(); await db.close(); await dropSchema(); } };
}
diff --git a/packages/feature-studio/test/feature.test.ts b/packages/feature-studio/test/feature.test.ts
index 2ed53a0..d788c73 100644
--- a/packages/feature-studio/test/feature.test.ts
+++ b/packages/feature-studio/test/feature.test.ts
@@ -11,8 +11,10 @@ test("anonymous gets 401, learners 403", async () => {
expect((await env.call("lea", "POST", "/api/studio/courses", { id: "x", title: "X" })).status).toBe(403);
});
-test("creating a course records the creator in course_authors; a per-course author without the role can edit it", async () => {
- env = await setupApp();
+const DBS = [["sqlite", "sqlite://:memory:"], ...(process.env.STC_TEST_PG_URL ? [["postgres", process.env.STC_TEST_PG_URL]] : [])] as const;
+
+test.each(DBS)("creating a course records the creator in course_authors; a per-course author without the role can edit it [%s]", async (_name, url) => {
+ env = await setupApp([], url);
expect((await env.call("ada", "POST", "/api/studio/courses", { id: "c1", title: "C" })).status).toBe(201);
const rows = await env.db.sql`SELECT course_id, user_id FROM course_authors`;
expect(rows.map((r: any) => [r.course_id, r.user_id])).toEqual([["c1", "u-ada"]]);
diff --git a/packages/feature-studio/test/markdown.test.ts b/packages/feature-studio/test/markdown.test.ts
new file mode 100644
index 0000000..9db40b9
--- /dev/null
+++ b/packages/feature-studio/test/markdown.test.ts
@@ -0,0 +1,38 @@
+import { expect, test } from "bun:test";
+import { renderMarkdown } from "../src/ui/markdown";
+
+test("headings, paragraphs, emphasis, inline code", () => {
+ expect(renderMarkdown("# Title\n\nSome **bold** and *em* and `x < y`.\nSecond line.")).toBe(
+ 'Title \nSome bold and em and x < y.\nSecond line.
',
+ );
+});
+
+test("raw HTML is escaped, except the details/summary hint blocks", () => {
+ expect(renderMarkdown("")).toBe("<script>alert(1)</script>
");
+ const h = renderMarkdown("\nExercise 1 — nudge \n\nTry **this**.\n\n ");
+ expect(h).toMatch(/\s*Exercise 1 — nudge<\/summary>/);
+ expect(h).toContain("Try this .
");
+ expect(h).toContain(" ");
+ expect(renderMarkdown("a ")).not.toContain(" {
+ expect(renderMarkdown("```js\nconst a = '';\n# not a heading\n```")).toBe(
+ 'const a = \'<b>\';\n# not a heading ',
+ );
+});
+
+test("lists, quotes, rules", () => {
+ expect(renderMarkdown("- a\n- b\n\n1. one\n2. two")).toBe("\none two ");
+ expect(renderMarkdown("> quoted\n\n---")).toBe("quoted
\n ");
+});
+
+test("links: safe schemes only; images resolved through the given resolver", () => {
+ expect(renderMarkdown("[ok](https://x.org/a?b=1&c=2)")).toBe(' ok
');
+ expect(renderMarkdown("[bad](javascript:alert(1))")).toBe("bad
");
+ expect(renderMarkdown("", { resolveUrl: (u) => `/raw/${encodeURIComponent(u)}` })).toBe('
');
+});
+
+test("front matter is not rendered", () => {
+ expect(renderMarkdown("---\ntitle: x\n---\n# H")).toBe('H ');
+});