# AP CS Curriculum Site — System Reference

**Purpose of this document:** This is the canonical technical reference for Erik Wiessmann's AP CS curriculum website (AP CSA, AP CSP, AP Cybersecurity — Academy at Palumbo). It is written to be handed directly to an AI assistant (e.g. pasted into a new conversation) so that assistant can understand the entire system without needing prior context. It is also the reference Erik uses himself when editing spreadsheets or planning new content.

If you are an AI reading this for the first time: read this whole document before making any changes. The system has real architectural conventions (especially around how widgets are authored via spreadsheet rows) that are easy to violate by guessing.

---

## 1. The Two Visual Themes

This site deliberately uses **two different visual themes** for two different kinds of pages. Do not mix them.

| Theme | Used by | Look |
|---|---|---|
| **Dark / terminal** (`css/core.css`) | `day.html`, `test.html` | Near-black background, JetBrains Mono font, amber (`#E8A33D`) + teal (`#4FD1C5`) accents. This is where students actually do lesson work. |
| **Light / friendly** (self-contained `<style>` in each file) | `index.html`, `policies.html`, `ask-a-question.html`, `guide.html` (this doc's own page) | Soft lavender background (`#F6F4FC`), Fredoka font for headings, warm orange (`#F5A623`) accent, white rounded cards. |

**Rule of thumb:** if a page is where a student clicks in to *do* the day's work, it's dark. If it's a landing/reference/utility page around that work, it's light.

---

## 2. File Structure

```
/ (root)
├── index.html              — the course hub (light theme)
├── day.html                — the actual lesson page, spreadsheet-driven (dark theme)
├── policies.html            — course policies, rules pulled live from spreadsheet (light theme)
├── ask-a-question.html     — student help-queue / quick-questions page (light theme)
├── test.html                — internal widget catalog for testing (dark theme) — NEVER linked from the live site, not for students
├── guide.html                — this documentation page (light theme)
├── SYSTEM_REFERENCE.md      — this file
├── Code.gs                  — Google Apps Script backend (deployed separately as a Web App)
├── js/                       — all widget + shared logic (22 files)
├── css/                      — all widget + shared styles (17 files)
└── labs/                     — a SEPARATE system: standalone lab tools, not spreadsheet-driven. See §9.
```

### `js/` files
`bits-visualizer.js`, `certificate.js`, `cipher-tool.js`, `core.js`, `day-loader.js`, `debug-challenge.js`, `ek-cards.js` (legacy, no longer used by `day.html` — see §7), `firewall-builder.js`, `hero.js`, `instruction.js`, `parsons.js`, `quiz.js`, `risk-matrix.js`, `rsa-tool.js`, `scenario.js`, `sheet-content.js`, `stepper.js`, `story.js`, `student-id.js`, `terminal.js`, `vocab.js`

### `css/` files
Same widget names, one `.css` per widget, plus `core.css` (shared theme + layout for the dark pages). `hero.js` has no separate CSS file — its styles live in `core.css`.

---

## 3. How `day.html` Actually Builds a Page

This is the most important architectural concept in the whole system. **`day.html` has almost no fixed content.** It's a thin shell; `js/day-loader.js` builds the entire page dynamically at load time from spreadsheet data.

### 3.1 The load sequence
1. `day.html` reads `?course=` and `?day=` from the URL (e.g. `day.html?course=apcsa&day=6`).
2. A full-screen loading overlay is shown immediately (inline `<style>` in `day.html`, so it doesn't wait on any external CSS to paint).
3. `buildDayPage()` (in `day-loader.js`) fetches three TSVs in parallel: the merged pacing tab, the merged daily-content tab, and the terminal-steps tab.
4. It finds the one pacing row matching **both** the course and day (see §3.2 — this used to be day-only before the pacing tabs were merged).
5. It builds the hero (title, LO/EK, pills).
6. It builds every widget for that day **in the exact order the rows appear in the spreadsheet** — not a fixed built-in sequence. See §3.3.
7. It builds the certificate, tracking completion of every "trackable" widget on the page.
8. The loading overlay fades out, and a decorative corner-flourish animation (four HUD-style corner brackets, amber/teal) draws itself in.

### 3.2 Pacing row lookup (course + day, not just day)
The pacing tab covers all three courses in one sheet (see §5.1), so day numbers repeat across courses. The lookup in `day-loader.js` is:
```js
const pacingRow = pacingRows.find(r => String(r.day) === String(day) && courseKeyMatches_(r.course, course));
```
`courseKeyMatches_()` normalizes both sides (lowercase, strip all whitespace) before comparing, so `"ap csa"`, `"AP CSA"`, and `"apcsa"` all match the internal course key `apcsa`. This same matching function is duplicated (by design, not an oversight) in `index.html` for its own pacing-guide rendering.

### 3.3 Row-order rendering (the core convention)
For a given course+day, every row in the daily-content tab becomes one widget instance, appended to `#dayContent` **in the same order the rows appear in the sheet**. This means:
- **To reorder widgets on a page, reorder the rows in the spreadsheet.** There is no separate ordering column.
- **The same widget type can appear more than once on the same day**, non-adjacent to each other if you want (e.g. `quiz`, then `story`, then another `quiz`). Each becomes its own independent instance with its own container id (`quiz`, `quiz-2`, `quiz-3`, ...) and its own certificate tracking entry, correctly labeled `(1)`/`(2)`/etc. only when more than one of that type exists that day.
- Widgets that don't title themselves (`terminal`, `risk`, `cipher`, `rsa`, `firewall`, `rule`) get a page-supplied heading section built dynamically around them (see `WRAPPER_TITLES` in `day-loader.js`). Widgets that self-title (`story`, `instruction`, `vocab`, `scenario`, `quiz`, `bits`) just get a bare container.
- If a widget's container ends up empty for any reason (unrecognized widget name, or a standing widget whose URL isn't configured yet), the whole wrapper — heading included — is removed from the page rather than left as an empty box.

### 3.4 The one exception to row order: `after_certificate`
An `instruction` row with `field_d` set to exactly `after_certificate` is pulled out of the normal flow entirely and rendered in a separate, fixed slot **after the certificate**, regardless of where the row sits in the sheet. This is the mechanism for "here's exactly what to do with the rest of class" instructions meant to be the literal last thing a student sees. This flag is checked **only** for `widgetname === 'instruction'` — it's ignored on every other widget type (their own `field_d` usage, if any, is left alone).

### 3.5 If no content exists for a day
If a pacing row exists but there are zero daily-content rows for that course+day, the page shows a "Content Coming Soon" heading instead of a blank page.

If no pacing row exists at all for that course+day, the page shows "No day scheduled here yet."

---

## 4. The Widget Library (16 types)

Every widget is authored as one row in the **daily content** tab: `course | day | widgetname | field_a | field_b | field_c | field_d | notes`. The `widgetname` column decides which widget renders and how the other columns are interpreted — the same 4 generic fields mean different things for different widgets.

**Universal conventions across every widget that has a "correct answer":** the correct answer is always listed **first** in the spreadsheet; the widget shuffles the display order itself so students never see it in a predictable position. Never rely on spreadsheet order to hide the answer — the code always reshuffles.

**Delimiter convention, used everywhere:** `;;` separates list items (e.g. multiple quiz questions, multiple vocab terms). `::` separates sub-fields within one item (e.g. `term::emoji::definition`).

### 4.1 `story`
Non-interactive narrative text. No completion tracking.
- `field_a` — paragraphs, `;;`-separated (each becomes its own `<p>`)
- `field_d` — a short tab label shown on the story's tab

### 4.2 `instruction`
Non-interactive instructional text/HTML. No completion tracking.
- `field_a` — small eyebrow tag (e.g. "How This Works")
- `field_b` — title
- `field_c` — paragraphs, `;;`-separated. **Real HTML is allowed** (bold, links, `<code>`, video `<iframe>` embeds — CSS already makes embedded videos responsive). Each item gets auto-wrapped in `<p>`.
- `field_d` — set to exactly `after_certificate` to move this specific row to the after-certificate slot (§3.4); otherwise unused.

### 4.3 `vocab`
Flip-card vocabulary. Trackable (completes when every card has been flipped).
- `field_a` — terms, `;;`-separated, each `term::emoji::definition`

### 4.4 `scenario`
A branching decision-point widget: one scenario, multiple choices, feedback per choice. Trackable (completes after any choice is made).
- `field_a` — the brief/setup text
- `field_b` — the question posed
- `field_c` — choices, `;;`-separated, each `text::feedback::resolution` — **best/correct choice listed first**

### 4.5 `quiz`
The most feature-rich widget. Trackable (completes when every question is answered correctly).
- `field_a` — heading
- `field_b` — questions, `;;`-separated. Each question is one of three formats:
  - **Legacy plain multiple choice** (no tag, still fully supported): `question::correct::wrong1::wrong2::wrong3`
  - **Tagged multiple choice**: `mc::question::correct::wrong1::wrong2::wrong3` — optionally append `::retry` to make wrong answers silently say "try again" instead of revealing the correct one (no explanation given — this is the "Silent Teacher" style, achieved via a quiz mode rather than a separate widget)
  - **Fill-in-the-blank**: `fill::question::answer1::answer2...` — any number of accepted answers, matched case/whitespace-insensitively
- `field_c` — set to exactly `sequential` for "one question at a time" mode: only the current question shows, wrong answers always behave like retry-mode (silent, no reveal) regardless of the `::retry` tag, and a correct answer slides the card out to the left while the next one slides in from the right. Leave blank for the normal "show all questions at once" mode.

### 4.6 `terminal`
A simulated Linux terminal with a fake filesystem. Trackable (completes when every listed task succeeds).
- `field_a` — step IDs to include, `;;`-separated, referencing rows in the **terminal-steps** master tab (§5.1) by `step_id`
- `field_b` — which filesystem template to load (see the 8 available templates in the terminal-steps README section, e.g. `home-basic`, `logs-basic`, `web-project`, `python-scripts`, `server-config`, `game-project`, `incident-response`, `csp-data`)

### 4.7 `bits`
Data-as-Bits Visualizer. Models a real (simplified) image file format: the first 24 bits are a header — 8 bits width, 8 bits height, 8 bits color depth — set by the **student toggling actual bits**, not a dropdown. The picture itself is drawn pixel by pixel below, each pixel cycling through however many shades the depth bits allow. Trackable (completes once several header bits are toggled and several pixels are drawn).
- No fields used — simple inclusion row.

### 4.8 `risk`
Risk-Assessment Matrix — place scenarios by likelihood/impact. Trackable.
- `field_a` — scenarios, `;;`-separated, each `text::likelihood::impact::feedback`

### 4.9 `cipher`
Caesar/Vigenère cipher tool — real encode/decode math, fixed UI. Trackable (completes once both cipher types have been tried with real input).
- No fields used — simple inclusion row. Multiple instances on one day are fully independent (e.g. for a partner encrypt/decrypt activity).

### 4.10 `rsa`
Real RSA public-key cryptography on small toy primes — actual modular exponentiation, not a simulation. Trackable (completes on a successful encrypt+decrypt round trip).
- `field_a` — **optional**, comma-separated list of primes to offer (e.g. `31,37,41,43`). Defaults to `11,13,17,19,23,29` if left blank.

### 4.11 `firewall`
Firewall Rule Builder — build an ordered allow/deny rule list, then predict outcomes against sample traffic. First-match-wins semantics, same as a real firewall. Trackable.
- `field_a` — the goal/challenge text
- `field_b` — starting rules, `;;`-separated, each `src::port::action` (`src` can be `any` or a CIDR range)
- `field_c` — traffic tests, `;;`-separated, each `description::expectedAction`

### 4.12 `rule` (Class Rules)
Not authored inline — this is a **flag row**. Its presence for a given course+day is the only thing that matters; all fields are left blank. The actual rule text is always pulled live from one central published "classroom rules" tab (§5.2), so the same list is never duplicated across days. Not trackable (informational).
- No fields used.

### 4.13 `message` (General Messages) — **standing feature, NOT a per-day row**
Unlike every other widget, `message` is **not** authored as a daily-content row at all. It automatically appears right after the hero on every single day, with no opt-in needed — but it's only actually visible when the messages tab currently has content in it; a configured-but-empty tab shows nothing (not an empty placeholder box). This asymmetry with `rule` (which stays strictly opt-in) was a deliberate decision: rules are static reference content shown on-demand, messages are time-sensitive announcements that should be seen automatically without a teacher remembering to flag each day.
- **Still pending your setup**: the messages tab doesn't exist yet. `GENERAL_MESSAGES_TSV_URL` in `day.html` is a placeholder. Needs a tab with `date` + `message` columns, published, and the URL sent over to be wired in.

### 4.14 `stepper` (Trace Visualizer / Predict Practice)
Steps through code line-by-line, showing how variables change. Two modes on the same engine. Trackable (completes at the final step).
- `field_a` — language label (e.g. "Python") — **one language per row**, unlike the hand-authored JS version which supports multiple languages side-by-side (a real, known limitation of the spreadsheet-driven version)
- `field_b` — code lines, `;;`-separated
- `field_c` — steps, `;;`-separated, each `lineIndex::key1=val1|key2=val2::note text`
- `field_d` — `auto` for Trace Visualizer (auto-advances, just shows state), or `quiz::key1=opt1,opt2,opt3;key2=optA,optB` for Predict Practice (pauses at each change, asks the student to predict the new value from the listed options before revealing it)

### 4.15 `parsons` (List/Loop/Parameter Practice)
Drag-and-drop code arrangement — given shuffled chunks, put them in the correct order with correct indentation. Trackable.
- `field_a` — goal text
- `field_b` — `functionSignature::successOutput`
- `field_c` — chunks, `;;`-separated, each `id::chunk text`
- `field_d` — template, `;;`-separated, each entry either `F::literal text::indentLevel` (a fixed, non-draggable line) or `S::correctChunkId::indentLevel` (a slot — the correct chunk id is embedded directly here; the palette is still shuffled for the student to solve)
- **One language per row**, same limitation as the stepper.

### 4.16 `debug` (Debugging Challenge)
Click the buggy line, pick the correct fix from shuffled options. Trackable.
- `field_a` — goal text
- `field_b` — code lines, `;;`-separated
- `field_c` — `bugLineIndex::correctFix::wrongFix1::wrongFix2` (correct fix listed first — the code shuffles the displayed order before rendering, same convention as quiz/scenario)
- `field_d` — language label (optional, defaults to "Code")
- **One language per row**, same limitation as the stepper/parsons.

### Removed widget
**`hunt` (Find & Fix)** was built, used, and then deliberately deleted at the teacher's request. `hunt.js`/`hunt.css` no longer exist. Any old spreadsheet row with `widgetname = hunt` is safely ignored (a console warning, no crash) rather than breaking the page.

---

## 5. Spreadsheet Tabs

All public tabs live in one Google Sheet, each published to web individually (**File → Share → Publish to web → choose the specific tab, not "Entire Document" → format Tab-separated values (.tsv)**). Each publish gives a URL like:
```
https://docs.google.com/spreadsheets/d/e/{long-id}/pub?gid={GID}&single=true&output=tsv
```

### 5.1 Public tabs (read by the website via published TSV)

| Tab | Read by | Columns | Notes |
|---|---|---|---|
| **pacing timeline** | `index.html`, `day.html` | `course \| day \| date \| weekday \| term \| unit \| topic_activity \| day_type \| learning_objective \| essential_knowledge \| mapping_note \| possible_lab_project` (+ Cybersecurity-only: `scenario`, `hands_on_activity`, `hands_on_note`) | **Merged** — all 3 courses in one tab as of the pacing consolidation. `course` column holds `"ap csa"`/`"ap csp"`/`"ap cyber"` (case/spacing-insensitive matching). Full year, ~173 rows per course. `possible_lab_project` and the Cybersecurity-only columns exist but aren't wired into any widget yet — reserved for a future "what to actually build after the 10-minute lecture" mechanism (see `instruction` + `after_certificate`, §3.4, which is the current answer to that need). |
| **daily content** | `day.html` | `course \| day \| widgetname \| field_a \| field_b \| field_c \| field_d \| notes` | The master content tab — every widget on every real day, plus `day = -1` reserved for test data. See §4 for the full widget schema table. |
| **vocabulary** (merged with cheat sheets) | `index.html` | `type \| course \| category \| term \| description \| example` | **Merged** — Vocabulary and Cheat Sheets share one tab now, `type` column (`vocab`/`cheat...`, matched via `startsWith('cheat')` so `cheat`/`cheats`/`cheatsheet` all work) tells them apart. `course` supports multiple courses per row via `;` (e.g. `"AP CSA;AP Cybersecurity"`). `example` is optional (used by cheat rows for code snippets, left blank by vocab rows). **A blank `course` does NOT mean "show for all courses"** — it means the row matches nothing; multi-course rows must explicitly list every course they apply to. |
| **frqs** | `index.html` | `course \| unit \| question \| excellent student response \| weak student response` | Free-response practice. Header must say exactly `course` (a prior "qrtfv" typo broke this once — worth spot-checking if FRQ ever silently shows nothing). |
| **quiz questions** | `index.html` | `course \| unit \| question \| option_a (always correct) \| option_b...option_h` | Standalone MCQ practice tab — **distinct from** the daily-content `quiz` widget. A pool of up to 7 wrong answers; 3 are randomly chosen each time, so retaking a unit's quiz doesn't show the same 4 options in the same order every time. |
| **resources** | `index.html` | (name/description/url — see `renderResources()` in `index.html` for exact fields) | |
| **terminal-steps** | `day.html` (whenever a day includes a `terminal` widget) | `step_id \| label \| command \| arg \| require_arg_match \| require_cwd \| notes \| fs_template` | Master library of ~77 terminal steps across 8 filesystem templates, referenced by id from `terminal` widget rows. **Never set `require_cwd` on a `cd` step** — `cd` logs the post-move working directory, not pre-move, so `require_cwd` on a `cd` step will never match. Use arg-matching alone for `cd` steps; reserve `require_cwd` for `cat`/`ls`/etc. |
| **classroom rules** | `day.html`, `policies.html` | One column: `rule` (header must say exactly this) | One rule per row, plain text (emoji-prefixed is fine, e.g. `💻 Use technology responsibly.`). Read live by both the `rule` widget (per-day opt-in) and the Classroom Rules section of `policies.html`. |
| **rank_titles** *(functionally private — see below)* | Backend only (`Code.gs`) | `course \| title \| stars_to_get_title` | 10 rank-name tiers per course, thematically written (e.g. Cybersecurity: "Script Kiddie" → "CISO"; CSA: "Hello World" → "Principal Engineer"; CSP: "Blank Canvas" → "Chief Technologist"). `course` values here use the **plain internal key with no spaces** (`apcsa`/`apcsp`/`apcyber`) — a different convention from the pacing tab's `"ap csa"` style, and this is correct/intentional, matching exactly what `Code.gs` expects (`String(row[0]).toLowerCase().trim() === course`). |
| **bulletquestions** *(functionally private — see below)* | Backend only (`Code.gs`, powers the "Quick Logistics" instant buttons on `ask-a-question.html`) | `course \| question` | **Known live bug**: one row has `"ap apcyber"` (stray extra space) instead of `"apcyber"` — this silently breaks those two quick-questions for Cybersecurity specifically, since `Code.gs` does an exact string match. Needs a manual cell fix. |

**Important nuance on `rank_titles`/`bulletquestions`:** although they may sit visually among the other tabs in the sheet's tab bar, they are **not** read via a published TSV link at all — `Code.gs` reads them directly via Google's Sheets API (`SpreadsheetApp.getSheetByName(...)`). Functionally, they belong with the private tier below, not the public one. Merging them with each other (they share a similar per-course-list shape) is possible and was discussed, but requires editing the live `Code.gs` script — a different, higher-stakes kind of change than any frontend edit, and was deferred by choice, not because it's infeasible.

### 5.2 Private tabs (backend/Apps Script only — never published to web)
`help_queue`, `roster`, `star_log`, `star_totals` — these hold live, sensitive, per-student operational data (the actual help queue, star totals, roster). Never publish these to web. Only `Code.gs` (deployed as a Google Apps Script Web App) reads/writes them, via the API endpoints `ask-a-question.html` calls.

---

## 6. `ask-a-question.html` — The Private Help Queue Page

A fully functional page backed by `Code.gs` (deployed separately as an Apps Script Web App, `API_URL` constant at the top of the file's `<script>`). Not a spreadsheet-TSV page — everything here goes through the Apps Script's own API (`?action=count`, `?action=bulletlist`, and a `POST` for submissions).

- **Quick Logistics buttons** — instant, no queue, no credit; pulled from `bulletquestions`.
- **Ask a Question** — two modes: **Need Help** (joins the visible queue, name shown, not the question) and **Verbal Question** (logs that a question was already asked out loud, for credit, doesn't join the queue).
- Auto-fills the student's name from `localStorage.getItem('studentName')` if they've already confirmed their ID elsewhere on the site (shared across all pages on the same domain).
- Light theme, same visual language as `index.html`/`policies.html`.

---

## 7. The Hero, EK/LO, and Certificate

- **Hero** shows the course/unit eyebrow, then two small bordered gray reference lines — **Learning Objective:** and **Essential Knowledge:** (labels in amber) — pulled directly from the pacing row, not the daily-content tab. These are deliberately small and low-emphasis (real border boxes, not prose) since students skim past them rather than read closely — that's intentional, not a bug.
- **`ek-cards.js`/`ek-cards.css` still exist in the shared library** but are **no longer used by `day.html`** — EK moved into the compact hero lines described above. Kept only for `test.html`'s catalog/legacy reference.
- **Certificate** auto-fills the student's name from the confirmed student-ID lookup (no typing required in the normal case); falls back to a manual text input only if a student somehow reaches a day page without ever confirming an ID on the hub.

---

## 8. Idle Behavior, Loading, and Visual Polish

- **Idle timer**: 5 min → a toast appears, counting up live. 8 min → the whole screen pulses red. 10 min → the red pulse stops and is replaced by a calm, static gray fog instead (the timer keeps counting the whole time — only the alarming visual de-escalates). Any activity (click/scroll/keydown/touch) resets everything instantly. All of this is suppressed entirely once the certificate is fully complete — no more nagging once everything's done.
- **Loading overlay**: a full-screen overlay with a spinner, present immediately on page parse (inline CSS, doesn't wait on external stylesheets), and only removed once `buildDayPage()` has actually resolved — timed correctly so a slow network doesn't let the page finish loading before the overlay ever shows.
- **Corner flourish**: four small HUD-style bracket-and-circuit-trace decorations in the four viewport corners, self-draw via a staggered stroke animation the moment the loading overlay comes down (not before — otherwise a slow network could let the animation finish before anyone ever sees it).

---

## 9. Lab Tools (`labs/` — a separate system from everything above)

Everything in §1–8 describes `day.html`'s spreadsheet-driven system. **Lab tools are different on purpose**: each one is a self-contained, single-purpose HTML tool (the compression lab, river-crossing puzzle set, Cyber Range labs, etc.), hand-built rather than generated from spreadsheet rows. They live in `labs/`, inside `lessons/`, mirroring the top-level `js/`/`css/` convention:

```
lessons/
└── labs/
    ├── js/lab-certificate.js
    ├── css/lab-certificate.css
    └── (each lab's own HTML file, e.g. text-compression.html)
```

**Do not confuse `lab-certificate.js` with `js/certificate.js`.** They are two different files, two different globals-free modules, deliberately never loaded on the same page:
- `js/certificate.js` — used only by `day.html`, tracks completion of that day's trackable widgets.
- `labs/js/lab-certificate.js` — used only by standalone lab tools, and additionally posts a graded score to the `student_work` sheet (see below). Same visual language, different job.

### 11.1 Identity is shared with the rest of the site, deliberately
`lab-certificate.js` reads and writes the **exact same** `localStorage` keys `student-id.js` already uses (`studentId`, `studentName`) — not a separate key. A student who's already confirmed their ID on the hub, or on any day page, gets their name auto-filled in a lab certificate with zero retyping. If the cached ID/name isn't there yet, the lab's own ID field falls back to a roster lookup (`?action=roster_name`) against the **same** deployed Web App URL as `STAR_API_URL` in `student-id.js` — pass that exact URL in as `webAppUrl` when calling `PCSCertificate.render()`, don't stand up a second deployment.

The `fetch()` call in `logWork()` deliberately omits an explicit `Content-Type` header, matching `awardStar()`'s existing convention in `student-id.js`: a plain string body defaults to `text/plain`, which Apps Script Web Apps accept without triggering the CORS preflight that `application/json` would (Apps Script doesn't handle that preflight).

### 11.2 `lab-certificate.js` API
```js
PCSCertificate.render({
  containerId: 'certificateWrap',   // an empty wrapper element already in the page
  webAppUrl: STAR_API_URL,          // same value as student-id.js's constant
  lab: 'text-compression',          // identifier used when logging a grade
  title: 'Compression Champion',
  bodyText: '...',
  items: [{ label: 'Song A — 82% saved' }],   // icons default to medals if omitted
  courseLabel: 'AP Computer Science A · Text Compression Lab',
  visible: true                      // gate this on the lab's own earning condition
});

// Call once, when the lab's own logic decides the certificate is earned —
// NOT on every render():
PCSCertificate.logWork('certificateWrap', { grade: 82 });
```
`render()` only draws the certificate; it never posts anything. `logWork()` is the only thing that writes to the sheet, so re-rendering a certificate as scores update doesn't spam duplicate log entries.

### 11.3 `student_work` sheet (private, via `Code.gs`)
A new private tab, written by a new `Code.gs` action:
- **Columns**: `timestamp, student_id, student_name, lab, grade`
- **Action**: `POST { action: 'log_student_work', student_id, student_name, lab, grade }`
- **One row per (student_id, lab)** — resubmitting a lab only overwrites the grade if the new one is *higher*, mirroring the "keep your best verified attempt" policy already used for stars.
- **Deliberately does not record `period`.** Unlike `star_log`, period isn't written here — pull it into a report via a lookup against `roster` in the sheet itself, since it's already available there and doesn't need duplicating.

---

## 10. Known Pending Items (as of this document)
1. **`GENERAL_MESSAGES_TSV_URL`** in `day.html` is still a placeholder — needs the messages tab created (`date` + `message` columns), published, and the URL sent over.
2. **`"ap apcyber"` typo** in `bulletquestions` — breaks two Cybersecurity quick-questions.
3. Real widget content exists for **days 1–5 only**, across all 3 courses. The full-year pacing guides exist (~173 days each), but days 6+ have no daily-content rows yet — this is genuinely ongoing work, not a bug.

## 11. Known, Accepted Limitations
- `stepper`, `parsons`, and `debug` each support **one language per spreadsheet row** — the multi-language side-by-side switcher only exists in hand-authored JS configs (e.g. on `test.html`), not the spreadsheet-driven version.
- A genuinely open-ended "write real code and see it render live" activity (like a full embedded code editor) is **not something this system does at all** — every widget simulates a narrow, fixed slice of behavior. For that kind of open-ended building, linking out to an external tool (Codio, Replit, etc.) alongside the 10-minute "lecture" page is the intended pattern, not something to build into this system.
