Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
185 changes: 185 additions & 0 deletions .agent/plans/2026-07-26-syncdeck-permalinks-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Stable Presentation Permalinks Plan

## Summary
Presentation URLs today are just their file path under `Decks/` as published to
the site root (e.g. `/CSA/Lists/lists.html`). Reorganizing folders breaks any
link built on that path, and the paths can get long. This plan adds a second,
stable link per deck: a short hash-based permalink, generated once and stored
in the deck's HTML, that survives file moves/renames because it never changes
once assigned.

Shape of the system:
- Each deck gets a `<meta name="syncdeck-permalink" content="<hash>">` tag,
written once by a generator script and never hand-edited.
- The hash is derived from the deck's filename stem at generation time, with a
deterministic collision-resolution step (not from the live file path, so
moving/renaming the deck later doesn't change it).
- A committed manifest (`config/permalinks.json`) maps `hash -> current public
path`, acting as the single source of truth for collision detection and for
building redirect targets at deploy time.
- At build time, one small static redirect stub is emitted per manifest entry
at `/p/<hash>.html`. It does an immediate `location.replace()` to the deck's
real (current) URL. Confirmed with the user that ActiveBits pings the iframe
again on each `load` event, so the redirect's second `load` still produces a
successful ping against the fully-initialized deck, i.e. this permalink is
safe to feed directly into the ActiveBits launcher, not just for humans.
- CI fails the build if any deck is missing the meta tag, if the manifest has
duplicate hashes, or if the committed manifest disagrees with what's
derivable from the decks themselves (e.g. a deck moved without the manifest
being regenerated).
- The existing "Get permalink" button (which builds an ActiveBits
`permalinkPath` URL wrapping the deck's real URL) is relabeled "Syncdeck
link" to disambiguate it from the new permalink. A third button/link
surfaces the new short permalink for copying.

## Implementation Changes

### 1. Hash generation
- Seed string: the deck's filename stem (basename without extension) at the
time the generator script is first run against it, e.g. `lists` for
`lists.html`.
- Hash function: FNV-1a (32-bit), base36-encoded. No dependency needed:
```js
function fnv1a(str) {
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
```
- Collision handling: if the resulting hash already exists in
`config/permalinks.json` pointing at a *different* deck, re-seed as
`${stem}#2`, `${stem}#3`, ... and rehash until the result is free. The
winning seed/hash is then fixed permanently in the deck's meta tag.

### 2. Manifest — `config/permalinks.json`
- Committed JSON file, sibling to `config/site-map.mjs`.
- Shape: `{ "<hash>": { "path": "<public path under Decks/>", "title": "<cached title>" } }`.
`path` is the same public-path convention `site-indexes.mjs` already uses
(e.g. `CSA/Lists/lists.html`), so it can be turned directly into a redirect
target.
- Acts as the single source of truth for collision checks (the generator
script only needs to consult this file, not scan every deck), and doubles as
a lookup table for any future feature (analytics, search, a client-side
resolver) that wants `hash -> deck` without scanning the tree.

### 3. Generator script — `scripts/generate-permalink.mjs`
- Usage: `node scripts/generate-permalink.mjs Decks/CSA/Lists/lists.html`.
- If the deck already has a `syncdeck-permalink` meta tag: no-op, unless the
manifest's recorded `path` for that hash no longer matches the deck's actual
location, in which case update the manifest's `path` (the hash itself never
changes on a move/rename).
- If the deck has no tag: compute the hash (with collision resolution per
above), insert the meta tag into `<head>`, and add/update the manifest
entry.
- `--check` mode (used by CI, see below): walk all deck HTML files under
`Decks/`, and fail with a clear message if:
- any deck is missing the `syncdeck-permalink` meta tag,
- any two decks share a hash,
- the committed manifest has an entry whose `path` doesn't match the deck
that currently owns that hash (stale manifest, e.g. after a move that
didn't re-run the script).
- Exit non-zero with a message telling the author which command to run to
fix it.

### 4. Redirect stub generation — `scripts/generate-permalink-redirects.mjs`
- Usage: `node scripts/generate-permalink-redirects.mjs .build/site` (same
invocation pattern as `generate-site-indexes.mjs`).
- Reads `config/permalinks.json` and, for each entry, writes
`.build/site/p/<hash>.html`.
- Stub content: a script-based `location.replace()` fired as early as
possible in `<head>`, plus a `<meta http-equiv="refresh">` fallback for the
no-JS case. Redirect target is computed as a path relative to `p/`, e.g.
`../CSA/Lists/lists.html`, so it works unmodified under both the local dev
server (root `/`) and the GitHub Pages project prefix
(`/Presentations/...`) without hardcoding `siteBaseUrl`.

### 5. CI wiring
- `.github/workflows/static.yml`: add a step before "Stage publishable site":
`node scripts/generate-permalink.mjs --check` — fails the workflow (and
therefore blocks deploy) on any of the violations above. Add a step after
staging (alongside "Generate index.html"):
`node scripts/generate-permalink-redirects.mjs .build/site`.
- New `.github/workflows/permalinks-check.yml`, triggered on `pull_request`:
checkout + `node scripts/generate-permalink.mjs --check` only, no build or
deploy steps. Catches a missing/broken permalink at PR time rather than
only at merge-to-main deploy time.

### 6. Index page changes — `scripts/site-indexes.mjs`
- Rename the existing "Get permalink" button (the one building an ActiveBits
`permalinkPath` URL) to "Syncdeck link" in the rendered markup. Internal
`data-launch="permalink"` attribute can be renamed to
`data-launch="syncdeck"` for clarity.
- Add a third action that surfaces the new short permalink:
- Read `config/permalinks.json` (or the deck's own meta tag) when building
each file entry, alongside the existing `defaultTitleForFile` lookup.
- Render a "Permalink" link/copy-button pointing at `p/<hash>.html`,
resolved relative to the current index page's folder depth using the same
`pathPrefix`/relative-`../` mechanism `renderTree` already uses for
per-folder back-links, so it resolves correctly regardless of which
folder's `index.html` is rendering it.

### 7. Docs — `CLAUDE.md`
- Add a step to "Adding a New Presentation": after creating the deck file,
run `node scripts/generate-permalink.mjs Decks/<path>/<deck>.html` before
committing, so the deck always ships with a permalink from day one.
- Document the new meta tag and manifest file in the architecture notes table.

## Test Plan
- Unit-style check: run `generate-permalink.mjs` against a fresh deck with no
tag, confirm meta tag + manifest entry are created and are stable on a
second run (idempotent).
- Collision test: force two decks to hash to the same value (mock/seed),
confirm the second gets a `#2`-seeded hash instead and both remain unique.
- `--check` test: manually remove a meta tag from one deck and confirm
`--check` fails with a clear message; restore it and confirm it passes.
- `--check` test: hand-edit the manifest to point a hash at the wrong path
and confirm `--check` catches the mismatch.
- Build test: run `stage-site.mjs` + `generate-permalink-redirects.mjs` and
open `.build/site/p/<hash>.html` directly in a browser, confirm it lands on
the correct deck with no console errors and correct relative asset loading.
- ActiveBits integration smoke test: launch a session using a permalink URL
(`/p/<hash>.html`) as the `presentationUrl` fed to
`launchPath`/`permalinkPath`, confirm the ping/pong handshake still
succeeds after the redirect (validates the "second `load` event" assumption
in a real ActiveBits session rather than just in principle).
- Index page smoke test: confirm "Start as instructor", "Syncdeck link", and
the new "Permalink" all resolve to working URLs from both the root index
and a nested folder index.

## Assumptions
- ActiveBits re-arms its iframe `load` listener rather than using a one-shot
listener, so a client-side redirect still produces a ping against the final
page. Flagged for confirmation in the test plan above since ActiveBits is
external to this repo.
- Hash seed is the filename stem only (per the original request), not the
full path; the manifest, not the seed, is what's authoritative once a hash
is assigned, so this choice only affects the *first* assignment, not
stability afterward.
- No cryptographic hash is needed since collisions are actively resolved and
detected, not merely made unlikely.
- Redirect stubs are static files, not real filesystem symlinks (GitHub
Pages' static artifact upload isn't a reliable place to depend on symlink
semantics, and relative asset URLs would break under a symlink regardless,
since the browser resolves relative paths against the URL path, not the
file the symlink points to).

## Rollout Sequence
1. Add `config/permalinks.json` (empty `{}`) and
`scripts/generate-permalink.mjs`.
2. Run the generator once across every existing deck under `Decks/` to
backfill meta tags + manifest entries for all current presentations.
3. Add `scripts/generate-permalink-redirects.mjs` and wire it into
`static.yml` alongside the existing index-generation step.
4. Add the `--check` step to `static.yml` before staging, and add the new
`permalinks-check.yml` `pull_request` workflow.
5. Update `scripts/site-indexes.mjs` (rename + new permalink button,
`data-launch="permalink"` → `data-launch="syncdeck"`) and `CLAUDE.md`
(new-deck step + architecture notes).
6. Validate a full local build (`stage-site.mjs` →
`generate-permalink-redirects.mjs` → `generate-site-indexes.mjs`) and spot
check several permalinks in a browser.
7. Commit, push, and confirm the GitHub Pages deploy succeeds with the new
CI check in place, and that a test PR triggers `permalinks-check.yml`.
18 changes: 18 additions & 0 deletions .github/workflows/permalinks-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Check presentation permalinks

on:
pull_request:
branches: ["main"]

jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Check permalinks
run: node scripts/generate-permalink.mjs --check
4 changes: 4 additions & 0 deletions .github/workflows/static.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ jobs:
cache: npm
- name: Install workspace dependencies
run: npm ci
- name: Check permalinks
run: node scripts/generate-permalink.mjs --check
- name: Build SyncDeck runtime
run: npm run build --workspace vendor/SyncDeck-Reveal
- name: Stage publishable site
Expand All @@ -68,6 +70,8 @@ jobs:
echo ".build/site directory was not created."
exit 1
fi
- name: Generate permalink redirects
run: node scripts/generate-permalink-redirects.mjs .build/site
- name: Generate index.html
run: node scripts/generate-site-indexes.mjs .build/site
- name: Setup Pages
Expand Down
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,31 @@ Full message schema: `vendor/SyncDeck-Reveal/reveal-iframe-sync-message-schema.m
- Use **`px`** for all font sizes and spacing in CSS custom properties (not `em`/`clamp`/`vw`) — Reveal scales the canvas via CSS transform; `em` values double-scale
- Never set `position` on `.reveal .slides > section` — Reveal needs `position: absolute` there for fade transitions; put padding/centering in a `.slide-inner` div inside each section instead
5. Check the style tokens for the chosen preset in `.agent/skills/STYLE_PRESETS_EXTENDED.md` (full library) or `.agent/skills/vendor/syncdeck/references/STYLE_PRESETS.md` (short reference).
6. Run `node scripts/generate-permalink.mjs Decks/<path>/<deck>.html` to give the new deck a stable permalink before committing. See "Presentation Permalinks" below.

## Presentation Permalinks

Every deck under `Decks/` carries a `<meta name="syncdeck-permalink" content="<hash>">` tag,
written once by `scripts/generate-permalink.mjs` and never hand-edited. The hash is derived
from the deck's filename stem when first generated (with automatic collision resolution) and
never changes afterward, even if the deck is later moved or renamed, so it's safe to share as
a stable short link.

- `config/permalinks.json` is the committed manifest (`hash -> { path, title }`) and the single
source of truth for collision detection.
- `node scripts/generate-permalink.mjs Decks/<path>/<deck>.html` assigns a permalink to a new
deck, or re-syncs the manifest's cached path if an existing deck was moved.
- `node scripts/generate-permalink.mjs --all` does the same across every deck; safe to re-run.
- `node scripts/generate-permalink.mjs --check` (run in CI on every PR and before deploy) fails
if any deck is missing the tag, two decks share a hash, or the manifest disagrees with the
decks themselves.
- At build time, `scripts/generate-permalink-redirects.mjs` emits a static redirect stub per
manifest entry at `/p/<hash>.html` that immediately `location.replace()`s to the deck's real,
current URL — this is what makes the permalink stable across reorganization.
- The index pages (`scripts/site-indexes.mjs`) surface three links per deck: "Start as
instructor" and "Syncdeck link" (both ActiveBits-hosted, built from the deck's real current
URL) and "Permalink" (the short `/p/<hash>.html` link, safe to share or feed into the
ActiveBits launcher directly).

---

Expand All @@ -188,6 +213,7 @@ Full message schema: `vendor/SyncDeck-Reveal/reveal-iframe-sync-message-schema.m
| Overview → storyboard | `overview: true` in any synced state is intercepted and routed to `reveal-storyboard-set` rather than `deck.setState()`, so students see the custom strip, not Reveal's grid. |
| No `chalkboard.storage` | The vendored chalkboard plugin does not write to `sessionStorage`. The host page is the source of truth (snapshot + delta buffer). Setting `storage` would cause divergence on reload. |
| Role starts as `standalone` | `reveal-iframe-sync.js` always initialises in `standalone` mode. The host must send `setRole` to promote to `instructor` or `student`. Never rely on the `role` config field. |
| Never hand-edit `syncdeck-permalink` meta tag or `config/permalinks.json` | The hash is generated once by `scripts/generate-permalink.mjs` and must stay stable across moves/renames. CI (`--check`) fails if a deck's tag and the manifest disagree. |

## Code Tracing Convention

Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX10_Relays.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="1iaz5g8">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 10: DC Relays</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX1_Intro_DC_Circuits.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="117ukjj">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Electronics &amp; DC Circuits Exercise 1</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX2_Switches.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="1wsmncz">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 2: Switches</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX3_Series_and_Parallel_Circuits.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="waswu2">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 3: Series and Parallel Circuits</title>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="1kn5gec">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 4: Voltage, Current, and Measuring Instruments</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX5_Resistance_and_Ohms_Law.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="1z1vuw">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 5: Resistance and Ohm's Law</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX6_Series_Circuits.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="dvh3cz">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 6: Solving Series Circuits and Kirchhoff's Voltage Law</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX7_Parallel_and_Mixed_Circuits.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="139lnms">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 7: Parallel &amp; Mixed Circuits</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX8_DC_Capacitors.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="c3twrg">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 8: DC Capacitors</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/DCCircuits/EX9_Electromagnetism.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="oqlljt">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exercise 9: Electromagnetism</title>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="1tf8dje">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Soldering &amp; Measuring Resistance</title>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="dy8mib">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2 - Communicating with Words</title>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="117n6al">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3 - More than Just Words</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/Engineering Communication/Disruptus.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="u1fk3e">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Disruptus</title>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="1ndepug">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Intro to Mechatronics &amp; Engineering Communication</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/Final Project/FP1_Signals_and_Motion.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="4obyzl">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>1. Signals and Motion</title>
Expand Down
1 change: 1 addition & 0 deletions Decks/AR1/Final Project/FP2_Design_and_Simulation.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="syncdeck-permalink" content="kpfojy">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2. Design and Simulation</title>
Expand Down
Loading