Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,19 @@ cd ai-engineering-from-scratch
python phases/01-math-foundations/01-linear-algebra-intuition/code/vectors.py
```

**Option B.1 — run the course site locally (offline + Code Lab).**

```bash
cd ai-engineering-from-scratch
node site/local-server.mjs
```

Then open `http://127.0.0.1:5174/index.html`.

For the student dashboard UI, open `http://127.0.0.1:5174/dashboard.html`.

Frontend docs: `site/FRONTEND.md`.

**Option C — find your level *(recommended)*.** Skip ahead intelligently. Inside Claude, Cursor, Codex, OpenClaw, Hermes, or any agent with SkillKit installed:

```bash
Expand Down
70 changes: 70 additions & 0 deletions site/FRONTEND.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Frontend (Local Student Dashboard)

This repo ships a local-first student UI (Scaler-style):

- `dashboard.html` → phase cards + global progress
- `phase.html` → per-phase lesson tiles + sidebar + progress
- `lesson.html` → lesson reader + sticky progress bar + Code Lab (run/test locally)

All course content is read directly from this repo via a local server (no GitHub/raw content fetch).

## Quick start

From the repo root:

```bash
cd ai-engineering-from-scratch
node site/local-server.mjs
```
Comment on lines +13 to +18
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Quick-start command conflicts with stated working directory.

Line 13 says “From the repo root,” but Line 16 then cds into the repo again, which fails for users already at root.

Suggested doc fix
 ## Quick start
 
 From the repo root:
 
 ```bash
-cd ai-engineering-from-scratch
 node site/local-server.mjs
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
From the repo root:

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@site/FRONTEND.md` around lines 13 - 18, The doc snippet contradicts its
preamble: it says "From the repo root" but then runs "cd
ai-engineering-from-scratch", which will fail for users already at root; in the
code block that contains "cd ai-engineering-from-scratch" and "node
site/local-server.mjs" remove the "cd ai-engineering-from-scratch" line (or
alternatively change the preamble to "From the parent directory") so the
sequence is consistent and simply runs "node site/local-server.mjs" from the
repo root (refer to the command lines "cd ai-engineering-from-scratch" and "node
site/local-server.mjs" in the snippet).


Open:

- Dashboard: `http://127.0.0.1:5174/dashboard.html`
- About/home: `http://127.0.0.1:5174/index.html`

## How it works

The server lives at:

- `site/local-server.mjs`

It provides:

- Static files: `GET /` serves files from `site/`
- Local content: `GET /content/<repo-path>` serves files from the course repo (e.g. lesson markdown at `/content/phases/.../docs/en.md`)
- Directory listing: `GET /api/list?path=<repo-path>` returns local directory entries (used by lesson panels)
- Lesson meta: `GET /api/lesson-meta?path=phases/.../...` returns metadata and resolved runnable files
- Runner APIs:
- `POST /api/run` executes `python3` for a selected file
- `POST /api/test` runs `pytest` if installed, else `unittest` discovery
- Rubrics/AI hooks:
- `GET /api/rubric?path=...` returns `rubric.json` if present, else a generated rubric
- `POST /api/review` currently returns `501` (stub) until an LLM provider is wired up

## Code Lab

In `lesson.html`, the **Code Lab** panel:

- auto-detects runnable artifacts via `/api/lesson-meta` (uses `catalog.json` + filesystem fallback)
- fetches the runnable file content from `/content/...`
- runs code via `/api/run`
- runs tests via `/api/test`

Notes:

- `Run` currently runs Python (`python3 -I ...`). Other languages are listed (Julia/TS/etc.) but not executed yet.
- Tests only run if the lesson directory includes a `tests/` folder or `test_*.py` files.

## Progress tracking (local only)

Progress is stored in browser `localStorage` by:

- `site/progress.js`

Resetting progress clears local completion + quiz answers for this browser only.

## Next steps (planned)

- Add non-Python runners (Node/TS, Julia, Rust) behind the same API.
- Implement `/api/review` with a configurable LLM provider and per-lesson rubrics.

11 changes: 5 additions & 6 deletions site/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,12 @@
var lessons = PHASES[i].lessons;
totalLessons += lessons.length;
for (var j = 0; j < lessons.length; j++) {
var staticDone = lessons[j].status === 'complete';
var userDone = false;
if (hasProgress && lessons[j].url) {
var lp = window.AIFSProgress.extractPath(lessons[j].url);
if (lp) userDone = window.AIFSProgress.isLessonComplete(lp);
}
if (staticDone || userDone) completeLessons++;
if (userDone) completeLessons++;
}
}
var completePhases = 0;
Expand Down Expand Up @@ -113,13 +112,12 @@
var total = p.lessons.length;
var done = 0;
for (var j = 0; j < p.lessons.length; j++) {
var staticDone = p.lessons[j].status === 'complete';
var userDone = false;
if (hasProgress && p.lessons[j].url) {
var lp = window.AIFSProgress.extractPath(p.lessons[j].url);
if (lp) userDone = window.AIFSProgress.isLessonComplete(lp);
}
if (staticDone || userDone) done++;
if (userDone) done++;
}
var statusClass = p.status.replace(/ /g, '-');
var roman = toRoman(p.id);
Expand Down Expand Up @@ -218,11 +216,12 @@

var statusClass = l.status.replace(/ /g, '-');
if (userComplete) statusClass = 'complete';
else statusClass = 'planned';

html += '<div class="modal-lesson' + (userComplete ? ' user-done' : '') + '">';
html += '<span class="modal-lesson-status ' + statusClass + '"' + (userComplete ? ' title="You completed this lesson"' : '') + '></span>';
if (l.url) {
html += '<a href="' + l.url + '" target="_blank" rel="noopener">' + escapeHtml(l.name) + '</a>';
if (lessonPath) {
html += '<a href="lesson.html?path=' + encodeURIComponent(lessonPath) + '">' + escapeHtml(l.name) + '</a>';
} else {
html += '<a>' + escapeHtml(l.name) + '</a>';
}
Expand Down
61 changes: 61 additions & 0 deletions site/dashboard.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - AI Engineering from Scratch</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' fill='%23fafaf5'/><rect x='2' y='2' width='28' height='28' fill='none' stroke='%233553ff' stroke-width='1.2'/><text x='6' y='22' font-size='14' font-family='monospace' fill='%233553ff'>AI</text></svg>">
<meta name="description" content="Local-first student dashboard for AI Engineering from Scratch. Browse phases, track progress, and continue where you left off.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&family=Source+Serif+4:ital,opsz,wght@0,8..60,400..700;1,8..60,400..700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=20260508a">
</head>
<body>
<header class="site-header">
<div class="header-inner">
<a href="dashboard.html" class="logo">
<span class="logo-icon" aria-hidden="true"></span> AI / FROM SCRATCH
</a>
<nav class="header-nav">
<a href="dashboard.html" class="active">Dashboard</a>
<a href="catalog.html">Catalog</a>
<a href="glossary.html">Glossary</a>
<a href="index.html">About</a>
</nav>
<div class="header-controls">
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme" type="button">
<span id="themeIcon" aria-hidden="true"></span>
</button>
</div>
</div>
</header>

<main class="dash">
<div class="container">
<div class="dash-top">
<div>
<div class="dash-eyebrow">Student dashboard</div>
<h1 class="dash-title">Your phases</h1>
<p class="dash-sub">Pick a phase, track progress, and keep shipping.</p>
</div>
<div class="dash-cta">
<a class="btn btn-primary" id="continueBtn" href="phase.html?phase=1">Continue</a>
<button class="btn" id="resetBtn" type="button" title="Clear local progress">Reset progress</button>
</div>
</div>

<div class="dash-kpis" id="dashKpis"></div>

<div class="phase-grid" id="phaseGrid"></div>
</div>
</main>

<script src="data.js?v=20260508a"></script>
<script src="progress.js?v=20260508a"></script>
<script src="header.js?v=20260508a" defer></script>
<script src="cmdpalette.js?v=20260508a" defer></script>
<script src="dashboard.js?v=20260508a"></script>
</body>
</html>

135 changes: 135 additions & 0 deletions site/dashboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
(function () {
var root = document.documentElement;
var stored = localStorage.getItem('theme');
if (stored) {
root.setAttribute('data-theme', stored);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
root.setAttribute('data-theme', 'dark');
} else {
root.setAttribute('data-theme', 'light');
}

document.addEventListener('DOMContentLoaded', function () {
initThemeToggle();
render();
});

function initThemeToggle() {
var btn = document.getElementById('themeToggle');
var icon = document.getElementById('themeIcon');
if (!btn || !icon) return;

function paint() {
var theme = root.getAttribute('data-theme');
icon.textContent = theme === 'light' ? 'N' : 'D';
}

btn.addEventListener('click', function () {
var current = root.getAttribute('data-theme');
var next = current === 'light' ? 'dark' : 'light';
root.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
paint();
});
paint();
}

function extractLessonPath(url) {
var m = url ? url.match(/(phases\/[^/]+\/[^/]+)\/?$/) : null;
return m ? m[1] : '';
}

function countPhaseDone(phase) {
var total = phase.lessons.length;
var done = 0;
var hasProgress = !!window.AIFSProgress;
for (var i = 0; i < phase.lessons.length; i++) {
var l = phase.lessons[i];
var userDone = false;
if (hasProgress && l.url) {
var lp = extractLessonPath(l.url);
if (lp) userDone = window.AIFSProgress.isLessonComplete(lp);
}
if (userDone) done++;
}
return { done: done, total: total };
}

function findContinueTarget(phases) {
var hasProgress = !!window.AIFSProgress;
for (var i = 0; i < phases.length; i++) {
var p = phases[i];
for (var j = 0; j < p.lessons.length; j++) {
var l = p.lessons[j];
var lp = extractLessonPath(l.url);
if (!lp) continue;
if (!hasProgress) return { phaseId: p.id, lessonPath: lp };
if (!window.AIFSProgress.isLessonComplete(lp)) return { phaseId: p.id, lessonPath: lp };
}
}
return { phaseId: 1, lessonPath: 'phases/01-math-foundations/01-linear-algebra-intuition' };
}

function renderKpis(phases) {
var el = document.getElementById('dashKpis');
if (!el) return;

var totalLessons = 0;
var doneLessons = 0;
for (var i = 0; i < phases.length; i++) {
totalLessons += phases[i].lessons.length;
var c = countPhaseDone(phases[i]);
doneLessons += c.done;
}

var pct = totalLessons ? Math.round((doneLessons / totalLessons) * 100) : 0;
el.innerHTML =
'<div class="kpi-card"><div class="kpi-label">Lessons<\/div><div class="kpi-value">' + doneLessons + ' / ' + totalLessons + '<\/div><div class="kpi-sub">' + pct + '% complete<\/div><\/div>' +
'<div class="kpi-card"><div class="kpi-label">Phases<\/div><div class="kpi-value">' + phases.length + '<\/div><div class="kpi-sub">Browse any time<\/div><\/div>' +
'<div class="kpi-card"><div class="kpi-label">Storage<\/div><div class="kpi-value">Local<\/div><div class="kpi-sub">No account needed<\/div><\/div>';
}

function renderPhaseCards(phases) {
var grid = document.getElementById('phaseGrid');
if (!grid) return;
var html = '';
for (var i = 0; i < phases.length; i++) {
var p = phases[i];
var c = countPhaseDone(p);
var pct = c.total ? Math.round((c.done / c.total) * 100) : 0;
html += '<a class="phase-card" href="phase.html?phase=' + encodeURIComponent(p.id) + '">';
html += '<div class="phase-card-top">';
html += '<div class="phase-card-num">PHASE ' + String(p.id).padStart(2, '0') + '<\/div>';
html += '<div class="phase-card-name">' + escapeHtml(p.name) + '<\/div>';
html += '<div class="phase-card-meta">' + c.done + ' / ' + c.total + ' lessons<\/div>';
html += '<\/div>';
html += '<div class="phase-card-bar"><span style="width:' + pct + '%"><\/span><\/div>';
html += '<div class="phase-card-desc">' + escapeHtml(p.desc || '') + '<\/div>';
html += '<\/a>';
}
grid.innerHTML = html;
}

function render() {
if (typeof PHASES === 'undefined' || !Array.isArray(PHASES)) return;

renderKpis(PHASES);
renderPhaseCards(PHASES);

var target = findContinueTarget(PHASES);
var btn = document.getElementById('continueBtn');
if (btn && target.lessonPath) btn.href = 'lesson.html?path=' + encodeURIComponent(target.lessonPath);

var resetBtn = document.getElementById('resetBtn');
if (resetBtn) {
resetBtn.addEventListener('click', function () {
if (!window.AIFSProgress) return;
var ok = window.confirm('Clear all your local progress (quiz answers and completed lessons)? This cannot be undone.');
if (!ok) return;
window.AIFSProgress.reset();
renderKpis(PHASES);
renderPhaseCards(PHASES);
});
Comment on lines +123 to +132
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset flow leaves the Continue button target stale.

After window.AIFSProgress.reset(), KPIs/cards rerender, but #continueBtn is not recomputed. Re-run full render() (or recompute target) so CTA matches new state.

Suggested fix
-        window.AIFSProgress.reset();
-        renderKpis(PHASES);
-        renderPhaseCards(PHASES);
+        window.AIFSProgress.reset();
+        render();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@site/dashboard.js` around lines 123 - 132, The reset handler currently calls
window.AIFSProgress.reset() then renderKpis(PHASES) and renderPhaseCards(PHASES)
but does not update the Continue CTA target; modify the click handler for
resetBtn to call the full render() (or explicitly recompute/update the Continue
button target) after reset so the `#continueBtn` state/target is recalculated;
update the resetBtn listener that references window.AIFSProgress.reset(),
renderKpis, and renderPhaseCards to invoke render() (or the function that builds
the Continue CTA) as the final step.

}
}
})();
12 changes: 12 additions & 0 deletions site/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@
}

function load() {
// Local-first mode: avoid any network calls.
try {
var host = String(window.location && window.location.hostname || '');
var proto = String(window.location && window.location.protocol || '');
if (proto === 'file:' || host === '127.0.0.1' || host === 'localhost') {
paint(0);
Comment on lines +47 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Local-host detection should include 0.0.0.0 and ::1.

Current guard misses common local dev hosts, so “local-first” can still make network calls in those setups.

Suggested fix
-      if (proto === 'file:' || host === '127.0.0.1' || host === 'localhost') {
+      if (
+        proto === 'file:' ||
+        host === '127.0.0.1' ||
+        host === 'localhost' ||
+        host === '0.0.0.0' ||
+        host === '::1'
+      ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var host = String(window.location && window.location.hostname || '');
var proto = String(window.location && window.location.protocol || '');
if (proto === 'file:' || host === '127.0.0.1' || host === 'localhost') {
paint(0);
var host = String(window.location && window.location.hostname || '');
var proto = String(window.location && window.location.protocol || '');
if (
proto === 'file:' ||
host === '127.0.0.1' ||
host === 'localhost' ||
host === '0.0.0.0' ||
host === '::1'
) {
paint(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@site/header.js` around lines 47 - 50, The local-host detection in header.js
using variables host and proto only checks for '127.0.0.1' and 'localhost' so
add '0.0.0.0' and the IPv6 loopback '::1' to the condition that calls paint(0);
update the if that currently reads (proto === 'file:' || host === '127.0.0.1' ||
host === 'localhost') to also test host === '0.0.0.0' and host === '::1' (and if
your code may encounter bracketed IPv6 addresses, normalize host by trimming
surrounding brackets before comparison) so local-first behavior covers common
local dev hosts.

var els = document.querySelectorAll('.header-github .star-count, #starCount');
for (var i = 0; i < els.length; i++) els[i].textContent = '—';
return;
}
} catch (e) {}

var cached = readCache();
if (cached != null) {
paint(cached);
Expand Down
Loading