Javis OS
Javis OS is an Agentic AI that assists entrepreneurs in business tasks, helping them grow their work.
AI translation
Squawk: audit capture, field inspection and findings closure for the ACSA asset assurance programme. Built by Thabile-Pridin JV.
BusinessTypeScript · JavaScriptGitHub @SarelJvR2024OnlineOwner 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.
Audit capture, field inspection, findings closure and reporting for the ACSA
asset assurance programme, built for Thabile-Pridin JV.
In aviation maintenance a squawk is a reported defect: it is logged, it is
rated, it stays open until someone verifies the fix. That is this application's
whole loop, across a three-year cycle and ten entities.
Reference: the design document (Asset Assurance Capture Platform, draft v0.2).
Section numbers in code comments point at that document.Read the full README ↗Audit capture, field inspection, findings closure and reporting for the ACSA asset assurance programme, built for Thabile-Pridin JV.
In aviation maintenance a squawk is a reported defect: it is logged, it is rated, it stays open until someone verifies the fix. That is this application's whole loop, across a three-year cycle and ten entities.
Reference: the design document (Asset Assurance Capture Platform, draft v0.2). Section numbers in code comments point at that document.
| Area | State |
|---|---|
| Home screen | /home — what is next, which audits are open and how far, the Round 1 calendar, what was just done, and the seven screens in order |
| Design system (section 5 interface standard) | Implemented as CSS tokens and primitives, light and dark |
| Data model (section 6) | Typed domain model, local-first store over IndexedDB |
| Seed data | Rev A2 all sites (06 Sep 2026): 324 check-points × 10 sites = 3,086, 6 disciplines, 99 ACSA documents mapped, 33 site variants, 78 open 2025 findings and 66 asset-system ratings across three airports |
| Answer Library (section 7) | Complete — all 324 checks, re-keyed to Rev A2 |
| Capture workspace (section 8) | Three-pane workspace, answer chips, real voice and photo capture, progress, ⌘K, keyboard |
| Field inspection mode | Two-level tree — discipline (or location) → asset system → check-points — collapsed rows, one item open at a time, one segmented outcome control, a full-width comment field, researched walkabout options with photo expectation, 56px add button, capture-first tray, offline |
| Things seen on the walk | WALK-xxxxx — an inspection item the register does not cover, with photographs, a voice note, an outcome and an optional asset system. Never counted toward the 324, on its own export sheet, and one tap from becoming a finding |
| Asset Assurance | The rating ACSA actually publishes, and the tab is named for it rather than for the evidence. Discipline → asset system, each row two lines carrying the severity letter, the likelihood digit, this audit's band and — faded behind it — the band the last audit gave the same system. Pick a severity and a likelihood and the band and the clause 4.6 treatment strategy follow from them — neither is typed. Several root causes and several mitigation actions, each action with its own owner, date and status. The panel shows everything the audit knows about the system — still open from an earlier audit, raised at this one, this audit's check-points including the compliant ones, and what the walk found — because that is the evidence the group reads before agreeing the cell. Nothing computes the band from it |
| Findings and rating | Each finding still carries its own B170 001M rating, agreed vs suggested, root cause, owner and due date. It opens in full from the asset system it belongs to |
| HIRA | Consolidation from findings with the photographs behind each group, post-walk re-read, both rating instruments live — B170 001M for the safety event, ACSA's ERM (J050 001FW cl. 9.2.2) for the business risk |
| Follow-up | Every earlier audit in one place, grouped by asset system. Open a system and its findings read as a timeline — the audit that raised each one, oldest first, with the current status and the date beside it. The detail pane merges everything ever recorded against one finding into a single stream: raised, each audit's outcome in words, evidence, photographs somebody went and took, voice notes, updates, and the possible events — so opened, then closed, then opened again reads as the sequence it is. Four-way verification, remediation and next step recorded separately, coverage guard, and multiple possible hazardous events each with their own likelihood |
| Audits | Every entity × visit in one place; open any, create new ones; the programme file seeds, the app extends |
| Dashboards | Airport, discipline and a real ten-site portfolio, with movement against 2025 |
| Visual review | All photographs and voice notes per discipline per airport, with an engineer feedback thread |
| AI assistance | Optional and advisory — off unless a key is set |
| Voice notes | Always recorded on the device; transcription and write-up are opt-in |
| Exports | Excel and CSV: register, asset systems, findings, hazards, closure, seen on the walk, evidence request, summary, photographs — plus the images as files |
| Pre-flight | /preflight — microphone, camera, storage, offline cache and the three services, checked on the device before anyone walks onto an apron |
| Shared record | Supabase, behind a team passphrase — every auditor's work in one audit, syncing by itself, and off entirely unless configured |
| Team captures | Share a device's work as one file and merge another auditor's; evidence is never overwritten |
| Word report templates | Not started — design document phase 4 |
src/app/globals.cssidb-keyval) — local-first, offline capablex-api-version contract is not documented,
and guessing at an undocumented wire format is worse than a documented
dependency. Nothing outside src/app/api/photos/route.ts imports itLocal-first is a deliberate choice while the hosting and data-governance question (design document Q4) is open: nothing leaves the device, and the store sits behind an interface so a server backend can be added without touching the UI.
npm install
npm run dev # http://localhost:3000
npm run build && npm start
Forty-seven suites, no framework, all plain node. See tests/README.md — and
check each suite's exit status, not the output: a for loop over them
reports the status of the loop, not of the suites.
node tests/risk-matrix.test.mjs # the ACSA matrix, all 25 cells
node tests/capture.test.mjs # capture is real, not fabricated
node tests/scope.test.mjs # one audit per entity per visit
node tests/carryforward.test.mjs # earlier visits reach the next one
node tests/review.test.mjs # feedback never becomes the record
node tests/tablet.test.mjs # the tablet is the device
node tests/portals.test.mjs # checks reach the right view
node tests/reset.test.mjs # starting again is safe
node tests/completion.test.mjs # complete means every mode answered
node tests/audits.test.mjs # any audit reachable, new ones creatable
node tests/voice.test.mjs # a rewrite never overwrites what was said
node tests/sites.test.mjs # ten sites, one register, right counts
node tests/photos.test.mjs # stored, captioned, and only sent on purpose
node tests/hazards.test.mjs # a finding is in at most one hazard
node tests/erm-matrix.test.mjs # two instruments, and where they disagree
node tests/merge.test.mjs # two auditors' work, combined without loss
node tests/checkscreen.test.mjs # the brief, and that nothing was dropped
node tests/figures.test.mjs # the prose still matches the register
node tests/assets.test.mjs # a stand-in tag can never reach the portal
node tests/sharepoint.test.mjs # nothing reaches the tenant by accident
node tests/vision.js # starts its own servers
node tests/record.js # starts its own server
node tests/shared.js # starts its own server and a fake Supabase
npm run build && npm start & # then, against a running server:
BASE=http://localhost:3000 node tests/e2e.js # 21 assertions
BASE=http://localhost:3000 node tests/robustness.js # 38 assertions
BASE=http://localhost:3000 node tests/exports.js # 32 assertions
BASE=http://localhost:3000 node tests/persite.js # 25 assertions, four sites
BASE=http://localhost:3000 node tests/flow.js # 49 assertions
BASE=http://localhost:3000 node tests/offline.js # 19, with the network cut
BASE=http://localhost:3000 node tests/team.js # 15, two devices, one audit
BASE=http://localhost:3000 node tests/preflight.js # 18, no mic, and a clock an hour fast
BASE=http://localhost:3000 node tests/a11y.js # 46, contrast and the keyboard
BASE_NO_KEY=... BASE_WITH_KEY=... node tests/ai.js # 26 assertions
Run tests/risk-matrix.test.mjs after any edit to src/lib/risk.ts,
tests/erm-matrix.test.mjs after any edit to src/lib/erm.ts,
tests/hazards.test.mjs after any edit to the hazard register, RecordActions
or the assist parsers,
tests/capture.test.mjs after any edit to src/lib/media.ts,
src/components/Capture.tsx, CheckDetail.tsx or field mode, and
tests/sites.test.mjs after any edit to the register, the site table or
programme.json.
A standard Next.js project. No database, no environment variables required.
Option A — via GitHub (recommended)
Option B — from your machine
npx vercel # links the project and deploys a preview
npx vercel --prod # promote to production
| Variable | Effect |
|---|---|
ANTHROPIC_API_KEY | Turns the AI assistance on. Without it the endpoint reports itself unavailable and every AI affordance disappears; the app is fully usable either way. |
ASSIST_MODEL | Overrides the model id. Defaults to claude-opus-5 ($5 / $25 per million input / output tokens). Set it to claude-sonnet-5 ($2 / $10) if the bill matters more than the judgement. |
ELEVENLABS_API_KEY | Turns Transcribe on for voice notes. Without it a note is still recorded, kept and played back; its text is typed by hand. |
TRANSCRIBE_MODEL | Overrides the transcription model id. Defaults to scribe_v1. |
TRANSCRIBE_LANGUAGE | Pins transcription to one language. Leave unset. Auto-detection is what carries an auditor switching between English and Afrikaans inside one sentence. |
ASSIST_VISION | 1 sends photographs to the model. Unset or 0 and no image byte leaves, whatever the client sends — the route strips them. Every AI affordance still works on captions alone. |
ASSIST_ENDPOINT | Where the assist request goes. Defaults to the Anthropic API; point it at an in-tenant endpoint if ACSA's governance requires the data to stay there. Nothing in the UI changes. |
SUPABASE_URL | Turns the shared record on, with the two below. The project URL, e.g. https://xxxx.supabase.co. All three or none: any one missing and the route reports itself unavailable and refuses to sync. |
SUPABASE_SECRET_KEY | The secret key (sb_secret_…), never the publishable one. It stays on the server; no browser ever sees it, and the table has row-level security on with no policies so the publishable key can do nothing at all. |
SQUAWK_TEAM_PASSPHRASE | What lets a device join the audit. Five or six unrelated words — somebody will read it to a colleague once and type it on a phone. It is the only thing between a URL and every site's findings and photographs. |
BLOB_TOKEN_VAR | Names which *_READ_WRITE_TOKEN to use, for a project with more than one blob store. Only needed when two are set; see . |
Setting any of these is a data-governance decision, not a technical one — see Photographs, Voice notes and AI assistance below.
Fonts load from Google Fonts at runtime because the build environment this was
written in cannot reach fonts.googleapis.com. On Vercel you can switch
src/app/layout.tsx back to next/font/google for build-time optimisation.
A finding says something is wrong. The asset says what it is wrong with, and that is the half a maintenance planner needs to raise a job card: "corroded busbar" is a photograph, "corroded busbar, MV Switchboard 3B" is work.
ACSA has not supplied the register. src/data/assets.sample.json is a
stand-in so the linking, the screens and the export are built and tested for
the day it arrives — 1,506 rows across all ten sites, coherent with the
register's own disciplines and asset systems.
An invented asset tag filed against a real finding, in a list ACSA reads, is worse than no tag at all. Three guards, deliberately redundant:
| The tag says so | every id begins with SAMPLE-, so a tag pasted into an email or a screenshot announces itself |
| The file says so | meta.source is "sample", and the asset panel carries a standing warning while it is |
| The sync refuses | sendableAssets() drops them, and the plan reports how many it withheld |
Links do reach the workbook, where a person is reading and the prefix tells them what they are looking at. They do not reach the portal, where a machine files them and nobody looks again.
Overwrite src/data/assets.sample.json in the same shape and set meta.source
to "acsa". Nothing else changes — the warning disappears, the sync starts
sending the tags, and the links captured against sample tags stay exactly where
they are (they are stored as tags, so they simply stop resolving and show as
"not in the register" until re-linked).
The register is fetched on first use rather than shipped in the initial bundle, the same treatment the Answer Library gets and for the same reason.
Squawk's deliverable is the workbook. The portal is where ACSA and TPJV keep the same audit as a living list, and Sync writes this visit's capture straight into it — as the signed-in auditor, not as a service account, so the portal's own history says who did it.
| Portal | Gets |
|---|---|
| Check-points list | one row per check somebody actually answered, keyed on Title = the check-point id |
| Findings list | one row per hazard, keyed on Title, plus a status and progress update on every prior finding verified this visit |
| Evidence library | every photograph, into Evidence/<Site name> <ICAO>/, named by its own reference |
Title is the join in both directions. That is the register's own instruction:
checkpoints[].idis the portal key (Check-points list, Title column);priorFindings2025[].portalIdis the Findings list Title. Keep both unchanged in Squawk so results can sync back.
Hazards, not findings. Only a hazard carries the ERM rating, and the portal rates on ERM. That is also the model: the same missing diesel cut-out fuse was PF-02 for Electrical and PF-21 for Process Safety in March 2025 — two rows, two ratings, one fuse. The hazard is the event, and it is what persists year on year. Syncing findings would put the duplication back. A finding in no hazard is counted in the plan and not written, so it cannot look synced when it is not.
ermConfirmed. A suggestion is
indistinguishable from a judgement once it is in a list the Audit & Risk
Committee reads."" turns "we did not get to it" into "we looked and it was fine".Title and PATCHed if
it is there. A hazard remembers the portalId it was given on its first
successful create, so the next visit updates it.It used to render only where a portal was already configured, on the reasoning that a button which can only explain why it does not work is clutter. That is true of a button and false of a setup — and the setup is where this feature sat: not broken, invisible. Nobody could see whether the sync existed, what it wanted, or how far along it was, because the one screen that knows all three was behind the thing it was waiting for.
So the button is always offered (except to ACSA), and it opens five steps, each either done, to do with what to do about it, or not checked yet because the step before it has not passed:
Not-checked-yet is not a failure and does not look like one. Half of setting this up is knowing which half is somebody else's problem.
Two details that are there because they are how this actually fails: the
redirect URI it tells you to register is built from window.location.origin,
so it is the exact string this deployment will send rather than one typed from
memory; and it says out loud that NEXT_PUBLIC_* is read at build time, so
setting the variable without redeploying changes nothing and looks identical.
The lists to create, the columns each needs and the alternative names that would
also be matched are one export in src/lib/sharepoint.ts — LIST_NAMES,
CHECK_FIELDS, FINDING_FIELDS and columnContract() — and the readiness
screen renders them from it. Whoever builds the site builds it from that screen.
A screen restating the names in its own words would be a second copy to keep in
step with the matcher, and the cost of them disagreeing is a column that exists
and is silently never written. tests/sharepoint.test.mjs builds a list from
the contract and asserts the matcher then reports nothing missing.
Sign in → read the portal → read the plan → then a button that writes.
Until that last button, the app has issued nothing but GETs. The plan shows
every row that would be added or changed, every field that has no column to go
in, and everything deliberately left out with the reason.
SharePoint's internal column names are not its display names — "Risk priority"
can be field_7 — and writing to a guessed name returns 200 OK having stored
nothing, which is the worst failure available because it looks like it worked.
So the internal names are read off the lists on every run and matched by
display name. A field with no column is reported in the plan and skipped.
Prince Mahlangu completed the Microsoft side: a single-tenant registration,
an SPA redirect at /graph-callback on the live origin, no client
secret, delegated Sites.ReadWrite.All with admin consent for TPJV, and a
service account holding Read on the site plus Contribute without Delete on
Check-points, Findings and Evidence.
The four values themselves are in Vercel, not in this repository, and this
section deliberately does not print them. They were briefly committed as
defaults in src/lib/graph.ts on the reasoning that none of them is a
credential — which is true; a client id travels in the address bar of the
sign-in window and a tenant id is resolvable from any tenant's domain through
Microsoft's own public discovery endpoint — and on the assumption that the
repository was private, which it is not. "Not a credential" is not the same as
"worth publishing": together they name the exact app registration to aim a
consent prompt at, and the exact site to aim it for, at a national key point.
So they were taken back out the same hour. Set:
| Variable | What it is |
|---|---|
NEXT_PUBLIC_GRAPH_CLIENT_ID | Application (client) ID |
NEXT_PUBLIC_GRAPH_TENANT | Directory (tenant) ID — the GUID, not organizations |
NEXT_PUBLIC_GRAPH_SITE | The SharePoint site the portal lives at |
NEXT_PUBLIC_GRAPH_ORIGIN | The origin whose /graph-callback is registered |
They are read at BUILD time — setting them without a new deploy changes nothing.
The cost of keeping them out of the code is the failure this was meant to
prevent: an unset build-time variable does not error, it produces an app that
quietly has no sync, which is how the Supabase three cost a day. So each one is
named on the sync screen and on /preflight when it is empty, rather than left
to be discovered, and a tenant set to organizations is called out on sight
instead of failing mid-sign-in with AADSTS50194.
Three consequences worth knowing before the day:
localhost is refused by Microsoft with AADSTS50011,
after a password and an MFA prompt. The sync screen checks this up front,
before you reach for the phone — as long as NEXT_PUBLIC_GRAPH_ORIGIN is
set. Unset, it says "not checked" rather than pretending it is fine.tests/sharepoint.test.mjs, not just intended.https://<your-domain>/graph-callback.Sites.ReadWrite.All.
Grant admin consent.NEXT_PUBLIC_GRAPH_CLIENT_ID, NEXT_PUBLIC_GRAPH_TENANT,
NEXT_PUBLIC_GRAPH_SITE and NEXT_PUBLIC_GRAPH_ORIGIN in Vercel, and
redeploy. They are read at BUILD time — setting them without a new deploy
changes nothing. Put them in Vercel only, never in this repository.No credential is ever typed into Squawk and none is stored in it. The
access token lives in the tab's memory for as long as the tab is open and is
never written to localStorage, IndexedDB or the store — a reload signs you
out. That is deliberate: this is a tablet carried around a national key point,
and a bearer token for the evidence library on it is worth more than the
tablet. Signing in again is one button, once, at the end of an audit.
The scope is delegated: the sync can do exactly what the signed-in auditor could do by hand in a browser, and Graph enforces that, not this code.
src/
app/
(app)/ home · capture · field · review · findings · hazards · closure · dashboard
(the tabs read Checks · Inspection · Review · Asset Assurance · HIRA · Follow-up ·
Dashboard — labels changed, routes deliberately did not)
api/assist/ the AI endpoint — text out, never audio or images
api/transcribe/ voice-note transcription — the only route audio leaves by
lib/
register.ts the register and the 2025 data, entity-scoped, no React
sites.ts which checks apply where, and each site's portal ids
media.ts recording, photo downscaling, EXIF date, the blob store
photos.ts what a photograph is called, and the export zip
sync.ts the queue that gets photographs to the record store
globals.css design tokens for both themes
manifest.ts the web manifest — installable, starts at /capture
components/
AppShell.tsx header, nav, cycle strip, command palette, role switch
CheckDetail.tsx the check screen — brief left, capture right, answer pinned
OfflineReady.tsx registers the worker, warms the two lazy payloads
RootCauseAdvice.tsx candidate causes and, more usefully, what to ask
HazardAdvice.tsx the event a finding exposes, named at the check
RecordActions.tsx the rating and treatment block — findings AND hazards
AddItemSheet.tsx record something the register does not cover, in ten seconds
OutcomeControl.tsx Pass · Fail · N/A · Later — one segmented control, brand
palette only, because an outcome is not a rating
VisitStrip.tsx one carried item across every audit, as a shape
ui/GroupRow.tsx the collapsible group header, shared by every screen that groups
ExportPanel.tsx the export sheet
TeamMerge.tsx share a device's captures, merge another's
ui/ primitives and icons
lib/
types.ts domain model
merge.ts combining two auditors' work into one audit
shared.ts the shared record, from the device's side
risk.ts ACSA B170 001M matrix — Red / Amber / Green
erm.ts ACSA's enterprise risk matrix — declared, and empty
store.ts state, persistence, selectors
programme.ts entities, visits, zones — all data-driven
answers.ts lazily-loaded Answer Library
assist.ts offline composer + optional AI client
exports.ts what each export sheet contains
xlsx.ts minimal .xlsx writer
data/
checks.json 324 check-points with ACSA mappings and external basis
answers.json 9,836 researched options, loaded on demand
priorFindings.json the 23 March 2025 findings
programme.json entities, the 3-year cycle, zones
tests/ forty-seven suites — see tests/README.md
The app opens at /home in a browser and at /capture when installed, and
that is deliberate. /home is the orientation screen — what is next, which
audits hold work and how far, the Round 1 calendar, what was just done, and
the seven screens in the order the work uses them. /dashboard stays the
analytical view and the two must not converge: findings, ratings, the B170
001M profile and movement against 2025 belong there and appear nowhere on
/home. The manifest's start_url is still /capture because it must be a
real screen rather than a redirect — a launch that begins with a network
request fails on an apron — so the auditor who taps the installed icon is
mid-audit and lands in the work, and the one typing the address is arriving
and lands in the orientation. tests/home.test.mjs holds both halves of that.
The risk matrix is ACSA's, not a generic one. src/lib/risk.ts implements
B170 001M: severity A–E × likelihood 1–5, banded Red / Amber / Green. Clause
4.6 states no other format is accepted by the SACAA Director of Civil
Aviation. Do not "simplify" this to I/II/III. The unit test exists to stop
exactly that.
Cells are printed severity-first (B4), and the band does not come from
that string. B170 001M's own tables write the cell likelihood-first; the
portal writes no combined cell at all, carrying severity and likelihood as
two separate fields; and the generated dashboard and the Cluster 3 report
print it severity-first. Sarel settled it: follow the register and the
dashboard, so the app prints what the report's reader will see.
The band is looked up on an internal, likelihood-first key that is never
displayed. That decoupling is the point: RED and AMBER are keyed on a
cell string, so flipping the printed form without it would have made every
lookup miss and banded all 25 cells Green — the whole instrument silently
inverted, with no error anywhere. risk-matrix.test.mjs asserts the printed
form, the bands, and the 6 / 12 / 7 split independently, so the notation can
change again without the matrix being able to move.
A suggested rating is not a rating. An issue button carries a suggested
severity and likelihood, but the audit rates as a group exercise, so a finding
carries ratingConfirmed and stays Suggested — click to agree until someone
clicks the cell the group settled on. Only then does it reach the KPIs, the
dashboard or the export. Keep it that way.
A blank status means not captured, never compliant. This holds in the UI, in the dashboard and in every export.
Sarel, 2026-09-12: ACSA's explanation on a check is that they are compliant, and it has to be verified when they submit the evidence. That is a real third state on the desk and it was being recorded as plain Compliant, which loses the fact that somebody still owes us a document.
It is a flag on Compliant, not a fifth answer. Compliance is still
"C" | "NC" | "N/A" | "NV"; the tag is Response.evidencePending. A fifth
token would look tidier and would fall through every rule that switches on the
four — the discipline counts, the rating gate in carryforward.ts, the
completion logic and four export sheets — and each one is a place a check
silently stops being counted. It is also what the state actually is:
compliant, with the proof outstanding. So every existing count keeps treating
it as compliant and nothing had to be rewired to add it.
The invariant: the flag can only ever be true while the answer is "C".
It is written in exactly one place — setCompliance — which forces it false on
any other answer, so "evidence pending" can never be left stranded on a
non-compliance and an RFI built from it cannot ask ACSA to prove a failure.
tests/evidencepending.test.mjs asserts there is no second writer.
The field screen is the one place that needed care: it sets compliance too, and
the naive version cleared the tag when the field half confirmed the same
verdict — quietly taking a row off the list. It carries the tag through when the
outcome is still C; any other outcome supersedes it.
Where the RFI comes from. The register sheet has an Evidence pending
column of its own, immediately after the status. The status cell still reads
exactly Compliant, so every existing filter, count and pivot keeps working;
the caveat lives beside it where adjacency makes it impossible to read one
without the other. Filter that column and the Compliant when column already
says what each site has to produce. The discipline summary also counts of which
evidence pending, because "how many does this discipline owe us" is the
question the tag exists to answer and per-discipline is how the requests get
sent. Generating the RFI itself is not built — the logic is Sarel's to
define; what is built is a tag that can carry it.
Rev A2 covers 3,086 check-points across ten sites, but it is not ten
registers. Every site uses the same 324-item register and the same check-point
numbers — KSIA-ELE-005, ORTIA-ELE-005 and CO-ELE-005 are the same
requirement at three sites, which is the point: results compare across the
group. Where a check-point does not apply its number is simply not used there
(gaps, no renumbering).
Squawk therefore holds the register once and derives the rest:
portalIdFor() gives the site-prefixed id — the portal's sync key and the
number an auditor reads out — and checksFor() gives that site's applicable
subset. Storing 3,086 rows instead would mean ten copies of every requirement,
threshold and walkabout instruction, which drift apart the first time one is
corrected and nobody remembers there are nine others.
| Class | Sites | Checklist | Removed |
|---|---|---|---|
| International | King Shaka, O.R. Tambo, Cape Town | 324 | — |
| Regional | Bram Fischer, King Phalo, Chief Dawid Stuurman, George, Kimberley, Upington | 319 | Passenger boarding bridges (5) |
| Corporate | ACSA Corporate Office | 200 | All airfield work (124) — AGL, ILS, AWOS, X-ray, PBBs, fuel, BHS, water treatment, gas hot water, all Civil, fuel farm MHI |
Applicability is a TPJV assumption for the register, confirmed by the discipline leads before each site audit; nothing is removed from the contract scope. Where a system exists in the register but not at a particular site — a hydrant fuel system, a water treatment plant at a small regional — the check stays and the auditor records N/A. Only whole asset systems that cannot exist at that class of site are removed.
tests/sites.test.mjs recomputes all ten counts and the 3,086 total from the
register itself rather than trusting the numbers above.
The portal's 78 open 2025 findings are carried in and seeded as outstanding items for the next visit to verify: King Shaka 15 (March 2025), O.R. Tambo 33 (May 2025), Cape Town 30 (March 2025). The other seven sites are baseline audits and correctly carry none.
portalId (ORTIA-ELE-P01) is the Title of the item in ACSA's Findings list
and is the key a closure syncs back on. It is never regenerated or renumbered.There used to be pf and pfq columns on the register row itself. They were
King Shaka's numbers, so a Cape Town check announced a King Shaka finding, and
seven never-audited sites announced one too. They are gone.
Everything in this app used to answer one question: what did we find about ACSA's assets? That is the audit, it is what ACSA pays for, and it is what the workbook, the findings register and the dashboards are made of.
From the safety findings screen onward the app also holds a second kind of record, and it answers a different question entirely: did TPJV do this properly?
Sarel, 28 September 2026:
This doesn't necessarily form part of the audit, but it forms part of the admin and the project management of the project and the audit. So this is evidence we will need — to confirm, or to have as evidence, that we follow the right procedures and have got all our evidence in place for the project.
The two are told apart by who is being examined.
| Audit evidence | Project evidence | |
|---|---|---|
| Question | What is wrong with ACSA's assets? | Did TPJV follow the procedure? |
| Subject | ACSA | TPJV |
| Audience | ACSA, as the deliverable | ACSA on request, a DoL inspector, TPJV's own file |
| Examples | 324 check-points, findings, hazards, photographs | Immediate safety findings, attendance, PPE checks, induction, toolbox talks, interviews |
| Source | the register | the contract, the OHS Act, SWP-07, TK-003 |
| If it is missing | a gap in the report | a gap in TPJV's defence |
Three consequences, and all three are load-bearing.
Project evidence never touches a completion figure. adhoc.test.mjs already
guards this for walk items, for the reason it gives: the moment "312 of 324" can
become "313 of 324" because somebody recorded something, every number this
product publishes is one nobody can reconcile. Safety findings, attendance and
PPE checks are counted separately or not at all, and never against the register.
It outlives the visit. A finding is scoped to the entity and the visit,
because the next audit wants to know what the last one left open at that
airport. useSafetyFindings is scoped to the entity only, deliberately: an
immediate safety finding nobody closed is exactly what the team walking back in
needs to see, whichever visit raised it.
It is not ours to rate. Nothing here goes on B170 001M or on ERM. An ISF is not a hazard with a severity; it is a thing that could hurt somebody today, and its states are raised, notified, issued and closed. Giving it a risk band would invite somebody to triage it, and the whole point of SWP-07 is that it is not triaged.
The one record that sits in both columns is the immediate safety finding. It is project evidence — it proves TPJV reported what it saw — and it is also reported to ACSA on the day, under the scope of work's requirement that "all safety related findings picked up during the audit must be reported immediately". That is why it produces a written notice addressed outward, and why it is the only project record with a document generator behind it.
/isf. TK-003 form 1, on the tablet, and the first of the paper forms to move.
SWP-07 sets the whole shape: stop, make safe only if it can be done without risk, notify the ACSA site representative verbally at once, complete the form, issue written notification the same day, log it, track it to closure.
Three things follow, and they are the design:
One field raises it. The person raising this is standing in front of the thing and wants to leave. Type what you saw, tap Raise, deal with the danger. Location, risk, action and notification are all filled in afterwards, and an ISF sitting half-complete in the register is doing its job — it exists, and the register says what is missing. A form demanding all of it first is a form filled in that evening from memory, with times that are fiction.
The clocks are the record. raisedAt to notifiedAt is what "at once"
means. raisedAt to the written notice is what "the same day" means. Both are
instants, not checkboxes, and both are on the row.
A message is not a notification. "Notify verbally at once" is not discharged
by a WhatsApp. The method is recorded either way — what happened is worth
knowing — but only in person, by phone or by radio moves the finding out of
raised, which is the state the register shouts about.
The written notice is composed by the app rather than by the auditor, because the difference between it going out the same day and not is usually whether somebody had to write it from scratch after four sites. It states what is unknown instead of omitting it: a notice that silently drops the immediate action reads as though none was taken, and it goes out under TPJV's name.
Rules live in src/lib/isf.ts, guarded by tests/isf.test.mjs.
/interviews, reached from the More menu. The second of the paper forms to
move, and the one ACSA asked for by name.
Scope of work 4.2, the on-site audit phase: "Interview key personnel and stakeholders (e.g. Airport Operations Departments, Contractors, etc.) to gather information and insights." It is also a separately priced activity — pricing schedule 1.2.6(b), "Interviews with key personnel and stakeholders", at every one of the ten airports. An interview nobody wrote down is work TPJV did, invoiced for, and cannot show.
Rebuilt 28 September 2026. The first version carried statements linked to check-points, a quote-or-summary distinction on each one, a notice-given toggle and a citability verdict — modelled on what the record would need to be quoted in a report. Sarel, watching it live: "this is too formal, we just need a record of who we interview on which day, what the location was, from and to. and space for their signature and an approval at the end to close of the record of the days interviews." What was said is the auditor's own judgement on the check screen; this register proves the conversation happened.
It is now shaped exactly like site attendance, because it answers the same kind of question:
One day, one record. openInterviewDay opens or returns — the screen has
no control that could ask for a second register for the same date.
One field starts an entry. A name. Role and location can wait until there is somewhere safer to fill them in, and the register is deliberately still useful with them blank.
A signature signs a statement, and the times are not part of it. Name, role and location — patch any of those and the mark is cleared, in both directions: ending the interview hours later must not wipe a signature given at the time.
The day closes on one approval, not a tally. The auditor (or whoever is closing the day out) approves the whole list at once, with an optional drawn signature of their own. It refuses while anybody is still mid-interview or nothing has been recorded — but signing every individual entry is not required to close: a person who declined to sign is still an honest record of the day, and refusing to let the day close over that would make the approval lie about what happened rather than attest to it.
It is not in the nav bar, deliberately. The bar already costs a swipe at eight entries, the ninth was paid for once for the safety register whose clock starts when somebody reaches it, and the fourteen project-evidence forms still to build cannot each take a slot. An interview is arranged rather than stumbled into, so it can afford two presses.
Rules live in src/lib/interviews.ts, guarded by tests/interviews.test.mjs.
/attendance, reached from the More menu. TK-003 form 2 — Site Attendance
& Induction Confirmation — completed on arrival each day and signed by each
person. It feeds SF-005 sheet 4, Induction & Access, and it is held as J14
of the site safety file, because SF-005 has no sheet for it: the question it
answers turned out to be one nobody had written a sheet for. Who was on site
the day that finding was raised? That is what TPJV is most likely to be asked
a year later, and the hardest thing to reconstruct from anything else.
This is also the first record in the app that carries a signature, which the roadmap named as the only genuinely new capability the remaining forms need — five of the eight TK-003 forms are worth nothing without one, because an attendance register without signatures is a list somebody typed.
One day, one record. The store opens-or-returns, and the screen has no control that could ask for a second register for the same date. Two registers for one day is the quietest failure available here: both look complete, neither says the other exists, and the one that gets exported is the one that is wrong. A day's date cannot be edited afterwards for the same reason.
Arrival is stamped, not typed. Adding somebody records the moment they walked in, because that is when this form is actually completed. A blank time field gets a round number put in it at five o'clock.
A signature signs a statement. The person marks this is who I am, who I work for, what I do, and my airside induction is confirmed with this reference. Change any of those afterwards and the mark is cleared — it was of something else, and a signature that survives an edit is a signature on a document somebody altered. The times are deliberately outside that list: departure is recorded hours later by whoever closes the day down, and clearing every signature each evening would leave the register unsigned exactly when a reader comes to it.
A typed name is not a signature. The record holds both — the drawn mark, and
the name as given, so the row is still attributable if the image is ever lost —
but only stored bytes count as signed. If the capture fails, the pad says so and
reports nothing to the caller, because a row that reads as signed with
nothing behind it is worse than an unsigned one: nobody goes looking for it.
Signatures live under their own key in the media store and carry a reference
(ATT-7K2P9_S01) that is never reused.
One gap, stated plainly. They are not backed up to the record store yet.
src/lib/sync.ts walks check-point and inspection photographs only — it has
never walked the ISF's or the interview register's photographs either, so this
is one gap rather than three. The register says so on screen and names copying
the register out as the thing that actually works, rather than pointing at a
sync button that would not clear it.
Entitlement, not just presence. Contract Data 20.1 gives site access "Following Airside Induction and Permit Process completions", and airside permits are per person, per airport, expiry-dated. So the form records the induction reference and its expiry, and a lapse is judged against the day rather than against now — a permit that expired last month does not make September's attendance improper, and one that had already expired in September does.
The diary is one free-text field on purpose. TK-003 form 8, the Daily Site Closeout, is the checklist; it is a different form, and its real purpose is catching a safety finding that was raised and not reported. Merging the two would turn an account of the day into a compliance tick and lose both.
Each row is also an action log, not only a sign-in sheet. Added 28
September 2026, on Sarel's word watching the register live: "a record of
everyone that was on site for the day, from which company, their role, start
and end, location(s), and notes on what was done for the day … like an action
log of the day", with room to say who assisted with what. location and
notes are both free text and both optional, on the row rather than on the
day: a person moving from a substation to a switchroom in one morning writes
that in one field rather than filling in a second structured one, and "assisted
T. Nkosi with the DB3 fuse replacement" says who helped without a link to
maintain. Neither is part of what the signature covers — an activity note
added, or corrected, after the person has signed and gone must not unsign
them.
Rules live in src/lib/attendance.ts, the pad in
src/components/SignaturePad.tsx, guarded by tests/attendance.test.mjs.
/evidence, reached from the More menu. TK-003 form 7, completed as
evidence is collected and signed by the collector. It feeds the findings
register and TK-012.
It looks like audit capture and it is not, and that mistake is exactly why this form was missed off the first, inferred list of what the tablet should replace. It tracks ACSA's own documents, handed over on site — a chain-of- custody record rather than an observation. What a document proves is decided on the check-point it bears on, by an auditor with it in front of them. What this answers is narrower, and nothing else in the app answers it: what did they give us, who handed it over, is it a copy or their only original, and have we given it back.
180 of the 324 check-points are confirmedBy: "Document" — the number or the
signature on a piece of paper is the compliance — so this is the register
behind more than half the audit.
Requested is a state, and it is the useful one. A log that records only what arrived cannot say what is outstanding, and what is outstanding is the entire reason an RFI exists. So the register is ordered by what is still owed rather than by when things were logged, and the composed log prints the outstanding items first: four documents nobody has sent, buried under thirty that arrived, is a request that goes unchased for a month.
Producing it contradicts the refusal. ACSA saying a document cannot be produced is a real and reportable outcome — usually it is the finding — so it is recorded rather than left as an absence. Receiving the document later clears that declaration automatically, because otherwise the same row reads as both received and missing and the register has to pick one.
An original is an obligation with a clock on it. ACSA's only copy in a TPJV bag has to go back, and the log is what says whether it did. A photograph is never an original held, however the box is ticked — nothing left ACSA's premises — so it can never sit on the return list forever.
And it answers the other record. A check marked compliant, evidence pending is a debt somebody recorded at a desk. If nothing in this log names that check, nobody is chasing it. The screen says how many, and names them.
Rules live in src/lib/evidence.ts, guarded by tests/evidence.test.mjs.
The findings register answers what did we see?. The hazard register at
/hazards answers what could happen?, and they are not the same list.
A finding is an observation — a missing record, a worn coupler. A hazard is the event the failed control was protecting against, and that is what carries a severity. Rate the findings instead and the register that reaches ACSA is a risk profile made of paperwork: no gaseous suppression in a substation, a fire-detection gap in the same room and an unsigned maintenance record for the panel in it score as three medium paperwork risks where there is one red physical one. It reads perfectly plausible and it is wrong.
So hazards are built by consolidating findings, and one hazard commonly stands behind several. The two counts are reported separately everywhere and must never be added together.
Three things on that screen are worth knowing:
parseGroups enforces it, dropping a repeat claim and refusing a group left
with nothing. A finding in two hazards is double-counted in every total
downstream.Nothing is applied without a tap. A proposal becomes a hazard when somebody accepts it, and the rating stays unrated — counting in no KPI, no dashboard and no export column — until somebody clicks a cell on the matrix.
A hazard carries two ratings, and they are different instruments measuring different things.
B170 001M (src/lib/risk.ts) rates a safety hazard in the format the
SACAA Director of Civil Aviation accepts: severity A–E by likelihood 1–5, banded
Red / Amber / Green.
The ERM matrix (src/lib/erm.ts) rates business risk and decides what
enters ACSA's Combined Assurance Coverage Plan. J050 001FW Combined Assurance
Framework, Version 1, 19 October 2017, clause 9.2.2: consequence 5–1 by
likelihood 1–5, giving priority I / II / III — Unacceptable, Tolerable,
Acceptable. Clause 9.1.2 is why it is carried at all: risks rated as I and II
as a minimum shall be included in the Combined Assurance Coverage Plan.
Three things about the pair that are easy to get wrong, and expensive:
Laid on top of each other the two disagree on five cells — 1B, 2A, 3B, 4C, 5D, ERM harsher in every one. That is not a defect in either. It is also the answer to a question this project escalated: the Rev A2 register's matrix disagreed with B170 001M on those exact five cells, which looked like a transcription error. It was not. The register carries the ERM matrix, and the real error is that ACSA's Compliance Check-list Template cites B170 001M in its header while printing the ERM grid underneath. Worth telling Prince; nothing in this repo needs changing for it.
tests/erm-matrix.test.mjs transcribes the grid independently from the
framework and asserts all 25 cells, the 10/9/6 split, the axis direction, the
percentage bands, clause 9.1.2, and the five disagreements — computed from both
grids rather than remembered, so the day either is edited the claim is
re-checked. Run it after any edit to erm.ts or risk.ts.
Every photograph is called KSIA-ELE-001_P01 — the check-point's portal id and
a sequence number — and that one string is:
photographs/KSIA-ELE-001_P01.jpg)FALE/2026-09/KSIA-ELE-001_P01.jpg)So somebody holding the workbook can find the photograph without asking anybody.
Sequence numbers are never reused. Delete _P01 and the next photograph is
_P03. A reference that silently came to mean a different image would let a
finding evidenced by _P02 in March be evidenced by a different photograph in
September, with nothing on screen or in the workbook saying so. A gap in the
numbering is the cheap price.
| Why | For how long | |
|---|---|---|
| The tablet | The working copy — captioned and looked at on site, offline | The audit. Never deleted because a record copy exists |
| The record store | The copy that survives the device | Retention, still undecided |
| The export zip | Handed over, attached to an email, filed | With the deliverable |
| The Word report | Evidence beside the finding it supports | Phase 4, not built |
And a fifth thing that is not a copy: the thumbnail. Every photograph
carries a small inline preview inside the persisted record, so it travels with
the shared record to the other auditor's device while the full-resolution bytes
do not. blobKey points into the capturing device's own IndexedDB and means
nothing anywhere else; the record store holds the full image with
access: "private" and there is no read route, so nothing in the app can
render it. The practical consequence, stated plainly because it will otherwise
be discovered on site: after a sync the second device shows the thumbnail, the
caption, the reference, the asset and the location — everything except an image
you can zoom into. See OPEN-QUESTIONS.md §7 for the decision this needs.
ACSA rates, reports and compares asset systems year on year: that is the row in their register, the unit a Cluster report is written about, and the number a station manager is asked to explain. Squawk rated findings and hazards and had no rating at all on the thing ACSA publishes, so an asset system's band was something a reader inferred from the worst finding under it.
Inferring it is wrong twice over. Three Amber findings on one asset system is not an Amber system. And a system with no findings is not automatically Green — it may simply not have been looked at, which is a different and worse fact than "sound".
So the asset system carries its own severity, its own likelihood and its own
ratingConfirmed, agreed by the group on B170 001M like every other rating
here, and the band and the clause 4.6 treatment strategy are derived from
the cell rather than typed. A rating nobody tapped renders dashed and says
"suggested"; the figure at the top of the screen counts agreed ratings only.
Two axes, not a 5×5 grid — on this screen only. The matrix is the right
control where a group argues over a cell and points at it, and the findings and
hazard screens still draw it. Here it was 25 cells and about 300px sitting above
the evidence it is meant to be agreed from, met once per asset system, 75
times. RatingPicker is two rows of five. Nothing about the instrument moved:
the scales are the same SEVERITIES and LIKELIHOODS, the band is the same
bandFor(), and the rule the matrix enforces by its shape — a half-set rating
is not a rating — is enforced here in code: ratingConfirmed goes true only
when both axes are answered, and clearing either takes it back to unrated.
Nothing on the screen computes the band. The findings, the 2025 carry-overs, this audit's check-points and the walk items are evidence the group reads before agreeing the cell, which is why the panel shows all of them — the compliant checks included. A system whose checks all passed and whose 2025 finding is still open is a real and common shape, and only seeing both halves makes it visible. A blank compliance is shown as NOT CAPTURED and never as compliant.
Root causes and mitigation actions are plural. Switchgear rated Unacceptable regularly fails for two reasons at once, and closing it out is three jobs owned by three people on three timelines; one of each forces the rest into a comment where nothing tracks them. Each action carries its own owner, target date and status, and neither owner nor date is ever defaulted — a name against a job nobody accepted and a date nobody agreed are both worse than a blank the screen can flag. Two auditors' lists union on merge; the band itself is last-writer-wins, because a band is one decision the group made.
A finding can carry several. "TRF 02 at AS1 is off due to oil below the minimum threshold" is one finding with at least three futures — a trip that takes a stand off supply, a winding failure that costs a replacement and a lead time, an oil fire. They are not variations of one event: they have different likelihoods and different consequences, and an audit that records only the worst over-rates the common case while one that records only the likely under-rates the severe.
Each event carries a likelihood on ACSA B170 001M's 1–5 — the same scale the finding's own rating uses, never the ERM instrument's, which is a separate instrument that disagrees with it on five of twenty-five cells.
There is no severity on a possible event, deliberately. A rating of record
is agreed by the group on the matrix and lives on the Hazard. A severity typed
into a follow-up screen would be a second, un-agreed rating for the same event,
which is exactly the drift ratingConfirmed exists to stop. Unrated is a real
state and the default: what this captures is the auditor's list for that
conversation, not the outcome of it.
They are recorded against the visit's verification, so the timeline can say which audit thought of which — an event nobody had considered in 2025 that the 2026 walk turned up is 2026's contribution. Two auditors' lists union on merge rather than the later save winning, because two people standing at the same transformer will name different futures for it and that is the point.
Separate from the register's area column, and deliberately so. area is
ACSA's own category and a third of its 133 values at KSIA are not places at all
("Appointments", "Documentation", "Lessons learnt"). A photograph's location
is the auditor's own words — "Stand 12", "north switch room" — the words
somebody would use on the radio to send a maintenance team to the same spot.
It costs no taps in the common case. The inspection carries a location of its own, the screen keeps the last one named as a running value, and a photograph taken on a check inherits it at the moment of capture. Typed once per place, not once per check; editable per photograph, because one inspection can carry evidence from two places. Both are free text with the site's areas offered as suggestions and none of them forced — real zone names have not been supplied.
Both reach the workbook: Where it was inspected on the register sheet, Where it was taken on the Photographs sheet.
Every photograph is captioned, or it is incomplete. A photograph with no caption is a JPEG in a browser's storage that nobody can search, report on or recognise in six months — the workbook row would carry a filename and a timestamp. So an uncaptioned photograph is shown in the same warn treatment a finding with no owner gets, the export writes NO CAPTION rather than a blank cell that reads as "nothing to say", and the export panel counts how many are outstanding before you produce the workbook.
The caption carries its source. One an auditor wrote and one the assistant proposed and a person accepted are not the same evidence, and the export says which. Typing over a proposal hands authorship back.
Images never enter the persisted store. Zustand's persist writes the whole
store to one key on every change; base64 images in it would re-serialise
megabytes per keystroke. The store holds metadata and a 240px thumbnail; the
full image lives under its own key in the media store. Deleting a photograph
deletes the stored image too.
Every image is downscaled before it is stored — longest edge 1600px, JPEG
q0.82. A phone photograph is 4–12 MB as taken and 300–600 KB stored, still far
more than enough to read a serial plate. The canvas re-encode strips EXIF, which
is mostly welcome; but when a photograph was taken is audit evidence, so
DateTimeOriginal is read off the original first and kept.
The app asks the browser not to evict the audit
(navigator.storage.persist()) and reports the answer rather than assuming it.
Safari clears script-writable storage for a site not visited for about a week
unless it is added to the home screen; Chrome evicts under pressure. If the
browser refuses, the export panel says so in words.
The export panel shows what the evidence costs the tablet and warns above ~200 MB. A device running out of storage mid-audit must fail visibly: the browser's own quota error is opaque and arrives while somebody is on an apron.
SQUAWK_READ_WRITE_TOKENA tablet is a capture device, not a records system. Browser storage is evicted under pressure, cleared with site data, and gone with the device — and a finding challenged in six months is evidenced by the photograph or by nothing.
cloudUrl is outstanding, by definition,
so it survives a reload, a crashed tab and a flat battery.access: "private", so a Public store refuses and the error says exactly that
rather than passing through an SDK message. It deliberately does not retry
as public: whether site photographs sit on an open URL is not a decision a
retry makes. Reading one back needs a signed URL — a route to build when
somebody needs it; the local copy serves the app today.SQUAWK, so
the variable is SQUAWK_READ_WRITE_TOKEN. Reading only the default name would
make a correctly created store look identical to no store at all — nothing
uploading, nothing erroring, the header quietly saying "on device only"
forever, and somebody believing the photographs were being kept. Any
*_READ_WRITE_TOKEN is taken, and GET /api/photos names the one it used.squawk-blob is in iad1 (Washington,
D.C.). ACSA is a South African state-owned entity and these are photographs of
national key points, so where they physically rest belongs with whoever owns
the data-governance answer. It cannot be changed later, only recreated.Vercel Blob was chosen over SharePoint or an in-tenant Azure account. It is the least new infrastructure, and it puts ACSA site photographs with a third party neither ACSA nor TPJV governs. That is the Q4 question and it was decided knowingly; if ACSA's governance requires otherwise,
src/app/api/photos/route.tsis the single place that changes.
Images in the export panel produces a zip of the photographs, separate from
the workbook on purpose: the index is a few kilobytes and the evidence is not,
so an auditor can send a discipline lead the workbook without a hundred
megabytes attached and fetch the images when somebody asks. The zip carries a
MANIFEST.csv so it still reads on its own once separated from the workbook —
which, being a separate download, it will be.
One step only Sarel can do, once:
supabase/0001_shared_record.sql from this
repo, whole. It creates the table, the index, turns row-level security on
with no policies, and adds the conditional-upsert function. It is idempotent.SUPABASE_URL, SUPABASE_SECRET_KEY and SQUAWK_TEAM_PASSPHRASE in
Vercel — none of them NEXT_PUBLIC_ — and redeploy./preflight says which of those has happened on the device in front of you.
Until all of it has, the app is unchanged and captures are handed over as a file
from the same sheet.
A device that has not joined says so rather than looking broken. The passphrase box existed from the start, at the bottom of the export sheet behind a menu, and nothing pointed at it — so a second device opened the app, showed 0/315 and an empty Visual review, and read exactly like a deployment with no data in it. Nothing had gone wrong; the device had simply not been let in, and nothing said so. The banner is that sentence. It is not a gate: Squawk is local-first, a device that never joins still captures and exports and merges from a file, so it is dismissible and stays dismissed. It shows only where it is true — a deployment that HAS a shared record, a device that has captured nothing, and nobody having waved it away.
Three probes, in a browser, on whichever deployment you are testing. None ever returns a key's value:
/api/photos → {"available":true,"via":"SQUAWK_READ_WRITE_TOKEN"}
/api/assist → {"available":true,"model":"claude-opus-5","vision":false}
/api/transcribe → {"available":true,"model":"scribe_v1"}
/api/sync → {"available":true,"missing":[]}
available: false means the variable did not reach the running deployment —
usually because it was added after the last build. Redeploy.
On /api/sync, missing names which of the three are empty — by name only,
never a value, a length or a prefix, and empty on a working deployment. Two of
three set looks identical to none set through available alone, and the only
way to tell used to be guessing in the Vercel dashboard. Pre-flight repeats the
same names in plain words, so the deployment can be diagnosed from the device
without anyone opening the dashboard at all. Naming an absent variable discloses
nothing: with no SQUAWK_TEAM_PASSPHRASE set, every passphrase is refused and
no row moves in either direction, so the list says only that there is nothing
here to get into. Note that variables scoped to Production only do not reach a
Preview deployment — the same three have to be scoped to Preview as well, and
this is what that looks like when they are not. On /api/photos,
via is how you tell a working prefix from one being silently ignored.
available: true only means the token is present. If the store was created
Public, this probe still passes and the first upload fails with a message
telling you to recreate it.
ambiguous: true means two stores and no choice. A project that has had two
blob stores has two tokens, and the second one does not replace the first — it
sits alongside it. When more than one *_READ_WRITE_TOKEN is set and
BLOB_TOKEN_VAR does not say which to use, the route uploads nothing and
reason says so.
BLOB_READ_WRITE_TOKEN gets no preference for being the default name. It is
usually the older connection — the store that existed before anyone chose a
custom prefix — so preferring it is how a deployment keeps quietly writing to the
store it replaced. Giving the newer store a prefix is the deliberate act; being
the default is not. That is deliberate: picking one by any
tidy rule is wrong half the time, and a record copy in a store nobody
remembers is a record nobody will ever look in. The photograph stays on the
device regardless.
Two ways to resolve it, either is fine:
BLOB_TOKEN_VAR to the name of the one you mean, and keep both. This is
the safer of the two: it is reversible, and it does not depend on working out
which store a token belongs to.candidates lists every name
the route can see, so you know what is there to remove.This is not hypothetical — it is what this deployment did. squawk-blob was
created Private with the prefix SQUAWK, giving SQUAWK_READ_WRITE_TOKEN, but
/api/photos on production answered via: "BLOB_READ_WRITE_TOKEN" — a second,
earlier token still present and preferred by the old rule. Photographs were
being offered to a store nobody had chosen.
Nobody has said how long photographs are kept, where the master lives once an audit closes, who the custodian is, or what happens at device handover. The record store makes the question answerable; it does not answer it.
ASSIST_VISIONWhether site photographs reach the model is a separate decision from whether they reach the record store, and it defaults to off:
tests/vision.js proves this against a real forwarded request body rather than
by reading the code.
A note is recorded, stored and played back on the device, always. That needs no key, no service and no network, and nothing in the app may make it conditional. The audio is the evidence; everything below is convenience on top of it.
Three separate steps turn a spoken note into written text, and each is a separate decision by the person holding the tablet:
| Step | What it does | Default |
|---|---|---|
| Live text | The browser writes words on screen as you speak. This is not on-device — Chrome streams the audio to Google's speech service to do it. English-only, and absent on iPadOS Safari. | Off. A switch in the shortcuts panel, and the app says on screen while it is running. |
| Transcribe | Sends that one recording to the transcription service and stores what came back, word for word. Handles English and Afrikaans in the same sentence, which the browser cannot. | Only where ELEVENLABS_API_KEY is set, and only on a tap. Never on save, never in the background. |
| Write it up | Asks the model to turn the verbatim transcript into the written observation for that check — the wording an auditor would have typed, not a literal reading of a ramble. | Only where a model is configured, and only on a tap. |
The verbatim transcript is kept after the rewrite, next to the audio. A tidied sentence that quietly dropped one of three items, or turned 40mm into 40cm, is the exact failure this application exists to prevent, and the only way to catch it is to still have the original. The rewrite lives in its own field, is labelled suggested wording — not in the record, and reaches the observation only when someone presses Use it — where it is appended to what the auditor already typed, not substituted for it.
Before Transcribe existed, dictation started itself on every recording and said so nowhere. Store version 7 turns it off for anyone upgrading mid-audit; captured audio and existing transcripts are untouched.
Two layers, deliberately separate.
The composer needs no model. Compose from taps turns the buttons the auditor has pressed into a readable observation — deterministically, offline, on the tablet. That is the everyday path and it must never depend on a network.
On top sits an optional assistant that drafts the observation, reads an ACSA procedure back in plain English, and offers a second opinion on a rating. Every result is a suggestion shown beside the record with Use it and Discard. It never sets a status, never rates a finding, never marks anything captured, and the rating opinion is printed as text — it does not move the cell.
It also writes up a transcribed voice note, proposes a caption for a
photograph, and proposes root causes — where the useful output is not the
cause but the question to put to the responsible person, because a root cause
is something they know and the auditor does not. askInstead is rendered at
least as prominently as the cause, and picking a cause is the only thing
RootCauseAdvice can change; the questions are for the room and are never
written into the record.
Three more tasks serve the hazard register. hazard names the event a
finding exposes, from the check screen, while the auditor is still in front of
the asset. consolidate groups the findings that describe one physical
thing. reassess re-reads a hazard after the site walk. All three propose;
none of them rates, and reassess is told in the prompt that a photograph may
raise a new hazard but may not raise a likelihood on its own — likelihood on
this scale is occurrence history, and a photograph cannot show that. Without
that sentence a model looks at a rusty panel and pushes the likelihood up, which
is the wrong reasoning applied to the right evidence.
What is sent: the check in front of the auditor, a transcript for a write-up,
and — only when ASSIST_VISION is on — the photographs being asked about.
Audio is never sent. src/lib/assist.ts has the context builders, which are the
single place that decides this.
Images sent for consolidation are labelled — each one is preceded by a line naming the photograph and the finding it hangs off. Unlabelled, eight photographs are eight pictures of an airport and the model cannot say which two findings its photographs showed to be one thing; an attribution a reviewer cannot check is worse than no grouping at all.
Every key stays server-side. Five things can put data somewhere other than the
tablet — live text, Transcribe, the assistant, photographs through the
assistant, and the record copy — and all five are off until someone turns them
on. That is Q4 and it is not settled. If
ACSA's governance requires data to stay in their tenant, point
src/app/api/assist/route.ts and src/app/api/transcribe/route.ts at
in-tenant endpoints; nothing in the UI changes.
Squawk is read on a tablet held at arm's length on an apron at midday. That is the accessibility problem here, and it is not a minority one: low-contrast grey in direct sun is invisible to everybody, and roughly one man in twelve cannot separate the red dot from the green one.
Four light-theme tokens were below WCAG AA when this was measured — --ink-4 at
2.34:1 and, in dark, 3.23:1, both used at 9 to 11.5px. The ink ladder is
now 18.3 / 7.6 / 6.8 / 5.2 on a panel: four steps that still read as four steps,
none of them unreadable. --good and --warn moved a shade for the same
reason.
Alongside that: a main landmark and one h1 per screen naming the screen and
the audit (both supplied by AppShell, so a page adds an h2, not a second
h1); a skip link as the first thing the keyboard reaches; and every status dot
either saying what its colour means or marked aria-hidden because the row it
sits in already says it in words.
tests/a11y.js computes all of this from the tokens as the browser resolves
them, in both themes, and drives all eight screens. It fails if a token drifts
back under AA or a dot goes quiet.
NEXT_PUBLIC_GRAPH_CLIENT_ID | Turns Sync to the portal on. The client id of the Entra ID app registration; a public value by design in this flow, not a credential — but not in the repository either, see below. |
NEXT_PUBLIC_GRAPH_TENANT | The tenant to sign in against. A tenant ID, not organizations — the registration is single tenant and the common endpoints refuse one with AADSTS50194. The sync screen says so if it is set wrong. |
NEXT_PUBLIC_GRAPH_SITE | The SharePoint site the portal lives at. |
NEXT_PUBLIC_GRAPH_ORIGIN | The origin whose /graph-callback is registered as a redirect. Only used to warn, in advance, that sign-in from anywhere else will be refused. Unset means the check is skipped, not passed. |
SQUAWK_READ_WRITE_TOKEN | Turns the record copy on: every photograph is also written to Vercel Blob, privately. This is the live deployment's name — the store squawk-blob was created with the custom prefix SQUAWK, and Vercel generates the value; you never type it. The route accepts any *_READ_WRITE_TOKEN, so the default BLOB_READ_WRITE_TOKEN works too, and GET /api/photos reports which name it found. Without one the app is unchanged — capture, caption, export, all local — and says on screen that photographs are on the device only. |
Where ACSA states no threshold, the absence is the finding. Do not fill it with an industry norm. The screen says so explicitly and the Answer Library raises it as an issue.
Site variants are real. 40 checks carry a threshold stricter than the network default at FALE — monthly water sampling, yearly hydrant pressure test, vacuum-only circuit breakers. They are shown above the default, not in a tooltip.
Citation confidence is shown for a reason. 36 of the researched external citations are at document level rather than clause level and are marked as such. A clause number nobody confirmed is worse in an audit report than none.
Zones, entities and visits are data. src/data/programme.json. Adding an
airport or the next visit is a data change. Zones are deliberately empty and
field mode says so on screen rather than pretending the register's area
categories are places.
Capture must be real, and the test says so. Voice and photo were
fabricated until src/lib/media.ts existed: the mic button wrote a fixed
duration and a transcript lifted from the Answer Library straight into the
auditor's observation. Audio and images now come from getUserMedia /
MediaRecorder and the camera, blobs live under their own IndexedDB keys
(never inside the persisted store, which rewrites on every keystroke), and a
transcript is only ever what the dictation engine heard or the auditor typed.
An empty transcript is a correct answer. tests/capture.test.mjs exists to
stop any of that being quietly undone.
An audit belongs to one entity on one visit. State is keyed by
${entity}/${visit} (src/lib/store.ts). Screens read through
useResponses / useVerifications / useCaptures / useVisitFindings,
never the raw slices — a raw read shows whatever scope was last selected.
Anything that names a site or a visit follows the scope: owners, the cycle
strip, the closure lifecycle, export filenames. tests/scope.test.mjs
enforces it.
Findings carry forward, and that is a mechanism now. src/lib/carryforward.ts
merges the seeded 2025 findings with anything raised at this entity on an
earlier visit that nobody closed, and closure verifies both the same four
ways. Marking one Closed closes the finding itself, so it stops carrying. A
rating the group never agreed carries as "Not audited" — surviving a visit
must not turn a suggestion into a decision.
A review comment is not the record. /review shows every photograph and
voice note per discipline for the airport in view, so a discipline lead can
answer evidence without knowing which of 324 checks carries it. Comments
there are never merged into the observation or the finding — an engineer who
thinks their aside might be quoted in an ACSA report writes a more careful,
less useful comment, and a report that absorbed one would be misattributing a
finding. tests/review.test.mjs enforces it. ACSA's role is read-only
everywhere else but can comment here, because their engineers answering a
photograph is the point of the screen.
Every figure in the prose is derived from the register, and checked. Rev
A2 shrank the check-list from 374 rows to 324; the app followed it, because
every count on screen is computed. The words did not. This file, the test
notes and a dozen source headers went on quoting a register of 374, a library
of 11,179, a desk-and-field split of 365 and 314, an overlap of 305, and 89
carrying a question to ask. Not one was true. A stale number in a
comment is not a typo here — the comments are how the next person learns why
something is the way it is, and a reader has no way to tell a stale figure
from a deliberate one. tests/figures.test.mjs now works the numbers out from
the data and reads every markdown file and source comment for a figure written
next to "check-points", "checks" or "options"; anything that disagrees fails,
naming the file and the line.
The shared record is a sync target, never the source of truth. Each device captures into its own IndexedDB whether or not Supabase answers, and a deployment with nothing configured behaves exactly like the Squawk that existed before it — the file merge below is still there. What the record adds is that a team is working one audit rather than several.
No browser ever talks to Supabase. The obvious build hands the publishable
key to the client and leans on row-level security; that is right when every
user signs in and a policy can key on who they are. Access here is a shared
team passphrase, so there is no per-user identity for a policy to test — and a
publishable key in a browser plus a permissive policy is a URL away from every
finding and photograph in the programme. Instead the secret key lives in
/api/sync, the table has RLS on with no policies at all, and the
passphrase is checked server-side where a client cannot get round it. Same
reasoning as ASSIST_VISION.
It never falls open: any of the three variables missing and the route reports
itself unavailable and refuses. The merge rules are not reimplemented — rows
are shaped into a bundle and handed to the same src/lib/merge.ts the export
sheet uses, so evidence unions and a contested record resolves identically
whether it arrived over the wire or on a memory stick. tests/shared.js drives
two devices against a Supabase it can switch off, and most of its 41
assertions are the negative ones.
A refused merge does not advance the pull cursor. Rows are pulled, merged, and the server's cursor is stored — but if the merge is refused because the bundle names an audit this device is no longer in (somebody switched airport while a sync was in flight), nothing was applied, and moving the cursor anyway means those rows are never pulled again. Same rule as the push watermark, which already said it in words: a failed sync must re-send, not skip.
The pull cursor is deliberately held a minute behind the newest row.
Postgres reads now() at a transaction's START and makes its rows visible at
its COMMIT, so a push that takes a moment lands carrying a timestamp from
before it began. A device handed a cursor in that window would exclude the
slow device's rows from every pull it ever made again — and the slow device
will not re-send them, because its own push watermark has moved on. A check
captured on one tablet would simply never appear on another, and nothing
anywhere would report a problem; the workbook would just be short.
It takes one push overlapping another, so it shows up at three auditors rather than two. Overlapping the cursor costs a re-read of the last minute of the team's work per sync, which is free because the merge is idempotent — newer wins, evidence unions — and the reported "pulled" count is the number of records that actually changed on this device, so a caught-up tablet does not claim it pulled four records over and over.
This was invisible to the suite for a while because tests/fake-supabase.mjs
stamped each row at write time, in order: it was better behaved than
Postgres, and so proved something that was not true. It now stamps once per
transaction and can be told to hold a push open.
The other clock is the device's own, and it decides who wins. Every
contested record — over the wire and in the file merge alike — resolves on the
updatedAt stamped by the device that made the edit. That is the right rule
as long as the clocks are right, and a silent thief when one is not: a tablet
running ten minutes fast wins every clash it is part of, including against a
better answer somebody gave afterwards. The merge is not the thing to change —
the file route has no server to ask. What was missing was a device ever being
told, so /preflight now measures this tablet against the server, subtracts
the round trip so a slow connection does not read as a wrong clock, and says
what a wrong one costs.
A capture that could not be stored is never silent. Both write paths were
await putBlob(...) and then onCaptured(...), with no catch. On a full
tablet the promise rejected, the attachment was never added, and an auditor who
had just pressed the shutter on a defect saw nothing — no error, no
photograph, no reason to think anything had gone wrong. Eight photographs
selected with the second one failing lost the other six too.
A quota is the case that will actually happen on a phone holding an audit and a few hundred photographs, so it is named as itself and given a way out rather than a shrug; the guard is inside the loop; the message says how many were lost and that they were NOT stored, in those words, because an auditor must never walk away believing they have evidence they do not have; and it stays on screen rather than flashing, since the person is looking at a switch room rather than at the tablet.
One defect raised twice is flagged, never folded together. Two auditors
both tap the same issue button on the same check; each device mints its own
random F-XXXXX; the merge keys on id and keeps both. The audit now counts
one defect twice — rated twice, and twice in what reaches ACSA.
Combining them automatically would be the worse mistake. "No single line
diagram displayed" is one finding per switch room, and assetIds is how
those are told apart; folding them together destroys a real finding at a
national key point. So duplicateFindings() is only ever used to ask: the
merge report names the pair, and the Findings screen marks each row with the
other's id, because over the shared record the merge report is never shown and
the list is the only place the two are ever side by side. Only pairs a merge
just brought together are raised — one somebody has already looked at and kept
must not come back on every sync.
Two auditors, one audit. An ACSA audit is done by a team and the audit
lives in one device's IndexedDB, so until the shared record exists a day's
work comes back together by hand: each auditor shares a .squawk.json bundle
from the export sheet, and one device merges the rest. The rules are in
src/lib/merge.ts and three of them are load-bearing. A bundle from another
airport or another visit is refused, not warned about — Cape Town's captures
inside King Shaka's visit is a mistake nobody catches until the report is with
ACSA. Where both devices changed the same record the newer wins and the
report names it, because a merge that quietly drops somebody's afternoon is
worse than one that refuses. And evidence is unioned, never overwritten: a
photograph on the losing side of a last-write-wins is still a photograph of a
defect, so two auditors who photographed the same thing end up with both.
Photographs travel as references. A bundle carrying image bytes would run to
hundreds of megabytes and could not be sent, so the records travel and the
images are read from the record store where the upload queue has already put
them — and both the share button and the merge report say how many have not
got there yet, because finding that out at report time is a bad afternoon.
updatedAt on every record is what makes any of it possible; store version 13
back-fills it from the best timestamp each record already carried.
tests/merge.test.mjs runs the real module, tests/team.js drives two
browser contexts as two tablets.
It opens with no signal, and that is tested by cutting the network.
Everything else here about not losing captured work — IndexedDB, photographs
surviving a reload, the upload queue waiting for signal — assumed the app had
already loaded. public/sw.js is what earns that assumption: an auditor
airside who closes the tab, or whose phone drops the page from memory, still
opens Squawk and reaches everything they captured. Navigations are served from
cache and revalidated behind (a stale shell is not dangerous — the audit is in
IndexedDB, not the HTML — and nobody mid-walkabout should wait on a network
timeout to open a screen); /_next/static/* is cache-first because the
filenames are content-hashed. src/app/manifest.ts makes it installable, and
it starts at /capture rather than /, because / is a redirect and a
launch that begins with a redirect begins with a network request.
Four things are deliberately never cached, and each would be a defect rather
than a missing optimisation: /api/* (a cached availability probe would tell
an auditor a model is configured on a deployment where it is not),
/graph-callback (it carries an authorisation code and a state in its URL),
anything cross-origin (the photographs are not copied into a second store
as a side effect of a routing rule), and the redirecting root. The two lazy
payloads — the Answer Library and the asset register — are fetched
deliberately after load rather than on first use, because "offline-safe" and
"fetched when someone happens to open the right panel" are not the same thing.
tests/offline.js asserts all of it with the network actually switched off.
A shell is never stored ahead of the build it points at. Cache-first revalidation left one door open, and it is the day after a deploy: the auditor opens the app with signal and is served the cached shell instantly while the new html is written behind them; they walk out and lose signal; the phone drops the tab; they reopen — and get a shell asking for chunk filenames nobody ever fetched, because only the html was revalidated. Content-hashed assets cannot go stale, but they can be absent, and absent offline is a blank page airside with the whole audit sitting unreachable in IndexedDB. So an updated shell is promoted only once every asset it names can be served offline; a shell one build ahead of its own chunks is worse than one behind, which merely works. The first copy of a screen needs no such gate — the page is about to request those chunks itself — because making a first paint wait on a whole build would charge an apron for a problem only deploys have.
The check screen is a brief, not a dossier — and one space, not two
columns. Two lines never move: what to ask, and what evidence is needed.
Everything else the register carries for a check (the standard to audit
against with this site's stricter threshold inside it, a plain reading, ACSA's
procedure wording, the records it names, the evidence it expects, the external
instrument, the walkabout line, any conflict between documents) is a tab in
ONE panel that gets the whole width and scrolls inside itself — kept in full,
one tap away, and no longer deciding where the question is on the screen. The
tabs come in two groups in one strip: what the auditor does with this check
(the evidence, likely answers, issues, walkabout and snippet chips, each
carrying a count so nothing tapped is hidden by a panel that is closed), then
what the auditor reads to do it. The compliance buttons are in the
pinned bottom bar with Save rather than at the top of the screen: they are
still on screen at every scroll position, under the thumb rather than across
the screen from what commits them, and the check gets the height back. The
answer box, the voice note and the camera are pinned in the same bar — on a
phone they used to be below six groups of chips.
tests/checkscreen.test.mjs asserts both the shape and, field by field, that
nothing was dropped to get it.
The verdict and the things that are not a verdict are two rows. Compliant, compliant with the evidence still to come, and non-compliant are what the audit concludes about the asset; N/A and Not available are this check not applying here and ACSA not producing the document while we were on site. Drawn as five identical boxes those read as five equal choices, so the qualifiers sit on their own quieter row. The chosen answer carries an inset ring as well as a tint — a tint alone washes out on a tablet in daylight. The number keys are declared per option, never the array position: 1-4 are what they have always been and the new option is additive on 5, because a shortcut that quietly changes meaning is the fastest way to file a wrong answer against a check.
The writing tools are inside the box they write into. Compose from taps, Draft with AI and the microphone are 34px icons at the top right of the observation field rather than labelled buttons in the bar — three ways of filling a field belong at the field. Photo stays a full 44px labelled button in the bar: it is the one of the four reached for in gloves on an apron, where a small icon in a text field is the wrong target.
The Inspection list is a filter over one level, not a tree of two. Discipline was the first level of the hierarchy: six rows, every one of which had to be opened before an asset system was even visible. An auditor knows which discipline they are walking before they unlock the screen, so pressing it every session bought nothing. It is a select in the sticky bar now — a select rather than a chip strip, because 133 register categories in a horizontally scrolling row is not a filter anybody can use, and a select is one 40px control at any list length. What is left in the tree is the asset system, which is the unit ACSA rates, reports and compares year on year.
A search still beats the filter. An auditor standing in front of a thing types what it is; if the discipline filter hid it because they set it twenty minutes ago in another building, the answer would be "no results" for a check that exists. So a search runs across the whole register, and a line under the bar says it is doing that.
Things seen on the walk are filed in, not stacked on top. They used to be a block of cards above the register, on the reasoning that an item with no check-point behind it must not look like one of the 324. The reasoning is sound and the placement was wrong: what an auditor standing at a pump station wants is everything about that pump station. A walk item now sits in its asset system with the rest of the work, marked ADDED ON THE WALK in words and with a dashed border — never colour alone — and it is still outside every count. The group's fraction is of check-points; the walk figure is stated beside it, because a denominator that grows as you work is not a denominator.
The check opens in a sheet. It expanded in place first, which is what the brief asked for, and on a real phone it did not work: an expanded item rendered into the gap between the sticky bar and the bottom bar, so the answers, the comment, the photographs and Save were spread down a column about 220px tall with Save last. The sheet gets the whole viewport with the commit pinned under it, and the camera and microphone sit in that footer rather than in the middle of the form.
The navigator is one column, not two. The asset systems were a 210px rail
that filtered a 298px list of checks: a hierarchy the auditor had to infer
by watching the second column change, for 508px of a 1280px tablet. The
systems are now the list, with their checks nested under them and every group
collapsible. Save & next still walks the whole discipline, never just the open
group — the tree decides what is on the screen, never what is next — and when
the walk crosses into a shut system the tree opens it. It also starts at md
rather than lg, so a 768px tablet can finally change discipline; the
discipline select used to live in the lg-only rail.
Four header controls are one menu. Sync, Export, Start again and the keyboard-shortcut sheet each held a slot in the masthead, on every screen, for the whole audit — four controls reached for once a day, taking about a third of a 1280px header from the two used constantly: which check you are on, and jumping to another. They are behind More now, a word rather than a bare glyph, and each row carries a line saying what it does: "Reset" and "Start again" are the same button and only one of them mentions that it clears captured work. Start again sits last, under a rule, in the warn colour — it used to be one press from Export, which is save-my-work beside destroy-my-work on a tablet being carried around a substation.
The menu is offered at every width. Reset and the shortcut sheet were
hidden below sm to keep a phone header at two rows; Export and Sync were
not, so a menu hidden on a phone would have removed exporting from a phone
altogether. One slot at every width is what made the grouping worth doing.
The role stays out of it. TPJV / ACSA is not an action, it is state —
ACSA is read-only, with no capture, no Export and no Sync. An auditor who does
not notice the role does not go looking in a menu; they find that half the app
has quietly stopped working. So a pill says which role is live at all times
and opens the switch when pressed.
Popovers anchored to the masthead read their surfaces from --menu-*, not
--panel. The header redefines the surface tokens for the dark band, which is
right for a control inside it and wrong for a panel belonging to the page —
left alone the menu arrived dark purple on a light app. The --menu-* pair is
resolved on :root, so both themes stay correct and no value is written
twice.
The tablet is the device, not a narrow desktop. The touch floor lives in
one @media (pointer: coarse) block in globals.css — 44px targets, 16px
fields so iOS does not zoom the page in mid-capture — rather than a second
set of components; a mouse sees none of it. The check screen is one panel that
takes the width at every size — an iPad in landscape is 1180px, and two panes
beside a rail and a check list left the check itself about 500px of it.
tests/tablet.test.mjs enforces it.
Every check appears on both screens. src/lib/verification.ts is the
only place that decides. Until 4 October 2026 it read two different fields
to split the register into two lists — Capture got the 315 a desk could
progress (vtype's Evidence or Question), Field got the 290 with
something on site to do (the register review's inspect field) — real
reasoning, not a filter bug: nine checks, an ILS flight-calibration
certificate among them, genuinely have nothing on site to go and look at.
Sarel: "the different numbers might make people confused" — two counts on
one register reads as a discrepancy to explain even when both are correct,
so both screens now list all 324 checks. Where the walk genuinely has
nothing to confirm, N/A is one tap away rather than the check never
appearing at all.
tests/portals.test.mjs parses the register independently to check this.
Complete means every declared mode is answered. A response carries a desk
half and a field half, each stamped with who and when; captured is derived
from whether every mode the check's vtype declares is covered, and is never
set directly. Capture counts the desk half, Field counts the field half, and
only the dashboard, the progress ring and the export speak of "complete". The
export reports Desk done / Site seen / Complete as separate columns — one
"Captured" column would tell ACSA a site check happened when it had not.
The register is Rev A2, and the rebase is reproducible. src/data/source/
holds the register as issued; scripts/rebase-to-revA2.mjs transforms it into
checks.json, priorFindings.json and a re-keyed answers.json, and refuses
to write if any check would lose its vtype or its Answer Library options.
Rev A2 carries twelve fields where Squawk's Check carries twenty-five — the
ACSA reference block, citation confidence, site variants, walkabout text and
vtype are researched content this project produced and the script preserves
them by id. 303 ids are unchanged; the 21 ME Management items are matched to
their new Process Safety & Risk numbers by requirement text, not position,
because the renumbering is not positional (KSIA-MEM-002 is KSIA-PSR-029).
Answer Library content is reviewed content. Issue buttons seed findings with suggested severities, so they carry weight. A discipline lead signs off each set before it goes live (design document Q9).