This is the complete, consolidated spec for how every ASE Academy lesson is built, covering all the
modifications made across sessions (home + work). Apply ALL of it to every unit/lesson going forward
(Unit 2 → 10, then B3–B6). Built and proven on B2 Unit 1 "Shop Safety & Survival" (live at
Firestore academy_lessons/B2-1, admin-only while js/academy-config.js coming-soon switch is on).
Two layers: PLAYER features live in simulator.html and apply to EVERY lesson automatically.
CONTENT features are baked into the lesson HTML by the build passes in this folder.
Approved by Mario 2026-06-12, grounded in professional lesson-plan frameworks (Madeline Hunter, Gagné's Nine Events, the 5E model). EVERY lesson in EVERY unit follows this. This section is the authoritative build checklist; the numbered sections below (1–5) and the dated notes are the implementation detail. Status tags: [LIVE] = built into the player and/or B2 Unit 1; [TO BUILD] = approved, not yet built.
X.0) — the lesson's front door [LIVE except where noted]X.0 - <LESSON NAME>. The section's one-line dek renders as an italic subtitle directly
under the title (above the title's gradient underline), then a "⏱ Lesson Overview" badge.b2-u<unit>-l<lesson>-hero.jpg
if generated, gray placeholder otherwise)..fast 30-Second Overview table; .objective Goal banner; .statrow stat block; the HOOK =
.instructornote veteran pull-quote; a 2–3 paragraph roadmap with magazine drop cap; learning
objectives ("By the end you will be able to:" + .lo-bullets)..asetask ASE-task callout floated to the page top, with a divider line (hr.taskbreak) below it..subhead (22px/900) freezes full-width at the top as you
scroll and is replaced by the next; a floating back-to-top button appears once you scroll down..factbox, to support the point, not distract);
short demo clips where they help ($0 Google Labs .mp4; full video is later, needs gated Storage)..safety hazard boxes; bold key phrases; one <mark> highlight per section; numbered .steps
timelines; .mvf myth-vs-fact; .bigterms "remember these"; official GHS pictograms (.ghsgrid,
real red-diamond SVGs) wherever hazards/symbols are taught; .iconlist / .knowgrid where they fit.<hr class="subbreak"> divider between subjects..bigpicture navy card; authored per lesson, grounded in its goal/objectives/key
facts. [LIVE, all 8 B2-1 lessons].labskill card (a divider sits before it); its title renders outside the
box; auto-feeds the end-of-unit "🎓 Skills You Learned" checklist. [LIVE]Order rationale (don't reshuffle): overview/hook → teach with per-page checks → recap → vocabulary → assessment → hands-on practice. That is the professional-lesson-plan sequence.
academy_lessons/{MOD-N} (e.g. B2-1). Its sections[] = the unit's
lessons; the player paginates each lesson into bite-size pages and adds a Contents page + checkmarks.window.ACADEMY_SECTIONS.B2 (in simulator.html) is the 10-unit list (live vs coming-soon).window.ACADEMY_UNIT_CATS maps each unit to the ASE question category its Quick Checks pull from
(Unit 1 → F). Set this for every new unit..0 overview page now gets a wide full-width
hero image right under the title (the lesson's establishing shot). The player injects a
.lesson-hero figure automatically (placeholder art until the real-image pass, same as every other
lesson image). No source edit needed — it appears on all current + future lessons. When the image
pass happens, give each lesson a real hero file + add its prompt to the Question Image Helper worklist.X.0 - + the actual LESSON NAME with a "⏱ 30-Second Overview" badge underneath (player
renders this automatically when a page's sub-topic is "The 30-Second Overview"; the overview is the
.0 page, teaching pages are .1, .2 …)..fast 30-second overview table (a few label/one-line rows; player renders it as numbered circles)..objective "Goal:" line — player restyles it into a navy gradient key-takeaway banner..statrow — a 3-number stat block (gradient orange numbers)..instructornote — a first-person veteran tip; player restyles it into a pull quote..lo-bullets learning objectives.<h3 class="acad-h3">) — CONTENT.asetask callout at the top (player floats it to the page top): the ASE task(s) the section
covers, official task wording, plus an "On this page:" plain-English line. When 2+ tasks, list each on
its own line with its number (e.g. B2-F.1, B2-F.3); foundation sections get a "Foundation · ASE
B2-F Safety" box..subhead subject titles before each distinct subject (never use <h4>/<h5> — only
<h3 class="acad-h3"> starts a new page; subjects use .subhead)..imgph placeholder + a hidden .aiprompt
with a VERY descriptive labs.google.com prompt (object, material, color, camera angle, lighting, PPE,
setting). LAYOUT (final, see "Lead + thumbnail-strip layout" below): the player shows ONE lead image
per topic (floated, alternating L/R) and drops any extra images into a full-width thumbnail strip at the
topic's end — so authoring just supplies the images; the player handles the consistent placement.<mark> highlighter on ~1 must-remember phrase per section (solid yellow).<ol> inside .steps (player renders a numbered timeline).<hr class="secbreak"> between subjects..safety boxes for PPE/hazards (player renders a yellow/black hazard stripe + big "SAFETY FIRST")..mvf Myth-vs-Fact box where a beginner misconception exists..iconlist icon callout list for any grouped/parallel list..bigterms big-bold ANIMATED "remember these" callout whenever the lesson names/compares a set of
2–6 key things (e.g. the four hazard families). Numbered navy pills that fade in..factbox / .knowgrid knowcards / .hook where they fit..labskill) — CONTENT, the most important componentWhere a lesson teaches a hands-on skill, add a .labskill card (a green "🔧 Lab Skill Activity"
action card, distinct from the safety/goal boxes). Each one MUST have: .lab-kicker ("🔧 Lab Skill
Activity"), .lab-title (the skill name, also put it in data-skill="..."), .lab-goal ("What you
will practice"), a .lab-req row with a .lab-ppe box ("PPE required") + a "You will need" box,
numbered .lab-steps (an <ol>), and a .lab-done box ("✅ You did it right when…"). Do NOT reuse
the class lab — that collides with the .steps .lab "How to do it" label; the container class is
labskill. The end-of-unit Skills page auto-collects every .labskill (by data-skill/.lab-title),
so every lab card automatically becomes a tracked skill.
After the lessons the player appends, in order: a 📖 Glossary page (every key term A–Z, from
P.terms), then any Pre/Post-Test/Results/Practice-Hub the doc defines, then a 🎓 Skills You Learned
page as the VERY LAST step (a progress checklist of every .labskill in the unit, each linking back to
its lesson). The TOC shows a green ✓ on a lesson only when ALL its pages are visited.
X.0; teaching pages number X.1, X.2, … (the overview does NOT consume
a slot). Consistent across page titles, the drawer TOC, the in-page TOC, and search. Both TOCs show a
green ✓ on every completed sub-page (visited), and on a lesson only when ALL its pages are visited..subhead subjects auto-gets a jump-list at the top
(taps smooth-scroll to that subject). Built by the iframe bridge..acad-sub dek used to
strand itself at the top of the body (a random sentence floating above the first subject, e.g. "Who
keeps you safe and breathing on the job" right over the OSHA subhead). The iframe bridge now pulls a
leading .acad-sub OUT of the body and renders it as an .am-subtitle directly under the page title,
styled as a magazine deck (italic, muted, weight 500) so it reads as intentional — Mario approved this
over removing the line. The subtitle is appended INSIDE the .am-title element so it sits ABOVE the
title's gradient underline (the line is the title's ::after; title → subtitle → gradient line)._acSectionToPages inserts an hr.taskbreak right
after the .asetask run it floats to the page top, separating the intro furniture from the lesson body..subhead topic titles are 22px / 900 weight (player override) so subjects like "What a VOC Is and
the Clean Air Act" stand out clearly against the 16px body text..ptn circle + .ptt name, only when 2+ subheads); RIGHT column (.pagetoc-acts, divider on its
left) = the page's activities with icons — 📘 Match the Key Terms, ✅ Quick Check quiz, 🔧 Lab Skill
activity — each present only if that element exists on the page (.acad-keyterms / .acqz /
.labskill, plus .bigpicture as "🎯 Lesson wrap-up") and each scrolls to it. The topic (left) query
excludes .acttitle (.am-body .subhead:not(.acttitle)) so the Quick Check / Lab / Key Terms / etc.
out-of-flow titles never show up as numbered topics. Activity targets get scroll-margin-top:58px so
the frozen sticky title doesn't cover them when jumped to. Overview pages (X.0) have no on-page
subheads, so the left column instead lists the LESSON's teaching pages (player injects
window.__ACLESSONSECS=[{t,step}] for the overview; each link is data-step → posts type:'jump'
to academySetStep, navigating to that page). On the overview, the box uses PER-COLUMN headers — the
lesson-pages column is labeled "In this lesson" (they're other pages, NOT on this page) and only the
activities column keeps "On this page". Teaching pages keep the single "On this page" header. Built by the iframe bridge. The pagetoc links use data-jump
(topics) / data-jumpel (activities); the global hover-zoom rule MUST exclude all three
(a:not([data-pt]):not([data-jump]):not([data-jumpel])) or its display:inline-block breaks the flex
rows (numbers wrap two-up, text runs together, activities collapse to one line). .pagetoc a is also
pinned to display:flex. Don't add pagetoc links to the hover-zoom selector. (The exclusion now also
covers [data-step], the overview's lesson-page links.).subhead topic titles, the Quick Check .qztag, the Key Terms .aktl, and the Lab
.lab-title are all 22px / 900. Keep new section headers at 22px..acad-content .subhead is position:sticky;top:0 with
an opaque page-colored background that bleeds EDGE-TO-EDGE (padding:9px 52px;margin-left/right:-52px
cancel the body padding; mobile uses 13px), so the frozen bar is full-width. The current section title
freezes at the top of the scroll and the next one takes its place as you read (the iframe scrolls
internally — flex:1 1 auto — so native sticky works). A bridge scroll handler toggles .acstuck (soft
shadow) on whichever subhead is currently frozen. Don't remove the opaque bg, the negative margins, or
the sticky z-index (6). The Quick Check, Lab Skill, AND Match-the-Key-Terms titles are all .subhead acttitle (rendered outside their boxes) so they JOIN the freeze-swap. A floating .acgotop "back to
top" button (bridge-created, shows after scrollY>500) sits bottom-right. The big faded lesson watermark
number (.acad-fadenum) is pinned at top:58px (below the freeze bar) so it never clashes with a frozen title..ghsgrid (2-col cards: pictogram +
name + refinish meaning). Sourced from Wikimedia Commons (Special:FilePath/GHS-pictogram-*.svg),
cleaned + inlined by functions/scripts/swap-official-pictograms.js (idempotent; re-run replaces the
grid). All 9 standard pictograms: flame, health hazard, exclamation, corrosion, skull, gas cylinder,
exploding bomb, flame-over-circle, environment. ⚠️ When inlining SVGs, collapse newlines to a SPACE
(not ""), or attributes glue together (<svg xmlns → <svgxmlns) and the symbol won't render..subhead acttitle ABOVE the card
(same look as every topic), with an hr.subbreak divider before the Lab. The TOC topic query is scoped
to .am-body .subhead so these out-of-body titles aren't double-listed as topics.<a href="https://…" target="_blank"> links (e.g.
manufacturer SDS/TDS finders) open in a new tab — the iframe bridge routes them to the parent via a
type:'extlink' postMessage (window.open). The iframe sandbox already allows popups._acPickQuiz takes the top 10 page-relevant
questions (was 5), widening to the whole module if the unit's category is thin.<hr class="subbreak"> before every
.subhead except the first, so subjects on the same page are visually separated with breathing room..labskill card OUT of the body and renders it BELOW the
page's Quick Check (lab cards always sit under the quiz).academyScrubStart) to jump anywhere
in the unit.window._acPickQuiz), one at a
time, a wrong first pick gives a second chance without revealing the answer, then shows the correct
answer + the DB explanation, and a 0–100% score at the end. Right/ctrl-clicks are ignored so they
can't accidentally answer. Its "Quick Check" title sits outside the box (.subhead acttitle).
(window.dbQuestionsPool, category
from ACADEMY_UNIT_CATS, falls back to the whole module if a category is thin.)<hr class="secbreak"> sits above the Quick Check..objective .statrow .asetask .mvf .safety .factbox .knowgrid .hook .lesson-fig .instructornote .bonddiagram) fade/slide up as they enter view, and
numbered lists (.fast .steps ol .iconlist .masktypes) have their rows slide in one-by-one from
the right, in order (IntersectionObserver in the iframe bridge; respects prefers-reduced-motion).Source of truth: u1_enhanced.json ({result:{lessons:[{number,title,innerHtml,keyTerms}]}}).
Order of the content passes that produced it (each is a Workflow of one agent per lesson + a small
python "apply" that inserts the result without touching approved text):
<mark> highlights.workflows/; match results to lessons by ARRAY ORDER, the agents' number
field is unreliable.)Rebuild + publish after editing u1_enhanced.json:
python3 functions/scripts/academy-build/assemble_unit1.py functions/scripts/academy-build/u1_enhanced.json
python3 functions/scripts/academy-build/build_sim_harness.py functions/scripts/academy-build/u1_enhanced.json
cd functions && node scripts/seed-academy-b2-unit1.js
cd .. && npx @11ty/eleventy && firebase deploy --only hosting --project ase-portal-5d37c
build_sim_harness.py lifts the real player JS out of simulator.html; the pop CSS, scroll-reveal,
shape-wrap SVGs, and wrap_images/declutter_titles/sds_prefix/fix_checks transforms live there.
The .py scripts have hardcoded absolute paths — fix them first on a different machine.
After building/editing a unit's lessons, also refresh the image-generation worklist so it carries the exact prompts the lessons use (real filenames, grouped by the precise lesson section) instead of generic category placeholders:
python3 functions/scripts/academy-build/update-image-helper.py functions/scripts/academy-build/u1_enhanced.json B2 1
That prepends every .imgph/.aiprompt from the source into question-image-helper.html's "ASE Academy"
view (idempotent per module+unit via the b2-u1- uid prefix). Mario generates those in Google Flow and
saves each under the shown filename. Re-run it for every new unit (pass its source, module, unit number).
Real lesson art is wired WITHOUT rebuilding/re-seeding the lesson doc (the live doc has in-place edits not in the source, so never rebuild it). Pipeline:
academy-media-incoming/ (repo root, outside
the build), named EXACTLY as the Question Image Helper card shows (e.g. b2-u1-l1-osha-inspector.jpg).node functions/scripts/upload-academy-media.js — uploads every file to Storage academy_media/
(bucket is public-read) AND rewrites the player's media manifest between the ACADEMY_MEDIA_START/END
markers in simulator.html (the Set of filenames that actually exist, so no 404s for ungenerated art).node functions/scripts/wire-academy-images.js [DOC_ID] [SOURCE_JSON] — patches the LIVE Firestore
lesson doc IN PLACE, injecting data-file="<filename>" onto each .imgph by matching its caption to
the source's save as filename (the build strips the dev .aiprompt, so the live placeholders carry
only captions). Idempotent; backs up the doc to academy-build/_doc_backups/ first. Run ONCE per unit
doc — after that, every .imgph carries its filename, so future image drops just need steps 2 + deploy.npx @11ty/eleventy && firebase deploy --only hosting (the manifest lives in simulator.html).
PLAYER side (simulator.html, automatic): the lesson renderer reads each .imgph's data-file; if that
file is in window.ACADEMY_MEDIA, it loads the real academy_media/ image, else the gray placeholder.
Lesson docs are cached in memory only, so a fresh load shows the new art (no cache bust needed).Lead + thumbnail-strip layout (2026-06-12, final): the consistent rule Mario approved (combines the
"one image per topic" and "lead + thumbnails" options). Each TOPIC = a run of consecutive images (one
subhead's art). The FIRST image becomes the .lesson-lead — floated, alternating lead-left/lead-right
across topics, fixed size (desktop 44% / max 440px). EVERY extra image becomes a .figstrip strip
(.lesson-thumb, click-to-enlarge) dropped at the END of that topic (before the next subhead/callout).
The strip spans the FULL page width and the images grow evenly to fill it (2 extras = halves, 3 = thirds,
4+ wraps; a lone extra centers at 680px) — Mario's request: no empty space beside the strip. So a topic with 1 image and a topic with 6 read the same — that
consistency is the point. Topic boundary = _acTopicEnd (next subhead/heading/callout). Logic is in the
image-processing pass of _acSectionToPages; the float/strip CSS is in the injected lesson CSS. (This
deliberately shows fewer big images inline; the extras live in the strip — addresses "too many big
images / no consistent pattern".)
Per-lesson HERO banners: each lesson overview (.0) shows a wide banner b2-u<unit>-l<lesson>-hero.jpg
(display aspect 2/1; falls back to placeholder). The 8 Unit 1 hero prompts are in the Question Image
Helper "ASE Academy" tab — add new units' heroes with functions/scripts/academy-build/add-unit1-heroes.py
(copy + adjust the LESSONS list for each unit; idempotent).
mockups/academy-b2-simulator-view.html (in-simulator, faithful), mockups/academy-b2-unit1-preview.html
(plain), mockups/text-formatting-styles.html (the 10 styles), mockups/academy-animations.html
(10 CSS animations Mario can pick), mockups/qz-widget-test.html (quiz widget).