handzon-core 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/package.json +74 -0
  2. package/src/collections.ts +150 -0
  3. package/src/components/Footer.astro +85 -0
  4. package/src/components/Navbar.astro +74 -0
  5. package/src/components/Progress.tsx +36 -0
  6. package/src/components/Sidebar.astro +162 -0
  7. package/src/components/StepNav.astro +107 -0
  8. package/src/components/ai/ByokSetup.tsx +90 -0
  9. package/src/components/ai/ChatButton.tsx +30 -0
  10. package/src/components/ai/ChatPanel.tsx +244 -0
  11. package/src/components/auth/SignInButton.astro +41 -0
  12. package/src/components/auth/UserMenu.astro +79 -0
  13. package/src/components/auth/UserMenu.tsx +136 -0
  14. package/src/components/home/FilterBar.tsx +152 -0
  15. package/src/components/home/Hero.astro +60 -0
  16. package/src/components/home/Pagination.tsx +89 -0
  17. package/src/components/home/ResumeRail.tsx +50 -0
  18. package/src/components/home/TutorialCard.astro +185 -0
  19. package/src/components/mdx/Callout.astro +77 -0
  20. package/src/components/mdx/Checkpoint.astro +14 -0
  21. package/src/components/mdx/Checkpoint.tsx +49 -0
  22. package/src/components/mdx/Diff.astro +6 -0
  23. package/src/components/mdx/Diff.tsx +100 -0
  24. package/src/components/mdx/Download.astro +37 -0
  25. package/src/components/mdx/Embed.astro +56 -0
  26. package/src/components/mdx/File.astro +28 -0
  27. package/src/components/mdx/FileTree.astro +6 -0
  28. package/src/components/mdx/FileTree.tsx +71 -0
  29. package/src/components/mdx/Hint.astro +51 -0
  30. package/src/components/mdx/Mermaid.astro +6 -0
  31. package/src/components/mdx/Mermaid.tsx +47 -0
  32. package/src/components/mdx/Playground.astro +6 -0
  33. package/src/components/mdx/Playground.tsx +34 -0
  34. package/src/components/mdx/Quiz.astro +6 -0
  35. package/src/components/mdx/Quiz.tsx +102 -0
  36. package/src/components/mdx/Recap.astro +65 -0
  37. package/src/components/mdx/Reveal.astro +7 -0
  38. package/src/components/mdx/Reveal.tsx +25 -0
  39. package/src/components/mdx/Step.astro +12 -0
  40. package/src/components/mdx/Steps.astro +40 -0
  41. package/src/components/mdx/Tab.astro +22 -0
  42. package/src/components/mdx/Tabs.astro +67 -0
  43. package/src/components/mdx/Terminal.astro +6 -0
  44. package/src/components/mdx/Terminal.tsx +47 -0
  45. package/src/index.ts +55 -0
  46. package/src/layouts/BaseLayout.astro +112 -0
  47. package/src/layouts/TutorialLayout.astro +218 -0
  48. package/src/lib/ai/client.ts +92 -0
  49. package/src/lib/ai/context.ts +97 -0
  50. package/src/lib/content.ts +73 -0
  51. package/src/lib/mdx-components.ts +47 -0
  52. package/src/lib/progress/local.ts +89 -0
  53. package/src/lib/progress/remote.ts +199 -0
  54. package/src/lib/progress/types.ts +63 -0
  55. package/src/lib/progress/useProgress.ts +117 -0
  56. package/src/lib/rehype-mermaid-passthrough.ts +31 -0
  57. package/src/pages/Home.astro +408 -0
  58. package/src/pages/TutorialLanding.astro +324 -0
  59. package/src/pages/TutorialStep.astro +67 -0
  60. package/src/pages/paths.ts +36 -0
  61. package/src/server/auth/config.ts +102 -0
  62. package/src/server/auth/schema.ts +66 -0
  63. package/src/server/auth/session.ts +27 -0
  64. package/src/server/auth.ts +127 -0
  65. package/src/server/db/client.ts +14 -0
  66. package/src/server/db/migrate.ts +29 -0
  67. package/src/server/db/schema.ts +65 -0
  68. package/src/server/handlers/healthz.ts +6 -0
  69. package/src/server/handlers/progress.ts +90 -0
  70. package/src/server/handlers/tutorialStats.ts +67 -0
  71. package/src/server/http.ts +33 -0
  72. package/src/types/ai.ts +17 -0
  73. package/styles/base.css +127 -0
  74. package/styles/components/a11y.css +12 -0
  75. package/styles/components/byok.css +50 -0
  76. package/styles/components/chat.css +304 -0
  77. package/styles/components/checkpoint.css +49 -0
  78. package/styles/components/diff.css +44 -0
  79. package/styles/components/expressive-code.css +61 -0
  80. package/styles/components/filetree.css +68 -0
  81. package/styles/components/mermaid.css +19 -0
  82. package/styles/components/modal.css +25 -0
  83. package/styles/components/progress.css +19 -0
  84. package/styles/components/quiz.css +101 -0
  85. package/styles/components/reveal.css +25 -0
  86. package/styles/components/tabs.css +60 -0
  87. package/styles/components/terminal.css +55 -0
  88. package/styles/components.css +28 -0
  89. package/styles/global.css +15 -0
@@ -0,0 +1,127 @@
1
+ import type { AstroCookieSetOptions, AstroCookies } from "astro";
2
+ import { and, eq, isNull } from "drizzle-orm";
3
+ import { getAuthedUser } from "./auth/session.ts";
4
+ import { getDb } from "./db/client.ts";
5
+ import { learners, progressEntries } from "./db/schema.ts";
6
+
7
+ const COOKIE = "tt-device";
8
+ const ONE_YEAR = 60 * 60 * 24 * 365;
9
+
10
+ const COOKIE_OPTS: AstroCookieSetOptions = {
11
+ httpOnly: true,
12
+ // Only flip on `secure` in production — Astro's dev server is plain
13
+ // HTTP and `secure: true` silently drops the cookie there.
14
+ secure: import.meta.env.PROD,
15
+ sameSite: "lax",
16
+ path: "/",
17
+ maxAge: ONE_YEAR,
18
+ };
19
+
20
+ function randomDeviceId(): string {
21
+ // 32-char hex (128 bits) — plenty for an unpredictable, opaque id.
22
+ const bytes = new Uint8Array(16);
23
+ crypto.getRandomValues(bytes);
24
+ return Array.from(bytes)
25
+ .map((b) => b.toString(16).padStart(2, "0"))
26
+ .join("");
27
+ }
28
+
29
+ /**
30
+ * Resolve the current learner row.
31
+ *
32
+ * - No session + no cookie → mint a device learner + cookie (anonymous).
33
+ * - No session + cookie → return the existing device learner.
34
+ * - Session present → return the user-linked learner. If a device
35
+ * cookie also exists and points to an orphan
36
+ * (user_id NULL), re-key its progress to the
37
+ * user learner in a single transaction and
38
+ * drop the device cookie. One-time claim.
39
+ *
40
+ * `request` is required so we can read the Auth.js session. Endpoints
41
+ * that don't currently have it (older internal callers) can pass `null`
42
+ * to keep the anonymous-only path.
43
+ */
44
+ export async function getOrCreateLearner(
45
+ cookies: AstroCookies,
46
+ request: Request | null,
47
+ ): Promise<{ id: string; deviceId: string | null }> {
48
+ const db = getDb();
49
+ const authed = request ? await getAuthedUser(request) : null;
50
+
51
+ if (authed) {
52
+ const userLearner = await findOrCreateUserLearner(db, authed.userId);
53
+ const deviceId = cookies.get(COOKIE)?.value;
54
+ if (deviceId) {
55
+ await maybeClaimDeviceProgress(db, deviceId, userLearner.id);
56
+ cookies.delete(COOKIE, { path: "/" });
57
+ }
58
+ return { id: userLearner.id, deviceId: null };
59
+ }
60
+
61
+ // Anonymous path — unchanged from pre-auth behaviour.
62
+ let deviceId = cookies.get(COOKIE)?.value;
63
+ if (deviceId) {
64
+ const found = await db
65
+ .select()
66
+ .from(learners)
67
+ .where(eq(learners.deviceId, deviceId))
68
+ .limit(1);
69
+ if (found[0]) return { id: found[0].id, deviceId };
70
+ }
71
+ deviceId = randomDeviceId();
72
+ const [created] = await db.insert(learners).values({ deviceId }).returning();
73
+ cookies.set(COOKIE, deviceId, COOKIE_OPTS);
74
+ return { id: created!.id, deviceId };
75
+ }
76
+
77
+ async function findOrCreateUserLearner(
78
+ db: ReturnType<typeof getDb>,
79
+ userId: string,
80
+ ): Promise<{ id: string }> {
81
+ const existing = await db
82
+ .select({ id: learners.id })
83
+ .from(learners)
84
+ .where(eq(learners.userId, userId))
85
+ .limit(1);
86
+ if (existing[0]) return { id: existing[0].id };
87
+ const [created] = await db.insert(learners).values({ userId }).returning({ id: learners.id });
88
+ return { id: created!.id };
89
+ }
90
+
91
+ /**
92
+ * If `deviceId` points at an orphan (user_id NULL) learner with progress,
93
+ * re-parent its progress rows to `userLearnerId` and delete the orphan.
94
+ * Idempotent: subsequent sign-ins with the same device cookie no-op.
95
+ */
96
+ async function maybeClaimDeviceProgress(
97
+ db: ReturnType<typeof getDb>,
98
+ deviceId: string,
99
+ userLearnerId: string,
100
+ ): Promise<void> {
101
+ const orphans = await db
102
+ .select({ id: learners.id })
103
+ .from(learners)
104
+ .where(and(eq(learners.deviceId, deviceId), isNull(learners.userId)))
105
+ .limit(1);
106
+ const orphan = orphans[0];
107
+ if (!orphan || orphan.id === userLearnerId) return;
108
+
109
+ await db.transaction(async (tx) => {
110
+ // Move progress rows. ON CONFLICT: keep the row with the most
111
+ // recent updated_at — if the user already has progress on this
112
+ // (kind, scope, key), prefer whichever was touched last.
113
+ await tx.execute(/* sql */ `
114
+ INSERT INTO "progress_entries" ("learner_id","kind","scope","key","value","updated_at")
115
+ SELECT '${userLearnerId}'::uuid, "kind","scope","key","value","updated_at"
116
+ FROM "progress_entries"
117
+ WHERE "learner_id" = '${orphan.id}'::uuid
118
+ ON CONFLICT ("learner_id","kind","scope","key")
119
+ DO UPDATE SET
120
+ "value" = CASE WHEN EXCLUDED."updated_at" > "progress_entries"."updated_at"
121
+ THEN EXCLUDED."value" ELSE "progress_entries"."value" END,
122
+ "updated_at" = GREATEST(EXCLUDED."updated_at", "progress_entries"."updated_at");
123
+ `);
124
+ await tx.delete(progressEntries).where(eq(progressEntries.learnerId, orphan.id));
125
+ await tx.delete(learners).where(eq(learners.id, orphan.id));
126
+ });
127
+ }
@@ -0,0 +1,14 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+ import * as schema from "./schema";
4
+
5
+ let _client: ReturnType<typeof drizzle> | null = null;
6
+
7
+ export function getDb() {
8
+ if (_client) return _client;
9
+ const url = process.env.DATABASE_URL;
10
+ if (!url) throw new Error("DATABASE_URL is not set.");
11
+ const sql = postgres(url, { max: 5, idle_timeout: 20 });
12
+ _client = drizzle(sql, { schema });
13
+ return _client;
14
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Drizzle migration runner used by the scaffold's `db:migrate` script
3
+ * (and the Tier 2 Blueprint's `preDeployCommand`). Keeping it inside
4
+ * the framework lets the scaffold's `package.json` stay free of a
5
+ * direct `drizzle-orm` dependency — the previous setup imported
6
+ * `drizzle-orm/postgres-js/migrator` from the scaffold itself, which
7
+ * only resolved when pnpm's `shamefully-hoist=true` was on the
8
+ * workspace `.npmrc`. Render's strict install rejected it.
9
+ *
10
+ * Usage (scaffold side):
11
+ *
12
+ * import { runMigrations } from "handzon-core/server/db/migrate";
13
+ * runMigrations("./drizzle")
14
+ * .then(() => process.exit(0))
15
+ * .catch((e) => { console.error(e); process.exit(1); });
16
+ */
17
+ import { migrate } from "drizzle-orm/postgres-js/migrator";
18
+ import { getDb } from "./client.ts";
19
+
20
+ export async function runMigrations(migrationsFolder: string): Promise<void> {
21
+ if (!process.env.DATABASE_URL) {
22
+ console.log("DATABASE_URL not set — skipping migrations (Tier 1 build).");
23
+ return;
24
+ }
25
+ const db = getDb();
26
+ console.log("Running migrations…");
27
+ await migrate(db, { migrationsFolder });
28
+ console.log("Migrations complete.");
29
+ }
@@ -0,0 +1,65 @@
1
+ import { sql } from "drizzle-orm";
2
+ import {
3
+ index,
4
+ jsonb,
5
+ pgTable,
6
+ primaryKey,
7
+ text,
8
+ timestamp,
9
+ uniqueIndex,
10
+ uuid,
11
+ } from "drizzle-orm/pg-core";
12
+ import { users } from "../auth/schema.ts";
13
+
14
+ // Re-export the Auth.js tables so consumers (and drizzle-kit) see one
15
+ // schema barrel.
16
+ export { users, accounts, sessions, verificationTokens } from "../auth/schema.ts";
17
+
18
+ export const learners = pgTable(
19
+ "learners",
20
+ {
21
+ id: uuid("id").primaryKey().defaultRandom(),
22
+ // Nullable now — signed-in learners don't need a device cookie.
23
+ deviceId: text("device_id"),
24
+ // Optional FK to the Auth.js user. Set on first sign-in via the
25
+ // claim transaction; null for anonymous learners.
26
+ userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
27
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
28
+ },
29
+ (table) => ({
30
+ // Partial uniques: only enforced when the column is non-null. Lets
31
+ // multiple signed-in learners coexist without device ids and avoids
32
+ // the historical `device_id NOT NULL UNIQUE` constraint blocking
33
+ // user-only rows.
34
+ deviceIdUnique: uniqueIndex("learners_device_id_unique")
35
+ .on(table.deviceId)
36
+ .where(sql`${table.deviceId} IS NOT NULL`),
37
+ userIdUnique: uniqueIndex("learners_user_id_unique")
38
+ .on(table.userId)
39
+ .where(sql`${table.userId} IS NOT NULL`),
40
+ }),
41
+ );
42
+
43
+ export const progressEntries = pgTable(
44
+ "progress_entries",
45
+ {
46
+ learnerId: uuid("learner_id")
47
+ .notNull()
48
+ .references(() => learners.id, { onDelete: "cascade" }),
49
+ // 'step' | 'checkpoint' | 'quiz' | 'pref' | 'lastVisited' | 'tutorial'
50
+ // The 'tutorial' kind keys aggregate popularity events. Two keys
51
+ // matter for cross-learner counts: 'started' (first step view per
52
+ // learner) and 'completed' (all steps in the tutorial complete).
53
+ // The composite PK guarantees each learner is counted at most once
54
+ // per (slug, key), so a plain COUNT(*) gives unique-learner totals.
55
+ kind: text("kind").notNull(),
56
+ scope: text("scope").notNull(), // tutorial slug or 'global'
57
+ key: text("key").notNull(),
58
+ value: jsonb("value").notNull(),
59
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
60
+ },
61
+ (table) => ({
62
+ pk: primaryKey({ columns: [table.learnerId, table.kind, table.scope, table.key] }),
63
+ byLearner: index("progress_by_learner").on(table.learnerId, table.scope),
64
+ }),
65
+ );
@@ -0,0 +1,6 @@
1
+ import type { APIRoute } from "astro";
2
+ import { json } from "../http.ts";
3
+
4
+ // Hit by Render's healthCheckPath on both tiers. Both tiers are SSR
5
+ // node services in this template, so the response is computed at runtime.
6
+ export const GET: APIRoute = () => json({ status: "ok" });
@@ -0,0 +1,90 @@
1
+ import type { APIRoute } from "astro";
2
+ import { eq, sql } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { getOrCreateLearner } from "../auth.ts";
5
+ import { getDb } from "../db/client.ts";
6
+ import { progressEntries } from "../db/schema.ts";
7
+ import { isSameOrigin, json } from "../http.ts";
8
+
9
+ const MAX_BODY_BYTES = 32 * 1024;
10
+ const MAX_ENTRIES = 200;
11
+
12
+ const ProgressEntrySchema = z.object({
13
+ kind: z.enum(["step", "checkpoint", "quiz", "pref", "lastVisited", "tutorial"]),
14
+ scope: z.string().min(1).max(128),
15
+ key: z.string().min(1).max(128),
16
+ value: z.unknown(),
17
+ });
18
+
19
+ const ProgressBodySchema = z.array(ProgressEntrySchema).max(MAX_ENTRIES);
20
+
21
+ // Tier 1: no Postgres — returns an empty list (the frontend uses the
22
+ // local store and never reads this in that mode). Tier 2: hits Postgres.
23
+ export const GET: APIRoute = async ({ cookies, request }) => {
24
+ if (!process.env.DATABASE_URL) {
25
+ return json({ entries: [] });
26
+ }
27
+ const learner = await getOrCreateLearner(cookies, request);
28
+ const db = getDb();
29
+ const rows = await db
30
+ .select()
31
+ .from(progressEntries)
32
+ .where(eq(progressEntries.learnerId, learner.id));
33
+ return json({ entries: rows });
34
+ };
35
+
36
+ export const POST: APIRoute = async ({ cookies, request }) => {
37
+ if (!isSameOrigin(request)) {
38
+ return json({ error: "Cross-origin write rejected." }, { status: 403 });
39
+ }
40
+ if (!process.env.DATABASE_URL) {
41
+ return json({ written: 0 });
42
+ }
43
+
44
+ const lengthHeader = request.headers.get("content-length");
45
+ if (lengthHeader && Number(lengthHeader) > MAX_BODY_BYTES) {
46
+ return json({ error: "Payload too large." }, { status: 413 });
47
+ }
48
+ const raw = await request.text();
49
+ if (raw.length > MAX_BODY_BYTES) {
50
+ return json({ error: "Payload too large." }, { status: 413 });
51
+ }
52
+
53
+ let parsed: z.infer<typeof ProgressBodySchema>;
54
+ try {
55
+ parsed = ProgressBodySchema.parse(JSON.parse(raw));
56
+ } catch (e) {
57
+ return json({ error: e instanceof Error ? e.message : "Invalid JSON." }, { status: 400 });
58
+ }
59
+ if (parsed.length === 0) return json({ written: 0 });
60
+
61
+ const learner = await getOrCreateLearner(cookies, request);
62
+ const db = getDb();
63
+ const now = new Date();
64
+ const rows = parsed.map((b) => ({
65
+ learnerId: learner.id,
66
+ kind: b.kind,
67
+ scope: b.scope,
68
+ key: b.key,
69
+ value: b.value,
70
+ updatedAt: now,
71
+ }));
72
+ await db
73
+ .insert(progressEntries)
74
+ .values(rows)
75
+ .onConflictDoUpdate({
76
+ target: [
77
+ progressEntries.learnerId,
78
+ progressEntries.kind,
79
+ progressEntries.scope,
80
+ progressEntries.key,
81
+ ],
82
+ set: {
83
+ // `excluded` is the row Postgres would have inserted — without
84
+ // this the SET was a no-op (`value = progress_entries.value`).
85
+ value: sql`excluded.value`,
86
+ updatedAt: sql`excluded.updated_at`,
87
+ },
88
+ });
89
+ return json({ written: rows.length });
90
+ };
@@ -0,0 +1,67 @@
1
+ import type { APIRoute } from "astro";
2
+ import { eq, sql } from "drizzle-orm";
3
+ import { getDb } from "../db/client.ts";
4
+ import { progressEntries } from "../db/schema.ts";
5
+ import { json } from "../http.ts";
6
+
7
+ export interface TutorialStat {
8
+ slug: string;
9
+ started: number;
10
+ completed: number;
11
+ }
12
+
13
+ interface CacheEntry {
14
+ expiresAt: number;
15
+ payload: { stats: TutorialStat[] };
16
+ }
17
+
18
+ const CACHE_TTL_MS = 60_000;
19
+ let cache: CacheEntry | null = null;
20
+
21
+ /**
22
+ * Returns one row per tutorial slug with cross-learner started /
23
+ * completed counts. Tier 1 (no DATABASE_URL) returns an empty array so
24
+ * card hydration is a no-op and the build stays static.
25
+ *
26
+ * Each (learner, slug, key) is a single row thanks to the composite PK
27
+ * on `progress_entries`, so `COUNT(*)` is a unique-learner count — no
28
+ * extra `DISTINCT` needed.
29
+ */
30
+ export const GET: APIRoute = async () => {
31
+ if (!process.env.DATABASE_URL) {
32
+ return json({ stats: [] satisfies TutorialStat[] }, {
33
+ headers: { "Cache-Control": "public, max-age=60" },
34
+ });
35
+ }
36
+ const now = Date.now();
37
+ if (cache && cache.expiresAt > now) {
38
+ return json(cache.payload, {
39
+ headers: { "Cache-Control": "public, max-age=60" },
40
+ });
41
+ }
42
+
43
+ const db = getDb();
44
+ const rows = await db
45
+ .select({
46
+ scope: progressEntries.scope,
47
+ key: progressEntries.key,
48
+ count: sql<number>`count(*)::int`.as("count"),
49
+ })
50
+ .from(progressEntries)
51
+ .where(eq(progressEntries.kind, "tutorial"))
52
+ .groupBy(progressEntries.scope, progressEntries.key);
53
+
54
+ const bySlug = new Map<string, TutorialStat>();
55
+ for (const r of rows) {
56
+ const entry = bySlug.get(r.scope) ?? { slug: r.scope, started: 0, completed: 0 };
57
+ if (r.key === "started") entry.started = Number(r.count);
58
+ else if (r.key === "completed") entry.completed = Number(r.count);
59
+ bySlug.set(r.scope, entry);
60
+ }
61
+
62
+ const payload = { stats: Array.from(bySlug.values()) };
63
+ cache = { expiresAt: now + CACHE_TTL_MS, payload };
64
+ return json(payload, {
65
+ headers: { "Cache-Control": "public, max-age=60" },
66
+ });
67
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared HTTP helpers for API routes. Centralises the JSON response shape
3
+ * and the same-origin guard so routes don't reinvent either.
4
+ */
5
+
6
+ export function json(body: unknown, init: ResponseInit = {}): Response {
7
+ const headers = new Headers(init.headers);
8
+ headers.set("Content-Type", "application/json");
9
+ return new Response(JSON.stringify(body), { ...init, headers });
10
+ }
11
+
12
+ /**
13
+ * Reject cross-origin writes. SameSite=Lax on the device cookie already
14
+ * blocks most CSRF, but `Sec-Fetch-Site` is the modern, browser-supplied
15
+ * check — pair them for defense in depth. Same-origin and direct
16
+ * navigations are allowed; `none` (typed URL, bookmark) is allowed for GET
17
+ * only, which API mutating routes don't expose.
18
+ */
19
+ export function isSameOrigin(request: Request): boolean {
20
+ const site = request.headers.get("sec-fetch-site");
21
+ if (site === "same-origin") return true;
22
+ if (site === "none") return true;
23
+ if (site) return false;
24
+ // Older browsers without Sec-Fetch-Site: fall back to Origin/Referer.
25
+ const origin = request.headers.get("origin");
26
+ if (!origin) return true;
27
+ const host = request.headers.get("host");
28
+ try {
29
+ return new URL(origin).host === host;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
@@ -0,0 +1,17 @@
1
+ export interface AiConfig {
2
+ enabled: boolean;
3
+ name: string;
4
+ tagline?: string;
5
+ greeting?: string;
6
+ avatar?: string;
7
+ persona?: string;
8
+ provider: "anthropic" | "openai" | "google" | "openai-compatible";
9
+ model: string;
10
+ byok: "required" | "optional" | "disabled";
11
+ tone: "socratic" | "direct" | "encouraging";
12
+ contextBudgetTokens: number;
13
+ includeFutureSteps: boolean;
14
+ tools: { suggestPlaygroundEdit: boolean };
15
+ disabledSkills?: string[];
16
+ allowedDomains?: string[];
17
+ }
@@ -0,0 +1,127 @@
1
+ @import "@fontsource-variable/geist";
2
+ @import "@fontsource-variable/geist-mono";
3
+
4
+ *,
5
+ *::before,
6
+ *::after {
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ html {
11
+ background: var(--color-bg);
12
+ color: var(--color-fg);
13
+ font-family: var(--font-sans);
14
+ -webkit-font-smoothing: antialiased;
15
+ text-rendering: optimizeLegibility;
16
+ }
17
+
18
+ body {
19
+ margin: 0;
20
+ min-height: 100dvh;
21
+ }
22
+
23
+ a {
24
+ color: var(--color-accent);
25
+ text-decoration: underline;
26
+ text-underline-offset: 2px;
27
+ }
28
+
29
+ a:hover {
30
+ color: var(--color-fg);
31
+ }
32
+
33
+ :focus-visible {
34
+ outline: var(--border-default, 2px) solid var(--color-accent);
35
+ outline-offset: 2px;
36
+ }
37
+
38
+ /* Tutorial prose — consistent type scale, single weight family.
39
+ * Same weight (700) across every heading; size + tracking handles the
40
+ * hierarchy so it doesn't look "h1 thin / h2 super heavy".
41
+ */
42
+ .prose {
43
+ font-family: var(--font-sans);
44
+ line-height: 1.65;
45
+ font-size: 1rem;
46
+ max-width: 72ch;
47
+ }
48
+
49
+ .prose h1,
50
+ .prose h2,
51
+ .prose h3,
52
+ .prose h4,
53
+ .prose h5,
54
+ .prose h6 {
55
+ font-weight: 700;
56
+ letter-spacing: -0.015em;
57
+ line-height: 1.2;
58
+ margin-top: 2rem;
59
+ margin-bottom: 0.6rem;
60
+ color: var(--color-fg);
61
+ }
62
+
63
+ .prose h1 { font-size: 1.875rem; letter-spacing: -0.02em; }
64
+ .prose h2 {
65
+ font-size: 1.4rem;
66
+ padding-top: 1.25rem;
67
+ border-top: var(--border-default) solid var(--color-border);
68
+ }
69
+ .prose h3 { font-size: 1.15rem; }
70
+ .prose h4 { font-size: 1rem; }
71
+
72
+ .prose p { margin: 0.75em 0; }
73
+
74
+ .prose ul,
75
+ .prose ol {
76
+ padding-left: 1.5rem;
77
+ margin: 0.75em 0;
78
+ }
79
+
80
+ .prose li { margin: 0.25em 0; }
81
+
82
+ .prose code:not(pre code),
83
+ .tut-tab-panel code:not(pre code) {
84
+ font-family: var(--font-mono);
85
+ background: var(--color-surface-2);
86
+ padding: 0.15em 0.45em;
87
+ font-size: 0.875em;
88
+ color: var(--color-accent);
89
+ border-radius: 0;
90
+ }
91
+
92
+ .prose blockquote {
93
+ border-left: var(--border-thick, 3px) solid var(--color-accent);
94
+ padding-left: 1rem;
95
+ margin: 1em 0;
96
+ color: var(--color-muted);
97
+ }
98
+
99
+ .prose hr {
100
+ border: none;
101
+ border-top: var(--border-default, 2px) solid var(--color-border);
102
+ margin: 2em 0;
103
+ }
104
+
105
+ .prose img {
106
+ max-width: 100%;
107
+ height: auto;
108
+ border-radius: 0;
109
+ }
110
+
111
+ .prose table {
112
+ width: 100%;
113
+ border-collapse: collapse;
114
+ margin: 1em 0;
115
+ }
116
+
117
+ .prose th,
118
+ .prose td {
119
+ border: var(--border-default, 2px) solid var(--color-border);
120
+ padding: 0.5em 0.75em;
121
+ text-align: left;
122
+ }
123
+
124
+ .prose th {
125
+ background: var(--color-surface);
126
+ font-weight: 700;
127
+ }
@@ -0,0 +1,12 @@
1
+ /* sr-only utility for accessibility-only text */
2
+ .sr-only {
3
+ position: absolute;
4
+ width: 1px;
5
+ height: 1px;
6
+ padding: 0;
7
+ margin: -1px;
8
+ overflow: hidden;
9
+ clip: rect(0, 0, 0, 0);
10
+ white-space: nowrap;
11
+ border: 0;
12
+ }
@@ -0,0 +1,50 @@
1
+ /* BYOK setup */
2
+ .byok-panel {
3
+ position: fixed;
4
+ top: 50%;
5
+ left: 50%;
6
+ transform: translate(-50%, -50%);
7
+ width: min(440px, 92vw);
8
+ background: var(--color-surface);
9
+ border: var(--border-default) solid var(--color-border);
10
+ padding: 1.5rem;
11
+ z-index: 70;
12
+ border-radius: 0;
13
+ }
14
+ .byok-panel h2 { margin: 0 0 0.5rem; font-size: 1.25rem; }
15
+ .byok-desc { color: var(--color-muted); font-size: 0.9em; margin: 0.25rem 0 1rem; }
16
+ .byok-link { font-size: 0.85em; margin: 0 0 1rem; }
17
+ .byok-field { display: grid; gap: 0.35rem; margin-bottom: 1rem; }
18
+ .byok-field span {
19
+ font-family: var(--font-mono);
20
+ font-size: 0.72em;
21
+ text-transform: uppercase;
22
+ letter-spacing: 0.06em;
23
+ color: var(--color-muted);
24
+ }
25
+ .byok-field input {
26
+ background: var(--color-bg);
27
+ border: var(--border-default) solid var(--color-border);
28
+ color: var(--color-fg);
29
+ padding: 0.55rem 0.75rem;
30
+ font: inherit;
31
+ border-radius: 0;
32
+ }
33
+ .byok-actions { display: flex; justify-content: flex-end; }
34
+ .byok-actions button {
35
+ background: var(--color-accent);
36
+ color: var(--color-accent-fg);
37
+ border: 0;
38
+ padding: 0.55rem 1rem;
39
+ font-weight: 600;
40
+ cursor: pointer;
41
+ border-radius: 0;
42
+ }
43
+ .byok-actions button:disabled { opacity: 0.4; cursor: not-allowed; }
44
+ .byok-disclaimer {
45
+ margin: 0;
46
+ padding-top: 1rem;
47
+ border-top: var(--border-default) solid var(--color-border);
48
+ color: var(--color-muted);
49
+ font-size: 0.8em;
50
+ }