How the Operating Systems Visual Lab is put together, and why. This document is for anyone reading the source, extending the lab, or teaching from it and wanting to know what is real underneath.
The shape in one sentence: content, algorithms and presentation are three independent layers, and the simulation engines in the middle are pure functions with no UI in them.
src/
app/ application shell — layout, theming, hash routing, error boundary
components/ presentation primitives — buttons, cards, badges, tables, callouts
content/ educational content — topics, labs, algorithms, examples, lessons, challenges
engine/ simulation engines — pure TypeScript, zero React imports
features/ feature visualisations — one folder per domain area
hooks/ reusable React hooks — theme, routing, playback, storage, progress
pages/ route-level screens
styles/ design tokens and layered CSS
types/ the shared domain model
tests/ unit tests for engines, utilities, content and models
public/ static files copied as-is (favicon, 404 page, .nojekyll)
features → engine / content / components
pages → features / content / components
engine → types only
content → types only
- Engines never import React. That is what makes them unit-testable without a DOM.
- Components never import content. A button does not know what CPU scheduling is.
- Content never imports a visualisation. A lab describes itself in data; the UI decides how to draw it.
The rule is enforced socially (review) and practically (types): content stores ids and plain data, so there is nothing for it to call.
| Type | What it is |
|---|---|
Topic |
One of the eight learning areas: title, tagline, difficulty, objectives, prerequisites and its labs |
Lab |
One interactive activity: summary, objectives, difficulty, status, estimated time, algorithm ids, examples |
Algorithm |
Reference metadata for an algorithm: name, summary, properties, tunable parameters, teaching pseudocode |
EducationalExample |
A worked example with a ready-to-load input and an expected outcome |
SimulationStep<TData> |
One recorded moment: label, description, explanation, clock, highlights, the snapshot to draw, and optional metrics |
SimulationState<TData> |
The playback state: status, speed, current step index, the step list, parameters, error |
GuidedLesson / LessonStage |
The six-stage lesson and its discriminated stage union |
Challenge (content) |
An open practice task: prompt, scenario, requirements, hints, expected outcome |
Challenge (engine) |
A graded question: kind, expected answer, explanation, related lab |
ValidationResult |
{ valid: true } or { valid: false, issues: [{ path, message }] } |
SimulationStep is the keystone: it is both what the UI draws and what the text says about it. A step
cannot be rendered without its explanation, which is why every lab can say why something happened at
every point in the run.
interface SimulationEngine<TInput, TData> {
readonly id: Id;
readonly algorithmId: Id;
readonly labId: Id;
readonly name: string;
readonly description: string;
validate(input: TInput): ValidationResult;
run(input: TInput, config?: SimulationConfig): SimulationRun<TData>;
}
interface SimulationRun<TData> {
readonly algorithmId: Id;
readonly labId: Id;
readonly input: Record<string, number | string | boolean>;
readonly steps: readonly SimulationStep<TData>[];
readonly state: SimulationState<TData>;
readonly summary: Readonly<Record<string, number>>;
}Engine properties that the rest of the app depends on:
- Pure. Same input, same steps, every time. No timers, no randomness, no I/O.
- Validating.
validate()rejects malformed input and reports the exact field path, so the UI can point at the offending control instead of failing later. - Explaining. Every step carries a sentence that says why it happened.
- Summarising.
summaryholds the run's numbers, which is what the tests assert against and what the challenge builders read to derive expected answers.
Because engines are pure and untyped-when-it-comes-to-React, they are tested directly, with no DOM and no mocking. That is where most of the test suite's value sits.
src/engine/registry.ts holds descriptors, not run functions:
interface EngineDescriptor {
readonly engineId, algorithmId, labId;
readonly name, description: string;
readonly implemented: boolean; // false until the engine can produce steps
}Storing descriptors rather than implementations keeps discovery type-safe with no any and no circular
imports: the UI can show "available / planned" badges without importing every engine, while the feature
that actually renders a lab imports the one engine it needs.
1. Input the learner edits a workload (processes, references, blocks, requests)
2. Validate the engine rejects malformed input and names the exact field
3. Simulate a pure function returns typed steps plus a numeric summary
4. Play useSimulation moves through the step list and owns timing only
5. Render the feature draws the current snapshot (SVG chart, table, diagram)
6. Explain every step ships the sentence that says why it happened
Steps 1–3 are engine work; 4 is the only stateful, time-dependent part; 5–6 are presentation. Playback speed changes how fast you watch the run and never what the run is.
useSimulation owns: the current step index, play/pause, speed, step forward/backward, reset, and
cleanup of any interval. Every lab's controls (SimulationControls, SpeedControl, Timeline) are
thin wrappers over it, which is why all nineteen labs behave identically.
src/content/ holds topics, labs, algorithms, examples, lessons, challenges, plus
index.ts with lookups (getTopic, getLab, getAlgorithms, getLesson, ...) and aggregates
(getContentStats, searchContent).
Everything cross-references by id, never by object identity, so content is data rather than code.
A test suite (tests/content/) asserts that ids are unique, that every cross-reference resolves, and
that every available lab example carries an input — so a typo in a lab's algorithmIds fails the build.
src/engine/challenge/ turns the simulators into a graded quiz bank:
types.ts ChallengeKind, ChallengeAnswer (discriminated), Challenge, ChallengeBankInfo
builders/ one file per area; each question's expected answer is computed by running the engine
bank.ts CHALLENGE_BANKS (20) + CHALLENGES (110), ordered by bank, with filters and bank counts
grade.ts pure grader for the five answer kinds
session.ts per-session scoring and progress
validate.ts structural validation of the whole catalogue
The five kinds are multiple-choice, numerical, prediction, sequence and decision, and each maps
to exactly one answer shape:
| Kind | Answer | Comparison |
|---|---|---|
multiple-choice |
{ kind: 'choice', optionId } |
Exact option id |
numerical |
{ kind: 'number', value, tolerance } |
Within tolerance |
prediction |
{ kind: 'choice', optionId } |
Exact option id |
sequence |
{ kind: 'sequence', order } |
Exact order |
decision |
{ kind: 'decision', optionId } |
Exact option id |
Because the answers come from the same engines the labs use, a quiz question can never disagree with a simulation. Validation also asserts that every question's referenced lab resolves, which is how the "reproduce it in the lab" links stay honest.
GuidedLesson is one object per area with a fixed six-stage shape, and LessonStage is a discriminated
union (concept | visualization | experiment | walkthrough | challenge | reflection). Each variant
carries its own content — the experiment carries setup and observations, the challenge stage carries
bankIds — so the lesson view is a single map over stages with no per-topic branching.
Progress is pure data: src/utils/learning.ts holds the functions (percentages, next incomplete stage,
immutable stage toggles) and src/hooks/useLearningProgress.ts persists them. The functions are tested
directly as pure utilities.
src/utils/router.ts is a small hash router, and hash routing is a deliberate choice rather than a
limitation:
- A static host like GitHub Pages cannot rewrite unknown paths to
index.html, so/labs/lab-fcfswould 404 on refresh. Everything after#is resolved on the client instead. - Deep links therefore survive a refresh anywhere the bundle is served — the deployed GitHub Pages base path, a custom domain, or a local preview.
- Labs accept query parameters (for example
#/labs/lab-round-robin?algorithm=round-robin&quantum=2), so a specific configuration can be shared as a link.
parseRoute returns a discriminated Route; hrefFor(path) builds the #/... href; isRouteActive
decides which navigation item is current. There is no router dependency.
Styling is hand-written CSS with custom-property tokens (src/styles/tokens.css) — no CSS framework, no
CSS-in-JS. Layers are imported in a deliberate order in src/styles/index.css:
tokens → base → layout → components → lab → process → synchronization → deadlock
→ memory → paging → replacement → disk → fs → features → challenges → quiz → learning
Later files may build on earlier ones but not vice versa. Each lab area owns one stylesheet, so a change to the disk lab cannot break the paging lab.
What the design system guarantees across every screen:
- One type scale, one spacing ramp, one radius/shadow set, one focus ring, one set of motion easings.
- A fluid type scale and a responsive grid; layouts are desktop-first with tablet and phone breakpoints.
- Light, dark and system themes driven entirely by token overrides on
data-theme. - A compact top navigation, a collapsible sidebar (persisted in
localStorage), and breadcrumbs on every deep route. - One
SimulationControlscluster shared by all labs, so the controls are learned once.
Implemented: semantic landmarks and a skip link, one h1 per page, aria-current on active navigation,
labelled form controls, keyboard-operable playback including a step scrubber, a visible focus ring
everywhere, prefers-reduced-motion support that effectively disables animation and transitions, a
prefers-contrast: more mode that strengthens borders and text, and text equivalents for charts via the
metrics panel, event log and captions.
Known limitations, stated honestly:
- SVG charts are described in text and paired with data tables or an event log, but they are not yet a fully equivalent screen-reader experience for every visualisation.
- The wait-for graph, memory map and file-system block map rely heavily on position and colour; the accompanying panels are the accessible path to the same information, not a true substitute.
Nothing is on a server. Persistent state lives in localStorage:
| Key | Holds |
|---|---|
osvl-learning-progress |
Completed guided-lesson stages |
osvl-quiz-mastered |
Quiz questions answered correctly |
osvl.theme |
Light / dark / system |
osvl-sidebar-collapsed |
Sidebar rail state |
All reads are guarded for a non-browser environment, and the theme is applied by an inline script before
first paint so there is no flash of the wrong theme. useLocalStorage JSON-serialises values and is safe
when storage is unavailable (private windows, disabled storage).
| Concern | Choice |
|---|---|
| Bundler | Vite, base: '/Operating-Systems-Visual-Lab/' matching the GitHub Pages project path |
| Language | TypeScript strict, plus noUncheckedIndexedAccess and verbatimModuleSyntax |
| Lint | ESLint flat config, with react-hooks and react-refresh; non-component exports live in their own files |
| Tests | Vitest, run in tests/ against the engines, content and utilities |
| Scripts | dev, build, preview, typecheck, lint, test, check |
npm run check runs typecheck → lint → tests → production build, which is exactly what the deployment
workflow runs. The test suite currently holds 635 tests across 36 files, all passing.
The app ships no image assets: the home page's CPU timeline, every Gantt chart, memory map, track and graph is drawn with CSS or SVG at runtime. The only runtime dependencies are React and React DOM.
The production bundle is currently around 900 kB minified (~260 kB gzipped) and trips Rollup's default chunk-size warning. That is the clearest next optimisation: lazy-loading the nineteen simulator workstations behind route-level code splitting would shrink the initial bundle substantially, since a learner opening the home page does not need the file-system simulator yet. It is listed as future work rather than silently ignored.
The layering is what makes extension cheap. In rough order of effort:
- Add an algorithm reference — append an
Algorithmtosrc/content/algorithms.ts. Statusplannedis honest and renders without a simulator. - Add a simulator to an existing lab — implement
SimulationEnginefor the new algorithm, register a descriptor, and add the workstation wiring. Existing labs show the pattern. - Add a lab — add a
Labtosrc/content/labs.ts(objectives, difficulty, algorithm ids), then a workstation and itsSIMULATORSentry. - Add a challenge bank — create a builder under
src/engine/challenge/builders/, register the bank, and derive every expected answer from an engine run. - Add a guided lesson stage — extend the
LessonStageunion and the lesson view; the stage types are discriminated, so the compiler lists what you must handle.
Step-by-step instructions, including the tests each change should come with, are in contributing.md.
A worked reference exists for each layer: src/engine/scheduling/fcfs.ts is the reference engine, and
src/features/scheduling/SchedulingWorkstation.tsx is the reference visualisation. Together they show
the whole pattern — a lab component that contains no algorithm logic at all, only composition.