ESP32-S3 Bench Guide
ESP32-S3 N16R8 bench guide: live pinout, lessons, simulators and the bench log.
A free, offline reference and study tool for behavior technicians, analysts, and paraeducators. Plain-language definitions with sources, flashcards, practice…
EducationTypeScript · SvelteGitHub @RCJLabsOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
A free, offline-first reference and study app for people working in Applied Behavior
Analysis — behavior technicians, BCBAs and BCaBAs, paraeducators, and certification
candidates.
Status: M0 (foundation). The content pipeline, safety and copyright guards, app shell,
and CI are in place, with a small seed glossary. The full plan lives in the project notes;
this README covers how to work in the repo.
What this app deliberately does not do
These are designed features, not omissions, and several are enforced by the build:Read the full README ↗A free, offline-first reference and study app for people working in Applied Behavior Analysis — behavior technicians, BCBAs and BCaBAs, paraeducators, and certification candidates.
Status: M0 (foundation). The content pipeline, safety and copyright guards, app shell, and CI are in place, with a small seed glossary. The full plan lives in the project notes; this README covers how to work in the repo.
These are designed features, not omissions, and several are enforced by the build:
Not affiliated with, endorsed by, or sponsored by any certifying body.
npm install # also builds the workspace packages and runs svelte-kit sync
npm run dev # dev server; content recompiles on change
npm run build # production build — fails unless all content is `approved`
npm run preview # serve the built site
| Command | What it does |
|---|---|
npm run content | Run the content compiler (check / build, --channel=) |
npm run content:apply-review | Apply a reviewer's exported decisions to the content files |
npm run check | svelte-check type checking |
npm run lint | Prettier + ESLint |
npm test | Unit tests (schemas, guards, compiler) |
npm run test:e2e | Playwright, including the axe accessibility sweep |
Content is authored as files in content/, not in a CMS. Review is therefore a pull
request: you read a diff, not a web form.
content/
_registry/sources.yaml bibliography, with per-source rights and quotation permissions
taxonomy/*.yaml credential domains and task codes (facts only)
terms/<category>/*.md glossary entries
scenarios/<kind>/*.md situational guidance and escalation cards
packages/content-build validates everything and compiles it to JSON plus a prebuilt
search index. It runs as a Vite plugin, inside buildStart — not as a prebuild
script, because a script is bypassable (npx vite build, an IDE task, a pipeline change)
and a bypassable gate is not a gate.
| Channel | Allows | Used by |
|---|---|---|
dev | anything, including draft | local authoring |
pr | rejects draft | pull-request CI |
release | only approved | production deploys |
npm run build defaults to release, so a production build cannot contain unreviewed
content. An item approved by its own author is rejected too — review has to be independent.
Everything is born unreviewed and the release channel ships nothing that is not approved, so the backlog is the thing standing between the app and a release. Reviewing it all to the same depth is not realistic and would not be a good use of the depth anyway: a wrong gloss on "count" costs a reader a moment, and a wrong line on an escalation card costs something else.
/review sorts the queue into three tiers, and the tier is derived rather than
tagged, so it stays true as content is added:
Each tier shows how many items are left and roughly how long they will take, which is what turns "456 unreviewed" into a number of evenings.
Sampling is real and is recorded as such. A batch is one glossary category. The draw is seeded by the batch name and the content version, so it is reproducible, it does not depend on the order the loader returned items in, and it changes when the content changes rather than re-approving rows somebody already saw. Only the drawn items are queued — a reviewer who reads the whole batch has not sampled, and one who reads the first few has drawn a sample by convenience, which is the thing sampling exists to avoid. One flagged item in a draw stops the batch: the sample said something, and what it said was that this batch needs reading.
The rate is the reviewer's choice, from a tenth up to all of it. Nothing about that decision belongs to the author.
Carried approvals say so in the file. Term alone carries reviewMethod and
sampledWith, and every other schema is strict — so reviewMethod: sampled on an
escalation card is a parse error rather than a judgement call. The build also rejects an
approved term that does not say how it was approved, a sampled approval that does not name
its draw, and a method set on anything that is not approved. Afterwards, anybody can tell
which items a human read and which a draw carried, which is the whole point: an approval
that hides its own basis is worth less than one that states it.
Review is the one step in this project that cannot be automated away, so it has a tool of
its own. /review in the running app is a queue: one entry at a time, every field it
carries plus the author's account of what it was written from, and two buttons. Decisions
are kept in IndexedDB on the device and never sent anywhere — a decision only means
something once it is in git.
When a pass is done, the page exports the decisions as JSON, and in the repo:
npm run review:check # says what it would change, writes nothing
npm run review:apply # applies it
Neither takes a path. The newest aba-assist-review-*.json is looked for where browsers
put downloads, and the file it picked and how old it is are printed before anything is
written — picking up last week's export without saying so is how somebody re-applies a
stale pass and wonders why nothing changed. --file= still works and still wins, because
a reviewer who knows which file they mean should not have their choice guessed at. On
success it prints the commit and push that actually ships the result, since applying
decisions changes tracked files and nothing else.
That rewrites the review: block of each decided item — approved with your reviewer id
and the date, or needs-update with your note — and nothing else in the file. The diff is
the record, and it is small enough to read. Three things it refuses to do: sign an
approval as the item's author, record a flag with no note saying what is wrong, or apply
half a pass (any error and nothing is written at all).
One wrinkle worth knowing about: question files share a single review: block across a
whole file by YAML anchor. The moment one question in that file is decided differently
from its neighbours the anchor is no longer true, so the tool writes every block in that
file out in full. That is why an approval of one question can show up as a large diff.
The floor is a number — 150 approved terms of 259 — so something has to decide which 150, and the obvious answer is wrong in a way that took counting to see. The first version ranked every term by how many other entries cite it and took the top 150. Reasonable: a term the corpus keeps pointing at is one readers arrive at.
Then the set was checked against what actually has to ship. The RBT outline's 43 tasks name 141 distinct terms between them, and 27 of those fell outside the top 150 — forward chaining, backward chaining, total-task chaining, error correction, least-to-most and most-to-least prompting, scatterplot, the three-term contingency. Core technician vocabulary. Meanwhile 34 terms held places on the strength of BCBA question citations alone. A citation count has no opinion about credentials, and this corpus has three, the largest of which cites nearly every term there is.
That is not a thinner launch, it is a broken one. References into a withheld entry are pruned rather than left dangling, so the build would have shipped the RBT outline complete and approved with a quarter of its task links silently removed — on the outline this app's entire claim rests on.
So the set is assembled in the order the gate cares about: first every term an escalation card or the launch outline names (143 of them, together), then the most-cited of the rest to reach the floor. Same cost, and nothing that ships whole has a hole in it.
The same counting settled the floor itself. Lowering it looked like the cheapest possible way to reach a launch sooner, and it is not available: 150 is within seven of the 143 the required-complete kinds already name, so a smaller floor buys almost nothing and starts pruning the outline again. The number stays, on evidence rather than on taste.
LAUNCH_OUTLINE names the one outline a launch has to stand behind whole, and it is RBT
for a stated reason: the 3rd edition took effect on 2026-01-01 and most circulating study
material is still written against the 2nd. The other two outlines must be approved before
a release like everything else in their kind — they just do not get to drag the whole
glossary in with them. BCaBA names 214 terms and BCBA names 255, which is very nearly all
of them.
The tiering made the total tractable — the launch set is 170 entries, of which about 80 have to be read, for something under three hours rather than a thousand items — and three hours still did not get done, for a reason that has nothing to do with the number. It is not a thing anybody sits down and does. Ten items is.
So /review offers a sitting: pick 5, 10 or 20, see roughly what it costs before
starting, get a position and a bar while you work, and get a finish line that says what
was decided and offers the next one. The sizes are deliberately small, and each is priced
from the tiers of the items actually about to be offered rather than a corpus average,
because tier A and tier C differ by four times and the question being answered is "have I
got time for this right now".
Progress is derived from the stored decisions rather than kept in a counter. A counter would have to be incremented by every path that records a decision, and the first path anybody forgot would report a sitting that did not happen — on this page, that means overstating how much of the corpus a human has read, which is the one number here it would be worst to inflate. Deriving it also means a sitting survives a reload or an evicted tab for free, which matters because a reviewer doing this on a phone will hit both. Terms carried by a draw are excluded: carrying a batch is what you do instead of reading it, so counting thirty carried terms would finish a sitting of ten without anybody reading anything.
The keys matter as much as the sitting does, because the cost of a review is the friction
between items. a approves, f starts a flag by putting the cursor in the note,
Ctrl or ⌘ with Enter submits that flag without leaving the keyboard, s skips, b goes
back and ? lists the lot. Flagging used to be click, type, click while approving was one
key, which had it backwards: the flag is the decision that carries information. A flag
still requires a note, because a flag nobody can act on later is not a flag.
Two real defects surfaced while testing that flow, both unreachable at mouse speed:
decide awaited the write before
the queue updated, so the second press still read the old current, and the item after
it was stepped past without ever being decided. Decisions are chained now, so the second
press waits and then reads the queue fresh.a typed the
letter a into the textarea instead of approving. Focus now moves to the new item's
heading after every decision, which also tells a screen reader the card changed.A third turned up in the quiz, surfaced by the same suite on a slow device rather than by
the review work itself. countAvailable read the exam off the state, awaited that exam's
bank and then wrote a count — so a load started for one exam could land after the reader
had moved to another. The ordinary sequence hits it: the prerendered form ships with the
technician exam selected and hydration counts against it, then the saved filter switches
to the assistant exam a moment later. Pick area I, which the technician outline does not
have, and the late technician answer filtered to zero — "0 questions available" and a dead
Start button over a bank of twenty-one. Both loads now carry a token and discard their own
result if a later call has superseded it, with a token each rather than a shared one, since
one would have each cancelling the other's perfectly current answer. The regression test
holds the technician bank back by matching a question id only that bank carries, so the
interleaving is forced rather than waited for; it fails without the guard.
Both make the wrong thing impossible to express, rather than something a reviewer has to notice:
Copyright. Every content schema spreads in OfficialTextGuard, where
officialText and officialTitle are typed z.null(). Writing verbatim source text
into a content file is a parse error. Schemas are strict, so a stray officialText2:
fails too. Rights live on the source, so the same machinery covers textbooks — the
higher-risk case — not just certifying-body documents.
Safety. Scenarios are a discriminated union whose members have different shapes.
kind: 'escalation-only' has no steps field anywhere in its schema, so writing
procedural instruction into a restraint, self-injury, or suspected-abuse scenario is a
parse error. A risk lexicon over guidance prose catches the author who forgot the
flag, and escalation completeness is checked by rule (medical emergency ⇒ 911;
suspected abuse ⇒ protective services plus a mandated-reporter note; and so on).
packages/*/src/*.test.ts assert that each of these guards actually rejects — a guard
never observed to fail is a comment, not a guard.
The simulator was built to refuse to pad a thin bank by repeating items, so a short bank never produced a wrong number on screen — it produced a shorter paper and said so. That is honest, and it is also not the product: the technician exam is 85 items and the bank held 65, so the headline feature quietly was not the thing it said it was.
Coverage is now checked at build time (checkExamCoverage) and held by a test. The build
warns per area when the bank cannot fill one full paper, and warns for any task on the
outline that no question cites. The test in src/lib/quiz/blueprint.test.ts is the
ratchet: it fails if the technician bank cannot run a full-length paper, if any area drops
below 1.4× the items that area contributes to one paper, or if any of the 43 tasks has no
question. Raise RBT_MIN_RATIO as the bank grows and the build gets harder to pass, which
is the only mechanism that reliably stops content rot.
The BCBA bank is still far short and the build says so on every run — that is a backlog being named rather than a defect being hidden.
The scenario schema forbids procedural language inside an escalation card: physical management is a certified hands-on competency, and an app that describes it is doing harm whatever its intentions. That check ran over the whole card, which meant the single most important refusal the app can make — you have been told to restrain or seclude a learner — could not be written, because its title contains the word.
A card whose entire purpose is to refuse has to be able to say what it is refusing. So the check now applies two standards to the two halves of a card, which do different jobs:
restraint and seclusion may be named — and only when
the matching riskFlag is declared, so naming a situation and classifying it cannot come
apart.Every other word in the lexicon (hold them, prone, escort, pin, takedown…) stays
forbidden everywhere. Four tests hold the shape: the card that names what it refuses is
accepted; naming a restricted procedure without the matching flag is rejected; a situation
that describes how it is done is rejected; and the named word inside the escalation block
is rejected, flag or no flag. The build caught a genuine slip while this was being written
— "staff holding a current certification" in a legal note — which is the guard working
rather than a reason to loosen it further.
/ opens with one choice — RBT, BCaBA, BCBA or everything — and that choice drives the
app: the glossary, search, flashcards, the quiz and the study plan all follow it, and so
does the tracker. It is the shared content filter promoted to the front rather than a new
idea, which is why it costs almost nothing and why it persists across visits.
Making it mean one thing required fixing something first. The app kept two credential
states: the content filter, and the tracker's own role for whose supervision and
development requirements are being checked. Choosing Analyst on the home page and then
finding /tools still checking a technician's requirements is exactly what makes a mode
switch feel decorative, so the mode now writes both. Choosing "everything" deliberately
leaves the tracker alone — it has to be checking somebody's requirements, and silently
resetting it would discard a choice made on purpose.
Three constraints shaped the rest of the page:
The radio inputs are stretched over their chips rather than hidden at a pixel: the radio is the control, so it has to be the target. A 1px control fails WCAG 2.5.8 even when the label beside it is comfortably large, and the target-size test caught exactly that.
/plan reads what is already stored — per-area totals on every quiz attempt, the
scheduler's record of which terms have been seen — and turns it into a ranked list of
next actions. Nothing new is recorded to produce it, so it works offline and tells nobody
anything.
The arithmetic is the easy half. What the page is really for is refusing two numbers that would be the most reassuring things on the screen and the least supported:
Even once there is a number, the page says what it is: an accuracy on this app's questions. Not a score, not a prediction, not a probability of passing. A bank written by one author is not a calibrated instrument and has never been validated against the real exam, and an app in this market that implies otherwise is doing the thing the reviews of its competitors complain about.
Actions are ranked by how much the exam cares rather than by how bad the number looks: an area worth a quarter of the paper at 70% outranks one worth a twentieth at 50%, because that is where an hour is best spent. An unsampled area is scored as a coin flip plus a small bonus for the value of finding out — enough to prefer measuring over practising at equal weight, not enough to send somebody to sample a light area ahead of a heavy known weakness.
Data collection and graphing is the second-largest domain on the technician exam, and
this app taught it entirely in words: eight glossary entries describing a line graph to
somebody who had never been shown one. /graphs is six worked graphs — the parts of a
line graph, a change in level, a baseline that was already improving, data too variable
to read, a reversal, and a multiple baseline — each one built from a reviewed YAML
document under content/graphs/ and rendered as SVG at runtime.
Three guards are structural rather than editorial:
fictional is z.literal(true). Every series in this repository is invented to
show one thing. A graph of one person's behaviour is that person's data however the
axes are labelled, so "this is real" is unsayable rather than discouraged — the same
move as the copyright and supervisee-code guards.longDescription is required, and the build checks it names every condition on the
graph. A picture with no equivalent is not accessible content, and an alternative
that omits a phase is not an equivalent. The renderer also emits the full data table,
so the numbers are always reachable without reading pixels.y.from other than zero without a
yAxisNote fails the build, and a note without a truncated axis fails too. A scale
that starts part-way up is the commonest way an accurate graph misleads, and it is
exactly what this domain is about.The build also rejects a point plotted outside the axes (the renderer would clip it silently), conditions that leave a gap or overlap, two series that would differ by colour alone, and a multiple baseline whose tiers change at the same time — which is an AB replicated three times wearing a multiple baseline's clothes.
One rendering rule is load-bearing enough to be unit-tested rather than eyeballed: the data path is split at every phase change. A line drawn across a phase-change line asserts that the sessions either side belong to the same condition, which is the exact comparison the change was made to allow. A staggered design is drawn as stacked tiers for the same reason — one frame with three sets of phase lines says that every change applied to every behaviour.
/tools logs supervision contacts and professional-development units against the real
requirements. It is the one place in the app where somebody types about their working day,
so it is also the one place a client's name could end up in storage — and storing none is
what keeps this app outside HIPAA entirely.
The defence is the data model, not a warning. There is no clientName field, no dob, no
address: a supervisee is identified by a code constrained to ^[A-Z]{1,3}[-_ ]?\d{1,4}$,
which cannot spell a name, and contact formats are enums. The one free-text note runs a
client-side linter that warns on names, dates of birth, phone numbers, addresses and record
numbers. It warns rather than blocks, because it cannot tell "Jamie Rivera" from "Safety
Care" and a blocker that fires on the second teaches people to work around it.
The thresholds it checks against — 5% of monthly service hours, two contacts, 12 PDUs, 32
CEUs with 4 on ethics — are not constants in the code. They live in content/credentials/
beside the prose that states them, carrying the same handbook locator and going through the
same review queue, so there is one copy of each number rather than two that drift.
Everything under /tools was first-person: your fieldwork, the supervision you received,
your development units. A behaviour analyst supervising four technicians had nothing —
odd, given that they are the person the requirement is written at. The technician has
to receive the supervision; the analyst is the one who has to be able to show it was
delivered, per person, per month, if anybody asks.
/tools/caseload is that record. Contacts are still logged once, on the supervision page,
with a supervisee chosen; this reads them back the other way round. Two places to record
the same contact is two places for them to disagree.
Building it surfaced a real defect. One store holds both directions — a contact with no supervisee is supervision you were given, one with a supervisee is supervision you provided — and the monthly summary counted both. So an assistant analyst supervising technicians at their own organisation had their own 5% padded by the supervision they delivered, and the page told them a month was met that contained no supervision received at all. That is the worst thing a compliance tool can do, and no test caught it because every fixture logged one direction at a time. Both directions are now filtered explicitly, with unit and end-to-end tests that fail if they are ever pooled again.
The requirement comes from the supervisee's role, not the reader's. This one took a failing test to see, and it is the crux of the whole page: a BCBA has no ongoing supervision requirement, because they do not receive supervision — and a BCBA is exactly who is doing the supervising. Judging a caseload against the supervisor's own credential showed the person the page exists for an empty page. Each supervisee is measured against their own rule: the technician percentage for an RBT, the assistant-analyst one for a BCaBA.
A trainee gets no verdict at all, deliberately. A fieldwork trainee's supervision is
governed by the fieldwork requirements — a share of fieldwork hours with its own monthly
floor and ceiling — and measuring them against the monthly ongoing-supervision percentage
would be a confident wrong answer. The page says so and points at the tracker that does
know that rule.
The percentage needs the supervisee's hours, which nothing else in the app knows, so the supervisor records them — the same figure the technician gives their supervisor on paper. Without it the month reads "could not be judged" rather than short, the same way the reader's own months already do.
It prints, and it is careful about what it claims to be. A supervision attestation is
a document both parties sign and whose wording belongs to a certifying body or an
employer; producing something that looked like one would be this app claiming an authority
it does not have. What it produces is the evidence — who, when, how long, one-to-one or
group, observed or not, and the rule each month was judged against — with signature lines,
described as something to attach to whatever form is actually required. The contacts are
listed rather than folded into a <details>, because a disclosure cannot be reliably
opened for printing and a record whose working prints folded away is a claim with nothing
under it.
No client appears anywhere in it, structurally: a supervisee is a code, and the only free text is the note on a contact.
The export is the artifact and the app is the convenience. Fieldwork is verified from
documentation, sometimes years later, by somebody who will not accept "it was in an app" —
so /tools/fieldwork exports the whole record as four CSVs rather than a dump of what was
typed in:
The fourth is the one that is easy to leave out and the one that makes the other three auditable. A spreadsheet of hours with a "short" column and no statement of the threshold asks the reader to trust an app they have never seen.
Two details that are easy to get wrong. The restricted ceiling is derived, not written down: the rule is 40%, so a record built around a literal 800 is right only for a 2000-hour run and wrong for anybody accruing concentrated hours, where the same rule comes to 600 of 1500. And a ratio the handbook judges month by month carries no cumulative verdict — individual supervision is reported as a figure with "judged month by month; see the months file", because a cumulative percentage with "met" beside it would be a claim the handbook does not support.
Four files rather than one .xlsx with tabs, deliberately. A real workbook needs a
spreadsheet library, and this is an offline PWA whose whole free corpus is about 1.5MB —
several hundred kilobytes of formatting code would ship to everybody who opens the
glossary. CSV is what Excel and Sheets both import natively.
/tools/fieldwork is for analyst trainees, and it is deliberately not a progress bar with
hours in it. Supervised fieldwork is verified one calendar month at a time: a month below
its floor is worth nothing, not less, and hours above its ceiling are dropped. Somebody
who adds their hours up and watches the number climb can arrive at verification a year
later hundreds of hours short of what they thought they had, with nothing to be done about
it. So each month gets its own verdict with the reason attached, while there is still time
to fix the month — and the summary reports the hours that will not count as a figure of
its own, because that is what turns "I logged 2000 hours" into "1840 of them count".
Two ratios in the handbook — how much supervision must be one-to-one, how much of the work
must be unrestricted — are stated as requirements without the text saying whether they are
checked inside each month or across the whole experience. That difference decides whether a
light month is a lost month. So the tracker shows those two as figures and withholds the
verdict, saying plainly that it has not verified the scope and that the supervisor is who
to ask. It is the same posture as standardsVerified in the ethics content: an unverified
number is reported, never ruled on.
The numbers themselves — the floor and ceiling, the supervision percentages, the contact
counts, the 1.33 multiplier on concentrated hours, and the second ruleset that takes effect
in 2027 — live in content/credentials/bcba.yaml with their handbook locators, not in the
code. The schema rejects a scopeVerified: true ratio with no scope, a scope claimed
without verification, and a multiplier that does not reconcile with the two hour totals it
sits between.
A fieldwork period held a single supervisor code, set when the run started, and the exported record stamped it on every month. For anybody who never changed supervisor that was right. For everybody else it was silently wrong in the one artifact that has to hold up years later: a trainee who moved organisations produced a record attributing a year of earlier months to whoever happened to be current, with nothing on the page or in the spreadsheet admitting it.
The monthly verification form is completed per supervisor, so the code belongs on the month. It is there now, prefilled from the month before so the common case costs no typing, and visible as a field because the month it changes is the month somebody has to notice. The period keeps its code as the supervisor the run started with, and the export labels it exactly that rather than pretending to answer the wider question. A month supervised by two people is not modelled: the record names the one who signs for it and the note holds the rest, which is stated on the form rather than left to be discovered.
This is the first migration in the ladder that rewrites rows instead of adding a store, so it is also the first that can quietly do nothing and still leave a database that opens. The backfill copies each period's code down onto its existing months and is covered by a test that walks a v8 install through it — checked by breaking the backfill and watching that test go red. Nothing downstream depends on it having run: every reader treats a missing code as "not recorded", which is both the safe direction and what the backfill writes anyway.
Hours supervised by somebody who did not meet the supervisor requirements are worth nothing — not reduced, not prorated, gone. That is a larger loss than any monthly shortfall the tracker was already catching, and it is invisible from a log of hours: every month can look faultless and the whole run still be void. Nothing in the app mentioned it.
It cannot be checked here, and that shapes the design. Whether somebody holds an active certification, has held it a year, and is current on the supervision continuing-education requirement are facts about another person, on a registry this app cannot reach and must not cache. So the app asks, records the answer with the date it was given, and says in as many words that what it holds is the trainee's confirmation rather than a verification. Every row of the exported supervisors file says the same.
The checklist lives in content with its handbook page, not in code, so a handbook revision is a content edit rather than a release. Each item carries an id, so a confirmation survives the wording being improved.
Three deliberate calls:
Unconfirmed and incomplete are different states. Nobody has looked yet is the ordinary starting state of every record. Somebody looked and one item did not hold is a finding, and the more urgent of the two. Collapsing them would bury the second in the first.
None of it touches the hours. Zeroing somebody's credited hours because they have not filled in a checklist would be the app inventing a finding. The page says loudly what is unconfirmed, names how many months rest on each supervisor, and leaves the arithmetic alone.
The contract date is the one part that is arithmetic. Hours accrued before a supervision contract exists do not count, and a contract has a date and so does a month, so that is checkable rather than a prompt — and it is stated as a finding. A month the contract was signed inside is not reported: the log holds months, not days, and a false alarm on a compliance page teaches people to ignore the true ones.
Finding this cost one real bug, and it was the Svelte 5 hazard this repository has a
written rule about. The confirmed-item list arrives from a $state array, $state
returns a Proxy, and structuredClone on a Proxy throws DataCloneError — so the write
failed at the IndexedDB boundary with an error nothing on the page surfaced. The box
ticked, the form closed, and nothing was saved. It is the first array to reach the storage
layer from a component, which is exactly the case the rule was written for; it is
snapshotted now, and the end-to-end test that caught it would have caught it silently
failing again.
Hours and signatures are now counted separately. The rules decide whether a month's hours count; a signature on the monthly verification form decides whether they can be shown to anybody. A month can be faultless on the first and missing on the second — and reporting that month as "short" would send somebody to redo work that was fine, while leaving it silent loses the one chase worth making while the supervisor who was there still remembers it.
So the signature is recorded per month with its date, it never touches creditedHours or
a month's standing, and the page names the months still waiting rather than folding them
into a number. verificationSigned starts false on every backfilled month: nothing in the
old data says a form was signed, and inventing that claim on somebody's behalf is the one
thing a compliance record must not do.
Two smaller gaps closed alongside the supervisor checklist.
The final verification form is a separate document from the monthly ones and the last thing standing between a finished run and a submitted one. The monthly forms have been tracked since the signature work; this one had nowhere to live. It sits on the period, prints with the record, and appears in the export's first file.
The largest group supervision meeting is now recorded per month — and deliberately reported rather than judged. The handbook caps how many trainees a group may hold, and this app has not read that figure at source. A compliance tool that fails a month against a threshold it invented is the exact failure this whole corpus is built against, so the number is kept, shown, and handed to the reader with the limit named as something to check with their supervisor. The same posture as the ratios whose scope the handbook does not settle, for the same reason.
Recording it now is the entire point. It costs a moment at the time and cannot be reconstructed two years later, and if the limit turns out to have been exceeded, the month it happened in is precisely what nobody will remember. The figure only appears when there were group hours at all — asking about the size of a group that did not happen is noise — and it says so when group hours were logged with no size against them.
One accessibility fix fell out of this. Adding the final form put two date fields labelled "Signed on" on the same page, which is unambiguous visually and useless to anybody navigating by form field: a screen reader announces the same name twice with nothing to tell them apart. They are "Monthly form signed on" and "Final form signed on" now. The end-to-end suite caught it as a strict-mode violation, which is the accessibility problem stated in the test runner's own terms.
The exported spreadsheet has shipped a requirements file since it was written, for a reason stated in it: a column saying "short" with no statement of the threshold asks the reader to trust an app they have never seen. The printed page had exactly that flaw. Each month's checks carry their thresholds, but nothing said where any of them came from, so a supervisor reading the paper could not verify a single figure without opening the handbook and guessing at the page.
The record now prints the rules it was judged against, with the handbook page for each,
and ends with somewhere for both parties to sign and a plain statement that this is not
the verification form — the same posture as the caseload page, and for the same reason: a
document that looked like the real form would be this app claiming an authority it does
not have. The signature rows cite the documentation page rather than the hours page,
which is why the credential schema gained a documentationLocator: a citation that does
not check out is worse in an auditable record than no citation at all.
Fixed a test of mine while there. The fieldwork end-to-end helper waited for a month card to appear only when there were no cards at all, so every save after the first raced whatever the test did next. Waiting on the card count instead turned out to be wrong too — saving a month that already exists replaces it rather than adding one, which is how a correction is made here and has its own test. It now polls the stored row, which covers both.
/tools/timer is a repeating cue for partial interval, whole interval and momentary time
sampling. It exists rather than a link to a stopwatch because the three procedures differ
in when you score, and each biases the estimate a known way — partial overestimates,
whole underestimates, momentary is closest — which the setup screen says before the run
starts, because a technician who does not know that hands their supervisor a number
meaning something other than it appears to.
It shows a running percentage of intervals so the totals can be copied onto whatever data sheet the organisation actually uses, and then it throws them away. A per-interval record of one person's behaviour is client data however anonymous the row looks, and the promise that this app holds none is worth more than the convenience of keeping it. Only the settings persist.
Two implementation notes. Timing is a start timestamp plus arithmetic, never a counter: browsers throttle a hidden tab's timers to roughly once a minute and phones lock, and a drifting interval timer silently invalidates the data collected with it — so coming back catches up to the clock instead of resuming where the last tick left off. And the app takes a screen wake lock for the duration, because this is a tool somebody watches for ten minutes without touching.
Every other thing this app knows about a reader comes from asking them a question: a quiz grades an answer, a drill scores a recording, a flashcard asks for a recall. All three cost something to produce, which means they only ever measure the people willing to be tested. On a free reference app that people open in a hallway between sessions, that is a minority. Opening a term costs nothing and happens anyway.
So the app records it: one row per entry, with a count and a last-seen time, on the device
and nowhere else. The glossary offers the way back to what you were reading, and
/progress shows what you keep returning to — in a section that sits outside the
"nothing to show yet" branch the rest of that page lives behind, because the reader this
is for is precisely the one who has never finished a quiz.
The wording around it is the actual feature. A repeated lookup is not a knowledge gap, and saying it is would be a claim about somebody the app has never assessed:
The page therefore says it has seen you open these more than once, names those limits in two sentences, and leaves the diagnosis to the person it is about. Same posture as the study plan, which refuses to produce a score for the same reason.
Three details that are load-bearing rather than incidental. A visit inside thirty
minutes of a counted one does not count again, tracked against its own countedAt
rather than the last-seen time — deduping against last-seen would let somebody re-reading
every twenty minutes all afternoon record a single lookup, because each visit would push
the window ahead of itself. The title is stored on the row rather than resolved at
render: looking four kinds of id up means importing the scenario, ethics and graph corpora
into every page that shows the list, which is the same 450KB the corpus split exists to
avoid. And recording is an $effect keyed on the id rather than onMount, because one
component serves every slug — a mount hook records the first entry of a session and
nothing after it, which is a bug an e2e test was checked to catch.
It is on by default and it is a reading history, so both halves of that are answered
rather than assumed. Settings carries a switch and a separate clear button, because "I
would rather you did not keep a list of what I read" is a different request from "delete
everything" and answering the first with the second would cost somebody years of
supervision records. It rides along in the backup and goes with the erase. It is
deliberately not counted by hasStoredData, which decides whether to warn that data
has gone: a reader who has only ever browsed has nothing they would grieve, and greeting
them with "your progress has been deleted" is the false positive that teaches people to
ignore the true warning.
Settings has warned about this from the start: Safari and iOS clear a non-installed site's storage after about a week of inactivity, which for spaced repetition is exactly backwards — the reader who studies once a week is who the scheduling is for, and who loses it.
What nothing did was notice afterwards. A reader whose deck had been cleared opened
/study and read "nothing to review yet", opened /progress and read "no finished
sittings yet" — the same words a new reader sees. The app that had just lost a year of
supervision records said nothing about it and offered no restore, which is the difference
between an app with a known limitation and an app that looks broken.
So the app now keeps a witness: a timestamp in localStorage recording the last time it saw
a database with something in it. If that witness exists and IndexedDB is empty, data was
cleared, and /study, /progress and /settings say so and offer the backup file.
The limit is severe and is stated in the code rather than glossed. WebKit's cap covers all script-writable storage together — localStorage, IndexedDB, the Cache API and service worker registrations go in one sweep. A witness in localStorage therefore cannot survive the eviction it exists to witness, and the canonical Safari case is undetectable from inside the origin by any means available to a page. That case is handled the only way it can be: by saying in Settings, in advance, that this browser will do it.
What the witness does catch is every loss that takes IndexedDB and leaves the rest: quota eviction under storage pressure, a clear of site data that misses localStorage, and — the reason this earns its place regardless of browsers — a migration that completes and leaves empty stores. The migration ladder is this app's own code, and a bug there presents to a reader as exactly the same silence.
Three things it must never do, each with a test:
hasStoredData throwing is
passed on as null, not false.The notice can also be dismissed, because somebody with no backup can do nothing about it and a notice you cannot act on and cannot dismiss is a scold on every visit.
The defining complaint about the incumbent apps in this field is wrong answers with confident explanations and a report button that goes nowhere — one review put it as "finding inconsistencies multiple times makes it hard to trust anything on the app". This app has had a working report button from the start, and until now it showed nothing back. Asking people to report errors and never showing what became of any of them is the same promise those apps made.
/corrections has two halves.
What has been corrected comes from content/_registry/corrections.yaml. Each entry
carries wasWrong and nowSays as separate required fields, both long enough to be an
account rather than a label: "fixed a typo in negative reinforcement" tells nobody whether
they learned the wrong thing from it. foundBy: reader-report is recorded separately
because it is the category that proves the button works. No entry names a person.
What is known to be wrong now is derived from review status rather than authored — an
entry a reviewer flagged carries needs-update, and that is already the fact. This is the
harder half of the promise: anybody can list their fixes, and saying "this one is wrong and
we have not got to it" is the part that has to be true to be worth reading. The reviewer's
own note is deliberately not published. It was written for whoever would do the fix, and
retroactively making internal notes public is not a decision to take on somebody's behalf.
Two build rules. A correction naming an entry that is not in the corpus is an error,
because a dead reference on the one page whose job is to be trustworthy is worse than no
page, and ids do get renamed. And a missing corrections.yaml is an error rather than
"no corrections yet" — absence and emptiness look identical to a reader, so treating them
the same would let a deleted log silently erase the record of every mistake this app has
admitted to.
The page is empty today and says so in its own words. Nothing here has reached a reader: the site publishes as a preview with the banner on and search engines kept out, until the launch set has been through review. An empty log is worth more than an invented one, and the machinery has to exist before the first correction rather than after it.
Nothing is translated and nothing here translates anything. What landed is the shape a translation has to take, settled now because settling it later is expensive in a specific way: an approval is a claim about a particular piece of prose, and the shape decides whether approving an entry means "this file is reviewed" or only "part of this file is reviewed". Two hundred and fifty-nine entries carrying approvals is a bad moment to discover the answer was the second one.
A translation is a separate file holding only the translatable prose, pinned to the
exact version of the entry it renders. It lives at
content/translations/<lang>/terms/<slug>.md, and structure is inherited from the source
entry rather than repeated.
Two shapes were rejected, and the reasons are different.
A field on the term would mean building a second review system. Review status is per
file in every mechanism this repository already has — checkReviewStatus takes one review
block, the review queue lists one row per entry, apply-review writes one status per file,
CODEOWNERS routes by path. A nested translation needs a review ladder inside a file, and
nothing reads reviews that way. It would also mean every translation added later edits a
file that is already approved, and a Spanish reviewer reading a diff inside a file that is
mostly English.
A parallel tree of whole entries — content/es/terms/*.md holding complete documents —
is the tempting one, and it duplicates everything that is not language. category,
taskRefs, ethicsRefs, contrastWith, seeAlso, searchBoost and citations are
facts about the concept, not about the English sentence describing it. Duplicated, they
can disagree, and this corpus validates exactly those fields across entries: contrast pairs
must be symmetric, seeAlso must resolve, aliases must not collide, task codes must exist
in the outline. A second tree would run all of that again over a graph that can silently
diverge, or skip it. A seeAlso that resolves in English and dangles in Spanish is a new
class of broken link invented for nothing.
So a translation file has no field in which a structural fact could be written. Same move
as officialText: z.null().
Each translation carries its own review block, and a translation ships only if three things hold. Its own status is shippable — approving the English says nothing about a rendering the approver may not be able to read. The source entry ships — translating unreviewed content does not review it, and an approved Spanish rendering of a withheld English term would be the one route by which unapproved clinical prose reached a reader, arriving looking checked. And it is not stale.
Staleness is the rule that makes the rest work. translates.version must equal the source
entry's provenance.version, which already exists and is already maintained — a content
hash was the alternative and would turn every whitespace fix into a false alarm. Behind the
source means the English has been edited since, so a release withholds it and the build
says which versions disagree. Ahead of the source is an error in every channel: there is no
such version, so somebody edited the pin instead of re-reading the entry, which is exactly
the move that silences a staleness warning without fixing anything.
Examples are rendered one for one, in order, and the counts have to match. An extra example is not a translation of anything — it is new content arriving through the door with the fewest checks on it.
This runs opposite to intuition and it is the most load-bearing thing here. Two of the
editorial gates on English prose are English by construction. HOUSE_SPELLINGS maps
British spellings to American ones; pointed at Spanish it fires on nothing, which is not a
pass, it is an absence dressed as one. fleschKincaidGrade counts English syllables, and
it is a hard gate on definition.plain — pointed at Spanish it returns a number with no
meaning and then blocks or approves on it. Neither runs, and LANGUAGE_CHECKS records per
language which ones exist.
The consequence is that SampledApproval is deliberately absent from the translation
schema. A sampled draw is a defensible way to approve ordinary English definitions that a
dozen other rules have already been over, and an indefensible way to approve prose that
none of those rules can read. Every translation is read.
The same reasoning is why only glossary entries are translatable, enforced by typing
kind as a literal and by rejecting any other folder. Situations, ethics topics and
escalation cards are guarded by lexicons that read their prose for procedural instruction
and crisis language — the machinery that makes writing a restraint procedure into an
escalation card a build failure. Those lexicons are English. Translating a safety entry
into a language whose lexicon does not exist would route the guard and produce an unguarded
escalation card that the build reported as clean. Definitions carry no procedure, which is
what makes them the safe thing to start with.
The translator's attestation is a different shape from the author's, for the same reason.
Attestation.originalProse is z.literal(true) — "I wrote this myself, in my own words" —
which is false for a translation, and a schema that asks somebody to assert it teaches them
that the attestation block is a formality. A translator asserts faithfulRendering and
noNewClaims instead. The second is the safety-relevant one: this app explains what things
mean and refuses to say what to do, and a translation that helpfully expanded a definition
into advice would walk past every lexicon, which in that language is not running anyway.
Not one index holding both, for three reasons in order of weight. A merged index doubles a
lazily fetched download for a reader who uses one language. prefix matching and
fuzzy: 0.2 across two languages at once produce cross-language false hits that
boostDocument cannot cleanly suppress without storing a language on every document and
paying for it on every keystroke. And a translated corpus will be much smaller than the
English one for a long time, so merging would scatter a few Spanish results through a field
of English ones instead of letting the app say plainly how much is available.
What ships per language is the merged entry — the source term's structure with the
translated prose written over it — so a Spanish reader fetches one file and never touches
the English corpus at all. The alternative was shipping translations alone and joining them
in the browser, which means downloading the English to render the Spanish, and a join that
has to work offline. The merge keeps the translation's review and provenance rather than
the source's, because the Spanish approval is what let it reach a reader — and it carries
no top-level attestation at all. The author's asserts original prose and names what they
consulted, and the glossary page renders that line; carried onto translated prose it would
be a claim about text that is not on the page. Both attestations sit inside a translation
block instead, each beside the prose it describes, which also forces whoever builds the
Spanish page to decide what to show rather than silently showing the wrong one.
A languages.json manifest says what is actually available, and is not emitted when there
are no translations — so the absence of a language picker is the default rather than
something that has to be switched off. Per-language entries ship as one file rather than
bucketed by category the way English is; bucketing pays for itself at a few hundred
entries, and is worth revisiting when a language approaches the size of the English corpus.
No language picker, no route changes, no UI. There is no content to show, and building the surface before the corpus exists means guessing at what it has to display. What exists is the storage decision, the build rules that make it mean something, and the tests that prove each rule fires — including the two that matter most, checked by breaking them and watching the suite go red: a stale rendering is withheld from a release, and an approved translation cannot carry an unapproved entry into one.
One rule is dormant. A file's declared lang must agree with its folder, which catches a
file copied from one language's folder into another's — a mistake that is silent and
unrecoverable if the language is merely derived from the path. With a single language the
enum reaches that case first, so the rule cannot fire until there is a second language,
which is exactly when the copy becomes possible to make. Its neighbour is live and replaced
a silent failure: an unrecognised folder under translations/ used to be skipped without
comment, so somebody could start translations/fr/ and get a green build over work nothing
had read.
Being right about 2026 is what this app is for, and the failure mode it was built against is the one killing the incumbents: content keyed to an edition that has since been replaced, still on sale, with nothing on the page admitting it. Every one of those apps was accurate the day it shipped.
Review.nextReviewDue had been in the schema from the first commit. No content file ever
set it and no code ever read it — a staleness mechanism that existed as a field name,
which is worse than none, because it makes the gap look handled.
Only some content can go stale, and saying which is the design. A definition written from Michael 1982 does not rot; the paper is not going to be reissued with different contents. What rots is anything restated from a document a certifying body maintains and republishes — task codes, exam weights, cycle lengths, unit counts. So the requirement attaches to four kinds (outlines, credentials, ethics codes, competency models) rather than to all 259 terms, where it would be noise burying nine files that matter. It attaches to the kind, so a new credential is covered the moment it is added rather than when somebody remembers.
Two rules, deliberately of different severities:
staleness/no-due-date is an error in every channel. It is a structural question —
nobody decided how long this fact was good for — so it is refused without reference to
the clock. No build can start failing because a date rolled over overnight.staleness/overdue only stops a release. That is the difference between a ratchet
and a time bomb. An overdue handbook must not ship as though it were checked, and must
also not block an unrelated fix at three in the morning. The release channel already
downgrades to a preview, so an overdue fact does exactly what an unapproved one does: the
app still publishes, carries its banner and stays out of the index until somebody looks.Both were confirmed by backdating a real file and watching: on pr a warning and a build
that still succeeds, on release an error naming the date and how many days have passed.
The dates are our re-check horizons, not dates anybody publishes, and each one says why in the file beside it. The technician handbook and competency model are anchored to January 2027, when the recertification change lands and those numbers are most likely to be wrong; the analyst outlines to the same month, when the applicant cutoff makes the edition worth confirming; the ethics codes to mid-2027, since both have been effective and unchanged since 2022.
/about shows the whole schedule, because a build rule only a maintainer sees is half a
mechanism. The list the page renders and the list the rule enforces come from one function,
so they cannot drift into disagreeing about what is covered.
Two audiences read every page before any human does, and both were being handed a bare
document: no og: tags anywhere in src/, no JSON-LD, no canonical link. Pasting a term
into a work group chat produced a naked URL, and a glossary — the one content shape
search engines have a dedicated vocabulary for — described itself as ordinary prose.
One Seo.svelte, used by all 34 pages that have a title, rather than defaults in the
layout that pages override. <svelte:head> does not deduplicate: a layout default plus a
page override gives two og:title tags, and which one a scraper picks is not this app's
decision to make. An e2e test counts each tag and fails at two.
The absolute URL problem, and why it is a build-time constant. A canonical link and a
share card both have to be absolute, and nothing at runtime can work out what the origin
is — these pages are prerendered, and during prerendering SvelteKit's own page.url has
the origin http://sveltekit-prerender. So ABA_SITE_ORIGIN is baked in by Vite, and the
deploy workflow resolves it in the same step, from the same static/CNAME check as
the base path. That pairing is the point: an origin that disagrees with the base path
publishes canonical links pointing at pages that are not there, which is worse for
indexing than having no canonical link at all. Both shapes are verified —
https://rcjlabs.github.io/aba-assist/glossary/tact today, https://aba.rcjlabs.com/…
once the DNS record exists.
The structured data is deliberately thin. DefinedTerm on each entry, DefinedTermSet
on the index, WebSite on the home page, and nothing else. The failure mode for this
markup is overstatement, and it is punished by having rich results withdrawn, so there is
no author (entries are unsigned until a human reviewer approves them), no
datePublished the content files could not support, and no rating of any kind. The
index does not list its 259 entries in hasDefinedTerm either: it is allowed, and it
would add well over a hundred kilobytes of JSON to a page whose own HTML is smaller than
that, to duplicate the list already rendered below it. Each term points back at the set
instead, which is the same graph from the other end and costs nothing. There is no
SearchAction because there could not honestly be one — search runs in the browser
against a downloaded index, so there is no query URL to send anybody to.
The card is static/og.png, 1200x630, rendered once by node tools/make-og-card.mjs
and committed. Not a build step: a PNG that changes about never does not justify a
headless browser in every build, and a share card that silently regenerates is one nobody
looks at again. An e2e test fetches the path the tags advertise and fails on anything but
a 200 — a card that 404s is worse than no card, because the client renders a broken
preview instead of falling back to a plain link.
None of this pays off while robots.txt still says Disallow: /. It is in place for the
day the launch set is approved, not before.
Search had no measure at all. The only query-level tests in the repo were the escalation intent routes — and those exist because ranking was measured once and found unable to answer the queries that matter. Everything since had been changed on the strength of reading the code.
src/lib/search/quality.ts is a fixed set of 48 realistic queries with an expected answer
each, run against the index that actually ships: MiniSearch.loadJSON with the shared
options, which is exactly what the browser does. The suite refuses to fall below the day's
measurement — 45 of 48, mean reciprocal rank 0.839.
The rule for adding to the set is to write the query first and take the result you get. A query added because it already passes measures nothing. Three of the 48 fail and are kept:
giving them a break when they hit finds negative reinforcement at rank 31.they only do it when I am in the room finds stimulus control at rank 96.how do I fade prompts finds prompt fading at rank 6, under five situations.The first two are the hard class, and the corpus not answering a situation described
rather than named is the same finding that produced the authored intent routes. The third
is the interesting one: prompt fading alone is answered instantly, and four function
words bury it — there is far more situation prose than definition prose for how do I to
match against. Stripping function words from the query was tried before and did not
rescue it. A real fix is a ranking change, and the point of this file is that there is now
something to make one against.
Two things were learned by testing the harness itself rather than trusting it:
A pass count is a poor ratchet on its own. Deleting every field boost from the index configuration — about as large a ranking regression as this app could suffer — moved it by three queries, because most of the set is exact term names and those win on a title match whatever the weighting does. Mean reciprocal rank moves continuously, so an answer slipping from first to third registers even though it still passes. Both are asserted.
Three of the prefix queries were testing something else. momentar and friends are
close enough to the whole word that the fuzzy setting rescues them with prefix matching
switched off, so they were measuring fuzzy and reporting it as prefix. reinforc,
generaliz and discrimin stop far enough short that only prefix matching reaches them.
Checked by breaking the configuration three ways and watching both numbers fall: no boosts
42/48 and 0.802, no fuzzy 39/48 and 0.751, no prefix 42/48 and 0.805.
The build plan split this app into free and paid: free was the reference core, paid was exam prep — a timed simulation, a large question bank, per-area readiness analytics, a deck builder, tracker export, cross-device backup. The reasoning was that exam prep is the commoditised thing people already pay $50 to $400 for, and on-the-job reference is the unoccupied ground.
Audited against what actually shipped, five of those six are already here and free:
| Planned as paid | Where it is |
|---|---|
| Timed, domain-weighted exam simulation | /quiz, at the exam's own pace, with a flag-and-return navigator |
| Large question bank | 648 items — RBT 187, BCaBA 212, BCBA 249 |
| Per-area readiness analytics | /plan and /progress |
| Deck builder | /study, filtered by exam, area and category |
| Tracker export and PDF | Print on all four tools, JSON backup in settings |
| Cross-device backup | Not built, and the only one that needs an account and a server |
So the plan's own logic got inverted in execution: the part that was supposed to pay for the rest was built and given away, one milestone at a time, because each of those features made the free app better and there was never a moment where withholding one was the obvious call.
Given that, the paid tier was cancelled rather than retrofitted. The three ways to un-invert it were all worse than not charging:
What that deletes is worth stating, because it is most of two milestones. No Cloudflare Worker, no Ed25519 licence tokens, no Stripe, no Digital Goods API, no purchase outbox, and no server-side acknowledgement — which also removes the three-day auto-refund window that makes Play Billing a live liability from the moment it ships. The Play submission is an Education listing with no in-app purchases at all.
None of this is a promise never to charge for anything. It is a statement that nothing working here today is going to be moved behind a payment, and that the question gets asked again when the content is reviewed and the site is indexed — which is to say, when there is something to sell and somebody able to find it.
The premise of this app is one-handed use in a hallway, sometimes while something is going wrong, and the fastest route to an escalation card was: open the app, find Help, tap. A manifest shortcut makes that long-press, tap. Three of them — the escalation cards first, then today's flashcards and the interval timer — and the order is the point rather than a preference.
The shortcut URLs carry the base path explicitly, and a test asserts it. The PWA
plugin derives start_url and scope from SvelteKit's base path but does not touch
shortcut URLs, so a bare /help on a project site points at rcjlabs.github.io/help —
somewhere else entirely. It would look right in the manifest and fail only on a real
phone, months later. The test checks every shortcut URL starts with the manifest's own
scope and returns a 200; reverting one to a bare path was confirmed to fail it.
No per-shortcut icons. Android falls back to the app icon, which leaves three identical marks told apart by their labels — legible, and better than three hurried glyphs nobody can read at 96 pixels.
Sharing was missing altogether: this audience circulates things in group chats and
staff threads, and inside an installed app there is no address bar to copy from.
navigator.share opens the real system sheet where it exists; everywhere else the link
goes to the clipboard. Both paths are tested, and so is the one that is easy to get
wrong — cancelling the share sheet rejects with AbortError, which is somebody changing
their mind rather than a failure, and treating it as one would leave a link they decided
not to send sitting on their clipboard.
The shared URL is location.origin + location.pathname, not the build-time canonical
origin. A reader on a preview deployment sharing a link to production would be sharing a
page they are not looking at.
Spaced repetition only works if somebody comes back, and this app has no way of asking them to. No account, no server, no push — which rules out the mechanism every other review app uses. The Badging API is what is left: an installed app can put a count on its own icon, and the count outlives the app being closed.
The limit shapes the design, so it is worth stating before the feature. A page can only set the badge while it is running. Set it as the app closes and it reports how many cards were due at that moment — which, right after a session, is zero. Tomorrow, when twelve have come due, the icon still says nothing. The only thing that fixes that without a server is periodic background sync, which re-runs a service worker on a schedule the browser chooses. It is Chromium-only, needs the app installed, and is granted on the browser's own judgement of how much the app gets used.
So both halves ship. The app sets the badge on load and on visibilitychange; a small
static script imported into the generated service worker re-counts and re-sets it when the
browser allows a periodic wake. Where that is refused, the badge is simply the number from
the last visit — stale, but never invented. Settings says so in as many words, because a
reader who notices it going stale should learn there that it is a platform limit rather
than a bug.
visibilitychange rather than beforeunload or unload: on a phone neither of those
reliably fires, because the tab is frozen and discarded without either. Being hidden is
the last moment the page is certainly still running, and it is also the moment the number
matters most.
The count is deliberately unfiltered — every due card, not the ones the current glossary filter puts in play. A badge that changed because somebody left a category filter on last time would be reporting the filter rather than the work, and the service worker cannot reconstruct the filter anyway: it has no access to the app's settings, so a filtered badge would disagree with itself depending on which half set it last. One definition, two callers.
Zero clears rather than showing a zero. setAppBadge(0) clears on some platforms and
draws a bare dot on others, and a dot meaning "nothing is due" catches the eye and then
wastes the trip.
The one real cost is that "due" is now defined in two places — src/lib/study/badge.ts
for the app and static/badge-sw.js for the worker, which cannot import from src/.
They are kept to the narrowest shared surface to make drifting hard: a count over the
by-due index, at or before now, unfiltered. A test fetches the worker script and parses
it, because an importScripts of a 404 or a syntax error takes the whole service worker
down and would break offline for everybody.
There is no account and nothing leaves the browser — which also means nobody else has a copy. Two things make that survivable rather than just private.
The app asks to keep its storage. Safari and iOS clear a non-installed site's
IndexedDB after roughly a week of inactivity. For spaced repetition that is exactly
backwards: the reader who studies weekly is who the scheduling is for, and who loses it.
navigator.storage.persist() is requested after the first graded card or logged
supervision contact — not on arrival, because a permission prompt from a page somebody
has not used yet is the kind that gets denied for good. Settings says plainly whether the
browser agreed.
Backup, restore and delete are in Settings. The backup is one JSON file. Restoring replaces everything on the device rather than merging, because merging two devices' review histories means deciding which one is true, and getting that wrong corrupts the thing people most want back — so the semantics are one thing the UI can state and the reader can confirm.
The import is a trust boundary and is validated accordingly: a file this app did not write is refused by name, a file from a newer build is refused rather than silently losing the stores this build has no home for, rows are validated one at a time so a single corrupt flashcard cannot cost somebody two years of supervision records, and everything dropped is reported — a restore that lost a year of logs must not look like one that did not. The supervisee-code rule is enforced here too: a backup carrying a name where a code belongs has that row rejected, because the guard that keeps this app out of HIPAA has to hold at every door, not only the form.
The build plan makes Lighthouse budgets a gate on every milestone and they were never wired. Wiring them found three things, and only one of them was the app's fault.
The test server was lying. serve-pages.mjs exists because GitHub Pages resolves
extensionless URLs with a 200 where a generic server sends a 301, which breaks
service-worker precaching. It did not, however, gzip — and Pages does. The first audit
therefore blamed the app for 497 KB production never sends. It compresses now, which also
makes every Playwright run measure something closer to what a reader gets.
The home page shipped the whole corpus to render two numbers. It imported scenarios
and graphList for their .length, and importing anything at all from load.ts pulled
the ethics codes, every topic, the graphs, the practice guides and the credential facts,
because they all sat in the same eagerly-imported module. The counts now come from the
compiled manifest, and the corpora moved to corpus.ts — the split is by load shape
rather than by subject, so load.ts holds what every page needs and corpus.ts holds
what particular pages need.
Zod was in the browser bundle. The schema package's barrel builds every schema at
module scope, so one named import of CATEGORY_LABELS or searchOptions dragged the
whole validation library in — a hundred kilobytes of parser shipped to render a category
heading. @aba/content-schema/runtime is the Zod-free half: categories, the safety
lexicons, the MiniSearch options. Nothing in it may import Zod, and the script budget is
what catches it if something does.
Together: the home page went from 669 KB of script to 77 KB, first contentful paint from 5.0 s to 1.7 s on the emulated mid-tier phone, and Performance from 0.68 to 0.98.
npm run test:perf runs it locally; CI runs it on every push. The budgets are in
lighthouse-budgets.json and the assertions in lighthouserc.json, both set from
measured margins rather than aspiration — enough headroom that ordinary noise does not
fail a build and little enough to notice a regression.
It noticed one, and the budget was right. /tools eventually crossed the
first-contentful-paint assertion by eight milliseconds and blocked a deploy. The
tempting reading was runner noise on a shared machine, and it was wrong: building the
last passing commit and this one side by side on the same machine put /tools at 2610ms
before and 2307ms after, against CI's 2608–2619. The app really had crossed the line.
The cause was one import. corpus.ts bundles the ethics codes, every ethics topic, the
graphs, the practice guides and the competency assessment into a single module, and the
tracker imported credentials from it to read a handful of supervision percentages —
dragging 234KB of script onto the heaviest route in the app. /quiz was doing the same
thing for three heading labels. The credential facts now live in
src/lib/content/credentials.ts, which imports one JSON file and nothing else, and
corpus.ts re-exports them so the pages that want the whole corpus are unchanged.
/tools went from 659KB of script to 452KB.
This is the same failure the home page had, one level down, and it will happen again: a barrel module attaches everything it imports to everything that imports it, and one label constant is enough to do it. The budget is the thing that catches it.
Two audits are deliberately off. is-crawlable fails because a preview build's
robots.txt disallows everything, which is correct and flips on its own once review
completes. unused-javascript fires on route-split code that another route needs.
Installability moved out of Lighthouse. Lighthouse 12 removed the PWA category and
every manifest and service-worker audit with it, so the plan's "PWA installable" gate had
nothing left to assert against. It now lives in e2e/installable.spec.ts, checking
Chrome's actual criteria: a named manifest, a display mode that opens outside the browser,
icons at 192 and 512 with a maskable one, every icon actually fetchable, a start URL
inside the scope, and a service worker that takes control rather than merely installing. A
gate that silently stopped being checked is worse than one nobody wrote down.
Target is WCAG 2.2 AA, and the axe sweep runs over every route in light, dark, 320px, and forced-colors, with zero tolerance and no baseline file. Automated checks catch perhaps a third of real problems, so each milestone also gets a manual screen-reader pass.
Notable choices:
main is the scroll container, so the header and bottom nav never
overlay content. A sticky bar over a scrolling document permanently covers whatever is
beneath it, which makes those links genuinely un-tappable.Static output to GitHub Pages. Two things to know:
https://<origin>/.well-known/assetlinks.json, which a project Pages site
(user.github.io/aba-assist/) cannot serve. static/.nojekyll is required too, or Jekyll
silently hides the dot-directory and the file 404s.static/CNAME decides everything downstream. The deploy workflow reads it and sets
ABA_BASE_PATH from it: present means the app owns an origin and the prefix is empty;
absent means a project site served from /<repo>/. The prefix in turn determines the
service-worker scope, the PWA scope, and every precached URL — so the two must never
disagree, and the only way to keep that true is to derive one from the other.
Changing it invalidates every cached URL for existing installs. Settle it before the service worker reaches anyone who matters.
aba.rcjlabs.comThe root domain serves something else, and a subdomain is its own origin — which is all Android's asset-links check needs. In order:
aba, value rcjlabs.github.io.
(trailing dot). Not an A record, and not a forwarding rule — a redirect is a different
origin and breaks TWA verification.dig +short aba.rcjlabs.com returns the github.io name.static/CNAME containing aba.rcjlabs.com, then set the same domain under
Settings → Pages → Custom domain and tick Enforce HTTPS once the certificate
is issued (usually minutes; occasionally an hour).Do those in that order. Committing CNAME before DNS resolves takes the live site down:
Pages starts redirecting rcjlabs.github.io/aba-assist/ to a hostname that does not yet
answer.
Once it is live, /.well-known/assetlinks.json is served from the origin root. It ships
as an empty array until there is an app signing key; fill it with the SHA-256 fingerprint
of the key that actually signs the AAB before the TWA ships, or the app installs with the
browser URL bar visible. The deploy workflow warns while it is still empty.
Code is MIT (see LICENSE). Content carries its own per-item license field; entries
authored here are CC-BY-SA-4.0 unless marked otherwise.