Skip to content

Commit a60aae5

Browse files
StewAlexander-comKarim13014claude
authored
docs: add visual walkthrough to README with screenshots (#11)
Add a "A quick look" section to the README showing six screenshots of the UI: home, lesson browser, lesson view, code lab Run output, Evaluate feedback, and the floating tutor chat. Visuals over prose, short captions, 2x2 table layout. Screenshots are committed under docs/assets/screenshots/ and are generated by a new scripts/capture-screenshots.js Playwright script that serves the frontend and mocks /api/* with deterministic fixtures, so they can be regenerated without Ollama installed. A README in the screenshots folder documents the fixtures and the regenerate command. Co-authored-by: Claude Code <claude-code@anthropic.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ffd931e commit a60aae5

9 files changed

Lines changed: 330 additions & 0 deletions

File tree

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,63 @@ laptop.
4141

4242
---
4343

44+
## A quick look
45+
46+
A 30-second tour of the UI, lab, and tutor. Click any image to enlarge.
47+
48+
<table>
49+
<tr>
50+
<td width="50%" align="center">
51+
<a href="docs/assets/screenshots/01-home.png">
52+
<img src="docs/assets/screenshots/01-home.png" alt="Landing page with two learning paths: 'I'm new to Python' and 'I need a quick reference'." />
53+
</a>
54+
<sub><b>Land.</b> Two paths — beginner or quick reference. The "Ask tutor" button is always one tap away.</sub>
55+
</td>
56+
<td width="50%" align="center">
57+
<a href="docs/assets/screenshots/02-lesson-browser.png">
58+
<img src="docs/assets/screenshots/02-lesson-browser.png" alt="Beginner-path browser showing 46 sections starting with Variables &amp; Types, Numbers &amp; Math, Strings." />
59+
</a>
60+
<sub><b>Browse.</b> 46 sections, filterable, grouped by theme. Read in order or jump straight to a topic.</sub>
61+
</td>
62+
</tr>
63+
<tr>
64+
<td width="50%" align="center">
65+
<a href="docs/assets/screenshots/03-section-view.png">
66+
<img src="docs/assets/screenshots/03-section-view.png" alt="Variables &amp; Types lesson in Teaching mode, opened to the 'Big picture' explainer." />
67+
</a>
68+
<sub><b>Read.</b> Each section explains the <i>why</i> first, then the syntax. Switch between Teaching and Quick reference modes.</sub>
69+
</td>
70+
<td width="50%" align="center">
71+
<a href="docs/assets/screenshots/04-code-lab-run.png">
72+
<img src="docs/assets/screenshots/04-code-lab-run.png" alt="Inline code lab with a small Python program, the Run button, and a green 'Ran cleanly' stdout panel." />
73+
</a>
74+
<sub><b>Run.</b> Edit the snippet, press <b>Run</b>, see real stdout/stderr and exit code — actually executed, not faked.</sub>
75+
</td>
76+
</tr>
77+
<tr>
78+
<td width="50%" align="center">
79+
<a href="docs/assets/screenshots/05-evaluate-feedback.png">
80+
<img src="docs/assets/screenshots/05-evaluate-feedback.png" alt="Tutor evaluation: 'On track' verdict, prose feedback, a Next step, and official Python docs references." />
81+
</a>
82+
<sub><b>Evaluate.</b> The tutor sees your code <i>and</i> what it actually printed, gives a verdict, a next step, and links to official docs.</sub>
83+
</td>
84+
<td width="50%" align="center">
85+
<a href="docs/assets/screenshots/06-tutor-chat.png">
86+
<img src="docs/assets/screenshots/06-tutor-chat.png" alt="Floating chat panel mid-conversation about why Python variables are not typed, with a small code example in the reply." />
87+
</a>
88+
<sub><b>Ask.</b> A floating chat panel for free-form questions — your code and lesson context come along for the ride.</sub>
89+
</td>
90+
</tr>
91+
</table>
92+
93+
> The screenshots above are produced by
94+
> [`scripts/capture-screenshots.js`](scripts/capture-screenshots.js) with
95+
> deterministic UI fixtures so they stay reproducible without Ollama running.
96+
> Real model output will read differently — for the look of the UI, they're
97+
> faithful. See [`docs/assets/screenshots/README.md`](docs/assets/screenshots/README.md).
98+
99+
---
100+
44101
## How it works
45102

46103
```mermaid
409 KB
Loading
351 KB
Loading
454 KB
Loading
314 KB
Loading
402 KB
Loading
476 KB
Loading

docs/assets/screenshots/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Walkthrough screenshots
2+
3+
These images are linked from the project [README](../../../README.md) to give
4+
visitors a quick visual tour of the Python Tutor.
5+
6+
| File | What it shows |
7+
| ---------------------------- | -------------------------------------------------------------- |
8+
| `01-home.png` | Landing page — two learning paths and the "Ask tutor" FAB. |
9+
| `02-lesson-browser.png` | The 46-section beginner browser with search. |
10+
| `03-section-view.png` | A lesson opened in the **Teaching** reading mode. |
11+
| `04-code-lab-run.png` | The inline code lab after pressing **Run** (stdout panel). |
12+
| `05-evaluate-feedback.png` | Tutor evaluation: assessment, feedback, next step, references. |
13+
| `06-tutor-chat.png` | Floating chat panel mid-conversation. |
14+
15+
## How they're generated
16+
17+
The shots are captured by [`scripts/capture-screenshots.js`](../../../scripts/capture-screenshots.js)
18+
using Playwright. The script serves `frontend/` on a local port and **mocks**
19+
`/api/health`, `/api/run`, `/api/evaluate`, and `/api/chat` so the UI renders
20+
its happy-path states without requiring Ollama to be installed or running.
21+
22+
The mocked model responses are **deterministic fixtures** chosen to illustrate
23+
the UI — they are *not* real Gemma output. If you want screenshots of real
24+
model output, start the backend (`./run.sh`) and capture them manually.
25+
26+
To regenerate:
27+
28+
```bash
29+
npm i --no-save playwright
30+
npx playwright install chromium
31+
node scripts/capture-screenshots.js
32+
```

scripts/capture-screenshots.js

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
#!/usr/bin/env node
2+
/**
3+
* scripts/capture-screenshots.js
4+
*
5+
* Re-generates the README walkthrough screenshots under
6+
* docs/assets/screenshots/. Run from the repo root:
7+
*
8+
* npm i --no-save playwright # if not already
9+
* npx playwright install chromium # one-time browser download
10+
* node scripts/capture-screenshots.js
11+
*
12+
* The script serves the static frontend on a local port and mocks
13+
* /api/health, /api/run, /api/evaluate, /api/chat so the UI shows
14+
* realistic states without requiring Ollama to be running. The
15+
* mocked responses are deterministic and do not represent real
16+
* model output — they exist purely to make the UI screenshots
17+
* reproducible. If you want screenshots of *real* model output,
18+
* start the backend (./run.sh) and point a browser at it manually.
19+
*/
20+
'use strict';
21+
22+
const path = require('path');
23+
const http = require('http');
24+
const fs = require('fs');
25+
26+
let chromium;
27+
try {
28+
({ chromium } = require('playwright'));
29+
} catch (_) {
30+
console.error('playwright is not installed. Run: npm i --no-save playwright && npx playwright install chromium');
31+
process.exit(1);
32+
}
33+
34+
const REPO = path.resolve(__dirname, '..');
35+
const FRONTEND = path.join(REPO, 'frontend');
36+
const OUT = path.join(REPO, 'docs/assets/screenshots');
37+
38+
const MIME = {
39+
'.html': 'text/html; charset=utf-8',
40+
'.js': 'application/javascript; charset=utf-8',
41+
'.css': 'text/css; charset=utf-8',
42+
'.json': 'application/json; charset=utf-8',
43+
'.svg': 'image/svg+xml',
44+
'.png': 'image/png',
45+
'.ico': 'image/x-icon',
46+
};
47+
48+
function serveStatic(root, port) {
49+
const server = http.createServer((req, res) => {
50+
let urlPath = req.url.split('?')[0];
51+
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
52+
const fp = path.normalize(path.join(root, urlPath));
53+
if (!fp.startsWith(root)) { res.statusCode = 403; return res.end('forbidden'); }
54+
fs.stat(fp, (err, stat) => {
55+
if (err || !stat.isFile()) { res.statusCode = 404; return res.end('not found'); }
56+
const ext = path.extname(fp).toLowerCase();
57+
res.setHeader('content-type', MIME[ext] || 'application/octet-stream');
58+
res.setHeader('cache-control', 'no-store');
59+
fs.createReadStream(fp).pipe(res);
60+
});
61+
});
62+
return new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve(server)));
63+
}
64+
65+
function mockApi(route) {
66+
const url = route.request().url();
67+
const u = new URL(url);
68+
if (u.pathname === '/api/health') {
69+
return route.fulfill({
70+
status: 200, contentType: 'application/json',
71+
body: JSON.stringify({
72+
ok: true,
73+
ollama: { ok: true, model: 'gemma3:4b', host: 'http://localhost:11434' },
74+
version: '0.1.0',
75+
}),
76+
});
77+
}
78+
if (u.pathname === '/api/run') {
79+
return route.fulfill({
80+
status: 200, contentType: 'application/json',
81+
body: JSON.stringify({
82+
stdout: "Hello, tutor!\nx = 42\ntype(x) = <class 'int'>\n",
83+
stderr: '', exit_code: 0, duration_ms: 47, timed_out: false, truncated: false,
84+
}),
85+
});
86+
}
87+
if (u.pathname === '/api/evaluate') {
88+
return route.fulfill({
89+
status: 200, contentType: 'application/json',
90+
body: JSON.stringify({
91+
assessment: 'on_track',
92+
feedback:
93+
"Nice — you read the value into `x`, printed it, and asked Python for its type. " +
94+
"That's exactly the variables-and-types loop in miniature.\n\n" +
95+
"One small nudge: try assigning a *different* type to the same name (`x = \"hello\"`) " +
96+
"and re-print `type(x)`. Notice how the *name* doesn't change type, the *object* does — " +
97+
"this is the heart of Python's dynamic typing.",
98+
next_step: "Re-bind `x` to a string, then to a list, and print `type(x)` each time.",
99+
model: 'gemma3:4b',
100+
docs: {
101+
online: true, online_ok: true,
102+
references: [
103+
{ url: 'https://docs.python.org/3/library/stdtypes.html', label: 'Built-in types' },
104+
{ url: 'https://docs.python.org/3/library/functions.html#type', label: 'type()' },
105+
],
106+
},
107+
}),
108+
});
109+
}
110+
if (u.pathname === '/api/chat') {
111+
return route.fulfill({
112+
status: 200, contentType: 'application/json',
113+
body: JSON.stringify({
114+
reply:
115+
"In Python every value is an *object*, and each object knows its own type. " +
116+
"Names are just labels you stick on objects.",
117+
model: 'gemma3:4b',
118+
}),
119+
});
120+
}
121+
return route.continue();
122+
}
123+
124+
async function shot(page, name) {
125+
const file = path.join(OUT, name);
126+
await page.screenshot({ path: file, fullPage: false });
127+
console.log('wrote', path.relative(REPO, file));
128+
}
129+
130+
async function main() {
131+
fs.mkdirSync(OUT, { recursive: true });
132+
const port = 8773;
133+
const server = await serveStatic(FRONTEND, port);
134+
const base = `http://127.0.0.1:${port}/`;
135+
136+
const browser = await chromium.launch();
137+
const ctx = await browser.newContext({
138+
viewport: { width: 1280, height: 800 },
139+
deviceScaleFactor: 2,
140+
colorScheme: 'dark',
141+
});
142+
await ctx.route('**/sw.js', (r) => r.fulfill({ status: 404, body: '' }));
143+
await ctx.route('**/api/**', mockApi);
144+
const page = await ctx.newPage();
145+
146+
// 01 — home
147+
await page.goto(base, { waitUntil: 'networkidle' });
148+
await page.waitForSelector('.hero');
149+
await page.waitForTimeout(400);
150+
await shot(page, '01-home.png');
151+
152+
// 02 — lesson browser
153+
await page.goto(base + '#/beginner', { waitUntil: 'networkidle' });
154+
await page.waitForSelector('#view-browser:not([hidden])');
155+
await page.waitForTimeout(500);
156+
await shot(page, '02-lesson-browser.png');
157+
158+
// 03 — section view
159+
await page.goto(base, { waitUntil: 'networkidle' });
160+
const sectionKey = await page.evaluate(async () => {
161+
const res = await fetch('/content/sections.json');
162+
const d = await res.json();
163+
return d.sections[0].key;
164+
});
165+
await page.goto(`${base}#/s/${sectionKey}`, { waitUntil: 'networkidle' });
166+
await page.waitForSelector('#view-section:not([hidden])');
167+
await page.waitForSelector('.codelab');
168+
await page.evaluate(() => window.scrollTo(0, 0));
169+
await page.waitForTimeout(400);
170+
await shot(page, '03-section-view.png');
171+
172+
// 04 — code lab run
173+
await page.evaluate(() => {
174+
const ta = document.querySelector('#codelabEditor');
175+
if (ta) {
176+
ta.value = 'x = 42\nprint("Hello, tutor!")\nprint("x =", x)\nprint("type(x) =", type(x))\n';
177+
ta.dispatchEvent(new Event('input', { bubbles: true }));
178+
}
179+
});
180+
await page.click('#codelabRun');
181+
await page.waitForSelector('.codelab__runline');
182+
await page.evaluate(() => {
183+
const el = document.querySelector('.codelab');
184+
if (el) el.scrollIntoView({ block: 'start' });
185+
window.scrollBy(0, -80);
186+
});
187+
await page.waitForTimeout(400);
188+
await shot(page, '04-code-lab-run.png');
189+
190+
// 05 — evaluate feedback
191+
await page.fill('#codelabQuestion', 'Why does type(x) change when I reassign x?');
192+
await page.click('#codelabEval');
193+
await page.waitForSelector('.codelab__feedback');
194+
await page.evaluate(() => {
195+
const el = document.querySelector('.codelab__feedback');
196+
if (el) el.scrollIntoView({ block: 'start' });
197+
window.scrollBy(0, -80);
198+
});
199+
await page.waitForTimeout(400);
200+
await shot(page, '05-evaluate-feedback.png');
201+
202+
// 06 — floating chat panel
203+
await page.evaluate(() => window.scrollTo(0, 0));
204+
await page.waitForTimeout(200);
205+
await page.click('#tutorChatFab');
206+
await page.waitForSelector('#tutorChatPanel:not([hidden])');
207+
await page.evaluate(async () => {
208+
try {
209+
const r = await fetch('/api/health');
210+
const d = await r.json();
211+
const sub = document.getElementById('tutorChatSub');
212+
const banner = document.getElementById('tutorChatBanner');
213+
if (sub) sub.textContent = `Connected · ${d.ollama?.model || 'local model'} · /api ready`;
214+
if (banner) { banner.hidden = true; banner.textContent = ''; }
215+
} catch (_) {}
216+
});
217+
await page.evaluate(() => {
218+
const log = document.getElementById('tutorChatLog');
219+
if (!log) return;
220+
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
221+
const codeBlock = `x = 42 # x labels an int\nx = "hello" # now x labels a str — the int is GC'd\nprint(type(x))`;
222+
log.innerHTML = `
223+
<div class="tutor-chat__msg tutor-chat__msg--user">
224+
<p>In Python, what does it mean that variables are not typed?</p>
225+
</div>
226+
<div class="tutor-chat__msg tutor-chat__msg--assistant">
227+
<p>In Python every value is an <em>object</em>, and each object knows its own type. Names (what other languages call variables) are just labels you stick on objects.</p>
228+
<pre class="tutor-chat__code"><code>${esc(codeBlock)}</code></pre>
229+
<p>So <code>x</code> itself isn't typed — the <em>object it points to</em> is. That's why <code>type(x)</code> can change between lines without any cast.</p>
230+
</div>
231+
`;
232+
log.scrollTop = log.scrollHeight;
233+
});
234+
await page.waitForTimeout(400);
235+
await shot(page, '06-tutor-chat.png');
236+
237+
await browser.close();
238+
server.close();
239+
}
240+
241+
main().catch((e) => { console.error(e); process.exit(1); });

0 commit comments

Comments
 (0)