Accessibility
A self-assessed conformance report against WCAG 2.1 Level AA, in VPAT 2.5 form. It is an assessment, not a certification, and the criteria we have not evaluated say so.
| Level | Supports | Partially | Does not | Not evaluated | Not applicable |
|---|---|---|---|---|---|
| Level A | 24 | 0 | 0 | 1 | 5 |
| Level AA | 14 | 0 | 0 | 3 | 3 |
Level A
| Criterion | Conformance | Remarks |
|---|---|---|
| 1.1.1 Non-text Content | Supports | Every <img> has alt. Icon-only controls carry aria-label; the journal, drill close and search all name themselves. |
| 1.2.1–1.2.3 Audio/Video | Not Applicable | The product contains no audio or video. |
| 1.3.1 Info and Relationships | Supports | Corrected 2026-08-22 (51 placeholder-only inputs named, 235 header cells given scope="col", the enrollment packet's sibling <label>s associated) and completed 2026-08-30. Every control the desks render now carries a PERSISTENT VISIBLE label — a wrapping <label>, or, in the funding grid, its column and row headers plus its own accessible name, which is the standard association for a data grid and keeps the grid readable. app/tools/labels.js renders every desk and sub-tab and refuses an unlabelled control on every build. It measures the RENDERED html rather than the source: every control here is built by string concatenation, and a source scan of this file reported zero inputs with placeholders on a file that had dozens. |
| 1.3.2 Meaningful Sequence | Not Evaluated | DOM order matches visual order in the source, but this needs a screen-reader pass through a live drill to assert. |
| 1.3.3 Sensory Characteristics | Supports | Instructions name controls by their label, not by position or colour. |
| 1.4.1 Use of Colour | Supports | Status is carried by a text pill as well as a colour — ok, mut, neg chips all contain words. The remaining gap was the bars: the funding ladder and the cap and over-cap meters conveyed magnitude by colour and length, and a STATE by hue alone — the same bar at the same length meaning "within cap" in one colour and "over" in another. Corrected 2026-08-30: every bar renders through one helper that gives it role="img" and an aria-label stating its value and, where it has one, its state. That also gives a bar a value for a screen reader, which got nothing from a <div> with a width. app/tools/contrast.js refuses a bar without one. |
| 1.4.2 Audio Control | Not Applicable | No audio. |
| 2.1.1 Keyboard | Supports | Tested 2026-08-23 against a POPULATED book (710 positions), and it was failing. 27 elements carried the click dispatcher's data-act on tags that cannot hold focus — 25 clickable table rows and two divs — so a keyboard user could reach every button and none of the primary drill-ins (open this athlete, this room, this payment). An earlier sweep passed only because an empty tenant renders no rows. Non-focusable act targets are now promoted after every render and Enter/Space is handled; verified by focusing a row and firing Enter, which opens the player drill. |
| 2.1.2 No Keyboard Trap | Supports | Verified 2026-08-23: a drill opened from the keyboard is closed by Escape. The drill is a non-modal <aside>, so focus is never confined to it in the first place. |
| 2.1.4 Character Key Shortcuts | Supports | No single-character shortcuts are bound. |
| 2.2.1 Timing Adjustable | Supports | Nothing expires under the user. Toasts auto-dismiss but carry no action and never contain the only copy of information — a refusal is also re-rendered on the surface it refused. |
| 2.2.2 Pause, Stop, Hide | Supports | No auto-updating or moving content. |
| 2.3.1 Three Flashes | Supports | Nothing flashes. |
| 2.4.1 Bypass Blocks | Supports | A skip link was added 2026-08-22. It is positioned off-screen and returns on :focus rather than being display:none, which would take it out of the tab order and make it decorative — the usual way this control is implemented and then does nothing. |
| 2.4.2 Page Titled | Supports | The title names the school and the product. |
| 2.4.3 Focus Order | Supports | Verified 2026-08-23: no positive tabindex anywhere in the app, so tab order is DOM order, and DOM order follows visual order. The skip link is the first focusable element on the page. |
| 2.4.4 Link Purpose (In Context) | Supports | Links and buttons are labelled with what they do. |
| 2.5.1 Pointer Gestures | Supports | Every control is activated by a single click or tap. Verified 2026-08-30: no touchstart, touchmove or gesturestart handler exists on any of the four surfaces, so nothing requires a path-based or multipoint gesture. |
| 2.5.2 Pointer Cancellation | Supports | Activation is a single delegated click listener — the up-event — so a press begun in error is cancelled by moving off the control. mousedown is bound in one place only, to place the caret inside a money box, and never to activate anything. |
| 2.5.3 Label in Name | Supports | Evaluated 2026-08-30, corrected 2026-08-30. Twelve controls carried an aria-label not containing their visible text. Ten are glyphs (×, ✕) whose rendered character is a symbol rather than a text label, where a descriptive name is the better answer and the criterion is met. The two that did fail were the text-size controls, which render as the text A− and A+ but were named "Smaller text" and "Larger text" — so a speech-input user saying "A plus" did not reach them. They are now named "A− smaller text" and "A+ larger text": the visible characters lead the name, and the words that made the old name useful are kept. |
| 2.5.4 Motion Actuation | Not Applicable | No function is actuated by device motion. Verified 2026-08-30: no devicemotion or deviceorientation listener on any surface. |
| 3.1.1 Language of Page | Supports | <html lang="en"> on all four entry points. The desk console and the athlete portal had NO <html> element at all — the bundles began at <meta charset> — so neither declared a language until 2026-08-22. Found by verifying this row rather than by asserting it. |
| 3.2.1 On Focus | Supports | Focus changes nothing but appearance. |
| 3.2.2 On Input | Supports | No control submits on change; every write is an explicit button. |
| 3.3.1 Error Identification | Supports | Refusals are returned as text naming what was refused and why — the server's refusal copy is deliberately a sentence an operator can act on, not a code. |
| 3.3.2 Labels or Instructions | Supports | Corrected 2026-08-30. A placeholder is not a label: it disappears at the first keystroke, which leaves the person who most needs to re-read what a field wanted holding an unlabelled box full of their own half-typed text. Every control the desks render now has a visible caption that stays put, and the two form builders (mfield and askFields) associate one by construction. Held by app/tools/labels.js. |
| 4.1.1 Parsing | Supports | The build refuses to ship a bundle whose inline scripts do not parse (app/tools/parsecheck.js, in CI). |
| 4.1.2 Name, Role, Value | Supports | Native elements throughout. The user menu carries aria-haspopup, the drill role="dialog", the toast role="status", the stage aria-live="polite", the nav aria-label. |
Level AA
| Criterion | Conformance | Remarks |
|---|---|---|
| 1.2.4–1.2.5 Captions/Audio Description | Not Applicable | No media. |
| 1.3.4 Orientation | Supports | Responsive; no orientation lock. The athlete portal is phone-first by design. |
| 1.3.5 Identify Input Purpose | Supports | The enrollment packet's identity and contact fields carry autocomplete tokens (name, bday, tel, street-address, address-level1/2, postal-code, email) as of 2026-08-22. The SECRETS are deliberately excluded and stay autocomplete="off": ssn, routing and account render as type=password, and naming them would ask the browser to remember exactly the values this system goes out of its way never to store. |
| 1.4.3 Contrast (Minimum) | Supports | Measured 2026-08-23 on every rendered text node across all eleven desks, and it was failing. Two tokens were the whole cause: --faint at 2.92:1 (the 8.5px uppercase label on every stat card and table header) and --pos at 4.40:1 (every "none ✓ / all met ✓" — the reassuring half of the page, unreadable to the people most likely to need reassurance). Corrected to 4.86:1 and 4.80:1, hues unchanged. Re-measured: 7,117 text elements on a populated roster, zero failures. The ATHLETE PORTAL carries its own palette and was worse — --faint at 2.57:1 on a card and 2.35:1 on the paper behind it, the lowest anywhere in the product, on the surface a student uses on a phone and often outdoors. Its light and dark themes were corrected in OPPOSITE directions (5.06/4.64 and 4.61/5.04); a token moved one way for both would fix one theme by breaking the other. ⚑ Not measured against a school's CUSTOM brand palette; the theming engine computes a ratio when deriving chrome, but that is a separate claim from this one. |
| 1.4.4 Resize Text | Not Evaluated | Layout is relative-unit based; not tested at 200%. |
| 1.4.5 Images of Text | Supports | No images of text. Figures are live text. |
| 1.4.10 Reflow | Not Evaluated | Wide tables scroll inside .scrollx containers rather than forcing the page sideways, which is the right pattern, and the stylesheet carries 640/760/980px breakpoints. The console shipped with NO viewport meta, so none of them ever engaged on a phone — corrected 2026-08-22. Downgraded from Partially Supports to Not Evaluated on 2026-08-23, after an attempt: the harness could not produce a viewport below about 1590px, so nothing narrow was ever rendered. The previous rating was inferred from the CSS, which is what this report exists not to do. |
| 1.4.11 Non-text Contrast | Supports | Focus rings corrected 2026-08-22; the rest MEASURED 2026-08-30, which is what this row was previously unable to say. app/tools/contrast.js resolves the palette tokens, composites each border over the ground it actually sits on and computes the ratio. Three control boundaries were below 3:1 — two button variants overrode the base button's border with a near-invisible hairline — and a --edge token was added at the alpha where this palette crosses 3:1 (3.04:1 on white, 3.14 and 3.18 on the two darker grounds). Pill outlines are 1.17:1 and are NOT counted as failures: a pill is not a control, it carries its state as a word inside it, and 1.4.11 governs what is required to identify a component. Segment dividers inside a bounded action bar are excluded on the same reasoning. The gate runs on every build. |
| 1.4.12 Text Spacing | Not Evaluated | Not tested with a spacing override stylesheet. |
| 1.4.13 Content on Hover or Focus | Supports | Hover produces tints, not content. Everything else is click-driven. |
| 2.4.5 Multiple Ways | Supports | Global search reaches athletes, offers, funding, work, deals, payments, agreements and accounts, alongside the nav. |
| 2.4.6 Headings and Labels | Supports | Headings describe their section; labels describe their control. |
| 2.4.7 Focus Visible | Supports | Global :focus-visible ring; the two opt-outs were corrected 2026-08-22. |
| 3.1.2 Language of Parts | Not Applicable | Single language. |
| 3.2.3 Consistent Navigation | Supports | The nav is the same on every desk, filtered only by what the seat may work. |
| 3.2.4 Consistent Identification | Supports | One renderer behind each repeated element (pctCell for every funded bar), so the same thing looks and reads the same everywhere. |
| 3.3.3 Error Suggestion | Supports | Refusals say what to do — "reshape it or free room first", "open it on the Banking desk, then write the offer". |
| 3.3.4 Error Prevention (Legal, Financial, Data) | Supports | Destructive and money-moving acts require confirmation, and the most dangerous require the id to be TYPED rather than a checkbox ticked. Every write is journaled with actor and time. |
| 4.1.3 Status Messages | Supports | #toast is role="status" and <main> is aria-live="polite". |
Known defects, and what we commit to
1. Visible persistent labels (1.3.1, 3.3.2). aria-label gives every input a
name, but the visible instruction still vanishes on input. The real fix is a
persistent <label> above each field, as the offer composer's money shape
already does with <label class="mshape">.
2. Table header semantics (1.3.1). Add scope="col"/scope="row". Cheap,
mechanical, and it is what makes a money table navigable by row.
3. Skip link (2.4.1).
4. autocomplete on the enrollment packet (1.3.5) — the surface where it
matters most, because it is a student filling in tax and banking identity on
a phone.
5. Colour-only magnitude on funding bars (1.4.1) — add a text value.
6. The evaluation gap itself. Keyboard operation, focus order, reflow, text
spacing and measured contrast are all Not Evaluated. That is the next piece
of work, and it is the piece that converts this from a source audit into a
conformance claim.
Security
The summary below is the short form. The full HECVAT Lite is available to institutions on request — it maps our architecture and our known gaps, so it goes to a named reviewer rather than onto the open web.
Athlete tax and banking identity is held apart from the desks that spend money: it is written by the athlete in their own portal and no staff seat can read it back. Refusals are logged with the seat, the path and the reason — never the body, because those bodies carry tax identifiers.
Request the HECVATSub-processors
Every party that can touch customer data, named before anyone asks — which is what a sub-processor list is for. All customer data at rest is in the United States.
| Party | What it handles | Where |
|---|---|---|
| Google LLC | application hosting, compute, and the customer database | United States |
| Google LLC | identity — Google sign-in | United States |
| Twilio SendGrid | outbound email — offers and enrollment invitations | United States |
No other party receives customer data. Nothing is sold, and nothing is used to train a model.
The layer below this — which managed services, how secrets are held, how the tenancy boundary is implemented, what runs where — is architecture rather than compliance. It is in the HECVAT, and the HECVAT goes to a named reviewer.
Incident response
A written runbook with severities, and containment levers that are commands rather than intentions.
- Severity is defined before the incident, so nobody negotiates it during one.
- Containment levers are specific — revoke a seat, pin a revision, restore to a point in time — and each is a command somebody can run under pressure.
- Backups are verified, not assumed. The runbook exists because writing it found that production had automated backups switched off; every answer in it was checked against the live configuration rather than the design.
The full document is shared with institutions alongside the HECVAT.