Date: 2026-08-03
Status: Design approved except one open item (level wording, see §9)
Mockup: mockups/lab-skill-row-mockups.html (self-contained, opens with no internet)
Decided by: Mario, via chip interview + six mockup iterations on 2026-08-03
Mario's ask was "we need to add the lab skills the user will learn per lesson or unit per ase module." That split two ways and only one half turned out to be real work:
SPRAYGUNS, DENTTYPES, VEHICLETYPES, BATTERYTYPES, all retired
showcase decks that appear in no student menu. Nothing left to author.Placement, in Mario's words: "it should be on the lesson box, to the right side, next to the practice, test, start buttons." So the passport is not a separate page. The Academy lesson list itself becomes the roadmap, one row per lesson, each carrying that lesson's lab state.
Scope: current module only (not a career-wide record across B2 to B6).
Rendered in simulator.html, inside academySelectUnit, around line 7698. Current action group
(.atp-actions) holds: Practice icon (.atp-tp), Test icon (.atp-exam), then the open button
(.atp-open). Final order after this change:
[Practice] [Test] [ LESSON / Start ] [ LAB / Not attempted ]
Both text buttons become two-line pills sharing one geometry block:
| Top line | small label, LAB or LESSON, 8px, weight 900, letter-spacing 1.3px, opacity .68 |
| Bottom line | the value, 11px, weight 900, uppercase |
| Padding | 4px 13px 5px (cut from the original 7px so two lines cost as little height as possible) |
| Radius | 20px, matching today's .atp-open |
| Icon | wrench on LAB; play on Start/Continue, redo arrow on Review |
Both pills measure exactly 32px tall. They share one CSS rule for geometry rather than two, so they cannot drift apart when either is edited later.
emThe surrounding row CSS is written in em, which compounds. The difficulty-modal sizing failed
twice for exactly this reason (see CLAUDE.md: .diff-btn span matched both the wrapper and the
inner span). The two pill text lines are therefore fixed px with line-height: 1. Do not
convert them to em.
Three earned levels are metals; everything below is a flat outline. A metal always means the student earned something.
| State | Treatment | Meaning (see §9, wording not final) |
|---|---|---|
| Not attempted | grey outline #c3ccd4 / text #59646f |
Has not started this lab |
| Ungraded | dashed purple outline #7d5fb3 / text #5b3f8c |
Finished the lab, waiting on instructor sign-off |
| Exposed | amber outline #e8961b / text #9c5f08 |
Performed the skill with help |
| Practiced | copper, filled metallic | Performed the skill on their own |
| Proficient | warm stainless, filled metallic | Performed to standard, without help |
| Mastered | gold, filled metallic | Can do it to standard every time, and teach it |
All three metals use one shared .metal class carrying a diagonal gloss sweep.
The sweep fires ON HOVER ONLY, once per hover — it never loops (Mario, 2026-08-03).
.metal::after{content:"";position:absolute;top:0;left:-70%;width:45%;height:100%;
background:linear-gradient(100deg,transparent,rgba(255,255,255,.85),transparent);
transform:skewX(-18deg);pointer-events:none}
@media(hover:hover){.metal:hover::after{animation:shine .85s ease-out}}
@keyframes shine{from{left:-70%}to{left:130%}}
@media(prefers-reduced-motion:reduce){.metal::after{display:none}}
The pill needs position:relative; overflow:hidden for the sweep to clip correctly.
Three things this buys, all deliberate:
@media(hover:hover) keeps touch devices out of it. Phones and tablets have no real hover and
can otherwise get stuck in a hover state after a tap. The consequence to accept: students on a
phone never see the sweep. That is fine, because the metal reads as metal from the gradient and
bevel alone; the sweep is a flourish on top, not the thing carrying the meaning.prefers-reduced-motion removes the element entirely, so there is nothing to animate at all.Verified in-browser: default state has animation-name: none with the sweep parked off-screen at
-95px, the hover rule resolves to 0.85s ease-out ... 1 normal ... shine (iteration count 1), and
the word infinite appears nowhere in the stylesheet.
Exact gradients for copper, steel B and gold are in the mockup and should be copied verbatim.
All metals are pure CSS, no image, so they stay crisp at any zoom and add zero network requests.
LAB tag instead of stacked. Saved 1px of height. Not worth losing the stacked look.Shown as xN at the right end of the LAB top line, using space that was already empty.
It costs zero width and zero height: every pill is still 138px wide (154px for "Not attempted")
and 32px tall with the count present, including the two-digit x10.
Rule: the count shows from the very first rating. A rated lab always carries at least x1.
The two states with no rating yet, Ungraded and Not attempted, carry no count, because there is
nothing to count and x0 would read as an error.
| Level | Target |
|---|---|
| Exposed | about 1 run of the lab |
| Practiced | about 3 runs |
| Proficient | about 5 runs |
| Mastered | about 10 runs |
These are a guide, not a promotion rule. This matters and was raised with Mario explicitly:
LABLEVEL_V1 deliberately does not derive the level from a score or a count, because "a
student can tick every box on a lab they were walked through and still not be Proficient."
Publishing "repeat it 3 times to become Practiced" risks students reading the count as the thing
that earns the level.x3 means runs completed, while the legend's
x3 means runs typically needed. A student at LAB x3 / PRACTICED could conclude they are
owed Proficient.xN entry states outright that hitting the number does not promote you, the instructor sets the
level after watching you work. Mario accepted this framing.Practical note flagged to Mario: at 10 ratings for Mastered across 424 labs, Mastered becomes rare. That may be intended, since a gold pill should mean something, but 10 is the number to revisit if Mastered should be reachable within a semester.
Opens that lesson's own skill, not a separate passport page. Content, per Mario's choice "show the standard, hide the scored questions":
Always visible
Hidden until the lab has been done
After rating, everything unlocks, including which rubric lines they missed, their level, the date, and the rep count.
Sits at the bottom of every unit's lesson list, after the rows, inside a bento card per CLAUDE.md rule 15 (accent top border, title, short description above the content).
Layout: a vertical list, real pill on the left, meaning on the right. 9 rows total.
xN on the LAB button means.Using the real pills in the legend rather than swatches is deliberate. A student matches what is on the row to what is in the key without translating anything.
Rejected: a grid layout (three across, more compact but harder to scan), and a collapsed "What do these mean?" bar (saves room but a first-timer has to notice and tap it).
lab_grades/{studentUid} — one document per student holding every lab they have ever done, so
the whole passport costs a single Firestore read. This matches the caching discipline in
CLAUDE.md; do not add a per-lesson read.
lab_grades/{studentUid} = {
"<deckId>": { // e.g. "b5-l04"
score: 4, total: 5, pct: 80,
lines: [true,true,false,true,true], // index-aligned to the deck's rubric lines
level: "proficient", levelNum: 3, levelLabel: "Proficient",
reps: 3, // true count, may exceed attempts[].length
first: <ms epoch>,
attempts: [ ... ], // capped at 12: first + latest 11
lessonTitle: "...", lesson: "Lesson L04", module: "B5",
by: "<admin email>", at: <ms epoch>
}, ...
}
Field names are an external contract (the STC Collision Portal Phase 2 consumes them). Keep
lines[] index-aligned or old grades silently mislabel.
State derivation for the pill:
| Condition | Pill state |
|---|---|
| no entry for this deckId | Not attempted |
entry exists, no levelNum |
Ungraded |
levelNum 1 to 4 |
Exposed / Practiced / Proficient / Mastered |
The skill content lives in each lesson's labassignment block, inside the deck files and Firestore.
The menu cannot load 95 decks to paint a unit. It needs a small generated lookup, following the
existing _deck-versions.js pattern, keyed by deckId and carrying at minimum the SKILL line
(and ideally the procedure, standard, rubric and safety for the card).
functions/scripts/academy-build/build-lab-skills-doc.js already harvests exactly this content for
the human-readable doc, so the generator should be extended rather than rewritten.
Re-run it after any lesson change, same rule as build-deck-versions.js.
Precedent for the menu-side lookup: window.ACADEMY_LESSON_META (time, slides, ASE tasks).
| Check | Result |
|---|---|
| LAB pill height, all six states | 32.0px, identical |
| LESSON pill height | 32.0px, identical to LAB |
| LAB pill width | 138px, except "Not attempted" at 154px |
Effect of adding xN |
none, 138px either way, including x10 |
| Lesson row height | 75.8px, unchanged from the single-line version |
| Phone rows whose buttons wrap at 375px | 0 of 6 |
| Horizontal overflow, page and 375px pane | none |
Why the row height does not grow: the time chip and ASE task chip under the lesson title already make the row taller than any pill, so the pill growing from 28px to 32px is absorbed.
The four level definitions. These are my draft of his ladder, and students will read them as the definition of their own skill level. They are his grading language, not mine:
Two smaller ones, both with a defensible default if he does not care:
x0 instead of no count? (Default: no count.)simulator.html is ~1.3 MB. Give the build a fresh session with room._atpInjectCss, a JS string-concatenation block. Injected CSS
must be a single line with no newlines, or the whole main script fails to parse. This has
already cost one syntax error historically.simulator.html —
that pair opens a Nunjucks comment and kills the build with
"expected end of comment, got end of file". Write ){ #someId{...}, with a space.
Pre-deploy check: searching simulator.html for that pair must return 0.
⚠ This document may not contain the pair either. It is a .md under docs/, which Eleventy
renders through Nunjucks as a page, so the first version of this spec — which quoted the two
characters literally, three times — broke the build for the entire repo ("Wrote 0 files") until
it was reworded on 2026-08-03. Describe the pair; never type it.@media(max-width:760px) sets .atp-ltext to full width and
.atp-actions to width:100%. The pills must take flex:1 1 auto; min-width:0 there, matching
how .atp-open already behaves, or they will not share the line.--project ase-portal-5d37c, verified twice (CLAUDE.md rule 5a; a hook
blocks any deploy without the explicit flag)./Users/Shared/asecollisiontestprep.com, but git is now the only sync between the two Macs.