Skip to content

0.3.0 — UI redesign, and Tex - #11

Merged
CodedRichy merged 32 commits into
mainfrom
feat/0.3.0-ui-redesign
Sep 1, 2026
Merged

0.3.0 — UI redesign, and Tex#11
CodedRichy merged 32 commits into
mainfrom
feat/0.3.0-ui-redesign

Conversation

@CodedRichy

Copy link
Copy Markdown
Owner

38 commits. The headline is Tex, the in-app assistant, and the rule it is built around: the model never states a number.

Tex

  • The question box answers instead of routing. state/answers.ts computes every figure locally from the engine — attendance now, the cost of one more absence, tomorrow's cost against the timetable, what each subject needs in the final, standing.
  • state/glossary.ts is a deterministic term + capability lookup. No model, works offline and signed out, cannot invent.
  • The Cloudflare Worker only ever names a question shape and a subject, both fixed enums. Three layers enforce that: the prompt asks, the response schema constrains decoding, and parseAction rejects. Only the third runs on code we control.
  • Signed-out students now get a sign-in nudge at the point the assistant would have been used, instead of silence.

Worker

  • Now has tests and a CI job. It had neither, so parseAction — the last line of defence — was unchecked on every push.
  • Routes on gemini-flash-lite-latest; the pinned model was retired mid-development and every request 404'd.
  • Logs what the router was asked, content deliberately apart from identity.

Fixes

  • The search pill grows into the palette and shrinks back, rather than a dialog appearing unrelated to it.
  • Home no longer reports a confirmed score of 0.00 as an all-clear. pending and unsettled both miss the ordinary middle of a semester — every internal in, no exam sat — so the common case printed a zero under "Confirmed" with "Every subject has been assessed" beneath it.
  • Two History data-loss defects: a blank SGPA field deleted the row, and an edit silently discarded grade-card provenance.

838 tests (763 app, 75 worker), tsc and clippy -D warnings clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF

CodedRichy and others added 30 commits September 1, 2026 12:06
The pip strip drew one square per class, and it scaled with the wrong
number: a student at 45% got a wall of thirty amber squares, so the drawing
that meant "you are in trouble" was simply larger than the one that meant
"you have room". It read as a punishment graphic. It also said nothing about
the two lines the budget exists because of.

KTU has two attendance thresholds, and the gap between them is the reason
this app exists: 75% is eligibility, while full marks under R 7.5.ii need
85%. Every point between is internal marks bleeding away while the student is
told they are "fine". The meter marks both lines, shades the band between
them, and tints the fill with the status of the band it lands in.

The right-hand end prices the position in marks rather than repeating the 85%
constant on every card - seven identical labels on one screen said nothing,
while "3 of 5 marks" differs per subject and is the figure no other KTU
calculator shows. It comes from the engine's own attendanceMarks(); nothing
here computes or guesses a figure, and a subject with no percentage on record
draws no meter at all rather than an empty track.

The track carries a written aria-label with both thresholds and the marks
earned, so the fact reaches a screen reader as a fact - the old strip was
aria-hidden and conveyed nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkUwXEMQaeVRe8DE8aT5XX
.card and .tile declared the same six properties independently - the same
gradient border-box, the same --surface-1, the same transparent 1px border -
and differed only in radius and padding. Two names for one drawing meant a
change to "the card" silently missed half the app, and it is why every screen
looked like every other screen. They now share one rule and carry only their
own layout.

The ramp existed and went unused. --surface-1 carried every content container
while --surface-2 and --surface-3 sat in the tokens, so no screen had a
visible first thing and hierarchy fell entirely on type size - Home was
spending four numeric focal points at one elevation. A `promoted` modifier
moves the one element that is genuinely the subject onto the second ground;
Home's Standing tile takes it. At most one per screen.

Radius is one value. .card at 6px and .tile at a hard-coded 14px put three
different corners on Home at once, counting the notice.

The light-mode edge tokens were strengthened (0.14/0.06/0.03 to
0.24/0.11/0.05). A screenshot pass showed the card edge was effectively
invisible against the cream ground, so containers stopped reading as
containers on paper while dark mode separated them fine. The ground/card
lightness gap is deliberately small in light, which leaves the edge doing more
work there, not less.

552 tests pass, including the contrast test that reads the real stylesheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkUwXEMQaeVRe8DE8aT5XX
The header carried a wordmark, five view tabs, a semester list and a KPI block
in one strip, and nothing fitted. A vertical nav rail was tried first and cost
the ledger - the widest thing in the app - a 13rem column on every screen for
five items, so navigation comes back to the top with the strip actually cleared
out: the CGPA moves into the account block, where it reads as an identity fact
beside the student's semester rather than as the largest number in a row that
then had nowhere left to put nav.

Home becomes the app's own mark rather than a tab. The wordmark and the home
route were two targets in the same corner; they are one now, and the launch
animation flies its X into that button instead of into an h1 that no longer
exists.

The launch findings move from a banner into a notification bell. The banner was
honest and it was also the first thing between a student and their marks on
every single launch. The two urgent classes stay where they were: the "save is
not landing" banner keeps its row, because that one reports the app losing
data, and Home keeps its Needs-attention card.

The ask box (Ctrl+K) is new. It routes, it does not generate: every row
resolves to a view or a subject that already exists, and every figure beside a
subject is the engine's own - `plan.skip` for how many classes can still be
missed, `plan.attend` for the run needed to climb back. Question words are
dropped by a stop list so "how many classes can I miss in ML" resolves to the
same subject the bare code would, and a question with nothing nameable left in
it answers for every subject. Nothing here is inferred, so a model put behind
this box later selects among these rows - it does not get to invent one.

Two bugs found while building it, both from shared names:

  - `Mark` paints its strokes in --brand and --brand-deep, which vanished on a
    --brand-filled button. Repainted in the button's foreground on .homebtn
    only, keeping the asymmetry that stops an X reading as a close button.

  - `.pill` is defined twice in app.css, once for the header and once for the
    status chips, and the chip rule won on source order - handing a search
    field a badge's centred text. The ask box is `.ask` now and shares nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Three of the assistant's requirements cannot be met inside a Tauri binary, and
they are all the same problem: trust cannot live on the machine being trusted.
A shipped Gemini key is extractable from files on the user's disk, a rate limit
enforced in JS is a limit the user deletes, and a Clerk session token proves
nothing until something verifies its signature. This is the smallest service
that solves all three.

What it deliberately does not see: marks, attendance, CGPA, name or register
number. The request carries a question and the semester's course list; the
response carries a route. Every figure a student reads is still computed by the
engine on their own machine, which is what lets the app keep saying it never
states a number it cannot show its working for.

"Never answer anything outside the app" is enforced twice, and neither is the
prompt. Gemini decodes against a JSON schema whose subject field is an enum of
the codes the client actually sent, so prose is not a permitted value and an
invented course code is not generatable; then parseAction revalidates whatever
came back against the same closed set before the client sees it. The prompt is
for accuracy, the schema is for safety, and the first is never relied on for
the second.

Ordering in the request path is deliberate. Identity is checked before the body
is read, so an unauthenticated caller cannot make us parse what they sent. The
quota is claimed before the model is called, because counting on success lets
anyone who can reliably provoke an upstream error spend our budget without
spending their own. The counter is a Durable Object rather than KV: KV would be
cheaper and would also let twenty parallel requests race a read-modify-write
through a five-unit quota.

Failures never explain themselves. Expired and forged must look identical from
outside, and an upstream error string can carry a URL or a key fragment.

Not wired to the app. The ask box answers attendance and leave questions today
with no network, no key and no account. Turning this on means requiring sign-in
and sending questions to Google, which is a deliberate change to the app's
privacy position rather than a config flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Both popovers shipped dead. Solid delegates every `onClick` to `document`, and
the close-on-outside-click listener was on `document` as well - and
`stopPropagation` does not stop other listeners bound to the SAME node, only
`stopImmediatePropagation` does, and only for handlers registered after it. The
wrapper's guard therefore did nothing: the trigger's toggle opened the popover
and the dismissal closed it again within one click.

Closing is now decided by geometry - was the click inside the wrapper - which
is independent of listener order and of the framework's delegation strategy.

The regression tests dispatch real bubbling MouseEvents rather than calling the
handlers, because the whole bug lived in how two listeners on one node
interleave; a test that invoked the click handler directly would have passed
against the broken build. Confirmed by reverting the fix: all six fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Nothing in this app ever refreshed itself. `lastSync` was written by one button
on one screen and by nothing else, so a student who opened TargetX to check
whether they could skip tomorrow's class was reading whatever the portal said
the last time they thought to press it - a number that looks authoritative and
is quietly days old, which is worse than no number.

`state/autosync` fixes that under three limits. It runs only when the password
is ALREADY in the OS credential vault, so this feature never prompts, never
stores anything new, and cannot be the reason a password gets saved. It runs at
launch and never on a timer, because etlab updates attendance once or twice a
day when a teacher gets round to it and polling someone else's college server
every fifteen minutes is load for data that did not change. And it is throttled
to three hours, so reopening the app four times is one sync.

It is fire-and-forget: nothing waits on it, nothing blocks on it, and a failure
becomes a line in the bell rather than a dialog thrown at someone who did not
ask for a sync. The app's whole value is that it works with no network at all.

The launch check's doc claimed a portal sync could never run on launch because
"the password is never stored". That stopped being true when the remember-me
vault shipped. Corrected to the reason that still holds: the check is
arithmetic that must finish before first paint, and a network round trip cannot
be on that path.

Notifications now dismiss individually as well as in bulk. Three findings are
three different decisions, and clearing them together forces a student to
re-read the two they had not dealt with. Dismissals last for the run of the app
only - every finding is re-derived from the record on each launch, and a
dismissal saved to disk would be a promise never to mention a problem again,
which these problems do not earn by being ignored.

Two bugs found by writing the tests rather than by using the app:

  - The first three dismissal tests were vacuous. They drove `App`, whose
    findings arrive on a 450ms timer, so the popover was empty and every
    assertion passed by describing nothing. They render `Bell` directly now
    with fixed findings.

  - Outside-dismissal on `click` closed the popover on every dismissal.
    Removing a row detaches it, so by the time the document listener ran,
    `contains` answered false for a node that was plainly inside. Moved to
    `pointerdown`, which fires before any handler can mutate the tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
The manual sync was two separate forms on the Data screen, one per portal, and
a student who wanted current numbers had to know there were two, find both, and
run them in turn. Attendance and published results are one question to the
person asking it, so they are now one button in the header.

The two sources are refreshed independently and neither can sink the other.
They are different servers with different logins and different failure modes,
and KTU being down at result time is not a reason to leave a student without
today's attendance. A run reports per source, and the bell shows one row per
portal that failed rather than a merged "sync failed" - a student whose KTU
fetch broke while attendance came through needs to know attendance is current.

A manual press honours none of the automatic run's throttling. The student
pressed the button; declining the request because one ran twenty minutes ago
would be the app arguing with them about what they just asked for. The launch
refresh is the same code path with the staleness gate in front of it, and the
gate drops from three hours to 45 - under a class period, so tabbing back
between classes gets a fresh number.

The KTU vault key moves into `state/creds` as a constant. Two callers read it
now, and a second copy of that string that drifted by one character would fail
as "no saved login", silently, forever.

`.bell` splits into `.iconbtn` plus `.bell`. The refresh button had been given
the bell's class, which made `button.bell` ambiguous - the popover tests were
finding the refresh button and failing, which is exactly what that selector
would have done to anyone reading the DOM later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
RFC 8252, "OAuth 2.0 for Native Apps", implemented in `src-tauri/src/oauth.rs`.
Every part of it is shaped by the fact that a desktop binary cannot keep a
secret - the same constraint that keeps the Gemini key out of the client:

  - PKCE with S256, no client secret. Anything static shipped inside the app is
    extractable, so the credential is a per-attempt random verifier instead.
    `plain` was not an option: it puts the verifier in browser history and in
    every proxy log on the way.

  - The system browser, never an embedded webview. The student types a password
    on a page whose URL bar they can see, in the browser where their password
    manager lives. An in-app webview is the phishing shape - the application
    could read the password field and the user cannot tell that it does not.

  - A loopback listener on an ephemeral port, accepting exactly one request. The
    OS picks the port, so two copies of the app cannot collide and no stale
    listener can be squatted. A preflight with no code and no error does not
    consume the accept.

  - The refresh token in Windows Credential Manager, next to the portal logins.
    The access token is short-lived, lives in a signal, and is never written
    anywhere - storing a bearer credential to save one refresh call is a bad
    trade.

Endpoints come from the provider's OpenID discovery document rather than being
hardcoded, so a URL shape that changes is not a silent breakage. The browser is
opened from Rust via `rundll32 url.dll,FileProtocolHandler` rather than through
a shell, where `cmd /c start` would parse the URL as a command line and a bare
`&` would change what runs. The authorization URL never crosses into the
webview at all - the same webview that renders a college portal's HTML.

State is compared before the code is used for anything, and a mismatch is
refused without explaining which half was wrong. A rejected refresh clears the
stored token rather than failing the same way on every launch forever.

SIGN-IN GATES THE ASSISTANT AND NOTHING ELSE. Every figure on every screen is
computed on the machine from data the machine fetched, and a student who never
signs in - or whose provider is down, or whose refresh has expired - still
opens the app and reads their attendance. The tests pin that: a refused
sign-in, an absent account and a locked vault all leave the app working and
three of them say nothing at all, because being signed out is a normal state
and not a fault to nag about on every launch.

Configuration is two public build-time values in `.env` (gitignored, with an
example committed). Blank means the build simply has no accounts.

The PKCE challenge is checked against RFC 7636's own worked example, because a
drift there fails at the provider with an opaque error.

Not verified against a live Clerk instance - there is no instance yet. The flow
is standard OIDC and reads its endpoints from discovery, but the first real
sign-in is still the test that matters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
…e header

The bell and the account menu appeared blank in a running dev build. They were
not: the window had been open since before the fix that made them work, and had
silently dropped the hot-module updates. Restarting the dev server showed both
panels behaving correctly with no code change at all.

What went in before that was established is this commit's real content, and it
stands on its own merits rather than on the diagnosis that prompted it. Both
panels now render through a Portal into `document.body`, positioned in viewport
coordinates measured from their trigger, which removes two copies of the same
hand-rolled outside-click handling and adds Escape-to-close - which neither
inline version ever had.

The Portal is also insurance against a real hazard that was NOT demonstrated
here: the header carries `backdrop-filter`, and a backdrop-filtered element
establishes a containing block for absolutely positioned descendants. A panel
hanging 220px below a 64px header is exactly the shape that goes wrong if an
engine also clips to that box, and raising z-index would not help because a
clip is applied regardless of stacking. Being out of that subtree means the
question never arises.

The outside-click listener still tests geometry rather than propagation, and
still on pointerdown - both for the reasons already documented - with one thing
added: the anchor and the panel are no longer in one subtree, so both have to
count as "inside".

The tests query `document` rather than the render container, because that is
where a portalled panel actually is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
…four

RFC 8252 says a provider should accept any port on 127.0.0.1, and this used an
OS-assigned ephemeral one. Clerk refuses a wildcard and wants exact redirect
URIs registered, which most providers do, so sign-in now tries a short list of
fixed candidates and takes the first free one.

Several rather than one on purpose. A single hardcoded port is a single point
of failure: anything else on the machine binds it and sign-in is dead with no
route for the student to fix it. They are from the IANA dynamic range and away
from the round numbers development servers reach for, so a student is not
fighting whatever is on 8080.

Every candidate has to be registered in the provider. A port missing there
fails only when the earlier ones happen to be busy, which is rare,
machine-specific and looks random - so the list is pinned by a test, and the
comment above it says what registering means.

Scopes drop from "openid profile email offline_access" to "openid
offline_access". Nothing reads the other two: the Worker rate-limits on the
token's `sub`, and the account menu shows a semester and a CGPA that are
computed on this machine. Asking for a name and an email address we never look
at means a consent screen that overstates what the app does and a token
carrying more than it needs. They go back in the day something displays them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Public by definition - the issuer appears in every token the app receives, and
keeping it in the file rather than in a secret means the deployment is
reproducible from the repository. The Gemini key is still set with
`wrangler secret put` and still never touches version control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
`class="link primary"` was reused from the launch banner, where
`.launch-notice .link.primary` scopes it back to looking like a link.
Unscoped in the account menu it matched `button.primary` as well, so the
control took the solid button's background and the link's metrics, and the row
being `space-between` next to two lines of copy left it narrow enough to wrap
"Sign in" across two lines inside the swatch.

It gets its own class rather than another scoped override, because the next
person to reuse `.link primary` outside a launch notice would hit exactly this
again. The text beside it is allowed to shrink and the action is not, which is
the actual fix - in a `space-between` row with a two-line neighbour, the button
is what gives up its width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
An attempt that never came back held its loopback port for the full five-minute
browser timeout, and the listener thread stayed parked in a blocking `accept`.
Pressing Sign in again bound the next candidate, so four failed attempts inside
five minutes walked through every registered port - and the fourth failure
would have been "could not open a port", which says nothing about the redirect
URI that actually caused the first three.

Starting a new attempt now cancels the previous one, and finishing an attempt
releases its own port whatever the outcome was. Setting the flag alone cannot
do it: the thread is blocked in `accept` and nothing wakes it until a
connection arrives, so one is made to itself purely to let the loop come round
and read the flag.

Found while debugging a live sign-in against Clerk, where the first attempts
failed on an unregistered redirect URI and left two of the four ports held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
`profile` and `email` go back into the scope request. They came out this
morning because nothing displayed them; the account menu now shows the name,
the address and the picture, which is the right standard for a scope - ask for
a claim when something shows it, not in case something might.

The avatar is fetched once in Rust and handed over as a `data:` URI, NOT as the
provider's https URL. Pointing the webview at Google's CDN would mean a request
to them every time the header drew - this machine's IP, and the fact that the
app is open, repeatedly, from an application whose whole position is that it
needs no network. Inlining also means the picture still draws offline and the
app's `img-src` stays `'self' data:` rather than being widened for a CDN.

The fetch is bounded rather than trusted: https only, a declared image type
from a small allow-list, 512KB cap, and every failure resolves to no picture at
all. An account with no photograph and an account whose photograph would not
load are the same thing to the person looking at it.

The ID token's claims are read without verifying its signature, which is
correct here and nowhere else: it arrived on the direct TLS response to our own
PKCE exchange, so OIDC Core 3.1.3.7 does not require validation, and the claims
are used for a name and a face rather than for anything that grants access. The
Worker still verifies signatures properly, because there the token arrives from
a client we do not trust.

The header chip keeps showing the semester and CGPA rather than the account
name. An academic tracker should lead with the thing being tracked; whose
Google account is attached to it belongs in the menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Walked the app from a cleared record with a real browser rather than reasoning
about it, and the first thing a new student meets was hostile.

A red alert badge on a brand new install. The launch check's "nothing recorded
yet" is severity `info`, but the badge was red whatever the finding was, so the
app opened by alarming somebody for having just arrived. The badge now takes
the tone of the worst finding in the list, so a real warning still reads as one.

"S1 - 1 semester on record", with nothing in it. Setup creates the current
semester before anything is in it, and the count read the key rather than the
contents, so the app opened with a claim it could not support - the one thing
this app is supposed never to do. A semester with no subjects is not on record,
and with none at all the count is absent rather than zero.

The same primary action twice: "Get your marks in" in the header and again in
the empty card, two identical dark-green buttons on one screen offering a
choice between two things that do the same thing. The header action is now for
a student who already has data.

Then the empty state said "Nothing recorded yet" directly under a lede saying
"nothing recorded yet" - one sentence twice in an eyeful, which reads as an
error rather than as a start. The heading now names the student's next move,
its button names the act rather than the outcome, and the card is centred in
the space it has instead of pinned to the corner of an otherwise blank screen.

Onboarding's three routes were one drawing with a 10px eyebrow on the first,
which asks a student to read three paragraphs to discover the app already has
an opinion. It has one: portal sync brings every semester in a single pass, and
the other two are for students who cannot use it. The recommendation now looks
recommended. Step two was also called "Your subjects" while one of its three
routes ends with no subjects at all; it is "Your marks", which all three do.

And sign-in, which was the question that started this: it changed one line of
text inside a menu the student had to still be holding open to see. It now
greets them by name, with their face, and says what it unlocked - because being
named is the proof the round trip carried an identity back. It fires on a real
sign-in and never on a launch resume, which would be a nag rather than an
acknowledgement, and it takes itself away rather than asking to be dismissed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
The Standing tile is grid-column: span 2 and the gauge that fills its right
half only renders once a target exists, so a student with no target got a
double-width card carrying one number and a sentence. The empty half now
carries the action that fills it - five values rather than a number pad,
because a student arrives wanting 'above eight', not 8.37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
The ask box only ever answered what local matching could find. This wires the
second half: when the palette has nothing to press, Enter hands the question to
the Cloudflare worker, which returns a route - a view or a subject - and never
prose and never a number.

Local stays first, and Enter only leaves the machine when there is no local hit.
The engine's answer is free, offline, instant and cannot hallucinate; calling a
metered API per keystroke would be an unbounded bill for answers already held.

What crosses the wire is the question and a list of {code, name}. No marks, no
attendance, no CGPA, no name, no register number. PaletteAsk.test.tsx asserts
the payload by equality rather than by a no-digits regex, because a course code
has digits in it and that regex would have proved nothing.

Also corrects the header comment in HeaderPopovers.test.tsx, which still blamed
a WebView2 backdrop-filter clipping bug for blank panels. The cause was a stale
dev server that had stopped applying HMR updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
…listing everything

gemini-2.0-flash was retired and the endpoint returns 404, so every question in
the app was failing at the model call. Verified against the live API with the
worker's own schema: flash-lite routes 'how many classes can i miss in ML' to
{kind:subject, code:CST305, view:attendance} at temperature 0.

Moved to the floating alias rather than another pinned version. That is the
opposite of the usual advice and is the point: a pinned model's failure mode is
a 404 that arrives without warning and takes the whole feature down, which is
what just happened. The alias can only change the model behind a fixed JSON
schema that the API enforces during decoding and parseAction rejects anything
outside - very little surface for a change to break.

The palette also opened onto every view and every subject before a key was
pressed. That is not a result set, it is the whole app enumerated in the one
place a student came to narrow it down. The list now starts when the typing
does, and an untouched box renders no verdict at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
The loose subsequence match exists so "cn" finds Computer Networks. Applied to
words pulled out of a sentence it matches far too much: "what happens if i miss
one more class" leaves the terms [happens, one], and "one" subsequence-matches
Computer Networks - o, n, e in order. Four of seven subjects matched that query.

The damage is not the wrong rows. A local hit stops Enter from reaching the
router, so a phantom match silently disables the one path that could answer a
question the engine cannot. Loose matching is now allowed only when the query is
a single term, which is the case it was built for.

The test seeds Computer Networks specifically so it can fail: a suite holding
only Machine Learning passes against the broken build, because that name has no
o-n-e in it. Verified by reverting the fix in place - the test goes red - and
restoring it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
…the university's

Two data-integrity bugs on the one screen where the student edits by hand.

Clearing the SGPA box and blurring deleted the entire SemesterHistory - the
SGPA, both credit totals, the source and any recorded conflict - with no
confirmation and no undo. Select-all, Backspace, Tab was enough, and the CGPA in
the header dropped with no explanation. Meanwhile erasing everything two screens
away has a two-step confirmation, so the guard was on the wrong action. Blank now
means "I do not know this": the stored figure goes back in the box, and removing
a semester is a deliberate two-press control that names the SGPA it will discard.

Typing over an SGPA kept the record's existing source, so a hand-typed number on
a row that came from a KTU grade card still claimed `source: gradecard` - the app
asserting the university published something it never did, and holding that claim
at rank 3, where a later real fetch of the correct figure ties and can silently
lose. An edited SGPA is now `manual` (rank 2): still trusted over a portal scrape
because it was deliberate, and correctly beaten by the card it replaced. The
displaced figure goes into `conflict`, which already exists to hold exactly this,
so the row keeps showing "KTU grade card said 8.42" and the student can put it
back. Editing only the credits still keeps the card's provenance - the SGPA is
unchanged, so the record is still the card's.

`sameSgpa` is exported rather than duplicated: two definitions of "the student
changed it" would drift.

Each test was verified against a reverted fix - both pairs go red - because a
test that cannot fail is worse here than no test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Traced 24 real student phrasings through the matcher. Three questions the engine
answers instantly and offline were reaching the network instead, and one had
regressed.

"am i short on attendance" found nothing at all. The stop list strips domain
words - attendance, marks, classes - because otherwise every phrasing of every
question matches every subject; but those same words are exactly what names a
SCREEN, and stripping them left the question with only "short", which named
nothing. Views are now matched on the raw question against an explicit `keys`
list, so the words that identify a screen are used to find it. The keys are a
list rather than the hint text because the hints are English sentences and
matching "what needs doing" would have "what" open Home.

"what do i need in the final to pass cn" stopped resolving when in-sentence
subsequence matching was closed - the fix for phantom matches took this with it.
Initials give it back: "Computer Networks" -> "cn" exactly, which cannot
over-match the way a subsequence does. "one" is not the initials of anything.

Consequence worth recording: the router is now called for almost nothing. Local
matching answers the engine questions, routes the navigational ones, and sends
attendance-shaped questions to Attendance. What remains for the model is the
long tail of phrasings the keyword list misses - which is the right division,
since the local path is free, offline and cannot invent anything.

The phantom-match test was rewritten rather than deleted: "what happens if i
miss one more class" now legitimately matches the Attendance view, so the
assertion moved to the property that actually matters - no SUBJECT is claimed to
match. Verified it still goes red against the reverted fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Every attendance figure in the app answered "am I above 75%". A student at 86%
is told by every portal that they are fine; two absences put them at 84% and
cost a CIE mark under R 7.5.ii, and nothing anywhere tells them until the mark
is gone. That gap is the product.

`absenceCost` reports both sides - "86% and 4 marks becomes 84% and 3" - rather
than a bare delta, because a difference on its own is a figure that cannot show
its working. `freeSkips` is the budget that actually binds: the last absence
that costs no mark, which for most students is far smaller than the number that
keeps them eligible.

Composition, not new arithmetic. The percentage after n absences is the same
effective figure the rest of the engine uses, recomputed with the classes added
to `held`; the marks on both sides come from `attendanceMarks`. Nothing here
carries a second opinion about what a percentage is worth, and the tests assert
against the band table rather than against copied output.

Duty leave is deliberately held at today's credited value. `creditDutyLeave`
caps DL at a fraction of held, so a larger denominator could mathematically
admit more DL - but a student does not acquire duty leave by missing a class,
and letting the cap rise would report a skip as cheaper than it is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
…rom identity

Local matching in the app now answers almost everything - the keyword list
routes attendance, marks, results and sync questions without calling out - so
the requests that reach this Worker are, by construction, the phrasings the
keyword list MISSED. That set is the only evidence there is about what to add
to it, and it was being discarded the instant a response was returned.

Refusals are recorded as carefully as successes and are the more valuable half:
a declined question is a student who wanted something the app would not give
them, and nothing else in the system records that. Upstream failures are
recorded too, because a run of them is the difference between "nobody asked"
and "everybody asked and got nothing".

Identity is deliberately not in the log. No `sub`, no token, no name, no email -
the caller is verified on every request and counted for quota, that value just
never reaches this file, and the quota counter needs no content to work. Holding
both halves would make this a named student's question history over time, which
carries DPDP obligations that a routing corpus does not. Course codes are out
for a weaker version of the same reason: a distinctive question plus one
semester's course list re-identifies a student in a small cohort. The count is
kept, since that is all the router's behaviour depends on.

The write is in ctx.waitUntil and logAsk swallows every error, so a logging
failure cannot turn an answered question into a 502. The binding is optional in
the Env type: removing the dataset degrades to no logging rather than to a
broken endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
…reen

"If I take a leave tomorrow, how badly does it affect my attendance" opened the
Attendance screen, which shows a static budget - classes left before 75% - and
never what the NEXT absence costs. Those are different facts and the second one
was the question. The box now says it.

The model still never states a figure. It names which question this is and which
subject it is about, both from fixed enums, and every number in the sentence is
computed here by the engine from this student's own records. What changed is
where the answer appears, not who produces it.

Both sides of a change are always given - "86% becomes 84%, costs 1 mark" - never
a bare delta, because a difference on its own is a figure the student cannot
check against their own portal.

`tomorrowCourses` is the join that was missing: the timetable and the attendance
budget have both been in the app all along, two hundred pixels apart, with
nothing multiplying them. Portal subject strings are matched back to the
student's own courses by code then by name, and a period matching nothing is
dropped rather than guessed at - naming a subject they do not have is worse than
a shorter list.

Topics are only added where the engine can answer exactly. There is no "general"
topic and no free-text field: a question shape with no solver behind it falls
through to a route, because the alternative is a sentence nobody can show the
working for. `answerFor` returns null on no courses, no attendance or no
timetable, and the caller routes instead - a screen showing nothing is more
honest than a sentence built from nothing.

Detection is local keywords, so "can i skip tomorrow" is answered with no network
round trip at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
`attended` and `held` appeared in exactly one place in the whole app - as
editable inputs inside an expanded Ledger row, one subject at a time, behind a
click. So the app printed "78%" and "you can miss 4 more" on its most prominent
screen and never showed the two numbers those come from, which is the one thing
it says it will never do. A student wanting to check their own figure had to
open seven rows one at a time.

Three places, one addition each:

The Attendance card now carries "39 of 50 attended" under the subject name, plus
the credited duty leave when there is any - without it the working would not add
up, since DL is exactly why the shown percentage differs from attended/held. It
deliberately does not repeat the percentage sitting one line above: saying it
twice would make the counts read as a second opinion rather than as the source.

The Ledger row now shows 39/50 beside the percentage, in the column that was
already there.

The calendar was hue-only - empty divs whose sole carrier was a hover `title`,
above a legend marked aria-hidden. Under a red-green deficiency the three
statuses that matter collapse into identical squares, and to a screen reader the
grid was a table of empty cells with its key hidden. Each block now carries a
glyph and an sr-only status, and the legend is exposed.

Also adds a roll-up the period log could always have supported and never did:
attendance counted per subject from the day-by-day record, beside the totals the
portal published. Two independent figures for one fact, which should agree - and
where they do not, the app states the disagreement rather than silently
preferring the number it happens to store.

And `/5` on the Att mk column is now `specFor(type).attMax`. CourseSpec.attMax
exists so it can vary and constants.ts instructs the next maintainer to spell out
per-type values; on that day the literal would have printed "4/5" for a course
the engine scores out of 8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Measured coverage first: 28 realistic questions across every domain the app
pulls, 6 answered in the box. All four existing topics were attendance ones, so
marks, CGPA, history and meta questions still only opened a screen. That was a
wiring gap, not a capability gap - `requiredEseCell`, `requiredSgpaForCgpa`,
`planForSgpa` and `cgpaFromSemesters` were all exported and tested already.

Three topics added: `need_to_pass` states the mark a subject needs in the final
and, separately, what the student's own target grade asks for - "you need 28"
and "28 for your target, 19 to pass" are different sentences and the second is
the useful one. `standing` answers CGPA and the SGPA required from here on.
`attendance_now` is the plainest question there is and the one that fell
through entirely: the detector only fired on forward-looking words, so "can i
miss one" was answered and "what is my attendance" was routed to a screen.

Coverage went 6/28 to 18/28 - but two of those were WRONG answers, which is why
the guard exists. "How is sgpa calculated" contains "sgpa" and was being
answered with the student's own CGPA: a confident reply to a question nobody
asked, worse than routing, since a route at least lands where the real answer
might be. Rule questions now fall through, and the test is possessive rather
than topical - "how badly would it affect MY attendance" is about this student
and is answered; "how does attendance affect marks" is about the regulation and
is not.

`standing` reads `SgpaPlan.reachable` as three states, not two. goals.ts:395 is
explicit that `reachable: false` means "this route does not guarantee it", not
"nothing does" - `conditional` is the case where the target is still open on a
harder route. Collapsing them would tell a student their target is gone when it
is not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Three gaps, one shape: the box could compute things about the student but
could say nothing about the rules it computes with, nothing about the app
doing the computing, and nothing about itself.

Definitions. "What is condonation" contains no explainer verb, so the topic
detector read it as an eligibility question and replied with the student's
own attendance budget - a confident answer to a question nobody asked.
Definitions are now their own kind of answer claimed before the detector
runs, rather than a shape it has to be guarded against. Every figure in them
is interpolated from the engine constant the calculation itself uses, so the
drawer's glossary can no longer drift into confidently teaching a regulation
the app no longer applies.

Capabilities. Nothing anywhere described what TargetX is. Not the app, and
not the Worker's system prompt, which listed five view names and stopped -
so "where does this data come from", "how do I import a grade card" and "is
my password stored" could be answered by neither half. Six entries, and the
same facts added to the prompt so the model can tell "we do not hold that"
from "that is not our subject": a question about fees is about college and
is still off_topic, because no screen here could ever answer it.

Sign-in. The offer to press Enter was gated on being signed in, on the
reasoning that offering something you will then refuse is worse than
offering nothing. True - but the else branch was silence, at the exact
moment a student was reaching for the one feature an account unlocks. It
now names what signing in is for, says the rest of the app does not need
one, and still says nothing when there is no endpoint or no Clerk to sign
in against, because a button that cannot work is the same bug in a coat.

The capability matcher shipped in an earlier draft dead on arrival: a
heredoc turned every \b into a literal backspace byte, so the patterns read
as /^Hhow (?:do|can|does|often)^H/ and matched none of the six. Terminals
render 0x08 as nothing, so the file looked correct everywhere. Fixed, and
the test that would have caught it is here - verified by re-injecting the
corruption and watching eight assertions go red.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
The palette hard-cut: the header pill stayed where it was and a 38rem dialog
appeared 12vh down the screen with no visible relationship to it. Two
objects, and the student is left to work out that the second came from the
first. They are one object, so it now moves like one.

FLIP, with the correction that makes it hold. The naive version animates
width on an element the scrim is centring, so every frame re-centres the box
while the translate delta - computed once, against the final layout - stays
fixed, and the thing visibly drifts off course. The shell is pinned to its
measured rect for the flight and released after, landing on the pixel it was
already on.

Two lifecycles now run where there was one. The palette has to outlive
`open` by the length of the shrink, and the header pill is hidden while the
palette stands in for it - so a shrink that never finishes leaves a dialog
on screen forever, and a teardown mid-flight leaves the search control
invisible for the rest of the session with no way back but a reload. Both
are covered, and the cleanup one was confirmed by removing it and watching
the test go red.

Duration reads --med, which the tokens already collapse to 0ms under
prefers-reduced-motion, so this inherits the accessibility behaviour rather
than restating it. morph() returns null - and the caller falls back to the
old instant show and hide - whenever there is nothing honest to animate:
no pill on screen, a zero-sized box, or no Web Animations API.

Also renames the pill's label. It read "Ask anything" while the bar it grows
into read "Ask Tex", which was survivable while they looked like two
controls and is a contradiction now that they are visibly one.

Verified in a real browser rather than by inspection: pill 544x38 at y=13
grows to 608x64 at y=108 and returns to the same rect, with no inline style
left pinned, no stranded root attribute, and no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
The same defect the CGPA hero was fixed for, one tile down, still standing.

`sgpa()` returns 0 over an empty register, so a semester with nothing graded
printed "0.00" under the word Confirmed - a score, on a screen whose whole
claim is that it never states a figure it cannot show the working for. It is
now a dash, because an average over nothing is absent, not zero.

The sentence beneath it made the number worse. `pending` and `unsettled`
were built to catch every reason Confirmed could be empty, and between them
they miss the ordinary middle of a semester: every internal in, no exam sat.
Nothing pending, nothing unsettled, nothing confirmed - and the tile
answered that with "Every subject has been assessed", which is true, and
which reads as an all-clear sitting directly over a zero. Assessed is not
graded, and the tile now says which one it means. The plain all-clear is
still there for the case it was written for, where something really is
confirmed.

The text export had it too, and was the only absent value in that report not
already written as "-".

Found by screenshotting the app rather than by reading it, and none of the
763 tests covered it: the state is the common one, so every fixture that
graded a subject to test something else walked straight past it. Four tests
added, both fixes confirmed by reverting them and watching them go red.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
…ma fail

The Worker had no tests and was not in CI, so `parseAction` - the last of the
three topic restrictions and the only one running on code we control rather
than on a remote service's behaviour - had nothing checking it on any push.
The other two are properties of Gemini: a prompt is a request that can be
talked out of, and a response schema is enforced by an API that Google can
change under us. It already did once this session, when the pinned model was
retired and every request 404'd.

75 tests, written against `parseAction` as a security boundary rather than as
a mapper - the input is assumed hostile and the assertion is what does NOT
get through. An invented course code, a code that differs only by case or
whitespace, an invented view, a `__proto__`-shaped payload, and any field
carrying prose or a figure. One asserts the structural claim the whole design
rests on: a parsed action serialises with no digit in it, so the model cannot
state a number about a student even if it tries.

The log tests assert the split the file's header promises and nothing
enforced: no `sub`, no token, no course codes beside the question text, and
a logging failure - unbound dataset, throwing dataset, failing digest - never
reaching the student as an error.

Confirmed the suite can fail by dropping the known-code check and watching
three tests go red.

Also: `npx tsc --noEmit` in worker/ was failing on the new files because
vitest resolved at runtime from a hoisted install but was never a declared
dependency. Declared, and the worker now has a `test` script and a CI job -
without which these tests would exist and never run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
Version lives in four places and the updater reads only one of them
(tauri.conf.json), so they are bumped together: a Cargo.toml that disagrees
with tauri.conf is a build whose About screen and whose update feed report
different versions.

Note the jump. tauri.conf already said 0.2.0 but v0.2.0 was never tagged, so
the only release that exists is v0.1.0 and installed copies will be offered
0.3.0 directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b60a84cb50

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread app/src/state/ask.ts

let res: Response;
try {
res = await fetch(`${ENDPOINT.replace(/\/+$/, "")}/ask`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow the assistant Worker in the packaged CSP

When VITE_ASK_ENDPOINT is configured, this fetch targets the external Cloudflare Worker, but app/src-tauri/tauri.conf.json still restricts connect-src to 'self' and https://raw.githubusercontent.com. Packaged WebViews therefore reject every /ask request under CSP and askRemote reports the Worker as offline; add the deployed Worker origin (or derive it from the configured endpoint) to the CSP.

Useful? React with 👍 / 👎.

Comment thread app/.env.example
Comment on lines +12 to +14
# Clerk Dashboard -> Configure -> OAuth Applications -> create one with:
# - a PUBLIC client (no secret), PKCE required
# - redirect URI http://127.0.0.1:*/callback (loopback, RFC 8252)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register each fixed OAuth callback URI

The setup instructions tell deployers to register http://127.0.0.1:*/callback, while the OAuth implementation explicitly uses one of four fixed ports because Clerk requires exact redirect URIs. Following this example leaves none of 49731 through 49734 registered, so Clerk rejects the authorization request before the loopback listener receives a callback. Document all four concrete callback URIs instead.

Useful? React with 👍 / 👎.

Comment thread app/src/state/auth.ts
Comment on lines +97 to +101
export function accessToken(): string | null {
const s = session();
if (!s) return null;
if (s.expiresAt !== null && s.expiresAt - 60 <= Date.now() / 1000) return null;
return s.accessToken;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh expired assistant sessions before routing

Once a session has an expiresAt within one minute, accessToken() returns null but leaves session() populated. The palette then tells the student to sign in again, while the profile still treats them as signed in and only offers Sign out; resumeAccount() only runs at launch. Users whose access tokens expire must restart or manually sign out before they can use the assistant again, so refresh the stored token or clear the session and expose sign-in on this path.

Useful? React with 👍 / 👎.

Comment on lines +251 to +255
const { credited } = creditDutyLeave(held, dutyLeave, dlCapPct);
const pctAt = (extra: number) => {
const denominator = held + extra;
if (denominator <= 0) return 0;
return round((Math.min(attended + credited, denominator) / denominator) * 100, 2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reapply the duty-leave cap for projected absences

For students with duty leave above today's cap, this fixes credited DL at the current denominator even after extra absences increase held. The existing attendancePlan solver correctly lets the cap grow with held classes, so absenceCost can overstate the percentage/mark loss (for example, claimed DL beyond 10% becomes incrementally creditable as held grows). Recompute min(claimed, (held + extra) * cap) inside pctAt so the new absence answer matches the engine's attendance model.

Useful? React with 👍 / 👎.

…ne false

Four things, found by using the app rather than by reading it.

EXTERNAL LINKS. Every external link in the desktop build was dead. They were
`<a target="_blank">`, which a webview does nothing with, and no opener plugin
was ever shipped. The one that mattered is on a FAILED SYNC: a student whose
app had just broken was handed a dead link to the issue form, so the single
path out of a broken state led nowhere. The privacy statement and the Data
screen's issue link were the same bug.

`diagnostics_dir` argues against shipping an opener, and the argument is real:
this webview also renders a college portal's HTML, so letting it ask the OS to
launch things is not free. That is answered by scope rather than by going
without. The capability grants `opener:allow-open-url` against this project's
own GitHub URLs and nothing else, so a hostile page in this webview can ask for
the issue tracker and cannot ask for anything beyond it. Verified in the
generated ACL, not just in the source that requests it.

TIMETABLE. "Wednesday" sat on top of Period 1. The day column was 11ch, and
`ch` is the width of a ZERO - no day name is made of zeroes. Measured: the
column offered 63px of inner width and "Wednesday" sets 75.2px, so it spilled
12.2px, and because the cell is `position: sticky` with its own background the
spill painted over its neighbour rather than being clipped. Thursday cleared by
2.6px, so the column was under-sized for the whole set and Wednesday was merely
the first to show it. 14ch, measured again after: every day now clears.

DOCS THAT HAD GONE FALSE. Tex added an optional account and a server, and five
places still said there were neither - the download page ("No account. No
server."), the privacy statement ("exactly three situations"), its own lede,
`PRIVACY.md`, and the app's own Data screen. A wrong claim about privacy on a
public page is the one defect class this project cannot afford, whatever else
it gets wrong. All five now say what is actually sent (the question and the
course list), what is not (every number), and what comes back (a destination,
never a figure). DEPLOYMENT.md goes from three hosts to five for the college IT
department that firewalls them, and says plainly that blocking the last two
costs nothing but the question box.

UPGRADE SAFETY. A test that a v0.1.0 record still opens, because that is the
only defect class here with no undo and the update is automatic - the student
does not choose the moment. The fixture is the shape read off `AppState` at
that tag rather than assumed, which is how I caught my own error: I had taken
`onboarded` for a newer field, and had it been one, every upgrading student
would have been dropped back into first-run setup with two semesters already on
file. It existed at v0.1.0. The test now asserts `needsSetup()` is false for an
upgrader, so if that ever regresses it fails by name.

847 tests (772 app, 75 worker), tsc and cargo check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
@CodedRichy
CodedRichy merged commit 7da0a00 into main Sep 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant