From 5a087317a509bdf42780b5dcbd402731f443396d Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 12:06:32 +0530
Subject: [PATCH 01/32] Attendance: replace the pip strip with a threshold
meter
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
Claude-Session: https://claude.ai/code/session_01AkUwXEMQaeVRe8DE8aT5XX
---
.../2026-09-01T06-22-44Z__app-src-ui.md | 101 +++++++++++++
app/src/styles/screens.css | 114 +++++++++++++--
app/src/ui/Attendance.tsx | 138 +++++++++++-------
app/src/ui/__tests__/Attendance.test.tsx | 31 +++-
4 files changed, 305 insertions(+), 79 deletions(-)
create mode 100644 .impeccable/critique/2026-09-01T06-22-44Z__app-src-ui.md
diff --git a/.impeccable/critique/2026-09-01T06-22-44Z__app-src-ui.md b/.impeccable/critique/2026-09-01T06-22-44Z__app-src-ui.md
new file mode 100644
index 0000000..b4e2915
--- /dev/null
+++ b/.impeccable/critique/2026-09-01T06-22-44Z__app-src-ui.md
@@ -0,0 +1,101 @@
+---
+target: app/src/ui
+total_score: 28
+max_score: 40
+na_heuristics:
+p0_count: 0
+p1_count: 4
+timestamp: 2026-09-01T06-22-44Z
+slug: app-src-ui
+---
+Method: dual-agent (A: design review · B: detector + screenshot evidence)
+
+## Design Health Score — 28/40 (Good)
+
+| # | Heuristic | Score | Key Issue |
+|---|---|---|---|
+| 1 | Visibility of System Status | 2 | Ledger edits commit silently; only save feedback fires on FAILURE |
+| 2 | Match System / Real World | 4 | Near-perfect; only the 14 raw column codes leak |
+| 3 | User Control and Freedom | 2 | No undo anywhere; one hover-click deletes a subject + all its marks |
+| 4 | Consistency and Standards | 3 | Four "selected" idioms; drawer tabs styled by inline styles, outside tokens |
+| 5 | Error Prevention | 3 | Strong where present, undermined by unconfirmed row delete |
+| 6 | Recognition Rather Than Recall | 3 | Glossary is one of three mutually exclusive tabs |
+| 7 | Flexibility and Efficiency | 2 | Zero keyboard shortcuts; no sort/filter/search on 14-column grid |
+| 8 | Aesthetic and Minimalist Design | 2 | Two class names draw one identical container; one elevation level |
+| 9 | Error Recovery | 3 | Excellent copy, then a raw alert() in Data.tsx:565 |
+| 10 | Help and Documentation | 3 | Glossary exists only on the Semester screen |
+
+## Design Specificity Verdict
+
+Authored for a KTU tracker, not reskinnable. Brand hue chosen because its job is status (H:178, clear of danger/warn/good). Mono justified ("every number is a mark"). Glossary is regulation text. No Inter, no purple gradient, no three-card grid.
+
+Deterministic scan: 0 findings on app/src/ui + index.html, BUT those scans never evaluated CSS (.tsx is regex-mode; HTML shell has no styles). Direct stylesheet scan found 3: two side-tab left-stripe borders, one un-waived overused-font (Space Grotesk waived only for a scratchpad doc path, not the app). No visual overlays; CLI-only.
+
+## Overall Impression
+
+"Everything is a card" is TRUE BUT LOCALIZED. Rendered container counts: Data 8, Home 6, Subject detail 2, Ledger 1, History 1. Ledger and History are already card-free (the 1 is the shared global notice). Disease is concentrated in Data and Home.
+
+Mechanism: .card and .tile are THE SAME DRAWING under two names — identical gradient border-box, identical --surface-1, identical 1px solid transparent. Differ only in radius (14px vs 6px) and padding. motion.css already treats them as one. A `card` grep undercounts by half: 8 .card + 5 Home tiles.
+
+Underneath: tokens define a 3-step elevation ramp; --surface-3 is used 3 times in the whole app (scrollbar thumb, hover, holiday stripe), never for content. --surface-1 carries every container. Hierarchy system built, one level used.
+
+The fix already exists in-repo: .claims is a borderless hairline-ruled list whose comment says "The grid version is the reflex and it flattens an argument into a brochure." Never propagated past one component.
+
+## What's Working
+
+1. Refusal to invent a number, enforced at every render site (dashes not 0.00, "no completed semester yet", withdrawn semesters excluded and said out loud). This is the trust position of a tool competing with the university's own portal.
+2. Progressive disclosure via native details — keyboard/SR behavior free, styled as text affordance not competing with card primary action.
+3. Measured color with a failing test behind it — --text-faint lifted 2.95:1 to 4.64:1; comments catch OKLCH values outside sRGB being silently clipped.
+
+## Priority Issues
+
+[P1] Two class names, one container, one elevation level.
+Why: Operate mode = scanability. Data renders 8 identical rectangles; Home 5 tiles whose only differentiator is one amber border the CSS forbids reusing. Hierarchy falls entirely on typography while Home already spends two .huge figures + a --text-2xl stat + a gauge. Four focal points, one surface, no ranking.
+Fix: collapse .card/.tile into one class with modifiers; convert Data's seven ops and Attendance's subject list to .claims-style ruled rows; PROMOTE the one or two real subjects per screen to --surface-2 so two levels exist.
+Command: /impeccable layout
+
+[P1] Attendance is three screens stacked on one scroll.
+Why: screen titled "How many classes can you miss?" but the day calendar and weekly timetable answer neither that nor each other. Student scrolls past two 820px-min-width tables to leave.
+Fix: segmented control — Budget / Day by day / Timetable — using the existing .seg pattern.
+Command: /impeccable layout
+
+[P1] Day-by-day calendar invisible to screen readers.
+Why: cal-block is a bare div with a title and NO text content inside an otherwise-empty td. Browse mode yields an empty cell; no text equivalent anywhere. title is sole carrier in five places app-wide.
+Fix: real (visually-hidden) text per cell — "Mon 18 Aug, period 3, absent". Never title alone.
+Command: /impeccable harden
+
+[P1] Destructive delete, no confirm, no undo.
+Why: .del invisible until hover, one click removes subject + every mark, and it is the LAST TAB STOP on every row. Recovery is "you exported a backup earlier."
+Fix: optimistic removal + "Removed PCCST501 · Undo" 10s in the existing notice slot.
+Command: /impeccable harden
+
+[P2] Real text defects from screenshots.
+- Truncation: "Design and Analysis of Algorit…" in COURSE column (dark AND light); same name renders full in Home tiles, so the column is the constraint.
+- Home "Needs attention": description column ~10-14 chars; breaks across 5 lines, one holding just "be".
+- Drawer spacing: ATTENDANCE VS INTERNALS ~17px below preceding paragraph vs ~50px above sibling GOAL — attaches to wrong thing.
+- Light mode: card borders near-invisible against cream ground; only the amber-flagged tile reads as bounded.
+Command: /impeccable polish
+
+## Persona Red Flags
+
+Alex (power user): zero keyboard shortcuts (one onKeyDown app-wide). white-space:nowrap makes a 14-col row wider than viewport, so tabbing scrolls the table sideways under him; sticky header does not help because the COLUMNS move. Target grade is a select per row. No sort/filter/search.
+
+Sam (screen reader): strong baseline (sr-only announcements, type-enforced labels, scope on every header) then — calendar unreadable, .drawer-tabs is a tablist wearing aria-pressed (nothing announces the panel changed), expanded row is td colSpan=14 holding a 7-input form.
+
+Jordan (first-timer): Home's SECOND tile says "12 CIE marks lost" while CIE is defined only in the drawer's THIRD tab. Data offers 5 import routes with nothing marked as first choice, though Setup has a .route-tag and Home says the portal is fastest. "SET CODE" is dev language in caps. Drawer tabs: only the third is in her vocabulary and it is placed last.
+
+## Minor Observations
+
+- Three corner radii visible on Home at once: 14px tiles, 6px cards, 3px notice.
+- colSpan={14} is a magic number 280 lines from the thead it must match.
+- About a dozen inline style color var(--text-faint) where .faint already exists.
+- screens.css is 957 lines; header still says "Screens beyond the ledger".
+- Pointer-tracked spotlight fires on 2 of Home's 5 tiles, implying the other three are inert.
+- Light and dark are pixel-position identical; only palette swaps.
+
+## Questions to Consider
+
+1. The ledger is where students live (flat hairlines, 3px radius); Home, read once a day, gets 14px corners, gradient border, spotlight, staggered mount. Which screen deserves the design budget?
+2. Attendance appears three times (ledger column, Home tile, whole screen). What if it were a MODE you flip the table into rather than a fourth peer view?
+3. The .claims comment already names the failure mode. So what made .card the default everywhere else?
+4. The app will not print a number it does not know. Is it equally rigorous about not drawing a container it does not need?
diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css
index 93aa88c..5ea0a78 100644
--- a/app/src/styles/screens.css
+++ b/app/src/styles/screens.css
@@ -934,24 +934,106 @@ a.link:hover { text-decoration-thickness: 2px; }
user-select: all;
}
-/* --- attendance: the miss budget, drawn ------------------------------------
+/* --- attendance: the threshold meter ---------------------------------------
*
- * Reuses the Data screen's `.cards` grid and `.card` shell so the surface reads
- * as the same app - the only new thing is the strip. Each pip is one class: a
- * filled square the student can still skip, or one they owe back. The colour is
- * a status colour on purpose (room is `--good`, a shortfall is `--warn`), which
- * is the one family the tokens reserve for exactly this kind of standing.
+ * This replaces the pip strip, which drew one square per class. That strip had
+ * two faults. It scaled with the WRONG number - a student at 61% got a wall of
+ * up to thirty amber squares, so the drawing that meant "you are in trouble"
+ * was simply bigger than the one that meant "you have room", and read as a
+ * punishment graphic. And it drew the budget while saying nothing about the two
+ * lines the budget exists because of.
+ *
+ * KTU has two attendance thresholds, not one, and the gap between them is the
+ * whole reason this app exists: 75% is eligibility - miss it and you cannot sit
+ * the exam - while full marks under R 7.5.ii need 85%. Every point between them
+ * is internal marks bleeding away silently, and no other calculator shows it.
+ * So the meter marks BOTH lines and shades the band between them. The student
+ * sees where they stand, what they are about to lose, and what they are trying
+ * to clear, in one glyph.
+ *
+ * The fill takes the status colour of the band it lands in. The band shading is
+ * deliberately weak - it is a region, not a value, and must never out-shout the
+ * fill or the two marks.
*/
-.att-pips {
+.meter {
display: flex;
- flex-wrap: wrap;
- gap: var(--s1);
- margin: var(--s2) 0;
+ flex-direction: column;
+ gap: var(--s2);
+ margin: var(--s3) 0 var(--s2);
+}
+
+.meter-track {
+ position: relative;
+ block-size: 0.5rem;
+ border-radius: 999px;
+ background: var(--surface-2);
+ overflow: hidden;
+}
+
+.meter-fill {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: 0;
+ border-radius: 999px;
+ background: var(--good);
+ /* Grows from the left on mount; the one authored motion on this screen. */
+ transition: inline-size 620ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+.meter-fill.warn { background: var(--warn); }
+.meter-fill.bad { background: var(--danger); }
+
+/* The 75-85 band: marks being lost while still eligible. */
+.meter-band {
+ position: absolute;
+ inset-block: 0;
+ background: var(--warn);
+ opacity: 0.16;
+}
+
+/* A threshold line. `strong` is the 85% full-marks line, the one to aim at. */
+.meter-mark {
+ position: absolute;
+ inset-block: -2px;
+ inline-size: 2px;
+ background: var(--text-faint);
+ border-radius: 1px;
}
-.att-pip {
- block-size: 0.85rem;
- border-radius: 2px;
- inline-size: 0.85rem;
+.meter-mark.strong { background: var(--text-dim); }
+
+.meter-ends {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--s3);
+ font-size: var(--text-xs);
+ color: var(--text-faint);
+}
+.meter-ends .num { color: var(--text-dim); }
+
+@media (prefers-reduced-motion: reduce) {
+ .meter-fill { transition: none; }
+}
+
+/* --- attendance: priced rows ----------------------------------------------
+ *
+ * The reference's neutral label/value list, rewritten in the only unit the
+ * student actually controls: classes. A row never states a figure the engine
+ * has not computed - where a plan is unknown the row is simply not rendered,
+ * rather than printing a zero.
+ */
+.priced {
+ display: flex;
+ flex-direction: column;
+ border-block-start: 1px solid var(--hairline);
+ margin-block-start: var(--s3);
+}
+.priced-row {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--s3);
+ padding-block: var(--s2);
+ border-block-end: 1px solid var(--hairline);
+ font-size: var(--text-sm);
}
-.att-pip.miss { background: var(--good); }
-.att-pip.recover { background: var(--warn); }
+.priced-row dt { color: var(--text-dim); }
+.priced-row dd { margin: 0; color: var(--text); font-weight: 500; }
diff --git a/app/src/ui/Attendance.tsx b/app/src/ui/Attendance.tsx
index 7449b8c..6516e29 100644
--- a/app/src/ui/Attendance.tsx
+++ b/app/src/ui/Attendance.tsx
@@ -1,5 +1,8 @@
import { For, Show, createMemo } from "solid-js";
-import { ATTENDANCE_MIN, courseLabel } from "../engine";
+import {
+ ATTENDANCE_FULL_MARKS_PCT, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN,
+ attendanceMarks, courseLabel,
+} from "../engine";
import type {
AttendancePlan, AttendanceStatus, DaywiseDay, TimetableDay,
} from "../engine";
@@ -21,17 +24,6 @@ import { setView } from "../state/nav";
* tell.
*/
-/**
- * Missable / recoverable classes drawn as a strip.
- *
- * The count in words is the load-bearing fact; the strip is a glance. Capped so
- * a subject with forty classes of room does not draw a hundred-pixel ribbon -
- * the headline number stays exact and the overflow is stated in words. The
- * strip itself is `aria-hidden`, because the same number is announced in the
- * heading beside it and a screen reader counting pips would only repeat it.
- */
-const MAX_PIPS = 30;
-
/** A subject's attendance standing, ready to render. */
interface Line {
index: number;
@@ -55,8 +47,9 @@ export function Attendance() {
How many classes can you miss?
{state.activeSemester} · the room you have above the {ATTENDANCE_MIN}%
- eligibility line, per subject. A full strip is a class you can still
- skip; an empty one is a class you owe.
+ eligibility line, per subject. The meter marks both lines that
+ matter: {ATTENDANCE_MIN}% to sit the exam, and{" "}
+ {ATTENDANCE_FULL_MARKS_PCT}% to stop losing internal marks.
@@ -288,18 +281,64 @@ function TimetableSection() {
}
/** One subject: its standing, the headline number, and the strip. */
+/**
+ * Where a percentage stands against KTU's two attendance lines, drawn once.
+ *
+ * The two lines are the point. `ATTENDANCE_MIN` (75) is eligibility - below it
+ * the exam cannot be sat - and `ATTENDANCE_FULL_MARKS_PCT` (85) is where R
+ * 7.5.ii finally pays all five internal marks. The band between them is the
+ * figure this whole app exists to surface: a student sitting at 78% is
+ * "fine" by the only number their college quotes them, and is losing internal
+ * marks every week for it.
+ *
+ * Rendered as one `role="img"` with a written label rather than as bare divs,
+ * because the meter carries a fact and a screen reader must get the fact, not
+ * a decorative strip. Nothing here computes a figure: `current` arrives from
+ * the engine, and where it is unknown the caller does not render a meter.
+ */
+function ThresholdMeter(props: { current: number }) {
+ const pct = () => Math.max(0, Math.min(100, props.current));
+ const tone = () => (
+ pct() >= ATTENDANCE_FULL_MARKS_PCT ? "" :
+ pct() >= ATTENDANCE_MIN ? " warn" : " bad"
+ );
+ /** What R 7.5.ii pays at this percentage. Engine-computed, never guessed. */
+ const earned = () => attendanceMarks(pct()) ?? 0;
+ const label = () =>
+ `Attendance ${pct().toFixed(0)}%. Eligibility line ${ATTENDANCE_MIN}%. `
+ + `Full internal marks from ${ATTENDANCE_FULL_MARKS_PCT}%. `
+ + `Currently earning ${earned()} of ${ATTENDANCE_MARK_MAX} attendance marks.`;
+
+ return (
+
+
+ {/* The bleed zone: eligible, but not earning full marks. */}
+
+
+
+
+
+ {/* The right end prices the position in the unit that actually moves the
+ student's grade. Repeating the 85% constant on every card said the
+ same thing seven times on one screen; what differs per subject - and
+ what no other calculator shows - is how many of the five R 7.5.ii
+ marks this attendance is currently earning. */}
+
+ {pct().toFixed(0)}%
+
+ {earned()} of {ATTENDANCE_MARK_MAX} marks
+
+
+
+ );
+}
+
function SubjectCard(props: { line: Line }) {
const plan = () => props.line.plan;
- // How many pips the strip draws, and whether the true count overran the cap.
- const budget = () => {
- const p = plan();
- if (p === null) return 0;
- return p.state === "surplus" ? p.skip : (p.attend ?? 0);
- };
- const shown = () => Math.min(budget(), MAX_PIPS);
- const overflow = () => Math.max(0, budget() - MAX_PIPS);
-
return (
@@ -316,8 +355,8 @@ function SubjectCard(props: { line: Line }) {
}>
{(p) => (
-
+ <>
+ –
@@ -328,13 +367,20 @@ function SubjectCard(props: { line: Line }) {
- 0}>
-
-
- {() => }
-
-
-
+ }>
+
+ {p().skip}
+
+ more class{p().skip === 1 ? "" : "es"} you can miss
+
+
+
+
+ {/* One glyph, both lines, every state - replacing the two pip
+ strips that differed only by hue. */}
+
+
+ At {p().current.toFixed(0)}% there is no way back above{" "}
@@ -345,31 +391,13 @@ function SubjectCard(props: { line: Line }) {
{p().attend} without missing one to get back.
- >
- }>
-
- {p().skip}
-
- more class{p().skip === 1 ? "" : "es"} you can miss
-
-
- 0} fallback={
-
No room to spare — the next miss drops you under {ATTENDANCE_MIN}%.
}>
-
-
- {() => }
-
-
- 0}>
-
+{overflow()} more not shown
-
+
+ At {p().current.toFixed(0)}%. Miss more than{" "}
+ {p().skip} and you drop below {ATTENDANCE_MIN}%.
+
-
- At {p().current.toFixed(0)}%. Miss more than{" "}
- {p().skip} and you drop below {ATTENDANCE_MIN}%.
-
-
+ >
)}
diff --git a/app/src/ui/__tests__/Attendance.test.tsx b/app/src/ui/__tests__/Attendance.test.tsx
index d830317..225208b 100644
--- a/app/src/ui/__tests__/Attendance.test.tsx
+++ b/app/src/ui/__tests__/Attendance.test.tsx
@@ -14,7 +14,7 @@
*/
import { cleanup, render } from "@solidjs/testing-library";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { ATTENDANCE_MIN, attendancePlan } from "../../engine";
+import { ATTENDANCE_FULL_MARKS_PCT, ATTENDANCE_MIN, attendancePlan } from "../../engine";
import type { Course } from "../../engine";
import { addCourse, edit, updateCourse } from "../../state/store";
import { Attendance } from "../Attendance";
@@ -68,9 +68,17 @@ describe("the attendance screen", () => {
expect(text).toContain("Compiler Design");
expect(text).toContain("more class");
expect(text).toContain(String(plan.skip));
- // One filled pip per missable class - the strip is the whole point.
- expect(c.querySelectorAll(".att-pip.miss")).toHaveLength(plan.skip);
- expect(c.querySelectorAll(".att-pip.recover")).toHaveLength(0);
+ // The meter replaced the pip strip. What matters is that it is drawn from
+ // the engine's percentage and carries BOTH KTU thresholds in its label -
+ // the 75/85 gap is the fact this screen exists to show.
+ const meter = c.querySelector(".meter-track");
+ expect(meter).not.toBeNull();
+ const label = meter!.getAttribute("aria-label") ?? "";
+ expect(label).toContain(`${plan.current.toFixed(0)}%`);
+ expect(label).toContain(`${ATTENDANCE_MIN}%`);
+ expect(label).toContain(`${ATTENDANCE_FULL_MARKS_PCT}%`);
+ // A subject above the full-marks line is not tinted as a warning.
+ expect(c.querySelectorAll(".meter-fill.bad")).toHaveLength(0);
});
it("shows a deficit subject the run of classes needed to recover", () => {
@@ -83,8 +91,13 @@ describe("the attendance screen", () => {
expect(text).toContain("Computer Networks");
expect(text).toContain("to recover");
expect(text).toContain(String(plan.attend));
- expect(c.querySelectorAll(".att-pip.recover")).toHaveLength(plan.attend!);
- expect(c.querySelectorAll(".att-pip.miss")).toHaveLength(0);
+ // Below the eligibility line the fill carries the danger tone, and the
+ // label still names both thresholds.
+ const meter = c.querySelector(".meter-track");
+ expect(meter).not.toBeNull();
+ expect(meter!.getAttribute("aria-label") ?? "")
+ .toContain(`${plan.current.toFixed(0)}%`);
+ expect(c.querySelectorAll(".meter-fill.bad")).toHaveLength(1);
});
it("shows an unrecorded subject as 'not recorded', never a percentage", () => {
@@ -95,9 +108,11 @@ describe("the attendance screen", () => {
const text = c.textContent ?? "";
expect(text).toContain("Data Mining");
expect(text).toContain("not recorded");
- // Never invented as a full or empty house, and no strip drawn.
+ // Never invented as a full or empty house. No meter either: a meter needs
+ // a percentage, and there is none - drawing an empty track would be the
+ // same lie as printing 0%.
expect(text).not.toMatch(/\b0%|100%/);
- expect(c.querySelectorAll(".att-pip")).toHaveLength(0);
+ expect(c.querySelectorAll(".meter-track")).toHaveLength(0);
});
it("renders every state together without throwing", () => {
From 50eadf71eeed7d18f770dc2be886e57b0f78a629 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 12:16:40 +0530
Subject: [PATCH 02/32] One container treatment, and a second ground that is
actually used
.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
Claude-Session: https://claude.ai/code/session_01AkUwXEMQaeVRe8DE8aT5XX
---
app/src/styles/screens.css | 66 ++++++++++++++++++++++++++------------
app/src/styles/tokens.css | 19 ++++++++---
app/src/ui/Home.tsx | 4 ++-
3 files changed, 63 insertions(+), 26 deletions(-)
diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css
index 5ea0a78..636b19b 100644
--- a/app/src/styles/screens.css
+++ b/app/src/styles/screens.css
@@ -411,19 +411,57 @@ label { color: var(--text-dim); display: block; font-size: var(--text-sm); }
align-items: start;
}
-.card {
+/* --- the container surface -------------------------------------------------
+ *
+ * ONE treatment, shared. `.card` and `.tile` used to declare the same six
+ * properties independently - same gradient border-box, same `--surface-1`, 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 made every screen look like every other screen for no authored reason.
+ *
+ * The border is a gradient, not a colour: brightest at the top-left and faded
+ * by the bottom, so a container reads as a raised pane catching light rather
+ * than a rectangle with an outline.
+ *
+ * `isolation` makes each container its own stacking context so the pointer
+ * light `::before` (motion.css) can sit at z-index -1 - above the surface,
+ * below the content, clipped to the corners. `relative` anchors it.
+ */
+.card,
+.tile {
background:
linear-gradient(var(--surface-1), var(--surface-1)) padding-box,
linear-gradient(155deg, var(--edge-hi), var(--edge-mid) 42%, var(--edge-lo)) border-box;
border: 1px solid transparent;
- border-radius: var(--radius-lg);
- padding: var(--s4);
- /* Stacking context for the pointer-light `::before` (motion.css): `relative`
- anchors it, `isolation` lets its z-index -1 sit above the card's surface
- and below its content. Same setup the `.tile` carries. */
+ border-radius: var(--radius-card);
position: relative;
isolation: isolate;
}
+
+/* The second ground, and the whole point of having a ramp.
+ *
+ * `--surface-2` existed in the tokens from the start while `--surface-1` was
+ * left carrying every content container in the app, so no screen had a visible
+ * first thing - hierarchy fell entirely on type size, and Home was spending
+ * four numeric focal points at one elevation. This is the promotion: at most
+ * ONE per screen, on the element that is genuinely the subject.
+ *
+ * Note what it does NOT do, because the ramp is not monotonic across themes.
+ * In the dark, `--surface-2` (0.245) sits above `--surface-1` (0.195) and the
+ * promoted container reads as raised. On paper there is no headroom - the
+ * ground is 0.955 and `--surface-1` is already 0.99 - so `--surface-2` (0.925)
+ * sits BELOW the ground and the same container reads as a heavier, weightier
+ * panel instead. Both separate the subject from its siblings, which is the
+ * job; only the direction differs, and claiming "raised" in both would be a
+ * comment that contradicts the pixels in one of them. */
+.card.promoted,
+.tile.promoted {
+ background:
+ linear-gradient(var(--surface-2), var(--surface-2)) padding-box,
+ linear-gradient(155deg, var(--edge-hi), var(--edge-mid) 42%, var(--edge-lo)) border-box;
+}
+
+.card { padding: var(--s4); }
.card h3 {
font-size: var(--text-lg);
font-weight: 600;
@@ -585,26 +623,12 @@ label { color: var(--text-dim); display: block; font-size: var(--text-sm); }
align-items: start;
}
+/* Layout only. The surface itself is the shared `.card, .tile` rule above. */
.tile {
- /* The border is a gradient, not a colour: a hairline that is brightest at
- the top-left and fades away by the bottom, so the card reads as a raised
- pane catching light rather than as a rectangle with an outline. Two
- backgrounds, one clipped to the padding box and one to the border box,
- is the only way to put a gradient in a border without a pseudo-element. */
- background:
- linear-gradient(var(--surface-1), var(--surface-1)) padding-box,
- linear-gradient(155deg, var(--edge-hi), var(--edge-mid) 42%, var(--edge-lo)) border-box;
- border: 1px solid transparent;
- border-radius: 14px;
display: flex;
flex-direction: column;
gap: var(--s3);
padding: var(--s5);
- /* `isolation` makes the tile its own stacking context so the pointer-light
- `::before` (motion.css) can sit at z-index -1: above the tile's surface,
- below its content, clipped to the rounded corners. `relative` anchors it. */
- position: relative;
- isolation: isolate;
}
/* The two cards allowed to occupy two columns: standing is the question the
diff --git a/app/src/styles/tokens.css b/app/src/styles/tokens.css
index da40bb1..7ed202c 100644
--- a/app/src/styles/tokens.css
+++ b/app/src/styles/tokens.css
@@ -106,6 +106,11 @@
--radius: 3px;
--radius-lg: 6px;
+ /* The container radius. One value, because `.card` at 6px and `.tile` at a
+ hard-coded 14px put three different corners on Home at once (tile, card,
+ notice) for no reason a reader could name. Containers are the largest
+ shapes on any screen and the most obvious place an inconsistency shows. */
+ --radius-card: 12px;
/* Motion stays short and eased-out. A status app that animates for a third
of a second feels like it is thinking, and this one already knows. */
@@ -165,10 +170,16 @@
--danger-wash: oklch(0.50 0.20 25 / 0.09);
/* On paper the lit edge is a shadow, not a highlight: light comes from the
- same direction, but a white rim on a white card is invisible. */
- --edge-hi: oklch(0.30 0.01 68 / 0.14);
- --edge-mid: oklch(0.30 0.01 68 / 0.06);
- --edge-lo: oklch(0.30 0.01 68 / 0.03);
+ same direction, but a white rim on a white card is invisible.
+ Strengthened after a screenshot pass: at 0.14/0.06/0.03 the card edge was
+ effectively invisible against the cream ground, so in light mode the
+ containers stopped reading as containers at all - dark mode separated them
+ and light mode did not. The ground/card lightness gap is small here by
+ design (0.955 vs 0.99), which leaves the edge doing more work on paper
+ than it does in the dark, not less. */
+ --edge-hi: oklch(0.30 0.01 68 / 0.24);
+ --edge-mid: oklch(0.30 0.01 68 / 0.11);
+ --edge-lo: oklch(0.30 0.01 68 / 0.05);
--glass: oklch(0.955 0.008 68 / 0.74);
}
diff --git a/app/src/ui/Home.tsx b/app/src/ui/Home.tsx
index 370305e..090710a 100644
--- a/app/src/ui/Home.tsx
+++ b/app/src/ui/Home.tsx
@@ -260,7 +260,9 @@ export function Home() {
}>
-
+ {/* The one promoted surface on Home. Standing is what the screen is
+ for; everything else on it explains or qualifies this number. */}
+
Standing
{/* Registered credits are the denominator - except where a
From 84d8af0e4e8df218e9d368f4b48cb623cc7fdd46 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 14:53:11 +0530
Subject: [PATCH 03/32] feat(ui): rebuild the header around an ask box, a bell
and an account
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/styles/app.css | 346 ++++++++++++++++++++-
app/src/styles/motion.css | 7 +-
app/src/ui/App.tsx | 264 ++++++++++------
app/src/ui/Palette.tsx | 204 ++++++++++++
app/src/ui/__tests__/HeaderAccess.test.tsx | 22 +-
5 files changed, 716 insertions(+), 127 deletions(-)
create mode 100644 app/src/ui/Palette.tsx
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index 0a53d32..f3d5b59 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -10,10 +10,13 @@
.app {
display: grid;
grid-template-rows: auto auto auto 1fr;
+ /* The header carries navigation again. One row of five peer views is not
+ enough content to earn a 13rem column of its own, and the rail cost the
+ ledger - the widest thing in the app - that column on every screen. What
+ made the first attempt cluttered was not the tabs, it was the tabs sharing
+ a strip with a wordmark, a semester list and a KPI block; the KPIs now live
+ in the profile block and the strip has room. */
grid-template-columns: 1fr auto;
- /* The launch check's findings get their own row directly under the header.
- Without a named area they fell to the end of the grid, which put a
- warning about attendance below the table it was warning about. */
grid-template-areas:
"head head"
"notice notice"
@@ -22,13 +25,45 @@
height: 100%;
}
+/* --- header pills ---------------------------------------------------------- */
+
+.pill {
+ align-items: center;
+ appearance: none;
+ background: var(--surface-1);
+ border: 1px solid var(--hairline);
+ border-radius: 999px;
+ color: var(--text-dim);
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ font-size: var(--text-sm);
+ gap: var(--s2);
+ padding: var(--s2) var(--s4);
+ transition: background var(--dur-fast) var(--ease),
+ border-color var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
+}
+.pill:hover { background: var(--surface-2); color: var(--text); }
+
+/* The search pill is wider than its label so it reads as a field to type in
+ rather than a button that does something. */
+.ask .kbd {
+ border: 1px solid var(--hairline);
+ border-radius: var(--radius);
+ color: var(--text-faint);
+ font-family: var(--font-mono);
+ font-size: var(--text-xs);
+ padding: 0 0.35rem;
+}
+
/* --- header --------------------------------------------------------------- */
.head {
grid-area: head;
display: flex;
align-items: center;
- gap: var(--s5);
+ gap: var(--s4);
padding: var(--s3) var(--s5);
border-bottom: 1px solid var(--hairline);
/* The one genuinely glass surface in the app: the table scrolls underneath
@@ -86,14 +121,6 @@
.winbtn-close:hover { background: var(--danger); color: oklch(0.98 0 0); }
.winbtn:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
-.wordmark {
- font-size: var(--text-lg);
- font-weight: 700;
- letter-spacing: var(--tracking-tight);
- margin: 0;
-}
-.wordmark span { color: var(--brand); }
-
.sems { display: flex; gap: 1px; margin-left: var(--s2); }
.sem {
@@ -538,3 +565,298 @@ tr.row:hover .del { opacity: 1; }
made every sentence a headline. */
.route-panel .chart-note .num { font-size: var(--text-sm); }
.route-panel .chart-note strong { color: var(--text); font-weight: 600; }
+
+/* --- command palette -------------------------------------------------------
+ *
+ * A dialog is the right container here for the reason the craft floor allows
+ * one: it needs protected focus. It is a search box that takes over the
+ * keyboard, and anything less than a modal leaves arrow keys fighting the
+ * table underneath.
+ */
+.palette-scrim {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: flex;
+ justify-content: center;
+ align-items: start;
+ padding-block-start: 12vh;
+ background: oklch(0 0 0 / 0.42);
+ backdrop-filter: blur(2px);
+}
+
+.palette {
+ inline-size: min(38rem, calc(100vw - 2rem));
+ max-block-size: 60vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ background: var(--surface-1);
+ border: 1px solid var(--hairline-strong);
+ border-radius: var(--radius-card);
+}
+
+.palette-input {
+ appearance: none;
+ background: transparent;
+ border: 0;
+ border-block-end: 1px solid var(--hairline);
+ color: var(--text);
+ font: inherit;
+ font-size: var(--text-lg);
+ padding: var(--s4);
+}
+.palette-input:focus { outline: none; }
+.palette-input::placeholder { color: var(--text-faint); }
+
+.palette-list {
+ list-style: none;
+ margin: 0;
+ padding: var(--s2);
+ overflow-y: auto;
+}
+
+.palette-hit {
+ appearance: none;
+ background: transparent;
+ border: 0;
+ border-radius: var(--radius-lg);
+ color: inherit;
+ cursor: pointer;
+ display: grid;
+ grid-template-columns: 4.5rem 1fr auto;
+ gap: var(--s3);
+ align-items: baseline;
+ inline-size: 100%;
+ font: inherit;
+ padding: var(--s2) var(--s3);
+ text-align: start;
+}
+.palette-hit[aria-selected="true"] { background: var(--surface-2); }
+
+.palette-kind {
+ color: var(--text-faint);
+ font-size: var(--text-xs);
+ letter-spacing: var(--tracking-wide);
+ text-transform: uppercase;
+}
+.palette-label { color: var(--text); font-weight: 500; }
+.palette-detail { color: var(--text-faint); font-size: var(--text-xs); }
+
+.palette-empty { color: var(--text-faint); margin: 0; padding: var(--s5); }
+
+/* --- header identity, notifications and account ----------------------------
+ *
+ * The header's shape: a home button, the views, then a wide search, then the
+ * two things that are about you rather than about your marks - what needs your
+ * attention, and who you are signed in as.
+ */
+
+/* Home is a button, not a tab. It is the app's own mark, it is where every
+ other view returns to, and giving it the one filled swatch in the header
+ makes the wordmark and the landing route the same target instead of two
+ things competing for the top-left corner. */
+.homebtn {
+ appearance: none;
+ border: 0;
+ border-radius: var(--radius-lg);
+ background: var(--brand);
+ color: oklch(0.99 0 0);
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ inline-size: 2.1rem;
+ block-size: 2.1rem;
+ flex: none;
+ transition: filter var(--dur-fast) var(--ease);
+}
+.homebtn:hover { filter: brightness(1.12); }
+.homebtn[aria-current="true"] { box-shadow: 0 0 0 3px var(--brand-wash); }
+
+/* The ask box takes ALL the slack, with no cap.
+ *
+ * Both earlier attempts left dead air because something in the strip was
+ * capped: a 30rem maximum, then a 22rem fixed width, and on a 1920px window
+ * every remaining pixel had nowhere to go but into a gap. Exactly one element
+ * here is allowed to be elastic and it is this one, so there is no free space
+ * left to distribute. It is also the largest control in the header on purpose:
+ * this is the box you type a question into, and a question box the size of a
+ * button reads as a button. */
+
+.bell {
+ appearance: none;
+ background: transparent;
+ border: 0;
+ border-radius: var(--radius-lg);
+ color: var(--text-dim);
+ cursor: pointer;
+ display: grid;
+ place-items: center;
+ inline-size: 2.1rem;
+ block-size: 2.1rem;
+ position: relative;
+ flex: none;
+}
+.bell:hover { background: var(--surface-2); color: var(--text); }
+
+/* A count, not a dot. "3 things" and "something" are different messages, and
+ the student is deciding whether to stop what they are doing. */
+.bell-badge {
+ position: absolute;
+ inset-block-start: 1px;
+ inset-inline-end: 0;
+ min-inline-size: 1rem;
+ padding: 0 3px;
+ border-radius: 999px;
+ background: var(--danger);
+ color: oklch(0.99 0 0);
+ font-family: var(--font-mono);
+ font-size: 0.62rem;
+ line-height: 1rem;
+ text-align: center;
+}
+
+.profile {
+ align-items: center;
+ appearance: none;
+ background: transparent;
+ border: 0;
+ border-radius: var(--radius-lg);
+ color: inherit;
+ cursor: pointer;
+ display: flex;
+ font: inherit;
+ gap: var(--s3);
+ padding: var(--s1) var(--s2);
+ flex: none;
+ text-align: start;
+}
+.profile:hover { background: var(--surface-2); }
+
+.avatar {
+ display: grid;
+ place-items: center;
+ inline-size: 2rem;
+ block-size: 2rem;
+ border-radius: 999px;
+ background: var(--surface-3);
+ border: 1px solid var(--hairline);
+ color: var(--text-dim);
+ font-size: var(--text-xs);
+ font-weight: 700;
+ letter-spacing: var(--tracking-wide);
+ flex: none;
+}
+
+.profile-lines { display: flex; flex-direction: column; line-height: 1.25; }
+.profile-name { font-size: var(--text-sm); font-weight: 600; }
+/* The CGPA moved here from its own header KPI block. It is an identity fact -
+ it is who you are academically - and it was the largest number in a strip
+ that then had nowhere left to put navigation. */
+.profile-sub {
+ color: var(--text-faint);
+ font-family: var(--font-mono);
+ font-size: var(--text-xs);
+}
+
+/* --- popovers -------------------------------------------------------------- */
+
+.pop {
+ position: absolute;
+ inset-block-start: calc(100% + var(--s2));
+ inset-inline-end: 0;
+ z-index: 30;
+ inline-size: 22rem;
+ max-block-size: 60vh;
+ overflow-y: auto;
+ padding: var(--s3);
+ background: var(--surface-1);
+ border: 1px solid var(--hairline-strong);
+ border-radius: var(--radius-card);
+}
+.pop-wrap { position: relative; display: flex; align-items: center; flex: none; }
+.pop-title {
+ color: var(--text-faint);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: var(--tracking-wide);
+ margin: 0 0 var(--s2);
+ padding-inline: var(--s2);
+ text-transform: uppercase;
+}
+.pop-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: var(--s2);
+ border-radius: var(--radius-lg);
+}
+.pop-item + .pop-item { border-block-start: 1px solid var(--hairline); }
+.pop-item strong { font-size: var(--text-sm); font-weight: 600; }
+.pop-item .dim { color: var(--text-faint); font-size: var(--text-xs); }
+.pop-empty { color: var(--text-faint); font-size: var(--text-sm); margin: 0; padding: var(--s2); }
+.pop-row {
+ align-items: center;
+ display: flex;
+ gap: var(--s3);
+ justify-content: space-between;
+ padding: var(--s2);
+}
+.pop-row + .pop-row { border-block-start: 1px solid var(--hairline); }
+
+/* The mark is drawn in brand colours, which is right everywhere except on top
+ of a brand-filled square, where it disappeared into its own background. On
+ the home button it is repainted in the button's foreground - keeping the
+ asymmetry that stops an X reading as a close button, but expressed as two
+ opacities of white rather than two greens. */
+.homebtn svg line { stroke: currentColor; }
+.homebtn svg line:first-of-type { stroke: oklch(1 0 0 / 0.5); }
+
+/* The label goes hard to the start behind its magnifier and the shortcut hard
+ to the end. Centring the pair made the widest control in the header read as
+ a title rather than as a field. */
+
+/* --- the ask box ----------------------------------------------------------
+ *
+ * NOT a `.pill`. It was one, and `.pill` is defined twice in this file - once
+ * for this header and once for the status chips ("3 short of 75%") - so the
+ * chip rule won on source order and quietly handed a search field a chip's
+ * centred text. A control you type into and a badge you read have nothing in
+ * common but a rounded end; sharing a class name was the whole bug.
+ */
+.ask {
+ align-items: center;
+ appearance: none;
+ background: var(--surface-1);
+ border: 1px solid var(--hairline);
+ border-radius: 999px;
+ color: var(--text-dim);
+ cursor: text;
+ display: flex;
+ font: inherit;
+ font-size: var(--text-sm);
+ gap: var(--s3);
+ justify-content: flex-start;
+ padding: var(--s2) var(--s4);
+ text-align: start;
+
+ /* Elastic, capped, and centred by its own auto margins. The cap stops it
+ becoming a 900px bar on a wide monitor; the auto margins mean the pixels
+ the cap gives back are split evenly on both sides instead of pooling into
+ one dead gap beside the bell, which is what every earlier version did. */
+ flex: 1 1 auto;
+ min-inline-size: 12rem;
+ max-inline-size: 34rem;
+ margin-inline: auto;
+ transition: background var(--dur-fast) var(--ease),
+ border-color var(--dur-fast) var(--ease),
+ color var(--dur-fast) var(--ease);
+}
+.ask:hover { background: var(--surface-2); color: var(--text); }
+.ask:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
+
+.ask > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ask svg { flex: none; opacity: 0.7; }
+/* Hard to the far end, so the shortcut reads as the field's affordance rather
+ than as the last two words of the placeholder sentence. */
+.ask .kbd { margin-inline-start: auto; flex: none; }
diff --git a/app/src/styles/motion.css b/app/src/styles/motion.css
index 0051f1d..2274e01 100644
--- a/app/src/styles/motion.css
+++ b/app/src/styles/motion.css
@@ -175,9 +175,10 @@ button:active:not(:disabled) { transform: translateY(1px); }
}
.boot.leaving .splash-note { opacity: 0; transition: opacity var(--fast) var(--ease); }
-/* The wordmark's X is empty until the flying one lands on it. */
-.wordmark-x { display: inline-block; line-height: 0; vertical-align: -0.12em; }
-.wordmark-x.waiting { opacity: 0; }
+/* The home button is an empty swatch until the flying X lands in it. The
+ square itself stays visible - only the mark inside is held back, so the
+ header does not reflow when the animation finishes. */
+.homebtn.waiting > svg { opacity: 0; }
/* --- inline busy ---------------------------------------------------------- */
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index 8da8dc9..f0269d8 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -15,79 +15,12 @@ import { Home } from "./Home";
import { Ledger } from "./Ledger";
import { Setup } from "./Setup";
import { Mark } from "./Mark";
+import { Palette, usePaletteShortcut } from "./Palette";
import { runLaunchCheck, saveFindings } from "../state/launch";
import type { Finding } from "../state/launch";
import { checkForUpdate } from "../sync/update";
import type { Available } from "../sync/update";
-/**
- * Header KPIs.
- *
- * Confirmed and projected sit side by side and are never merged. One blended
- * number would be the most flattering thing to show and the least honest — the
- * student could not tell which half is real.
- */
-function Kpis() {
- // With nothing entered every rollup is 0.00, which reads as a student who
- // scored zero rather than one who has not started. Dashes say the second
- // thing, which is this app's whole discipline applied to its own header.
- const started = () => summary().credits > 0 || overall().credits > 0;
- const dash = "–";
-
- return (
-
-
- CGPA
- {dash}
- nothing recorded yet
-
-
- }>
-
- {/* A live semester makes the header "started", but a CGPA needs a
- COMPLETED one. Without that guard a first year saw 0.00 beside
- their real projection, which reads as a failed year rather than as
- a year that has not finished. */}
-
-
- );
-}
-
/**
* The goal line.
*
@@ -319,7 +252,8 @@ export function UpdateNotice(props: { update: Available; onDismiss: () => void }
/**
* A save that is not landing.
*
- * Sits outside `LaunchNotice` and has no dismiss of its own, on purpose.
+ * Has no dismiss of its own, and stays a banner rather than moving into the
+ * notification bell, on purpose.
* Everything in that banner reports on something already written down, so
* closing it costs nothing; this reports that what is on the screen is NOT
* being written down, and the student needs it in front of them until they
@@ -345,24 +279,131 @@ export function SaveNotice() {
);
}
-function LaunchNotice(props: { findings: Finding[]; onDismiss: () => void }) {
+/**
+ * Everything asking for your attention, in one place.
+ *
+ * These findings used to be a banner across the top of the app. The banner was
+ * honest and it was also the first thing between a student and their marks on
+ * every single launch, saying the same thing every time until it was dismissed.
+ * A count on a bell states the same fact without spending a row of the screen
+ * on it, and the two urgent classes stay where they were: a save that is not
+ * landing keeps its banner, because that one reports the app is losing data,
+ * and Home keeps its "Needs attention" card, because that is a place a student
+ * goes to look rather than a thing that interrupts them.
+ */
+function Bell(props: { findings: Finding[]; onGo: () => void }) {
+ const [open, setOpen] = createSignal(false);
+ const count = () => props.findings.length;
+
+ // A popover that outlives a click elsewhere is a popover the student has to
+ // dismiss twice.
+ const onDocClick = () => setOpen(false);
+ onMount(() => document.addEventListener("click", onDocClick));
+ onCleanup(() => document.removeEventListener("click", onDocClick));
+
return (
- 0}>
- {/* Appears about half a second after launch, with nobody having done
- anything to summon it. Without a live region a screen reader user is
- simply not told their data did not reconcile. */}
-
- {(f) => (
-
- {f.title}
-
);
}
@@ -377,10 +418,12 @@ export function App() {
// flying: the X is travelling to its place in the wordmark.
// done: the overlay is gone.
const [phase, setPhase] = createSignal<"boot" | "flying" | "done">("boot");
- let wordmarkX: HTMLSpanElement | undefined;
+ let homeBtn: HTMLButtonElement | undefined;
let flyer: HTMLDivElement | undefined;
const [findings, setFindings] = createSignal([]);
const [dismissed, setDismissed] = createSignal(false);
+ const [paletteOpen, setPaletteOpen] = createSignal(false);
+ usePaletteShortcut(() => setPaletteOpen(true));
const [update, setUpdate] = createSignal(null);
const [updateDismissed, setUpdateDismissed] = createSignal(false);
@@ -447,7 +490,7 @@ export function App() {
setTimeout(() => {
setFindings(found);
setPhase("flying");
- // The wordmark has to be laid out before it can be measured, and the
+ // The home button has to be laid out before it can be measured, and the
// overlay has to have painted before it can be animated away from.
requestAnimationFrame(() => requestAnimationFrame(flyMark));
}, wait);
@@ -461,20 +504,20 @@ export function App() {
});
/**
- * Fly the opening X into the wordmark.
+ * Fly the opening X into the home button.
*
* The same drawing in both places, moved rather than swapped: the app does
* not cut from a splash to a dashboard, it puts its mark where it lives.
*
* Measured at the moment it runs rather than hardcoded, because the
- * wordmark's position depends on the window width and on whether setup is
+ * button's position depends on the window width and on whether setup is
* showing at all. If there is nothing to fly to - setup is open, or the
* header has not rendered - it fades instead, which is also what a student
* who has asked for reduced motion gets.
*/
const flyMark = () => {
const node = flyer;
- const target = wordmarkX?.getBoundingClientRect();
+ const target = homeBtn?.getBoundingClientRect();
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!node || !target || target.width < 1 || reduced) {
@@ -539,17 +582,39 @@ export function App() {
letter: the X is a drawing, and `Mark` hides itself from the
accessibility tree unless it is given one - so the only heading
on the screen announced as "Target". */}
-
Target
-
+ {/* The wordmark and the home route were two targets in the same
+ corner. They are one now: the mark is the button, and the app is
+ named by the window's own title rather than by a heading that had
+ to be spelled "Target" + a drawing to be announced at all. */}
+ setView("home")}>
+
+
+
+ {/* Home is not in this row - it is the button to the left. The rest
+ are peers with no order, so they stay a flat row. */}
+ {/* Search sits in the header rather than inside a view because it is
+ not a view: it crosses all of them. Labelled with its shortcut so
+ the keyboard route is discoverable without a tour. */}
+ setPaletteOpen(true)}
+ aria-label="Ask about your subjects, marks and attendance. Press Control K.">
+
+ Ask anything — how many classes can I miss?
+ Ctrl K
+
+
-
- {activeCourses().length} subjects
-
-
+ setDismissed(true)} />
+
@@ -574,9 +637,6 @@ export function App() {
-
- setDismissed(true)} />
-
{(u) => (
setUpdateDismissed(true)} />
@@ -587,6 +647,8 @@ export function App() {
+
+ setPaletteOpen(false)} />
>
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx
new file mode 100644
index 0000000..c05e0ad
--- /dev/null
+++ b/app/src/ui/Palette.tsx
@@ -0,0 +1,204 @@
+import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js";
+import { courseLabel } from "../engine";
+import { rows } from "../state/store";
+import { VIEWS, setView } from "../state/nav";
+import type { View } from "../state/nav";
+
+/**
+ * The command palette.
+ *
+ * The app had no keyboard route to anything and no way to find a subject: the
+ * semester table is fourteen columns with no sort, no filter and no search, so
+ * a student who knew exactly which subject they were worried about still had to
+ * read down the grid for it. This is that route.
+ *
+ * It ROUTES, it does not answer. Every row here resolves to a view or a subject
+ * that already exists, and the figures it prints beside a subject come from the
+ * engine's own evaluation. Nothing is generated, summarised or inferred, which
+ * is what makes it safe to put a question box at the top of an app whose whole
+ * trust position is that it never states a number it cannot show its working
+ * for. When a model is eventually put behind this box, it selects among these
+ * same rows - it does not get to invent one.
+ */
+
+interface Hit {
+ kind: "view" | "subject";
+ label: string;
+ /** The line under the label. Engine-computed for subjects. */
+ detail: string;
+ go: () => void;
+}
+
+/**
+ * Words that carry no target.
+ *
+ * A student types a question, not a keyword: "how many classes can I miss in
+ * ML". Matched literally that finds nothing, because no subject is called
+ * "how". The question words are dropped and what is left - "classes", "miss",
+ * "ml" - is matched term by term, so the sentence resolves to the same subject
+ * the bare code would have. This is not language understanding and does not
+ * pretend to be: it is a stop list, and every figure it leads to is one the
+ * engine already computed.
+ */
+const STOP = new Set([
+ "a", "am", "an", "and", "any", "are", "be", "can", "could", "do", "does",
+ "for", "get", "have", "how", "i", "if", "in", "is", "it", "many", "me",
+ "more", "much", "my", "of", "on", "or", "should", "show", "still", "take",
+ "tell", "the", "this", "to", "what", "whats", "when", "where", "which",
+ "will", "with", "you",
+ // Domain words that describe the QUESTION rather than name a subject. They
+ // are in every phrasing of it, so keeping them would match every subject.
+ "attendance", "bunk", "class", "classes", "cut", "day", "days", "leave",
+ "leaves", "mark", "marks", "miss", "percent", "percentage", "skip",
+]);
+
+/** The words in a query that could name something. */
+function terms(q: string): string[] {
+ return q.toLowerCase().split(/[^a-z0-9]+/)
+ .filter((w) => w.length > 0 && !STOP.has(w));
+}
+
+/** Subsequence match, so "cn" finds "Computer Networks". */
+function matches(haystack: string, needle: string): boolean {
+ if (!needle) return true;
+ const h = haystack.toLowerCase();
+ const n = needle.toLowerCase();
+ if (h.includes(n)) return true;
+ let i = 0;
+ for (const ch of h) {
+ if (ch === n[i]) i += 1;
+ if (i === n.length) return true;
+ }
+ return false;
+}
+
+export function Palette(props: { open: boolean; onClose: () => void }) {
+ const [query, setQuery] = createSignal("");
+ const [cursor, setCursor] = createSignal(0);
+ let input: HTMLInputElement | undefined;
+
+ const hits = createMemo(() => {
+ const q = query().trim();
+ // A question with nothing nameable left in it - "how many can I miss" -
+ // is not a failed search. It is a question about every subject, so every
+ // subject answers it.
+ const words = terms(q);
+ const anyTerm = (hay: string) =>
+ words.length === 0 || words.some((w) => matches(hay, w));
+ const out: Hit[] = [];
+
+ for (const v of VIEWS) {
+ if (!matches(v.label, q)) continue;
+ out.push({
+ kind: "view", label: v.label, detail: v.hint ?? "",
+ go: () => setView(v.id as View),
+ });
+ }
+
+ for (const row of rows()) {
+ const label = courseLabel(row.course);
+ if (!anyTerm(label) && !anyTerm(row.course.code ?? "")) continue;
+ // Only facts the engine already settled. A subject with no attendance on
+ // record says so rather than being given a percentage.
+ const att = row.ev.attendance;
+ const plan = row.ev.plan;
+ // "How many classes can I miss" is the question this app was built to
+ // answer, so it is the FIRST thing a subject row says - not a percentage
+ // the student then has to do arithmetic on. Both halves come straight
+ // from the engine's solver: `skip` is how many more classes can be
+ // missed while staying eligible, `attend` is the run needed to climb
+ // back. Neither is computed here.
+ const budget = plan === null
+ ? null
+ : plan.state === "surplus"
+ ? `can miss ${plan.skip} more`
+ : plan.attend === null
+ ? "cannot reach 75% this semester"
+ : `must attend ${plan.attend} in a row`;
+ const detail = att === null
+ ? "attendance not recorded"
+ : `${budget === null ? "" : budget + " · "}${att.toFixed(0)}% · CIE ${row.ev.cie} of ${row.ev.cieMax}`;
+ out.push({
+ kind: "subject", label, detail,
+ go: () => setView("ledger"),
+ });
+ }
+
+ return out;
+ });
+
+ // A filtered list whose selection stayed put would run the wrong row on
+ // Enter as soon as the results moved under it.
+ createEffect(() => { query(); setCursor(0); });
+
+ createEffect(() => {
+ if (props.open) { setQuery(""); setCursor(0); queueMicrotask(() => input?.focus()); }
+ });
+
+ const run = (hit: Hit | undefined) => {
+ if (!hit) return;
+ hit.go();
+ props.onClose();
+ };
+
+ const onKey = (e: KeyboardEvent) => {
+ const list = hits();
+ if (e.key === "Escape") { e.preventDefault(); props.onClose(); return; }
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setCursor((c) => Math.min(c + 1, Math.max(0, list.length - 1)));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setCursor((c) => Math.max(c - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ run(list[cursor()]);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+/** Ctrl/Cmd+K from anywhere. Returns nothing; it only wires the shortcut. */
+export function usePaletteShortcut(open: () => void) {
+ const onKey = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
+ e.preventDefault();
+ open();
+ }
+ };
+ onMount(() => window.addEventListener("keydown", onKey));
+ onCleanup(() => window.removeEventListener("keydown", onKey));
+}
diff --git a/app/src/ui/__tests__/HeaderAccess.test.tsx b/app/src/ui/__tests__/HeaderAccess.test.tsx
index 0250a84..a3ec907 100644
--- a/app/src/ui/__tests__/HeaderAccess.test.tsx
+++ b/app/src/ui/__tests__/HeaderAccess.test.tsx
@@ -2,10 +2,10 @@
/**
* The header, which is also the title bar.
*
- * Two things in it were unreachable by name. The wordmark's X is a drawing,
- * and `Mark` hides itself from the accessibility tree unless it is given a
- * title - so the only `h1` on the screen announced as "Target", losing the
- * last letter of the product's name. The add-semester button is drawn as a
+ * Two things in it were unreachable by name. Home is drawn as the app's mark
+ * with no text at all, and `Mark` hides itself from the accessibility tree by
+ * default - so without an explicit label the button that returns you to the
+ * landing screen announces as nothing. The add-semester button is drawn as a
* plus sign, and a glyph is the accessible name unless one is supplied.
*/
import { cleanup, render } from "@solidjs/testing-library";
@@ -52,14 +52,14 @@ beforeEach(() => {
setView("ledger");
});
-describe("the wordmark", () => {
- it("is announced with its X, not as 'Target'", () => {
+describe("the home button", () => {
+ it("is announced by name even though it is drawn as a glyph", () => {
const { container } = render(() => );
- const h1 = container.querySelector("h1.wordmark")!;
- const mark = h1.querySelector("svg")!;
- expect(mark.getAttribute("role")).toBe("img");
- expect(mark.getAttribute("aria-label")).toBe("X");
- expect(mark.getAttribute("aria-hidden")).toBeNull();
+ const home = container.querySelector("button.homebtn")!;
+ expect(home.getAttribute("aria-label")).toMatch(/^Home\./);
+ // Its own label is the accessible name; the drawing inside must not add a
+ // second one, or the button announces twice.
+ expect(home.textContent).toBe("");
});
it("still hides the mark wherever it is decoration", () => {
From 98b3fac888ee5f88c2e785b2696726e9f6bd4583 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 14:56:26 +0530
Subject: [PATCH 04/32] feat(worker): add the Cloudflare Worker behind the ask
box
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
worker/.gitignore | 3 +
worker/README.md | 100 +++
worker/package-lock.json | 1616 ++++++++++++++++++++++++++++++++++++++
worker/package.json | 18 +
worker/src/clerk.ts | 67 ++
worker/src/gemini.ts | 106 +++
worker/src/index.ts | 105 +++
worker/src/limit.ts | 78 ++
worker/src/schema.ts | 110 +++
worker/tsconfig.json | 16 +
worker/wrangler.toml | 19 +
11 files changed, 2238 insertions(+)
create mode 100644 worker/.gitignore
create mode 100644 worker/README.md
create mode 100644 worker/package-lock.json
create mode 100644 worker/package.json
create mode 100644 worker/src/clerk.ts
create mode 100644 worker/src/gemini.ts
create mode 100644 worker/src/index.ts
create mode 100644 worker/src/limit.ts
create mode 100644 worker/src/schema.ts
create mode 100644 worker/tsconfig.json
create mode 100644 worker/wrangler.toml
diff --git a/worker/.gitignore b/worker/.gitignore
new file mode 100644
index 0000000..a933f10
--- /dev/null
+++ b/worker/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.wrangler/
+.dev.vars
diff --git a/worker/README.md b/worker/README.md
new file mode 100644
index 0000000..049ede2
--- /dev/null
+++ b/worker/README.md
@@ -0,0 +1,100 @@
+# targetx-ask
+
+The Worker behind the app's ask box.
+
+## Why it exists
+
+Three requirements cannot be met inside a Tauri binary, because all three
+depend on trusting a machine we do not control:
+
+| Requirement | Why the client cannot do it |
+|---|---|
+| Keep the Gemini key secret | A Tauri bundle is files on the user's disk. Any shipped key is extractable, and the bill is ours. |
+| Rate limit per user | A limit enforced in JS is a limit the user deletes. |
+| Only signed-in users | A Clerk session token proves nothing until something verifies its signature. |
+
+## What it does and does not see
+
+The request carries **a question and the semester's course list**. It does not
+carry marks, attendance, CGPA, the student's name or their register number.
+
+The model chooses a route — a view, or a subject plus the view that answers
+questions about it. The app then computes the actual figure locally, the same
+way it does with no network at all. Nothing that reaches a student's screen was
+authored by a model.
+
+## Why the topic rule holds
+
+Not because the prompt asks nicely. Two independent mechanisms:
+
+1. **Structured output.** Gemini decodes against a JSON schema whose `code`
+ field is an enum of the codes the client actually sent. Prose is not a value
+ the schema permits, and an invented course code is not generatable.
+2. **`parseAction` on the way out.** Whatever comes back is validated against
+ the same closed set before the client sees it. Anything else becomes
+ `{ kind: "none" }`.
+
+The prompt is for accuracy. The schema is for safety. Never rely on the first
+for the second.
+
+## Deploy
+
+You need a Cloudflare account, a Clerk instance and a Gemini API key.
+
+```sh
+npm install
+
+# 1. Clerk issuer. Public, not a secret - Clerk Dashboard -> API Keys -> the
+# "Frontend API" / issuer URL, e.g. https://cheerful-cat-42.clerk.accounts.dev
+# Put it in wrangler.toml under [vars].
+
+# 2. The Gemini key. A secret. This command prompts and never writes to disk.
+npx wrangler secret put GEMINI_KEY
+
+# 3. Ship it.
+npx wrangler deploy
+```
+
+For local development put the key in `.dev.vars` (gitignored, never committed):
+
+```
+GEMINI_KEY=...
+```
+
+## Quota
+
+40 questions per user per UTC day, counted in a Durable Object keyed by the
+Clerk user id. Counted *before* the model is called: counting on success would
+let anyone who can reliably provoke an upstream error spend our budget without
+spending their own quota.
+
+Change `DAILY` in `src/limit.ts`.
+
+## Endpoint
+
+```
+POST /ask
+Authorization: Bearer
+
+{ "question": "how many classes can I miss in ML",
+ "subjects": [{ "code": "CST414", "name": "Machine Learning" }] }
+```
+
+```
+200 { "action": { "kind": "subject", "code": "CST414", "view": "attendance" },
+ "remaining": 39 }
+401 { "error": "unauthorized" }
+429 { "error": "rate_limited", "resetAt": 1757030400 }
+502 { "error": "upstream" }
+```
+
+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 yet
+
+The app does not call this. The ask box answers attendance and leave questions
+today with no network, no key and no account, straight from the engine. This
+service is for the questions the engine genuinely cannot answer, and turning it
+on means requiring sign-in and sending questions to Google — a deliberate
+change to the app's privacy position, not a config flag.
diff --git a/worker/package-lock.json b/worker/package-lock.json
new file mode 100644
index 0000000..b51e000
--- /dev/null
+++ b/worker/package-lock.json
@@ -0,0 +1,1616 @@
+{
+ "name": "targetx-worker",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "targetx-worker",
+ "dependencies": {
+ "jose": "^5.9.6"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20250109.0",
+ "typescript": "^5.7.2",
+ "wrangler": "^3.99.0"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz",
+ "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "mime": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.13"
+ }
+ },
+ "node_modules/@cloudflare/unenv-preset": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz",
+ "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.14",
+ "workerd": "^1.20250124.0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20250718.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz",
+ "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20250718.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz",
+ "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20250718.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz",
+ "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20250718.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz",
+ "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20250718.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz",
+ "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workers-types": {
+ "version": "4.20260702.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz",
+ "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0"
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild-plugins/node-globals-polyfill": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz",
+ "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==",
+ "dev": true,
+ "license": "ISC",
+ "peerDependencies": {
+ "esbuild": "*"
+ }
+ },
+ "node_modules/@esbuild-plugins/node-modules-polyfill": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz",
+ "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "escape-string-regexp": "^4.0.0",
+ "rollup-plugin-node-polyfills": "^0.2.1"
+ },
+ "peerDependencies": {
+ "esbuild": "*"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
+ "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz",
+ "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz",
+ "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz",
+ "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz",
+ "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz",
+ "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz",
+ "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz",
+ "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz",
+ "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz",
+ "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz",
+ "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz",
+ "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz",
+ "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz",
+ "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz",
+ "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz",
+ "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz",
+ "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz",
+ "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz",
+ "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz",
+ "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz",
+ "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz",
+ "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@fastify/busboy": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
+ "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
+ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
+ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.0.5"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
+ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
+ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
+ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
+ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/as-table": {
+ "version": "1.0.55",
+ "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz",
+ "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "printable-characters": "^1.0.42"
+ }
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/data-uri-to-buffer": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz",
+ "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.17.19",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz",
+ "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/android-arm": "0.17.19",
+ "@esbuild/android-arm64": "0.17.19",
+ "@esbuild/android-x64": "0.17.19",
+ "@esbuild/darwin-arm64": "0.17.19",
+ "@esbuild/darwin-x64": "0.17.19",
+ "@esbuild/freebsd-arm64": "0.17.19",
+ "@esbuild/freebsd-x64": "0.17.19",
+ "@esbuild/linux-arm": "0.17.19",
+ "@esbuild/linux-arm64": "0.17.19",
+ "@esbuild/linux-ia32": "0.17.19",
+ "@esbuild/linux-loong64": "0.17.19",
+ "@esbuild/linux-mips64el": "0.17.19",
+ "@esbuild/linux-ppc64": "0.17.19",
+ "@esbuild/linux-riscv64": "0.17.19",
+ "@esbuild/linux-s390x": "0.17.19",
+ "@esbuild/linux-x64": "0.17.19",
+ "@esbuild/netbsd-x64": "0.17.19",
+ "@esbuild/openbsd-x64": "0.17.19",
+ "@esbuild/sunos-x64": "0.17.19",
+ "@esbuild/win32-arm64": "0.17.19",
+ "@esbuild/win32-ia32": "0.17.19",
+ "@esbuild/win32-x64": "0.17.19"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz",
+ "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/exit-hook": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
+ "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/exsolve": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
+ "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-source": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz",
+ "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==",
+ "dev": true,
+ "license": "Unlicense",
+ "dependencies": {
+ "data-uri-to-buffer": "^2.0.0",
+ "source-map": "^0.6.1"
+ }
+ },
+ "node_modules/glob-to-regexp": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/jose": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.25.9",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+ "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sourcemap-codec": "^1.4.8"
+ }
+ },
+ "node_modules/mime": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
+ "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "3.20250718.3",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz",
+ "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "acorn": "8.14.0",
+ "acorn-walk": "8.3.2",
+ "exit-hook": "2.2.1",
+ "glob-to-regexp": "0.4.1",
+ "stoppable": "1.1.0",
+ "undici": "^5.28.5",
+ "workerd": "1.20250718.0",
+ "ws": "8.18.0",
+ "youch": "3.3.4",
+ "zod": "3.22.3"
+ },
+ "bin": {
+ "miniflare": "bootstrap.js"
+ },
+ "engines": {
+ "node": ">=16.13"
+ }
+ },
+ "node_modules/mustache": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
+ "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mustache": "bin/mustache"
+ }
+ },
+ "node_modules/ohash": {
+ "version": "2.0.12",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
+ "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/printable-characters": {
+ "version": "1.0.42",
+ "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",
+ "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==",
+ "dev": true,
+ "license": "Unlicense"
+ },
+ "node_modules/rollup-plugin-inject": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz",
+ "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==",
+ "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "estree-walker": "^0.6.1",
+ "magic-string": "^0.25.3",
+ "rollup-pluginutils": "^2.8.1"
+ }
+ },
+ "node_modules/rollup-plugin-node-polyfills": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz",
+ "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "rollup-plugin-inject": "^3.0.0"
+ }
+ },
+ "node_modules/rollup-pluginutils": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz",
+ "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "estree-walker": "^0.6.1"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
+ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.3",
+ "semver": "^7.6.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.33.5",
+ "@img/sharp-darwin-x64": "0.33.5",
+ "@img/sharp-libvips-darwin-arm64": "1.0.4",
+ "@img/sharp-libvips-darwin-x64": "1.0.4",
+ "@img/sharp-libvips-linux-arm": "1.0.5",
+ "@img/sharp-libvips-linux-arm64": "1.0.4",
+ "@img/sharp-libvips-linux-s390x": "1.0.4",
+ "@img/sharp-libvips-linux-x64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
+ "@img/sharp-linux-arm": "0.33.5",
+ "@img/sharp-linux-arm64": "0.33.5",
+ "@img/sharp-linux-s390x": "0.33.5",
+ "@img/sharp-linux-x64": "0.33.5",
+ "@img/sharp-linuxmusl-arm64": "0.33.5",
+ "@img/sharp-linuxmusl-x64": "0.33.5",
+ "@img/sharp-wasm32": "0.33.5",
+ "@img/sharp-win32-ia32": "0.33.5",
+ "@img/sharp-win32-x64": "0.33.5"
+ }
+ },
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sourcemap-codec": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+ "deprecated": "Please use @jridgewell/sourcemap-codec instead",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stacktracey": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz",
+ "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==",
+ "dev": true,
+ "license": "Unlicense",
+ "dependencies": {
+ "as-table": "^1.0.36",
+ "get-source": "^2.0.12"
+ }
+ },
+ "node_modules/stoppable": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz",
+ "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici": {
+ "version": "5.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
+ "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/busboy": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.0"
+ }
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.14",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz",
+ "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "exsolve": "^1.0.1",
+ "ohash": "^2.0.10",
+ "pathe": "^2.0.3",
+ "ufo": "^1.5.4"
+ }
+ },
+ "node_modules/workerd": {
+ "version": "1.20250718.0",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz",
+ "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20250718.0",
+ "@cloudflare/workerd-darwin-arm64": "1.20250718.0",
+ "@cloudflare/workerd-linux-64": "1.20250718.0",
+ "@cloudflare/workerd-linux-arm64": "1.20250718.0",
+ "@cloudflare/workerd-windows-64": "1.20250718.0"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "3.114.17",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz",
+ "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.3.4",
+ "@cloudflare/unenv-preset": "2.0.2",
+ "@esbuild-plugins/node-globals-polyfill": "0.2.3",
+ "@esbuild-plugins/node-modules-polyfill": "0.2.2",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.17.19",
+ "miniflare": "3.20250718.3",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.14",
+ "workerd": "1.20250718.0"
+ },
+ "bin": {
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=16.17.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2",
+ "sharp": "^0.33.5"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^4.20250408.0"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/youch": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz",
+ "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^0.7.1",
+ "mustache": "^4.2.0",
+ "stacktracey": "^2.1.8"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
+ "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/worker/package.json b/worker/package.json
new file mode 100644
index 0000000..24668a5
--- /dev/null
+++ b/worker/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "targetx-worker",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "wrangler dev",
+ "deploy": "wrangler deploy",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "jose": "^5.9.6"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20250109.0",
+ "typescript": "^5.7.2",
+ "wrangler": "^3.99.0"
+ }
+}
diff --git a/worker/src/clerk.ts b/worker/src/clerk.ts
new file mode 100644
index 0000000..07c0a9b
--- /dev/null
+++ b/worker/src/clerk.ts
@@ -0,0 +1,67 @@
+/**
+ * Who is asking.
+ *
+ * Clerk issues a short-lived JWT signed with a key whose public half is
+ * published at the instance's JWKS endpoint. Verification is therefore a pure
+ * signature check against a public key - no call to Clerk on the request path,
+ * no shared secret, and nothing that breaks when Clerk is having a bad day
+ * beyond new sign-ins.
+ *
+ * The token is treated as hostile input throughout. It arrived from a desktop
+ * binary on a machine we do not control, and the only thing making it
+ * trustworthy is the signature.
+ */
+import { createRemoteJWKSet, jwtVerify } from "jose";
+
+export interface Caller {
+ /** Clerk's stable user id. The rate limiter counts against this. */
+ sub: string;
+}
+
+/**
+ * One JWKS fetcher per isolate.
+ *
+ * `createRemoteJWKSet` caches the keys and re-fetches only when it sees a
+ * signing key it does not know, which is what makes per-request verification
+ * cheap. Building a new one per request would put an HTTPS round trip to Clerk
+ * in front of every question and hand an attacker a way to make us hammer
+ * Clerk by sending unknown key ids.
+ */
+let jwks: ReturnType | null = null;
+
+function keys(issuer: string) {
+ if (!jwks) jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
+ return jwks;
+}
+
+/**
+ * Verify a Clerk session token.
+ *
+ * Returns null on every failure rather than throwing, and never explains which
+ * check failed. "Expired" and "forged" are different facts to us and must look
+ * identical from outside: telling a caller their signature was valid but their
+ * audience was wrong is telling them how to get closer.
+ */
+export async function verify(
+ authorization: string | null,
+ env: { CLERK_ISSUER: string },
+): Promise {
+ if (!authorization?.startsWith("Bearer ")) return null;
+ const token = authorization.slice("Bearer ".length).trim();
+ if (!token) return null;
+
+ try {
+ const { payload } = await jwtVerify(token, keys(env.CLERK_ISSUER), {
+ issuer: env.CLERK_ISSUER,
+ // Clock skew between a student's laptop and Cloudflare is real and is not
+ // an attack. Thirty seconds is the usual allowance; it is far short of
+ // the token's own lifetime, so it does not meaningfully extend one.
+ clockTolerance: 30,
+ });
+
+ if (typeof payload.sub !== "string" || !payload.sub) return null;
+ return { sub: payload.sub };
+ } catch {
+ return null;
+ }
+}
diff --git a/worker/src/gemini.ts b/worker/src/gemini.ts
new file mode 100644
index 0000000..e06d3a9
--- /dev/null
+++ b/worker/src/gemini.ts
@@ -0,0 +1,106 @@
+/**
+ * The model call.
+ *
+ * Gemini is asked for a value from a JSON schema, not for a reply. Structured
+ * output is a constraint the API enforces during decoding: the response cannot
+ * be prose because prose does not satisfy the schema. That is the difference
+ * between asking a model to stay on topic and making it unable to leave.
+ *
+ * The prompt still says what the app is, because a well-briefed model picks the
+ * right route more often. It is not what makes the rule hold - `parseAction` on
+ * the way out is. Prompt for quality; validate for safety.
+ */
+import type { AskRequest } from "./schema";
+import { VIEWS } from "./schema";
+
+const ENDPOINT =
+ "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent";
+
+const SYSTEM = `You route questions inside TargetX, a KTU academic tracker.
+
+You do not answer questions and you do not state figures. The application
+computes every number itself from the student's own records; your only job is
+to say WHERE the answer already lives.
+
+Views:
+- home: overall standing, CGPA, what needs attention
+- ledger: this semester's marks, per subject, per component
+- attendance: attendance percentage, classes that can still be missed, CIE
+ marks earned from attendance, the weekly timetable
+- history: published results from past semesters
+- data: sync with etlab and KTU, import, backup
+
+Choose "subject" when the question names one of the given courses, and set
+"view" to where that question is answered - attendance questions to attendance,
+mark questions to ledger. Choose "view" when the question is about the student
+overall. Choose "none" with reason "off_topic" for anything not about this
+student's academic record, "unclear" when the question cannot be understood,
+and "no_match" when it is on topic but nothing here answers it.`;
+
+/** Mirrors `Action`. Gemini enforces this during decoding. */
+function responseSchema(codes: string[]) {
+ return {
+ type: "OBJECT",
+ properties: {
+ kind: { type: "STRING", enum: ["view", "subject", "none"] },
+ view: { type: "STRING", enum: [...VIEWS] },
+ // An enum of the codes the client actually sent, so an invented course
+ // code is not merely rejected later - it cannot be generated. Empty
+ // enums are not valid, so a student with no courses gets the field left
+ // open and `parseAction` catches anything that comes back in it.
+ ...(codes.length > 0 ? { code: { type: "STRING", enum: codes } } : {}),
+ reason: { type: "STRING", enum: ["off_topic", "unclear", "no_match"] },
+ },
+ required: ["kind"],
+ };
+}
+
+/** Raw parsed JSON from the model, still untrusted. Validate before use. */
+export async function route(
+ req: AskRequest, apiKey: string, signal: AbortSignal,
+): Promise {
+ const codes = req.subjects.map((s) => s.code);
+ const catalogue = req.subjects.map((s) => `${s.code} ${s.name}`).join("\n");
+
+ const body = {
+ systemInstruction: { parts: [{ text: SYSTEM }] },
+ contents: [{
+ role: "user",
+ parts: [{ text: `Courses this semester:\n${catalogue}\n\nQuestion: ${req.question}` }],
+ }],
+ generationConfig: {
+ responseMimeType: "application/json",
+ responseSchema: responseSchema(codes),
+ // Routing is a classification, not a composition. Sampling buys nothing
+ // here except a different answer to the same question on a retry.
+ temperature: 0,
+ maxOutputTokens: 128,
+ },
+ };
+
+ const res = await fetch(ENDPOINT, {
+ method: "POST",
+ // The key travels in a header, never in the URL. Query strings end up in
+ // proxy logs and error reports; headers are far less likely to.
+ headers: { "content-type": "application/json", "x-goog-api-key": apiKey },
+ body: JSON.stringify(body),
+ signal,
+ });
+
+ if (!res.ok) throw new Error(`gemini ${res.status}`);
+
+ const json = await res.json<{
+ candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>;
+ }>();
+
+ const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
+ if (typeof text !== "string") throw new Error("gemini: no content");
+
+ // Structured output means this should always parse. "Should" is not a
+ // guarantee we get to rely on when the input is a remote service.
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ throw new Error("gemini: unparseable");
+ }
+}
diff --git a/worker/src/index.ts b/worker/src/index.ts
new file mode 100644
index 0000000..a0ee2e0
--- /dev/null
+++ b/worker/src/index.ts
@@ -0,0 +1,105 @@
+/**
+ * TargetX ask endpoint.
+ *
+ * The only reason this service exists is that three of the app's requirements
+ * cannot be met inside a desktop binary:
+ *
+ * - the Gemini key must stay secret, and a Tauri bundle is files on the
+ * student's disk;
+ * - a rate limit enforced by the client is a rate limit the client deletes;
+ * - a Clerk session proves nothing until something verifies the signature.
+ *
+ * All three are the same problem - trust cannot live on the machine being
+ * trusted - and this is the smallest thing that solves it.
+ *
+ * What it deliberately does NOT do: compute anything about a student. Marks,
+ * attendance and CGPA never reach here. The request carries a question and a
+ * course list; the response carries a route. Every figure the student reads is
+ * still produced 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.
+ */
+import { verify } from "./clerk";
+import { claim } from "./limit";
+import { route } from "./gemini";
+import { parseAction, parseAskRequest } from "./schema";
+
+export { Quota } from "./limit";
+
+interface Env {
+ GEMINI_KEY: string;
+ CLERK_ISSUER: string;
+ QUOTA: DurableObjectNamespace;
+}
+
+/** No credentials, no cookies - the client sends a bearer token by hand. */
+const CORS = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-headers": "authorization,content-type",
+ "access-control-allow-methods": "POST,OPTIONS",
+};
+
+function json(body: unknown, status = 200, extra: Record = {}) {
+ return Response.json(body, { status, headers: { ...CORS, ...extra } });
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ if (request.method === "OPTIONS") return new Response(null, { headers: CORS });
+ if (request.method !== "POST") return json({ error: "method" }, 405);
+
+ const url = new URL(request.url);
+ if (url.pathname !== "/ask") return json({ error: "not_found" }, 404);
+
+ // Identity first, before the body is even read. An unauthenticated caller
+ // should not be able to make us parse anything they sent.
+ const caller = await verify(request.headers.get("authorization"), env);
+ if (!caller) return json({ error: "unauthorized" }, 401);
+
+ // Quota second, before the model is called. See `claim`: counting after a
+ // success would let a caller who can reliably provoke an error spend our
+ // budget without spending their own.
+ const quota = await claim(env.QUOTA, caller.sub);
+ if (!quota.ok) {
+ return json({ error: "rate_limited", resetAt: quota.resetAt }, 429, {
+ "retry-after": String(Math.max(1, quota.resetAt - Math.floor(Date.now() / 1000))),
+ });
+ }
+
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return json({ error: "bad_request" }, 400);
+ }
+
+ const ask = parseAskRequest(payload);
+ if (!ask) return json({ error: "bad_request" }, 400);
+
+ // A hung upstream must not hold a Worker invocation open indefinitely. The
+ // app has a local answer for most questions anyway, so a fast failure is
+ // worth more here than a slow success.
+ const abort = new AbortController();
+ const timer = setTimeout(() => abort.abort(), 8000);
+
+ try {
+ const raw = await route(ask, env.GEMINI_KEY, abort.signal);
+ const action = parseAction(raw, new Set(ask.subjects.map((s) => s.code)));
+
+ // The model returned something outside its own schema. That is not an
+ // action the app is allowed to act on, and it is not an error the student
+ // needs to see either - it is a shrug.
+ if (!action) {
+ return json({ action: { kind: "none", reason: "unclear" }, remaining: quota.remaining });
+ }
+
+ return json({ action, remaining: quota.remaining });
+ } catch {
+ // Nothing about the upstream failure is echoed back. The error text could
+ // carry a URL, a key fragment or a project id, and none of that is the
+ // student's business or safe to leak.
+ return json({ error: "upstream" }, 502);
+ } finally {
+ clearTimeout(timer);
+ }
+ },
+};
diff --git a/worker/src/limit.ts b/worker/src/limit.ts
new file mode 100644
index 0000000..e3a47f4
--- /dev/null
+++ b/worker/src/limit.ts
@@ -0,0 +1,78 @@
+/**
+ * The daily quota, counted where it cannot be lied to.
+ *
+ * A limit enforced in the app is not a limit: the app is a binary on the
+ * student's own machine and they can edit it. This counter lives in a Durable
+ * Object, which is the only thing in the Workers platform that gives a single
+ * authoritative copy of a value with serialised access to it - KV would be
+ * cheaper and would also let a client fire twenty parallel requests through a
+ * read-modify-write race and spend twenty units of a five-unit quota.
+ *
+ * One object per user id, so two students never contend with each other, and a
+ * heavy user's traffic is serialised only against themselves.
+ */
+
+/** Requests one user may make per UTC day. */
+const DAILY = 40;
+
+export interface Decision {
+ ok: boolean;
+ /** Requests left AFTER this one, so the client can show a real number. */
+ remaining: number;
+ /** Unix seconds at which the quota resets. */
+ resetAt: number;
+}
+
+function dayKey(now: number): string {
+ return new Date(now).toISOString().slice(0, 10);
+}
+
+function nextMidnight(now: number): number {
+ const d = new Date(now);
+ d.setUTCHours(24, 0, 0, 0);
+ return Math.floor(d.getTime() / 1000);
+}
+
+export class Quota {
+ private state: DurableObjectState;
+
+ constructor(state: DurableObjectState) {
+ this.state = state;
+ }
+
+ async fetch(): Promise {
+ const now = Date.now();
+ const today = dayKey(now);
+
+ // `blockConcurrencyWhile` is not needed here: a Durable Object already
+ // processes one fetch at a time per object, which is the entire reason
+ // this is a DO and not a KV key.
+ const stored = await this.state.storage.get<{ day: string; used: number }>("c");
+ const used = stored?.day === today ? stored.used : 0;
+
+ if (used >= DAILY) {
+ return Response.json({
+ ok: false, remaining: 0, resetAt: nextMidnight(now),
+ } satisfies Decision);
+ }
+
+ await this.state.storage.put("c", { day: today, used: used + 1 });
+ return Response.json({
+ ok: true, remaining: DAILY - (used + 1), resetAt: nextMidnight(now),
+ } satisfies Decision);
+ }
+}
+
+/**
+ * Claim one request against a user's quota.
+ *
+ * Counted BEFORE the model is called, not after. Counting on success would let
+ * a caller who reliably triggers a Gemini error spend our money without ever
+ * spending their own quota, which is the cheapest denial-of-wallet there is.
+ */
+export async function claim(
+ ns: DurableObjectNamespace, userId: string,
+): Promise {
+ const stub = ns.get(ns.idFromName(userId));
+ return await (await stub.fetch("https://quota/claim")).json();
+}
diff --git a/worker/src/schema.ts b/worker/src/schema.ts
new file mode 100644
index 0000000..88633fa
--- /dev/null
+++ b/worker/src/schema.ts
@@ -0,0 +1,110 @@
+/**
+ * The contract between the app and the model.
+ *
+ * This file is the enforcement point for "the assistant may not answer anything
+ * outside the app". That rule is NOT implemented as a sentence in a prompt.
+ * A prompt is a request, and a request can be talked out of; every published
+ * jailbreak is a demonstration of exactly that. It is implemented as a type the
+ * response has to parse into before it is allowed back to the client.
+ *
+ * The model's entire vocabulary is below. It can name a view, it can name a
+ * subject, or it can decline. There is no shape here that carries prose, and
+ * none that carries a number - so the model structurally cannot state a figure
+ * about a student's marks, correct or otherwise. Every number the student sees
+ * is still computed on their own machine by the engine, exactly as it is today.
+ *
+ * The model routes. The engine answers. That separation is the whole design.
+ */
+
+/** The views the app actually has. Anything else is a hallucinated route. */
+export const VIEWS = ["home", "ledger", "attendance", "history", "data"] as const;
+export type ViewId = (typeof VIEWS)[number];
+
+/**
+ * What the app asks a question about.
+ *
+ * Note what is NOT in here: no marks, no attendance percentages, no CGPA, no
+ * name, no register number. The model is routing a question to a subject, and
+ * it needs the subject's code and title to do that - it does not need to know
+ * how the student is doing, and sending that would be handing an academic
+ * record to a third party for no gain. What leaves the machine is a question
+ * and a course list. The answer is computed at home.
+ */
+export interface AskRequest {
+ question: string;
+ subjects: Array<{ code: string; name: string }>;
+}
+
+export type Action =
+ | { kind: "view"; view: ViewId }
+ | { kind: "subject"; code: string; view: ViewId }
+ /**
+ * The refusal is a first-class action rather than an error, because the
+ * honest answer to "what is the capital of France" from an academic tracker
+ * is a shrug, not a 400. `reason` is one of a fixed set - it is not free text
+ * the model wrote, for the same reason nothing else here is.
+ */
+ | { kind: "none"; reason: "off_topic" | "unclear" | "no_match" };
+
+const REASONS = ["off_topic", "unclear", "no_match"] as const;
+
+function isView(v: unknown): v is ViewId {
+ return typeof v === "string" && (VIEWS as readonly string[]).includes(v);
+}
+
+/**
+ * Parse a model response into an Action, or reject it.
+ *
+ * Deliberately hostile to its input. This runs on whatever the model returned,
+ * which is untrusted text no matter how well the prompt was written, and the
+ * only thing standing between it and the client is this function. Unknown
+ * fields are dropped rather than passed through; a subject code the client
+ * never sent is rejected rather than trusted, because a model that invents a
+ * course code would otherwise have the app navigate to a subject that does not
+ * exist.
+ */
+export function parseAction(raw: unknown, knownCodes: Set): Action | null {
+ if (typeof raw !== "object" || raw === null) return null;
+ const o = raw as Record;
+
+ if (o.kind === "view" && isView(o.view)) {
+ return { kind: "view", view: o.view };
+ }
+
+ if (o.kind === "subject" && typeof o.code === "string" && knownCodes.has(o.code)) {
+ // The view defaults rather than failing: a model that names the right
+ // subject and forgets where to show it has still done the useful half.
+ return { kind: "subject", code: o.code, view: isView(o.view) ? o.view : "attendance" };
+ }
+
+ if (o.kind === "none" && typeof o.reason === "string"
+ && (REASONS as readonly string[]).includes(o.reason)) {
+ return { kind: "none", reason: o.reason as (typeof REASONS)[number] };
+ }
+
+ return null;
+}
+
+/** Shape check on what the CLIENT sent, before any of it reaches the model. */
+export function parseAskRequest(raw: unknown): AskRequest | null {
+ if (typeof raw !== "object" || raw === null) return null;
+ const o = raw as Record;
+
+ if (typeof o.question !== "string") return null;
+ const question = o.question.trim();
+ // A cap, because the question is forwarded to a metered API and an unbounded
+ // string is an unbounded bill. Long enough for any real question.
+ if (question.length === 0 || question.length > 400) return null;
+
+ if (!Array.isArray(o.subjects) || o.subjects.length > 60) return null;
+ const subjects: AskRequest["subjects"] = [];
+ for (const s of o.subjects) {
+ if (typeof s !== "object" || s === null) return null;
+ const { code, name } = s as Record;
+ if (typeof code !== "string" || typeof name !== "string") return null;
+ if (code.length > 24 || name.length > 120) return null;
+ subjects.push({ code, name });
+ }
+
+ return { question, subjects };
+}
diff --git a/worker/tsconfig.json b/worker/tsconfig.json
new file mode 100644
index 0000000..9ec0009
--- /dev/null
+++ b/worker/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "module": "es2022",
+ "moduleResolution": "bundler",
+ "lib": ["es2022"],
+ "types": ["@cloudflare/workers-types"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": ["src"]
+}
diff --git a/worker/wrangler.toml b/worker/wrangler.toml
new file mode 100644
index 0000000..3c62ff3
--- /dev/null
+++ b/worker/wrangler.toml
@@ -0,0 +1,19 @@
+name = "targetx-ask"
+main = "src/index.ts"
+compatibility_date = "2025-01-09"
+compatibility_flags = ["nodejs_compat"]
+
+# The Clerk instance's issuer URL. NOT a secret - it is public, it is in every
+# token the app already holds, and keeping it in the file means the deployment
+# is reproducible from the repo. The Gemini key is NOT here; it is set with
+# `wrangler secret put GEMINI_KEY` and never touches version control.
+[vars]
+CLERK_ISSUER = "https://REPLACE-ME.clerk.accounts.dev"
+
+[[durable_objects.bindings]]
+name = "QUOTA"
+class_name = "Quota"
+
+[[migrations]]
+tag = "v1"
+new_sqlite_classes = ["Quota"]
From 902569111e9b593f6d9496f5679fa1f546aca99e Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:01:45 +0530
Subject: [PATCH 05/32] fix(ui): the bell and account menu closed on the click
that opened them
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/ui/App.tsx | 34 +++++++--
app/src/ui/__tests__/HeaderPopovers.test.tsx | 80 ++++++++++++++++++++
2 files changed, 108 insertions(+), 6 deletions(-)
create mode 100644 app/src/ui/__tests__/HeaderPopovers.test.tsx
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index f0269d8..064a49e 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -294,15 +294,31 @@ export function SaveNotice() {
function Bell(props: { findings: Finding[]; onGo: () => void }) {
const [open, setOpen] = createSignal(false);
const count = () => props.findings.length;
+ let wrap: HTMLDivElement | undefined;
- // A popover that outlives a click elsewhere is a popover the student has to
- // dismiss twice.
- const onDocClick = () => setOpen(false);
+ /*
+ * Close when the click lands outside, decided by geometry rather than by
+ * propagation.
+ *
+ * The propagation version did not work and could not: Solid delegates every
+ * `onClick` to `document`, and this listener is on `document` too.
+ * `stopPropagation` does not stop other listeners already bound to the SAME
+ * node - only `stopImmediatePropagation` does, and only for handlers
+ * registered after it. So the button's own toggle opened the popover and
+ * this handler closed it again in the same click, and the bell looked dead.
+ *
+ * Asking whether the click was inside the wrapper is independent of both
+ * listener order and the framework's delegation strategy.
+ */
+ const onDocClick = (e: MouseEvent) => {
+ if (wrap && e.target instanceof Node && wrap.contains(e.target)) return;
+ setOpen(false);
+ };
onMount(() => document.addEventListener("click", onDocClick));
onCleanup(() => document.removeEventListener("click", onDocClick));
return (
-
e.stopPropagation()}>
+
setOpen((o) => !o)}
aria-expanded={open()}
aria-label={count() === 0
@@ -357,13 +373,19 @@ function Bell(props: { findings: Finding[]; onGo: () => void }) {
function Profile() {
const [open, setOpen] = createSignal(false);
const cgpa = () => (overall().credits > 0 ? overall().cgpa.toFixed(2) : null);
+ let wrap: HTMLDivElement | undefined;
- const onDocClick = () => setOpen(false);
+ /* Same containment test as the bell - see the note there for why this is not
+ done with stopPropagation. */
+ const onDocClick = (e: MouseEvent) => {
+ if (wrap && e.target instanceof Node && wrap.contains(e.target)) return;
+ setOpen(false);
+ };
onMount(() => document.addEventListener("click", onDocClick));
onCleanup(() => document.removeEventListener("click", onDocClick));
return (
-
e.stopPropagation()}>
+
setOpen((o) => !o)}
aria-expanded={open()} aria-label="Account">
diff --git a/app/src/ui/__tests__/HeaderPopovers.test.tsx b/app/src/ui/__tests__/HeaderPopovers.test.tsx
new file mode 100644
index 0000000..4de05a7
--- /dev/null
+++ b/app/src/ui/__tests__/HeaderPopovers.test.tsx
@@ -0,0 +1,80 @@
+// @vitest-environment jsdom
+/**
+ * The bell and the account menu.
+ *
+ * Both shipped dead. Each one closed itself on the very click that opened it,
+ * because Solid delegates every `onClick` to `document` and the close-on-
+ * outside-click listener was on `document` as well - `stopPropagation` does
+ * not stop other listeners bound to the same node, so the toggle ran and the
+ * dismissal ran, in one click, every time.
+ *
+ * These tests dispatch REAL bubbling events rather than calling the handlers,
+ * because the bug lived entirely in how two listeners on one node interleave.
+ * A test that invoked the click handler directly would have passed against the
+ * broken build.
+ */
+import { cleanup, render } from "@solidjs/testing-library";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { App } from "../App";
+import { setView } from "../../state/nav";
+import { edit } from "../../state/store";
+
+afterEach(cleanup);
+
+beforeEach(() => {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true, configurable: true,
+ value: (q: string) => ({
+ matches: false, media: q, onchange: null,
+ addEventListener: () => {}, removeEventListener: () => {},
+ addListener: () => {}, removeListener: () => {},
+ dispatchEvent: () => false,
+ }),
+ });
+ edit((d) => {
+ d.semesters = { S5: { courses: [] } };
+ d.activeSemester = "S5";
+ d.history = {};
+ d.onboarded = true;
+ });
+ setView("home");
+});
+
+/** A click that bubbles to `document`, which is where the bug was. */
+const click = (el: Element) =>
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
+
+describe.each([
+ ["notifications", "button.bell", "Notifications"],
+ ["the account menu", "button.profile", "Account"],
+])("%s", (_name, selector, label) => {
+ it("opens on click and stays open", () => {
+ const { container } = render(() => );
+ const trigger = container.querySelector(selector)!;
+ expect(container.querySelector(".pop")).toBeNull();
+
+ click(trigger);
+ const pop = container.querySelector(".pop")!;
+ expect(pop).not.toBeNull();
+ expect(pop.getAttribute("aria-label")).toBe(label);
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("closes on a click outside it", () => {
+ const { container } = render(() => );
+ click(container.querySelector(selector)!);
+ expect(container.querySelector(".pop")).not.toBeNull();
+
+ click(document.body);
+ expect(container.querySelector(".pop")).toBeNull();
+ });
+
+ it("stays open when the click lands inside it", () => {
+ const { container } = render(() => );
+ click(container.querySelector(selector)!);
+ const pop = container.querySelector(".pop")!;
+
+ click(pop);
+ expect(container.querySelector(".pop")).not.toBeNull();
+ });
+});
From 648e8c53c517368d511b0d0f1fb368178186926f Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:06:57 +0530
Subject: [PATCH 06/32] feat: sync on launch, and let notifications be
dismissed one at a time
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/__tests__/autosync.test.ts | 123 +++++++++++++++++++
app/src/state/autosync.ts | 119 ++++++++++++++++++
app/src/state/launch.ts | 13 +-
app/src/styles/app.css | 36 ++++++
app/src/ui/App.tsx | 117 ++++++++++++++----
app/src/ui/__tests__/HeaderPopovers.test.tsx | 89 +++++++++++++-
6 files changed, 467 insertions(+), 30 deletions(-)
create mode 100644 app/src/state/__tests__/autosync.test.ts
create mode 100644 app/src/state/autosync.ts
diff --git a/app/src/state/__tests__/autosync.test.ts b/app/src/state/__tests__/autosync.test.ts
new file mode 100644
index 0000000..f6a1d98
--- /dev/null
+++ b/app/src/state/__tests__/autosync.test.ts
@@ -0,0 +1,123 @@
+/**
+ * The launch sync.
+ *
+ * Its preconditions matter more than its happy path. This runs without anybody
+ * asking for it, using a password out of the OS vault, against a college's
+ * server - so the cases worth pinning are the ones where it must decline to
+ * run, and the guarantee that it never throws into an `onMount` that has no
+ * catch around it.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const canSync = vi.fn(() => true);
+const fullSync = vi.fn();
+const canRemember = vi.fn(() => true);
+const loadCreds = vi.fn();
+const applySync = vi.fn();
+
+vi.mock("../../sync/etlab", () => ({
+ canSync: () => canSync(),
+ fullSync: (...a: unknown[]) => fullSync(...a),
+ EtlabError: class extends Error {},
+}));
+vi.mock("../creds", () => ({
+ canRemember: () => canRemember(),
+ loadCreds: (...a: unknown[]) => loadCreds(...a),
+}));
+vi.mock("../actions", () => ({ applySync: (...a: unknown[]) => applySync(...a) }));
+
+const { AUTO_SYNC_GAP_MS, autoSync, autoSyncError, syncIsStale } =
+ await import("../autosync");
+const { edit } = await import("../store");
+
+const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ canSync.mockReturnValue(true);
+ canRemember.mockReturnValue(true);
+ loadCreds.mockResolvedValue({ username: "24CS211", password: "secret" });
+ fullSync.mockResolvedValue({ semesters: {}, history: {} });
+ edit((s) => {
+ s.student.college = "https://portal.example.edu";
+ s.lastSync = ago(AUTO_SYNC_GAP_MS + 60_000);
+ });
+});
+
+describe("preconditions", () => {
+ it("does nothing in a build that cannot sync at all", async () => {
+ canSync.mockReturnValue(false);
+ expect(await autoSync()).toBe("unavailable");
+ expect(loadCreds).not.toHaveBeenCalled();
+ });
+
+ it("does nothing where there is no credential vault", async () => {
+ canRemember.mockReturnValue(false);
+ expect(await autoSync()).toBe("unavailable");
+ expect(loadCreds).not.toHaveBeenCalled();
+ });
+
+ it("does nothing before a college address is configured", async () => {
+ edit((s) => { s.student.college = ""; });
+ expect(await autoSync()).toBe("unavailable");
+ expect(loadCreds).not.toHaveBeenCalled();
+ });
+
+ it("never prompts and never syncs without a saved login", async () => {
+ loadCreds.mockResolvedValue(null);
+ expect(await autoSync()).toBe("no-creds");
+ expect(fullSync).not.toHaveBeenCalled();
+ });
+
+ it("treats a vault that will not answer as simply unavailable", async () => {
+ loadCreds.mockRejectedValue(new Error("vault locked"));
+ expect(await autoSync()).toBe("unavailable");
+ expect(fullSync).not.toHaveBeenCalled();
+ // Not the student's problem: they never asked for this sync.
+ expect(autoSyncError()).toBeNull();
+ });
+});
+
+describe("throttling", () => {
+ it("skips a sync that just happened", async () => {
+ edit((s) => { s.lastSync = ago(60_000); });
+ expect(await autoSync()).toBe("fresh");
+ expect(fullSync).not.toHaveBeenCalled();
+ });
+
+ it("runs once the gap has passed", async () => {
+ expect(await autoSync()).toBe("ok");
+ expect(fullSync).toHaveBeenCalledOnce();
+ expect(applySync).toHaveBeenCalledOnce();
+ });
+
+ it("treats a record that was never synced as stale", () => {
+ edit((s) => { s.lastSync = undefined; });
+ expect(syncIsStale()).toBe(true);
+ });
+
+ it("treats an unparseable timestamp as never synced rather than as fresh", () => {
+ edit((s) => { s.lastSync = "not a date"; });
+ expect(syncIsStale()).toBe(true);
+ });
+});
+
+describe("failure", () => {
+ it("reports rather than throws, because nothing catches it", async () => {
+ fullSync.mockRejectedValue(new Error("etlab is down"));
+ await expect(autoSync()).resolves.toBe("failed");
+ expect(autoSyncError()).toBe("etlab is down");
+ expect(applySync).not.toHaveBeenCalled();
+ });
+
+ it("clears the previous failure when a later sync works", async () => {
+ fullSync.mockRejectedValue(new Error("etlab is down"));
+ await autoSync();
+ expect(autoSyncError()).not.toBeNull();
+
+ fullSync.mockResolvedValue({ semesters: {}, history: {} });
+ edit((s) => { s.lastSync = ago(AUTO_SYNC_GAP_MS + 60_000); });
+ await autoSync();
+ expect(autoSyncError()).toBeNull();
+ });
+});
diff --git a/app/src/state/autosync.ts b/app/src/state/autosync.ts
new file mode 100644
index 0000000..7db6522
--- /dev/null
+++ b/app/src/state/autosync.ts
@@ -0,0 +1,119 @@
+import { createSignal } from "solid-js";
+import { canSync, fullSync } from "../sync/etlab";
+import { canRemember, loadCreds } from "./creds";
+import { applySync } from "./actions";
+import { state } from "./store";
+
+/**
+ * Sync on launch, when the student has already said we may.
+ *
+ * Until now 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. The number
+ * looked authoritative and was quietly days old, which is worse than no number.
+ *
+ * Three deliberate limits on it:
+ *
+ * 1. It runs ONLY if the password is already in the OS credential vault. This
+ * feature never prompts, never stores anything new, and cannot be the reason
+ * a password gets saved - that is still a box the student ticks on the Data
+ * screen. No vault entry, no automatic sync, no nagging.
+ *
+ * 2. It runs at launch and never on a timer. etlab is a college server that
+ * updates attendance once or twice a day when a teacher gets round to it;
+ * polling it every fifteen minutes would put load on someone else's
+ * infrastructure for data that did not change. Opening the app is also the
+ * exact moment the student is about to read the number.
+ *
+ * 3. It is throttled. Closing and reopening the app four times in a row is one
+ * sync, not four.
+ *
+ * It is fire-and-forget: nothing waits on it, nothing blocks on it, and a
+ * failure is reported quietly rather than thrown at someone who did not ask for
+ * a sync. The app's whole value is that it works with no network at all.
+ */
+
+/** Do not sync again if the last one was this recent. */
+export const AUTO_SYNC_GAP_MS = 3 * 60 * 60 * 1000;
+
+export type AutoSyncOutcome =
+ | "ok"
+ /** No saved login, so there is nothing to sync with. Not a failure. */
+ | "no-creds"
+ /** Synced recently enough that doing it again would buy nothing. */
+ | "fresh"
+ /** Browser build, or no college address configured yet. */
+ | "unavailable"
+ | "failed";
+
+const [autoSyncing, setAutoSyncing] = createSignal(false);
+/** The last automatic sync's failure, or null. Surfaced in the bell. */
+const [autoSyncError, setAutoSyncError] = createSignal(null);
+export { autoSyncing, autoSyncError };
+
+/** Milliseconds since the last successful sync, or null if there never was one. */
+export function sinceLastSync(now = Date.now()): number | null {
+ if (!state.lastSync) return null;
+ const then = new Date(state.lastSync).getTime();
+ if (!Number.isFinite(then)) return null;
+ return now - then;
+}
+
+/**
+ * True when a sync would be worth doing.
+ *
+ * A record that has NEVER been synced is stale by definition - that is a
+ * student who set the app up by hand, or whose first sync failed, and they are
+ * exactly who benefits from one happening without being asked.
+ */
+export function syncIsStale(now = Date.now()): boolean {
+ const age = sinceLastSync(now);
+ return age === null || age >= AUTO_SYNC_GAP_MS;
+}
+
+/**
+ * Run the launch sync if every precondition holds.
+ *
+ * Never throws. Every path returns an outcome, because the caller is a
+ * `void`-ed call in `onMount` and an escaping rejection there is an unhandled
+ * promise rejection in a WebView, where nobody will ever see it.
+ */
+export async function autoSync(): Promise {
+ if (!canSync() || !canRemember()) return "unavailable";
+
+ const base = String(state.student.college || "").trim();
+ if (!base) return "unavailable";
+
+ if (!syncIsStale()) return "fresh";
+
+ let stored;
+ try {
+ stored = await loadCreds(base);
+ } catch {
+ // A vault that will not answer is not a sync failure, and telling the
+ // student their sync failed when they never asked for one is noise.
+ return "unavailable";
+ }
+ if (!stored) return "no-creds";
+
+ setAutoSyncing(true);
+ setAutoSyncError(null);
+ try {
+ // The password is read from the vault into a local, handed to one request
+ // and dropped when this function returns. It is never put in a signal,
+ // never in app state and never logged - the same posture the manual panel
+ // holds, and the reason this lives here rather than in a component.
+ const result = await fullSync(base, stored.username, stored.password);
+ applySync(result);
+ return "ok";
+ } catch (exc) {
+ // Deliberately not rethrown and deliberately not a dialog. The student
+ // opened the app to read a number, not to be told about a background task
+ // they did not start. It becomes a line in the bell.
+ setAutoSyncError(exc instanceof Error ? exc.message : String(exc));
+ return "failed";
+ } finally {
+ setAutoSyncing(false);
+ }
+}
diff --git a/app/src/state/launch.ts b/app/src/state/launch.ts
index 5e760cb..403ccce 100644
--- a/app/src/state/launch.ts
+++ b/app/src/state/launch.ts
@@ -10,13 +10,18 @@ import { saveFault, state } from "./store";
* recomputation run here, once, rather than being buried in a screen the
* student may never open.
*
- * What it deliberately does NOT do is sync. A portal sync needs a password,
- * and the password is never stored; running one on launch would mean keeping
- * a credential on disk to save a click. Staleness is reported instead.
+ * What it deliberately does NOT do is sync. This check is arithmetic over data
+ * already in memory and must finish in milliseconds; a portal sync crosses the
+ * network to a college server and cannot be on the path to the first paint.
+ * Staleness is reported here and acted on separately - see `state/autosync`,
+ * which runs after this, only when the student has already chosen to keep
+ * their login in the OS credential vault, and never blocks anything.
*/
export type FindingKind =
- "reconcile" | "stale" | "empty" | "attendance" | "corrupt" | "save";
+ "reconcile" | "stale" | "empty" | "attendance" | "corrupt" | "save"
+ /** Raised by the background sync, not by this check. */
+ | "sync";
export interface Finding {
kind: FindingKind;
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index f3d5b59..093fa63 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -860,3 +860,39 @@ tr.row:hover .del { opacity: 1; }
/* Hard to the far end, so the shortcut reads as the field's affordance rather
than as the last two words of the placeholder sentence. */
.ask .kbd { margin-inline-start: auto; flex: none; }
+
+/* --- notification rows ----------------------------------------------------- */
+
+.pop-head {
+ align-items: baseline;
+ display: flex;
+ gap: var(--s3);
+ justify-content: space-between;
+}
+.pop-head .pop-title { margin-block-end: var(--s2); }
+
+.pop-item-head {
+ align-items: start;
+ display: flex;
+ gap: var(--s2);
+ justify-content: space-between;
+}
+
+/* Present at all times rather than on hover. A control that only exists once
+ the pointer finds it is a control a keyboard user is told about by nothing,
+ and this row is already quiet enough that a small glyph does not shout. */
+.pop-x {
+ appearance: none;
+ background: transparent;
+ border: 0;
+ border-radius: var(--radius);
+ color: var(--text-faint);
+ cursor: pointer;
+ flex: none;
+ font: inherit;
+ font-size: var(--text-base);
+ line-height: 1;
+ padding: 2px 5px;
+}
+.pop-x:hover { background: var(--surface-2); color: var(--text); }
+.pop-x:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index 064a49e..a10a80c 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -17,6 +17,7 @@ import { Setup } from "./Setup";
import { Mark } from "./Mark";
import { Palette, usePaletteShortcut } from "./Palette";
import { runLaunchCheck, saveFindings } from "../state/launch";
+import { autoSync, autoSyncError, autoSyncing } from "../state/autosync";
import type { Finding } from "../state/launch";
import { checkForUpdate } from "../sync/update";
import type { Available } from "../sync/update";
@@ -291,9 +292,17 @@ export function SaveNotice() {
* and Home keeps its "Needs attention" card, because that is a place a student
* goes to look rather than a thing that interrupts them.
*/
-function Bell(props: { findings: Finding[]; onGo: () => void }) {
+export function Bell(props: { findings: Finding[] }) {
const [open, setOpen] = createSignal(false);
- const count = () => props.findings.length;
+ /**
+ * Titles the student has waved away, for this run of the app only.
+ *
+ * Not persisted, on purpose. Every finding here is re-derived from the record
+ * on each launch, so a dismissal saved to disk would be a promise never to
+ * mention a problem again - and the problems these report do not go away by
+ * being ignored. Dismissing means "I have read this", not "this is fixed".
+ */
+ const [hidden, setHidden] = createSignal([]);
let wrap: HTMLDivElement | undefined;
/*
@@ -309,45 +318,98 @@ function Bell(props: { findings: Finding[]; onGo: () => void }) {
*
* Asking whether the click was inside the wrapper is independent of both
* listener order and the framework's delegation strategy.
+ *
+ * It listens for pointerdown rather than click, and that is not incidental.
+ * A click on a row's dismiss button removes that row, so by the time a click
+ * listener on `document` ran, the node it was handed had already been
+ * detached from the tree and `contains` answered false for something that
+ * was plainly inside the popover - which closed it on every dismissal.
+ * pointerdown fires before any handler has had the chance to mutate anything.
*/
- const onDocClick = (e: MouseEvent) => {
+ const onDocDown = (e: Event) => {
if (wrap && e.target instanceof Node && wrap.contains(e.target)) return;
setOpen(false);
};
- onMount(() => document.addEventListener("click", onDocClick));
- onCleanup(() => document.removeEventListener("click", onDocClick));
+ onMount(() => document.addEventListener("pointerdown", onDocDown));
+ onCleanup(() => document.removeEventListener("pointerdown", onDocDown));
+
+ const live = () => props.findings.filter((f) => !hidden().includes(f.title));
+
+ /**
+ * A failed background sync is a notification, not an error dialog.
+ *
+ * It is given the same shape as a launch finding so the list has one kind of
+ * row rather than two, and it routes to Data, which is the screen that can
+ * actually do something about it.
+ */
+ const syncFailure = (): Finding | null => {
+ const why = autoSyncError();
+ if (why === null || hidden().includes(SYNC_FAIL)) return null;
+ return {
+ kind: "sync", title: SYNC_FAIL, detail: why, severity: "warn",
+ goto: "data", action: "Open Data",
+ };
+ };
+
+ const items = (): Finding[] => {
+ const f = syncFailure();
+ return f ? [f, ...live()] : live();
+ };
+
+ const dismiss = (title: string) => setHidden((h) => [...h, title]);
+ const dismissAll = () => {
+ setHidden((h) => [...h, ...items().map((f) => f.title)]);
+ setOpen(false);
+ };
return (
+ {f.title}
+ {/* Per-row, because "3 things need attention" is three
+ different decisions and clearing them together forces the
+ student to re-read the two they had not dealt with. */}
+ dismiss(f.title)}>×
+
)}
@@ -357,6 +419,9 @@ function Bell(props: { findings: Finding[]; onGo: () => void }) {
);
}
+/** The one synthetic finding's title, used as its dismissal key. */
+const SYNC_FAIL = "That automatic sync did not land";
+
/**
* Account.
*
@@ -375,14 +440,14 @@ function Profile() {
const cgpa = () => (overall().credits > 0 ? overall().cgpa.toFixed(2) : null);
let wrap: HTMLDivElement | undefined;
- /* Same containment test as the bell - see the note there for why this is not
- done with stopPropagation. */
- const onDocClick = (e: MouseEvent) => {
+ /* Same containment test as the bell, on pointerdown for the same two reasons
+ - see the note there. */
+ const onDocDown = (e: Event) => {
if (wrap && e.target instanceof Node && wrap.contains(e.target)) return;
setOpen(false);
};
- onMount(() => document.addEventListener("click", onDocClick));
- onCleanup(() => document.removeEventListener("click", onDocClick));
+ onMount(() => document.addEventListener("pointerdown", onDocDown));
+ onCleanup(() => document.removeEventListener("pointerdown", onDocDown));
return (
@@ -523,6 +588,12 @@ export function App() {
// to null on every failure, so there is nothing to catch and nothing to
// report when it finds nothing.
setTimeout(() => { void checkForUpdate().then(setUpdate); }, 2000);
+
+ // Refresh from the portal without being asked, but only when the student
+ // has already put their login in the OS vault - see `autoSync`, which owns
+ // every precondition. Fire-and-forget and deliberately last: it crosses the
+ // network to a college server, and nothing on screen may wait on it.
+ setTimeout(() => { void autoSync(); }, 1200);
});
/**
@@ -648,7 +719,7 @@ export function App() {
- setDismissed(true)} />
+
diff --git a/app/src/ui/__tests__/HeaderPopovers.test.tsx b/app/src/ui/__tests__/HeaderPopovers.test.tsx
index 4de05a7..ad628bb 100644
--- a/app/src/ui/__tests__/HeaderPopovers.test.tsx
+++ b/app/src/ui/__tests__/HeaderPopovers.test.tsx
@@ -15,9 +15,10 @@
*/
import { cleanup, render } from "@solidjs/testing-library";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { App } from "../App";
+import { App, Bell } from "../App";
import { setView } from "../../state/nav";
import { edit } from "../../state/store";
+import type { Finding } from "../../state/launch";
afterEach(cleanup);
@@ -40,9 +41,18 @@ beforeEach(() => {
setView("home");
});
-/** A click that bubbles to `document`, which is where the bug was. */
-const click = (el: Element) =>
+/**
+ * A real press: pointerdown then click, both bubbling to `document`.
+ *
+ * Both are needed and in this order. The outside-dismissal listener is on
+ * pointerdown - a click listener was handed already-detached nodes when a row
+ * removed itself - and the buttons themselves are driven by click. A helper
+ * that fired only one of the two would test a sequence no pointer produces.
+ */
+const click = (el: Element) => {
+ el.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, cancelable: true }));
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
+};
describe.each([
["notifications", "button.bell", "Notifications"],
@@ -78,3 +88,76 @@ describe.each([
expect(container.querySelector(".pop")).not.toBeNull();
});
});
+
+/**
+ * Dismissal.
+ *
+ * `Bell` is rendered directly with fixed findings rather than through `App`.
+ * The launch check hands its findings over on a 450ms timer, so a test that
+ * mounted the whole app and looked immediately would find an empty popover and
+ * every assertion below would pass by describing nothing.
+ *
+ * A finding is re-derived from the record on every launch, so dismissing one
+ * cannot mean "never mention this again" - it means "I have read it". The
+ * per-row control is covered rather than only the bulk one, because clearing
+ * three unrelated problems together is the behaviour that would actually lose a
+ * student a warning they had not dealt with yet.
+ */
+describe("dismissing notifications", () => {
+ const FINDINGS: Finding[] = [
+ { kind: "attendance", severity: "warn", title: "1 subject below the floor",
+ detail: "MICROCONTROLLERS is under 75%.", goto: "ledger", action: "See which" },
+ { kind: "stale", severity: "info", title: "Last synced 6 days ago",
+ detail: "Attendance may have moved since.", goto: "data", action: "Sync now" },
+ { kind: "reconcile", severity: "warn", title: "S3 does not reconcile",
+ detail: "The published SGPA disagrees with the stored subjects.",
+ goto: "history", action: "Open History" },
+ ];
+
+ const openBell = () => {
+ const { container } = render(() => );
+ click(container.querySelector("button.bell")!);
+ return container;
+ };
+
+ it("shows every finding", () => {
+ expect(openBell().querySelectorAll(".pop-item").length).toBe(3);
+ });
+
+ it("removes the row that was dismissed and leaves the others", () => {
+ const container = openBell();
+ click(container.querySelectorAll(".pop-x")[1]!);
+
+ const titles = [...container.querySelectorAll(".pop-item strong")]
+ .map((n) => n.textContent);
+ expect(titles).toEqual(["1 subject below the floor", "S3 does not reconcile"]);
+ });
+
+ it("keeps the popover open after a dismissal", () => {
+ const container = openBell();
+ click(container.querySelector(".pop-x")!);
+ expect(container.querySelector(".pop")).not.toBeNull();
+ });
+
+ it("counts the badge down as rows go", () => {
+ const container = openBell();
+ expect(container.querySelector(".bell-badge")!.textContent).toBe("3");
+
+ click(container.querySelector(".pop-x")!);
+ expect(container.querySelector(".bell-badge")!.textContent).toBe("2");
+ });
+
+ it("clears everything at once, and closes", () => {
+ const container = openBell();
+ click(container.querySelector(".pop-head .link")!);
+
+ expect(container.querySelector(".pop")).toBeNull();
+ expect(container.querySelector(".bell-badge")).toBeNull();
+ });
+
+ it("offers no bulk control for a single finding", () => {
+ const { container } = render(() => );
+ click(container.querySelector("button.bell")!);
+ expect(container.querySelector(".pop-head .link")).toBeNull();
+ });
+});
From 13b2701bc403e6ee7bf1beacf1d624a1fbdf0834 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:14:35 +0530
Subject: [PATCH 07/32] feat: one refresh button for both portals
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/__tests__/autosync.test.ts | 163 +++++++++++++-------
app/src/state/autosync.ts | 186 +++++++++++++++--------
app/src/state/creds.ts | 10 ++
app/src/styles/app.css | 11 +-
app/src/styles/motion.css | 13 ++
app/src/ui/App.tsx | 85 ++++++++---
app/src/ui/Data.tsx | 3 +-
7 files changed, 330 insertions(+), 141 deletions(-)
diff --git a/app/src/state/__tests__/autosync.test.ts b/app/src/state/__tests__/autosync.test.ts
index f6a1d98..6da4138 100644
--- a/app/src/state/__tests__/autosync.test.ts
+++ b/app/src/state/__tests__/autosync.test.ts
@@ -1,16 +1,18 @@
/**
- * The launch sync.
+ * Refreshing both portals.
*
- * Its preconditions matter more than its happy path. This runs without anybody
- * asking for it, using a password out of the OS vault, against a college's
- * server - so the cases worth pinning are the ones where it must decline to
- * run, and the guarantee that it never throws into an `onMount` that has no
- * catch around it.
+ * The preconditions matter more than the happy path. This runs without anybody
+ * asking for it, using passwords out of the OS vault, against two colleges'
+ * servers - so the cases worth pinning are the ones where it must decline to
+ * run, the guarantee that one portal cannot sink the other, and the guarantee
+ * that nothing ever throws into an `onMount` that has no catch around it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const canSync = vi.fn(() => true);
+const canSyncKtu = vi.fn(() => true);
const fullSync = vi.fn();
+const syncKtu = vi.fn();
const canRemember = vi.fn(() => true);
const loadCreds = vi.fn();
const applySync = vi.fn();
@@ -20,24 +22,34 @@ vi.mock("../../sync/etlab", () => ({
fullSync: (...a: unknown[]) => fullSync(...a),
EtlabError: class extends Error {},
}));
+vi.mock("../../sync/ktu", () => ({ canSyncKtu: () => canSyncKtu() }));
vi.mock("../creds", () => ({
+ KTU_CRED_KEY: "https://app.ktu.edu.in",
canRemember: () => canRemember(),
loadCreds: (...a: unknown[]) => loadCreds(...a),
}));
-vi.mock("../actions", () => ({ applySync: (...a: unknown[]) => applySync(...a) }));
+vi.mock("../actions", () => ({
+ applySync: (...a: unknown[]) => applySync(...a),
+ syncKtu: (...a: unknown[]) => syncKtu(...a),
+}));
-const { AUTO_SYNC_GAP_MS, autoSync, autoSyncError, syncIsStale } =
- await import("../autosync");
+const {
+ AUTO_SYNC_GAP_MS, autoRefresh, refreshAll, refreshFailures, syncIsStale,
+} = await import("../autosync");
const { edit } = await import("../store");
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
+const of = (r: { results: Array<{ source: string; status: string }> }, source: string) =>
+ r.results.find((x) => x.source === source)!;
beforeEach(() => {
vi.clearAllMocks();
canSync.mockReturnValue(true);
+ canSyncKtu.mockReturnValue(true);
canRemember.mockReturnValue(true);
loadCreds.mockResolvedValue({ username: "24CS211", password: "secret" });
fullSync.mockResolvedValue({ semesters: {}, history: {} });
+ syncKtu.mockResolvedValue({ fetched: ["S4"], courses: 7, semesters: 1, mismatched: [] });
edit((s) => {
s.student.college = "https://portal.example.edu";
s.lastSync = ago(AUTO_SYNC_GAP_MS + 60_000);
@@ -45,50 +57,119 @@ beforeEach(() => {
});
describe("preconditions", () => {
- it("does nothing in a build that cannot sync at all", async () => {
+ it("does nothing in a build that cannot reach either portal", async () => {
canSync.mockReturnValue(false);
- expect(await autoSync()).toBe("unavailable");
- expect(loadCreds).not.toHaveBeenCalled();
- });
+ canSyncKtu.mockReturnValue(false);
+ const r = await refreshAll();
- it("does nothing where there is no credential vault", async () => {
- canRemember.mockReturnValue(false);
- expect(await autoSync()).toBe("unavailable");
+ expect(of(r, "etlab").status).toBe("unavailable");
+ expect(of(r, "ktu").status).toBe("unavailable");
expect(loadCreds).not.toHaveBeenCalled();
});
- it("does nothing before a college address is configured", async () => {
+ it("does not touch etlab before a college address is configured", async () => {
edit((s) => { s.student.college = ""; });
- expect(await autoSync()).toBe("unavailable");
- expect(loadCreds).not.toHaveBeenCalled();
+ const r = await refreshAll();
+
+ expect(of(r, "etlab").status).toBe("unavailable");
+ expect(fullSync).not.toHaveBeenCalled();
+ // KTU has its own fixed address and is unaffected by that gap.
+ expect(of(r, "ktu").status).toBe("ok");
});
- it("never prompts and never syncs without a saved login", async () => {
+ it("never prompts and never syncs a portal with no saved login", async () => {
loadCreds.mockResolvedValue(null);
- expect(await autoSync()).toBe("no-creds");
+ const r = await refreshAll();
+
+ expect(of(r, "etlab").status).toBe("no-creds");
+ expect(of(r, "ktu").status).toBe("no-creds");
expect(fullSync).not.toHaveBeenCalled();
+ expect(syncKtu).not.toHaveBeenCalled();
});
- it("treats a vault that will not answer as simply unavailable", async () => {
+ it("treats a vault that will not answer as simply absent", async () => {
loadCreds.mockRejectedValue(new Error("vault locked"));
- expect(await autoSync()).toBe("unavailable");
- expect(fullSync).not.toHaveBeenCalled();
- // Not the student's problem: they never asked for this sync.
- expect(autoSyncError()).toBeNull();
+ const r = await refreshAll();
+
+ // Not "failed": the student never asked for this, and the answer either way
+ // is that there is nothing to log in with.
+ expect(of(r, "etlab").status).toBe("no-creds");
+ expect(refreshFailures()).toEqual([]);
+ });
+
+ it("reads each portal's login under its own vault key", async () => {
+ await refreshAll();
+ const keys = loadCreds.mock.calls.map((c) => c[0]);
+ expect(keys).toContain("https://portal.example.edu");
+ expect(keys).toContain("https://app.ktu.edu.in");
+ });
+});
+
+describe("independence", () => {
+ it("still refreshes attendance when KTU is down", async () => {
+ syncKtu.mockRejectedValue(new Error("KTU login rejected."));
+ const r = await refreshAll();
+
+ expect(of(r, "etlab").status).toBe("ok");
+ expect(applySync).toHaveBeenCalledOnce();
+ expect(of(r, "ktu").status).toBe("failed");
+ });
+
+ it("still fetches results when etlab is down", async () => {
+ fullSync.mockRejectedValue(new Error("etlab is down"));
+ const r = await refreshAll();
+
+ expect(of(r, "ktu").status).toBe("ok");
+ expect(syncKtu).toHaveBeenCalledOnce();
+ expect(of(r, "etlab").status).toBe("failed");
+ });
+
+ it("reports one row per failed portal, never a merged failure", async () => {
+ fullSync.mockRejectedValue(new Error("etlab is down"));
+ syncKtu.mockRejectedValue(new Error("KTU login rejected."));
+ await refreshAll();
+
+ expect(refreshFailures().map((f) => f.source).sort()).toEqual(["etlab", "ktu"]);
+ expect(refreshFailures().map((f) => f.detail))
+ .toEqual(expect.arrayContaining(["etlab is down", "KTU login rejected."]));
+ });
+
+ it("never throws, because onMount has nothing to catch it", async () => {
+ fullSync.mockRejectedValue(new Error("etlab is down"));
+ syncKtu.mockRejectedValue(new Error("KTU login rejected."));
+ await expect(refreshAll()).resolves.toBeDefined();
+ });
+
+ it("clears an earlier failure once a later run works", async () => {
+ fullSync.mockRejectedValue(new Error("etlab is down"));
+ await refreshAll();
+ expect(refreshFailures()).toHaveLength(1);
+
+ fullSync.mockResolvedValue({ semesters: {}, history: {} });
+ await refreshAll();
+ expect(refreshFailures()).toEqual([]);
});
});
describe("throttling", () => {
- it("skips a sync that just happened", async () => {
+ it("skips an automatic refresh that just happened", async () => {
edit((s) => { s.lastSync = ago(60_000); });
- expect(await autoSync()).toBe("fresh");
+ expect(await autoRefresh()).toBeNull();
expect(fullSync).not.toHaveBeenCalled();
+ expect(syncKtu).not.toHaveBeenCalled();
});
- it("runs once the gap has passed", async () => {
- expect(await autoSync()).toBe("ok");
+ it("runs automatically once the gap has passed", async () => {
+ expect(await autoRefresh()).not.toBeNull();
+ expect(fullSync).toHaveBeenCalledOnce();
+ });
+
+ it("does not throttle a manual refresh - the student just pressed it", async () => {
+ edit((s) => { s.lastSync = ago(60_000); });
+ const r = await refreshAll();
+
+ expect(of(r, "etlab").status).toBe("ok");
expect(fullSync).toHaveBeenCalledOnce();
- expect(applySync).toHaveBeenCalledOnce();
});
it("treats a record that was never synced as stale", () => {
@@ -101,23 +182,3 @@ describe("throttling", () => {
expect(syncIsStale()).toBe(true);
});
});
-
-describe("failure", () => {
- it("reports rather than throws, because nothing catches it", async () => {
- fullSync.mockRejectedValue(new Error("etlab is down"));
- await expect(autoSync()).resolves.toBe("failed");
- expect(autoSyncError()).toBe("etlab is down");
- expect(applySync).not.toHaveBeenCalled();
- });
-
- it("clears the previous failure when a later sync works", async () => {
- fullSync.mockRejectedValue(new Error("etlab is down"));
- await autoSync();
- expect(autoSyncError()).not.toBeNull();
-
- fullSync.mockResolvedValue({ semesters: {}, history: {} });
- edit((s) => { s.lastSync = ago(AUTO_SYNC_GAP_MS + 60_000); });
- await autoSync();
- expect(autoSyncError()).toBeNull();
- });
-});
diff --git a/app/src/state/autosync.ts b/app/src/state/autosync.ts
index 7db6522..5047756 100644
--- a/app/src/state/autosync.ts
+++ b/app/src/state/autosync.ts
@@ -1,56 +1,74 @@
import { createSignal } from "solid-js";
import { canSync, fullSync } from "../sync/etlab";
-import { canRemember, loadCreds } from "./creds";
-import { applySync } from "./actions";
+import { canSyncKtu } from "../sync/ktu";
+import { KTU_CRED_KEY, canRemember, loadCreds } from "./creds";
+import { applySync, syncKtu } from "./actions";
import { state } from "./store";
/**
- * Sync on launch, when the student has already said we may.
+ * Refreshing the record, from both portals, without being asked twice.
*
- * Until now 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
+ * Until this existed nothing in the 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. The number
* looked authoritative and was quietly days old, which is worse than no number.
*
- * Three deliberate limits on it:
+ * 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; the caller decides what to say.
*
- * 1. It runs ONLY if the password is already in the OS credential vault. This
- * feature never prompts, never stores anything new, and cannot be the reason
- * a password gets saved - that is still a box the student ticks on the Data
- * screen. No vault entry, no automatic sync, no nagging.
+ * Automatic runs carry three limits that a manual press does not:
*
- * 2. It runs at launch and never on a timer. etlab is a college server that
- * updates attendance once or twice a day when a teacher gets round to it;
- * polling it every fifteen minutes would put load on someone else's
- * infrastructure for data that did not change. Opening the app is also the
- * exact moment the student is about to read the number.
+ * 1. They run ONLY if that portal's password is already in the OS credential
+ * vault. Nothing here ever prompts, ever stores anything new, or can be the
+ * reason a password gets saved - that is still a box the student ticks.
*
- * 3. It is throttled. Closing and reopening the app four times in a row is one
- * sync, not four.
+ * 2. They run at launch, never on a timer. etlab is a college server that
+ * updates attendance once or twice a day when a teacher gets round to it,
+ * and polling it for data that did not change is load on somebody else's
+ * infrastructure. Opening the app is also the moment the student is about to
+ * read the number.
*
- * It is fire-and-forget: nothing waits on it, nothing blocks on it, and a
- * failure is reported quietly rather than thrown at someone who did not ask for
- * a sync. The app's whole value is that it works with no network at all.
+ * 3. They are throttled, so reopening the app four times is one refresh.
+ *
+ * A manual refresh honours none of those: the student pressed the button, they
+ * get the request, however recently the last one ran.
*/
-/** Do not sync again if the last one was this recent. */
-export const AUTO_SYNC_GAP_MS = 3 * 60 * 60 * 1000;
+/** Do not refresh automatically if the last one was this recent. */
+export const AUTO_SYNC_GAP_MS = 45 * 60 * 1000;
-export type AutoSyncOutcome =
+export type SourceStatus =
| "ok"
- /** No saved login, so there is nothing to sync with. Not a failure. */
+ /** No saved login for this portal, so there is nothing to sync with. */
| "no-creds"
- /** Synced recently enough that doing it again would buy nothing. */
- | "fresh"
/** Browser build, or no college address configured yet. */
| "unavailable"
| "failed";
-const [autoSyncing, setAutoSyncing] = createSignal(false);
-/** The last automatic sync's failure, or null. Surfaced in the bell. */
-const [autoSyncError, setAutoSyncError] = createSignal(null);
-export { autoSyncing, autoSyncError };
+export interface SourceResult {
+ source: "etlab" | "ktu";
+ status: SourceStatus;
+ /** Why it failed, verbatim from the portal layer. Only set on "failed". */
+ detail?: string;
+}
+
+export interface Refresh {
+ at: string;
+ results: SourceResult[];
+}
+
+const [refreshing, setRefreshing] = createSignal(false);
+/** The last refresh's outcome, or null if none has run this session. */
+const [lastRefresh, setLastRefresh] = createSignal(null);
+export { lastRefresh, refreshing };
+
+/** The sources that failed in the last run. Surfaced in the bell. */
+export function refreshFailures(): SourceResult[] {
+ return (lastRefresh()?.results ?? []).filter((r) => r.status === "failed");
+}
/** Milliseconds since the last successful sync, or null if there never was one. */
export function sinceLastSync(now = Date.now()): number | null {
@@ -61,7 +79,7 @@ export function sinceLastSync(now = Date.now()): number | null {
}
/**
- * True when a sync would be worth doing.
+ * True when an automatic refresh would be worth doing.
*
* A record that has NEVER been synced is stale by definition - that is a
* student who set the app up by hand, or whose first sync failed, and they are
@@ -73,47 +91,93 @@ export function syncIsStale(now = Date.now()): boolean {
}
/**
- * Run the launch sync if every precondition holds.
+ * Read a saved login, treating a vault that will not answer as simply absent.
*
- * Never throws. Every path returns an outcome, because the caller is a
- * `void`-ed call in `onMount` and an escaping rejection there is an unhandled
- * promise rejection in a WebView, where nobody will ever see it.
+ * A locked or missing vault is not a sync failure and must not be reported as
+ * one: telling a student their sync failed when they never asked for a sync is
+ * noise, and the answer either way is "there is nothing to log in with".
*/
-export async function autoSync(): Promise {
- if (!canSync() || !canRemember()) return "unavailable";
-
- const base = String(state.student.college || "").trim();
- if (!base) return "unavailable";
-
- if (!syncIsStale()) return "fresh";
-
- let stored;
+async function vault(key: string) {
+ if (!canRemember()) return null;
try {
- stored = await loadCreds(base);
+ return await loadCreds(key);
} catch {
- // A vault that will not answer is not a sync failure, and telling the
- // student their sync failed when they never asked for one is noise.
- return "unavailable";
+ return null;
}
- if (!stored) return "no-creds";
+}
+
+async function refreshEtlab(): Promise {
+ if (!canSync()) return { source: "etlab", status: "unavailable" };
+
+ const base = String(state.student.college || "").trim();
+ if (!base) return { source: "etlab", status: "unavailable" };
+
+ const creds = await vault(base);
+ if (!creds) return { source: "etlab", status: "no-creds" };
- setAutoSyncing(true);
- setAutoSyncError(null);
try {
// The password is read from the vault into a local, handed to one request
// and dropped when this function returns. It is never put in a signal,
// never in app state and never logged - the same posture the manual panel
// holds, and the reason this lives here rather than in a component.
- const result = await fullSync(base, stored.username, stored.password);
- applySync(result);
- return "ok";
+ applySync(await fullSync(base, creds.username, creds.password));
+ return { source: "etlab", status: "ok" };
+ } catch (exc) {
+ return {
+ source: "etlab", status: "failed",
+ detail: exc instanceof Error ? exc.message : String(exc),
+ };
+ }
+}
+
+async function refreshKtu(): Promise {
+ if (!canSyncKtu()) return { source: "ktu", status: "unavailable" };
+
+ const creds = await vault(KTU_CRED_KEY);
+ if (!creds) return { source: "ktu", status: "no-creds" };
+
+ try {
+ await syncKtu(creds.username, creds.password);
+ return { source: "ktu", status: "ok" };
} catch (exc) {
- // Deliberately not rethrown and deliberately not a dialog. The student
- // opened the app to read a number, not to be told about a background task
- // they did not start. It becomes a line in the bell.
- setAutoSyncError(exc instanceof Error ? exc.message : String(exc));
- return "failed";
+ return {
+ source: "ktu", status: "failed",
+ detail: exc instanceof Error ? exc.message : String(exc),
+ };
+ }
+}
+
+/**
+ * Refresh both portals.
+ *
+ * Never throws. Every path resolves to a report, because one caller is a
+ * `void`-ed call in `onMount` where an escaping rejection is an unhandled
+ * promise rejection in a WebView that nobody will ever see.
+ *
+ * `Promise.all` rather than sequential: they are different servers, neither
+ * waits on the other, and both settle because each half catches its own
+ * failure. Nothing here can reject, so there is no `allSettled` to reach for.
+ */
+export async function refreshAll(): Promise {
+ setRefreshing(true);
+ try {
+ const results = await Promise.all([refreshEtlab(), refreshKtu()]);
+ const report: Refresh = { at: new Date().toISOString(), results };
+ setLastRefresh(report);
+ return report;
} finally {
- setAutoSyncing(false);
+ setRefreshing(false);
}
}
+
+/**
+ * The launch refresh: the same work, declined when it would buy nothing.
+ *
+ * Deliberately silent about being skipped. A student who opened the app four
+ * minutes ago does not need to be told that a background task they never
+ * started chose not to run.
+ */
+export async function autoRefresh(): Promise {
+ if (!syncIsStale()) return null;
+ return await refreshAll();
+}
diff --git a/app/src/state/creds.ts b/app/src/state/creds.ts
index d8c7360..6292dd7 100644
--- a/app/src/state/creds.ts
+++ b/app/src/state/creds.ts
@@ -21,6 +21,16 @@ export interface StoredCreds {
export const canRemember = (): boolean => canSync();
+/**
+ * The vault key the KTU results portal's login is kept under.
+ *
+ * A constant here rather than a literal in the one screen that used it, because
+ * two callers now read it - the Data screen's form and the background refresh -
+ * and a second copy of this string that drifted by one character would fail as
+ * "no saved login", silently, forever.
+ */
+export const KTU_CRED_KEY = "https://app.ktu.edu.in";
+
/** Save a portal login to the OS vault. Rejects if the store is unavailable. */
export const saveCreds = (base: string, username: string, password: string): Promise =>
invoke("cred_save", { base, username, password });
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index 093fa63..b55767d 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -683,7 +683,10 @@ tr.row:hover .del { opacity: 1; }
* this is the box you type a question into, and a question box the size of a
* button reads as a button. */
-.bell {
+/* Every square glyph button in the header - refresh, notifications - is one
+ drawing. They sit side by side and any difference between them would read as
+ meaning something. */
+.iconbtn {
appearance: none;
background: transparent;
border: 0;
@@ -697,7 +700,11 @@ tr.row:hover .del { opacity: 1; }
position: relative;
flex: none;
}
-.bell:hover { background: var(--surface-2); color: var(--text); }
+.iconbtn:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
+.iconbtn:disabled { cursor: default; }
+/* Not dimmed while it works. A control that fades the moment it is pressed
+ reads as having failed, and this one is doing exactly what was asked. */
+.refresh:disabled { color: var(--text-dim); }
/* A count, not a dot. "3 things" and "something" are different messages, and
the student is deciding whether to stop what they are doing. */
diff --git a/app/src/styles/motion.css b/app/src/styles/motion.css
index 2274e01..76e070d 100644
--- a/app/src/styles/motion.css
+++ b/app/src/styles/motion.css
@@ -328,3 +328,16 @@ button:active:not(:disabled) { transform: translateY(1px); }
animation: crawl 1.1s var(--ease) infinite;
transform-origin: center;
}
+
+/* The refresh glyph, turning while two networks are being waited on. There is
+ no percentage to report here, and a bar that fakes one would be lying about
+ knowing how far along it is. */
+@keyframes spin { to { transform: rotate(360deg); } }
+.spin { animation: spin 900ms linear infinite; transform-origin: 50% 50%; }
+
+/* Reduced motion gets a pulse rather than a freeze: something must still say
+ "working", and a stationary icon on a disabled button says "broken". */
+@media (prefers-reduced-motion: reduce) {
+ .spin { animation: pulse-dim 1.4s var(--ease) infinite; }
+ @keyframes pulse-dim { 50% { opacity: 0.35; } }
+}
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index a10a80c..7cb1d42 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -17,7 +17,8 @@ import { Setup } from "./Setup";
import { Mark } from "./Mark";
import { Palette, usePaletteShortcut } from "./Palette";
import { runLaunchCheck, saveFindings } from "../state/launch";
-import { autoSync, autoSyncError, autoSyncing } from "../state/autosync";
+import { autoRefresh, refreshAll, refreshFailures, refreshing } from "../state/autosync";
+import type { SourceResult } from "../state/autosync";
import type { Finding } from "../state/launch";
import { checkForUpdate } from "../sync/update";
import type { Available } from "../sync/update";
@@ -292,6 +293,42 @@ export function SaveNotice() {
* and Home keeps its "Needs attention" card, because that is a place a student
* goes to look rather than a thing that interrupts them.
*/
+/**
+ * Refresh both portals, by hand.
+ *
+ * The manual sync used to be two separate forms on the Data screen - one per
+ * portal - and a student who wanted current numbers had to know that there were
+ * two, find both, and run them in turn. Attendance and published results are
+ * one question to the person asking it.
+ *
+ * This honours none of the automatic run's throttling. The student pressed the
+ * button; declining to make the request because one ran twenty minutes ago
+ * would be the app arguing with them about what they just asked for.
+ *
+ * It needs both logins in the vault to do anything, and says so rather than
+ * spinning and reporting success over a portal it never contacted - see the
+ * per-source rows in the bell, which is where the outcome lands.
+ */
+function RefreshButton() {
+ const label = () => (refreshing() ? "Refreshing both portals…" : "Refresh from etlab and KTU");
+
+ return (
+ { void refreshAll(); }}
+ disabled={refreshing()} title={label()} aria-label={label()}>
+ {/* Spun by CSS while a refresh is in flight, which is the one honest
+ thing an indeterminate control can do: this crosses two networks and
+ there is no percentage to report. */}
+
+
+ );
+}
+
export function Bell(props: { findings: Finding[] }) {
const [open, setOpen] = createSignal(false);
/**
@@ -336,25 +373,25 @@ export function Bell(props: { findings: Finding[] }) {
const live = () => props.findings.filter((f) => !hidden().includes(f.title));
/**
- * A failed background sync is a notification, not an error dialog.
+ * A failed refresh is a notification, not an error dialog.
*
- * It is given the same shape as a launch finding so the list has one kind of
- * row rather than two, and it routes to Data, which is the screen that can
- * actually do something about it.
+ * One row per portal that failed, never a merged "sync failed": etlab and KTU
+ * are different servers with different logins, and a student whose KTU fetch
+ * broke while attendance came through fine needs to know that attendance is
+ * current. Shaped as a launch finding so the list has one kind of row rather
+ * than two, and routed to Data, which is the screen that can act on it.
*/
- const syncFailure = (): Finding | null => {
- const why = autoSyncError();
- if (why === null || hidden().includes(SYNC_FAIL)) return null;
- return {
- kind: "sync", title: SYNC_FAIL, detail: why, severity: "warn",
- goto: "data", action: "Open Data",
- };
- };
-
- const items = (): Finding[] => {
- const f = syncFailure();
- return f ? [f, ...live()] : live();
- };
+ const syncFailures = (): Finding[] =>
+ refreshFailures()
+ .map((r: SourceResult) => ({
+ kind: "sync" as const, severity: "warn" as const,
+ title: `${r.source === "ktu" ? "KTU" : "etlab"} did not refresh`,
+ detail: r.detail ?? "The portal did not answer.",
+ goto: "data" as const, action: "Open Data",
+ }))
+ .filter((f) => !hidden().includes(f.title));
+
+ const items = (): Finding[] => [...syncFailures(), ...live()];
const dismiss = (title: string) => setHidden((h) => [...h, title]);
const dismissAll = () => {
@@ -364,7 +401,7 @@ export function Bell(props: { findings: Finding[] }) {
return (
-
- Checking the portal…
+
+ Checking both portals…
}>
@@ -419,9 +456,6 @@ export function Bell(props: { findings: Finding[] }) {
);
}
-/** The one synthetic finding's title, used as its dismissal key. */
-const SYNC_FAIL = "That automatic sync did not land";
-
/**
* Account.
*
@@ -593,7 +627,7 @@ export function App() {
// has already put their login in the OS vault - see `autoSync`, which owns
// every precondition. Fire-and-forget and deliberately last: it crosses the
// network to a college server, and nothing on screen may wait on it.
- setTimeout(() => { void autoSync(); }, 1200);
+ setTimeout(() => { void autoRefresh(); }, 1200);
});
/**
@@ -719,6 +753,7 @@ export function App() {
+
diff --git a/app/src/ui/Data.tsx b/app/src/ui/Data.tsx
index 311f03c..6d94e3e 100644
--- a/app/src/ui/Data.tsx
+++ b/app/src/ui/Data.tsx
@@ -9,11 +9,10 @@ import { logDir } from "../state/diagnostics";
import { canSync, describeAcademics, parseAcademics } from "../sync/etlab";
import { parseGradeCard, pdfToText } from "../sync/gradecard";
import { KtuError, canSyncKtu } from "../sync/ktu";
-import { canRemember, deleteCreds, loadCreds, saveCreds } from "../state/creds";
+import { KTU_CRED_KEY, canRemember, deleteCreds, loadCreds, saveCreds } from "../state/creds";
import { SyncPanel } from "./SyncPanel";
/** The credential-vault key for the KTU result portal, distinct from any etlab base. */
-const KTU_CRED_KEY = "https://app.ktu.edu.in";
/**
* Data: everything that moves information in or out.
From 958a44fd676db7709cfb5ed85e3599fff6fec32b Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:24:10 +0530
Subject: [PATCH 08/32] feat(auth): sign in with Clerk, the way a desktop app
is allowed to
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/.env.example | 16 +
app/.gitignore | 1 +
app/src-tauri/Cargo.lock | 72 +++-
app/src-tauri/Cargo.toml | 8 +-
app/src-tauri/src/lib.rs | 7 +
app/src-tauri/src/oauth.rs | 565 +++++++++++++++++++++++++++
app/src/state/__tests__/auth.test.ts | 111 ++++++
app/src/state/auth.ts | 140 +++++++
app/src/ui/App.tsx | 66 +++-
9 files changed, 972 insertions(+), 14 deletions(-)
create mode 100644 app/.env.example
create mode 100644 app/.gitignore
create mode 100644 app/src-tauri/src/oauth.rs
create mode 100644 app/src/state/__tests__/auth.test.ts
create mode 100644 app/src/state/auth.ts
diff --git a/app/.env.example b/app/.env.example
new file mode 100644
index 0000000..7a28117
--- /dev/null
+++ b/app/.env.example
@@ -0,0 +1,16 @@
+# Sign-in (optional).
+#
+# Both values are PUBLIC. The issuer appears in every token the app receives,
+# and a PKCE client id is not a secret - that is the whole point of PKCE. There
+# is no client secret here and there must never be one: this app is files on a
+# student's disk, and anything shipped inside it is extractable.
+#
+# Leave both blank and the build simply has no accounts. Nothing else changes:
+# every figure on every screen is computed on the machine from data the machine
+# fetched, and sign-in gates the assistant and nothing else.
+#
+# 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)
+VITE_CLERK_ISSUER=
+VITE_CLERK_CLIENT_ID=
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..4c49bd7
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+.env
diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock
index 3856730..c2a9343 100644
--- a/app/src-tauri/Cargo.lock
+++ b/app/src-tauri/Cargo.lock
@@ -349,7 +349,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
- "rand_core",
+ "rand_core 0.10.1",
]
[[package]]
@@ -1192,7 +1192,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi 6.0.0",
- "rand_core",
+ "rand_core 0.10.1",
"wasm-bindgen",
]
@@ -2557,6 +2557,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2679,7 +2688,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
- "rand",
+ "rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
@@ -2727,6 +2736,17 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core 0.6.4",
+]
+
[[package]]
name = "rand"
version = "0.10.2"
@@ -2735,7 +2755,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
- "rand_core",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
]
[[package]]
@@ -2750,7 +2789,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
- "rand_core",
+ "rand_core 0.10.1",
]
[[package]]
@@ -3640,11 +3679,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
name = "targetx"
version = "0.2.0"
dependencies = [
+ "base64 0.22.1",
"keyring",
"log",
+ "rand 0.8.8",
"reqwest 0.12.28",
"serde",
"serde_json",
+ "sha2",
"tauri",
"tauri-build",
"tauri-plugin-fs",
@@ -5307,6 +5349,26 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "zerofrom"
version = "0.1.8"
diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml
index 365e920..2bae4f9 100644
--- a/app/src-tauri/Cargo.toml
+++ b/app/src-tauri/Cargo.toml
@@ -25,8 +25,14 @@ tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
# rustls rather than the system TLS stack: one less thing that differs
# between a developer machine and a student's laptop.
-reqwest = { version = "0.12", default-features = false, features = ["cookies", "rustls-tls", "charset"] }
+reqwest = { version = "0.12", default-features = false, features = ["cookies", "rustls-tls", "charset", "json"] }
tokio = { version = "1", features = ["rt", "macros"] }
+# Sign-in (RFC 8252). PKCE needs a CSPRNG and SHA-256, and the challenge is
+# base64url. No OAuth crate: the flow is one authorize URL and one form post,
+# and a library for that would be more surface than the code it replaces.
+sha2 = "0.10"
+base64 = "0.22"
+rand = "0.8"
tauri-plugin-updater = "2.10.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-fs = "2.5.1"
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index 7161ad5..a5f3a77 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -1,6 +1,7 @@
mod creds;
mod etlab;
mod ktu;
+mod oauth;
use tauri::Manager;
use tauri_plugin_log::{RotationStrategy, Target, TargetKind};
@@ -87,6 +88,7 @@ pub fn run() {
.plugin(log_plugin())
.manage(etlab::Etlab::default())
.manage(ktu::Ktu::default())
+ .manage(oauth::Oauth::default())
.invoke_handler(tauri::generate_handler![
etlab::etlab_start,
etlab::etlab_reset,
@@ -102,6 +104,11 @@ pub fn run() {
creds::cred_load,
creds::cred_delete,
creds::cred_has,
+ oauth::oauth_begin,
+ oauth::oauth_finish,
+ oauth::oauth_resume,
+ oauth::oauth_sign_out,
+ oauth::oauth_has_account,
log_error,
diagnostics_dir,
])
diff --git a/app/src-tauri/src/oauth.rs b/app/src-tauri/src/oauth.rs
new file mode 100644
index 0000000..4785cac
--- /dev/null
+++ b/app/src-tauri/src/oauth.rs
@@ -0,0 +1,565 @@
+//! Sign-in, the way a desktop application is allowed to do it.
+//!
+//! This is RFC 8252, "OAuth 2.0 for Native Apps", and every part of it is the
+//! way it is because a desktop binary cannot keep a secret:
+//!
+//! * **PKCE, not a client secret.** The app is files on the student's disk;
+//! anything static shipped inside it is extractable. PKCE replaces the
+//! secret with a per-attempt random verifier, so there is nothing to steal
+//! that is worth stealing twice.
+//!
+//! * **The system browser, not 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 has no way
+//! to tell that it does not.
+//!
+//! * **A loopback redirect on an ephemeral port.** `127.0.0.1:0` lets the OS
+//! pick a free port, so two copies of the app cannot collide and a stale
+//! listener cannot be squatted. The listener accepts exactly one request
+//! and dies.
+//!
+//! * **The refresh token in the OS vault, never on disk in the clear.** Same
+//! store `creds.rs` uses, for the same reason.
+//!
+//! The access token is returned to the frontend and held in memory only. It is
+//! short-lived by design, it is the thing sent to our own Worker, and writing
+//! it anywhere would be storing a bearer credential to save a refresh call.
+//!
+//! Endpoints are read from the provider's OpenID discovery document rather than
+//! hardcoded. Clerk publishes one, and a URL shape we guessed today would be a
+//! silent breakage the first time it changed.
+
+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+use base64::Engine as _;
+use rand::RngCore;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::collections::HashMap;
+use std::io::{BufRead, BufReader, Write};
+use std::net::TcpListener;
+use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
+use std::sync::Mutex;
+use std::time::Duration;
+
+/// How long the browser half of the flow may take before we give the port back.
+///
+/// Generous: this covers a student finding the browser window, typing a
+/// password, and doing whatever their second factor asks. It exists so an
+/// abandoned sign-in eventually frees the listener, not to hurry anybody.
+const BROWSER_TIMEOUT: Duration = Duration::from_secs(300);
+
+/// The vault slot the refresh token lives in. Distinct from the portal logins.
+const ACCOUNT_KEY: &str = "targetx-account";
+
+// --- discovery --------------------------------------------------------------
+
+#[derive(Deserialize)]
+struct Discovery {
+ authorization_endpoint: String,
+ token_endpoint: String,
+}
+
+async fn discover(issuer: &str) -> Result {
+ let url = format!("{}/.well-known/openid-configuration", issuer.trim_end_matches('/'));
+ let res = reqwest::get(&url).await.map_err(|e| e.to_string())?;
+ if !res.status().is_success() {
+ return Err(format!("The sign-in provider did not answer ({}).", res.status()));
+ }
+ res.json::().await.map_err(|e| e.to_string())
+}
+
+// --- PKCE -------------------------------------------------------------------
+
+fn random_urlsafe(bytes: usize) -> String {
+ let mut buf = vec![0u8; bytes];
+ rand::thread_rng().fill_bytes(&mut buf);
+ URL_SAFE_NO_PAD.encode(buf)
+}
+
+/// S256, not `plain`. `plain` sends the verifier itself in the authorization
+/// request, which puts it in browser history and in any proxy log on the way.
+fn challenge_for(verifier: &str) -> String {
+ URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
+}
+
+// --- the one-shot loopback listener ----------------------------------------
+
+/// What the browser came back with.
+struct Callback {
+ code: Option,
+ state: Option,
+ error: Option,
+}
+
+fn parse_query(target: &str) -> HashMap {
+ let mut out = HashMap::new();
+ let Some(q) = target.split_once('?').map(|(_, q)| q) else {
+ return out;
+ };
+ for pair in q.split('&') {
+ if let Some((k, v)) = pair.split_once('=') {
+ out.insert(k.to_string(), percent_decode(v));
+ }
+ }
+ out
+}
+
+/// Enough of percent-decoding for an OAuth callback: `%XX` and `+`.
+fn percent_decode(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ match bytes[i] {
+ b'%' if i + 2 < bytes.len() => {
+ let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
+ match u8::from_str_radix(hex, 16) {
+ Ok(b) => {
+ out.push(b);
+ i += 3;
+ }
+ Err(_) => {
+ out.push(bytes[i]);
+ i += 1;
+ }
+ }
+ }
+ b'+' => {
+ out.push(b' ');
+ i += 1;
+ }
+ b => {
+ out.push(b);
+ i += 1;
+ }
+ }
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+/// The page the student is left looking at. Deliberately plain, self-contained
+/// and offline: it renders in a browser tab we do not control, on a machine
+/// that may have just come back from an auth flow with no network left.
+const DONE_PAGE: &str = "\
+Signed in\
+\
+
Signed in
You can close this tab and go back to TargetX.
";
+
+/// Bind an ephemeral loopback port and accept exactly one request on it.
+///
+/// Returns the port immediately - the caller needs it to build the redirect URI
+/// before the browser is opened - and a receiver that yields the callback when
+/// the browser arrives. The thread ends after one request either way, so an
+/// abandoned sign-in leaks a thread for at most `BROWSER_TIMEOUT`.
+fn listen_once() -> Result<(u16, Receiver), String> {
+ let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
+ let port = listener.local_addr().map_err(|e| e.to_string())?.port();
+ let (tx, rx) = mpsc::channel();
+
+ std::thread::spawn(move || {
+ // `incoming` blocks; one iteration is all this listener is for. A
+ // browser preflighting the port (some do) would otherwise consume the
+ // single accept, so anything without a `code` or `error` is ignored and
+ // the loop waits for the real callback.
+ for stream in listener.incoming() {
+ let Ok(mut stream) = stream else { continue };
+
+ let mut line = String::new();
+ if BufReader::new(&stream).read_line(&mut line).is_err() {
+ continue;
+ }
+ // "GET /callback?code=... HTTP/1.1"
+ let target = line.split_whitespace().nth(1).unwrap_or("").to_string();
+ let params = parse_query(&target);
+
+ let code = params.get("code").cloned();
+ let error = params.get("error").cloned();
+ if code.is_none() && error.is_none() {
+ let _ = stream.write_all(b"HTTP/1.1 204 No Content\r\n\r\n");
+ continue;
+ }
+
+ let body = DONE_PAGE.as_bytes();
+ let _ = stream.write_all(
+ format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\
+ Content-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ )
+ .as_bytes(),
+ );
+ let _ = stream.write_all(body);
+ let _ = stream.flush();
+
+ let _ = tx.send(Callback {
+ code,
+ state: params.get("state").cloned(),
+ error,
+ });
+ break;
+ }
+ });
+
+ Ok((port, rx))
+}
+
+// --- pending flow state -----------------------------------------------------
+
+struct Pending {
+ verifier: String,
+ state: String,
+ redirect_uri: String,
+ token_endpoint: String,
+ client_id: String,
+ rx: Receiver,
+}
+
+#[derive(Default)]
+pub struct Oauth {
+ pending: Mutex
-
-
- Not signed in
-
- Sign-in is not built yet. Everything you see is
- computed on this machine.
-
-
+ {/* Sign-in gates the assistant and nothing else, and the copy says
+ so rather than leaving a student to guess what an account is for
+ in an app that works entirely offline. */}
+
+
+ Accounts are off in this build
+
+ Everything here is computed on this machine.
+
+
+ }>
+
+
+ Not signed in
+
+
+
+ Waiting for your browser…
+
+
+
+ { void signIn(); }}>
+ {authBusy() ? "Signing in…" : "Sign in"}
+
+
+ }>
+
+
+ Signed in
+
+ You can ask questions in the search box.
+
+ { void signOut(); }}>Sign out
+
+
+
+
+ {(why) => (
+
+ {why()}
+
+ )}
+
+
Data { setOpen(false); setView("data"); }}>
@@ -628,6 +672,12 @@ export function App() {
// every precondition. Fire-and-forget and deliberately last: it crosses the
// network to a college server, and nothing on screen may wait on it.
setTimeout(() => { void autoRefresh(); }, 1200);
+
+ // Restore a signed-in account from the refresh token in the OS vault.
+ // Silent either way: not being signed in is a normal state, and this
+ // gates nothing but the assistant - every figure on every screen is
+ // computed here from data this machine already has.
+ void resumeAccount();
});
/**
From 51b9260915a9d95c6f636df463f222102b677350 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 17:37:49 +0530
Subject: [PATCH 09/32] refactor(ui): extract Popover, and portal the header
panels out of the 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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/styles/app.css | 9 +-
app/src/ui/App.tsx | 68 ++++----------
app/src/ui/Popover.tsx | 98 ++++++++++++++++++++
app/src/ui/__tests__/HeaderPopovers.test.tsx | 37 +++++---
4 files changed, 142 insertions(+), 70 deletions(-)
create mode 100644 app/src/ui/Popover.tsx
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index b55767d..951dca4 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -768,10 +768,12 @@ tr.row:hover .del { opacity: 1; }
/* --- popovers -------------------------------------------------------------- */
+/* Positioned by the Popover component, in viewport coordinates, because it is
+ rendered into document.body rather than beside its trigger - see the note in
+ Popover.tsx for the WebView2 clipping bug that forces this. Fixed rather
+ than absolute for the same reason: there is no positioned ancestor left. */
.pop {
- position: absolute;
- inset-block-start: calc(100% + var(--s2));
- inset-inline-end: 0;
+ position: fixed;
z-index: 30;
inline-size: 22rem;
max-block-size: 60vh;
@@ -781,7 +783,6 @@ tr.row:hover .del { opacity: 1; }
border: 1px solid var(--hairline-strong);
border-radius: var(--radius-card);
}
-.pop-wrap { position: relative; display: flex; align-items: center; flex: none; }
.pop-title {
color: var(--text-faint);
font-size: var(--text-xs);
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index 0658e03..9020cde 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -16,6 +16,7 @@ import { Ledger } from "./Ledger";
import { Setup } from "./Setup";
import { Mark } from "./Mark";
import { Palette, usePaletteShortcut } from "./Palette";
+import { Popover } from "./Popover";
import { runLaunchCheck, saveFindings } from "../state/launch";
import { autoRefresh, refreshAll, refreshFailures, refreshing } from "../state/autosync";
import type { SourceResult } from "../state/autosync";
@@ -343,35 +344,7 @@ export function Bell(props: { findings: Finding[] }) {
* being ignored. Dismissing means "I have read this", not "this is fixed".
*/
const [hidden, setHidden] = createSignal([]);
- let wrap: HTMLDivElement | undefined;
-
- /*
- * Close when the click lands outside, decided by geometry rather than by
- * propagation.
- *
- * The propagation version did not work and could not: Solid delegates every
- * `onClick` to `document`, and this listener is on `document` too.
- * `stopPropagation` does not stop other listeners already bound to the SAME
- * node - only `stopImmediatePropagation` does, and only for handlers
- * registered after it. So the button's own toggle opened the popover and
- * this handler closed it again in the same click, and the bell looked dead.
- *
- * Asking whether the click was inside the wrapper is independent of both
- * listener order and the framework's delegation strategy.
- *
- * It listens for pointerdown rather than click, and that is not incidental.
- * A click on a row's dismiss button removes that row, so by the time a click
- * listener on `document` ran, the node it was handed had already been
- * detached from the tree and `contains` answered false for something that
- * was plainly inside the popover - which closed it on every dismissal.
- * pointerdown fires before any handler has had the chance to mutate anything.
- */
- const onDocDown = (e: Event) => {
- if (wrap && e.target instanceof Node && wrap.contains(e.target)) return;
- setOpen(false);
- };
- onMount(() => document.addEventListener("pointerdown", onDocDown));
- onCleanup(() => document.removeEventListener("pointerdown", onDocDown));
+ let anchor: HTMLButtonElement | undefined;
const live = () => props.findings.filter((f) => !hidden().includes(f.title));
@@ -403,8 +376,8 @@ export function Bell(props: { findings: Finding[] }) {
};
return (
-
Appearance
@@ -567,8 +533,8 @@ function Profile() {
-
-
+
+ >
);
}
diff --git a/app/src/ui/Popover.tsx b/app/src/ui/Popover.tsx
new file mode 100644
index 0000000..c5f1e4c
--- /dev/null
+++ b/app/src/ui/Popover.tsx
@@ -0,0 +1,98 @@
+import { Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
+import { Portal } from "solid-js/web";
+import type { JSX } from "solid-js";
+
+/**
+ * A panel hanging off a header control.
+ *
+ * It is rendered through a Portal, into `document.body`, and positioned with
+ * `fixed` coordinates measured from its anchor. That is not architectural
+ * neatness - it is the fix for a bug that only existed in the shipped app.
+ *
+ * The header carries `backdrop-filter`, which is the one genuinely glass
+ * surface in this UI. An element with a backdrop filter establishes a
+ * containing block AND, in WebView2 - the engine the Windows build actually
+ * runs on - clips its descendants to its own box. The header is about 64px
+ * tall and these panels hang some 220px below it, so in the real application
+ * they were painted and then clipped to nothing. Every test passed, and a
+ * headless Chromium screenshot showed them correctly, because neither of those
+ * is WebView2.
+ *
+ * Leaving the panel inside the header and raising its z-index cannot fix this:
+ * the clip is applied by the filtered ancestor regardless of stacking. Getting
+ * out of that subtree is the fix.
+ */
+export function Popover(props: {
+ open: boolean;
+ onClose: () => void;
+ /** Accessible name for the panel. */
+ label: string;
+ /** The control it hangs from; measured on open to place the panel. */
+ anchor: () => HTMLElement | undefined;
+ children: JSX.Element;
+}) {
+ const [box, setBox] = createSignal({ top: 0, right: 0 });
+ let panel: HTMLDivElement | undefined;
+
+ /**
+ * Measured on open, not on every frame.
+ *
+ * The header does not move while a panel is open - it cannot be scrolled and
+ * the window cannot be dragged with the panel up - so a resize observer here
+ * would be machinery for an event that does not happen.
+ */
+ const place = () => {
+ const a = props.anchor();
+ if (!a) return;
+ const r = a.getBoundingClientRect();
+ setBox({ top: r.bottom + 8, right: Math.max(8, window.innerWidth - r.right) });
+ };
+ createEffect(() => { if (props.open) place(); });
+
+ /*
+ * Close on a press outside, decided by geometry rather than by propagation.
+ *
+ * Propagation cannot work here: Solid delegates every `onClick` to
+ * `document`, and this listener is on `document` too - `stopPropagation`
+ * does not stop other listeners bound to the SAME node. The trigger's own
+ * toggle would open the panel and this handler would close it in one click.
+ *
+ * It listens for `pointerdown` rather than `click` because a click on a row
+ * inside the panel can remove that row, and a click listener would then be
+ * handed a node already detached from the tree - `contains` answers false for
+ * something that was plainly inside, and the panel closed on every dismissal.
+ *
+ * Both the anchor and the panel count as inside. They are no longer in the
+ * same subtree, which is the price of the Portal.
+ */
+ const onDown = (e: Event) => {
+ const t = e.target;
+ if (!(t instanceof Node)) return;
+ if (panel?.contains(t) || props.anchor()?.contains(t)) return;
+ props.onClose();
+ };
+
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && props.open) props.onClose();
+ };
+
+ onMount(() => {
+ document.addEventListener("pointerdown", onDown);
+ document.addEventListener("keydown", onKey);
+ });
+ onCleanup(() => {
+ document.removeEventListener("pointerdown", onDown);
+ document.removeEventListener("keydown", onKey);
+ });
+
+ return (
+
+
+
+ {props.children}
+
+
+
+ );
+}
diff --git a/app/src/ui/__tests__/HeaderPopovers.test.tsx b/app/src/ui/__tests__/HeaderPopovers.test.tsx
index ad628bb..22b5060 100644
--- a/app/src/ui/__tests__/HeaderPopovers.test.tsx
+++ b/app/src/ui/__tests__/HeaderPopovers.test.tsx
@@ -12,6 +12,12 @@
* because the bug lived entirely in how two listeners on one node interleave.
* A test that invoked the click handler directly would have passed against the
* broken build.
+ *
+ * The panels are queried on `document` rather than on the render container:
+ * they are rendered through a Portal into `document.body`, out of the header,
+ * because the header carries a backdrop-filter and WebView2 clips a filtered
+ * element's descendants to its own box - which painted them and then clipped
+ * them away in the shipped app while every test passed.
*/
import { cleanup, render } from "@solidjs/testing-library";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -61,10 +67,10 @@ describe.each([
it("opens on click and stays open", () => {
const { container } = render(() => );
const trigger = container.querySelector(selector)!;
- expect(container.querySelector(".pop")).toBeNull();
+ expect(document.querySelector(".pop")).toBeNull();
click(trigger);
- const pop = container.querySelector(".pop")!;
+ const pop = document.querySelector(".pop")!;
expect(pop).not.toBeNull();
expect(pop.getAttribute("aria-label")).toBe(label);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
@@ -73,19 +79,19 @@ describe.each([
it("closes on a click outside it", () => {
const { container } = render(() => );
click(container.querySelector(selector)!);
- expect(container.querySelector(".pop")).not.toBeNull();
+ expect(document.querySelector(".pop")).not.toBeNull();
click(document.body);
- expect(container.querySelector(".pop")).toBeNull();
+ expect(document.querySelector(".pop")).toBeNull();
});
it("stays open when the click lands inside it", () => {
const { container } = render(() => );
click(container.querySelector(selector)!);
- const pop = container.querySelector(".pop")!;
+ const pop = document.querySelector(".pop")!;
click(pop);
- expect(container.querySelector(".pop")).not.toBeNull();
+ expect(document.querySelector(".pop")).not.toBeNull();
});
});
@@ -121,43 +127,44 @@ describe("dismissing notifications", () => {
};
it("shows every finding", () => {
- expect(openBell().querySelectorAll(".pop-item").length).toBe(3);
+ openBell();
+ expect(document.querySelectorAll(".pop-item").length).toBe(3);
});
it("removes the row that was dismissed and leaves the others", () => {
const container = openBell();
- click(container.querySelectorAll(".pop-x")[1]!);
+ click(document.querySelectorAll(".pop-x")[1]!);
- const titles = [...container.querySelectorAll(".pop-item strong")]
+ const titles = [...document.querySelectorAll(".pop-item strong")]
.map((n) => n.textContent);
expect(titles).toEqual(["1 subject below the floor", "S3 does not reconcile"]);
});
it("keeps the popover open after a dismissal", () => {
const container = openBell();
- click(container.querySelector(".pop-x")!);
- expect(container.querySelector(".pop")).not.toBeNull();
+ click(document.querySelector(".pop-x")!);
+ expect(document.querySelector(".pop")).not.toBeNull();
});
it("counts the badge down as rows go", () => {
const container = openBell();
expect(container.querySelector(".bell-badge")!.textContent).toBe("3");
- click(container.querySelector(".pop-x")!);
+ click(document.querySelector(".pop-x")!);
expect(container.querySelector(".bell-badge")!.textContent).toBe("2");
});
it("clears everything at once, and closes", () => {
const container = openBell();
- click(container.querySelector(".pop-head .link")!);
+ click(document.querySelector(".pop-head .link")!);
- expect(container.querySelector(".pop")).toBeNull();
+ expect(document.querySelector(".pop")).toBeNull();
expect(container.querySelector(".bell-badge")).toBeNull();
});
it("offers no bulk control for a single finding", () => {
const { container } = render(() => );
click(container.querySelector("button.bell")!);
- expect(container.querySelector(".pop-head .link")).toBeNull();
+ expect(document.querySelector(".pop-head .link")).toBeNull();
});
});
From 7c05e27de9436f4326833fbe7f667e381f1ddfa4 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:09:10 +0530
Subject: [PATCH 10/32] fix(auth): register fixed loopback ports, and ask for
two scopes not 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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src-tauri/src/oauth.rs | 51 ++++++++++++++++++++++++++++++++++----
app/src/state/auth.ts | 21 +++++++++++++---
2 files changed, 64 insertions(+), 8 deletions(-)
diff --git a/app/src-tauri/src/oauth.rs b/app/src-tauri/src/oauth.rs
index 4785cac..eea81e5 100644
--- a/app/src-tauri/src/oauth.rs
+++ b/app/src-tauri/src/oauth.rs
@@ -14,10 +14,15 @@
//! the application could read the password field, and the user has no way
//! to tell that it does not.
//!
-//! * **A loopback redirect on an ephemeral port.** `127.0.0.1:0` lets the OS
-//! pick a free port, so two copies of the app cannot collide and a stale
-//! listener cannot be squatted. The listener accepts exactly one request
-//! and dies.
+//! * **A loopback redirect on one of a few fixed ports.** RFC 8252 says a
+//! provider should accept any port on 127.0.0.1, and an OS-assigned
+//! ephemeral port is what this originally used. Clerk - like most
+//! providers - refuses a wildcard and wants exact redirect URIs
+//! registered, so instead there is a short list of candidates and the
+//! first free one wins. Several, not one, because a single hardcoded port
+//! is a single point of failure: something else on the machine binds it
+//! and sign-in is dead with no way for the student to fix it. The listener
+//! accepts exactly one request and dies.
//!
//! * **The refresh token in the OS vault, never on disk in the clear.** Same
//! store `creds.rs` uses, for the same reason.
@@ -49,6 +54,18 @@ use std::time::Duration;
/// abandoned sign-in eventually frees the listener, not to hurry anybody.
const BROWSER_TIMEOUT: Duration = Duration::from_secs(300);
+/// The loopback ports sign-in may listen on, in order of preference.
+///
+/// EVERY ONE OF THESE MUST BE REGISTERED AS A REDIRECT URI IN THE PROVIDER, as
+/// `http://127.0.0.1:/callback`. A port missing there is a port that
+/// fails only when the earlier ones happen to be busy - which is the worst kind
+/// of bug, because it is rare, machine-specific and looks random.
+///
+/// Chosen from the IANA dynamic range and away from the round numbers
+/// development servers reach for, so a colleague running something on 8080 or
+/// 3000 does not collide with a student signing in.
+const CALLBACK_PORTS: [u16; 4] = [49731, 49732, 49733, 49734];
+
/// The vault slot the refresh token lives in. Distinct from the portal logins.
const ACCOUNT_KEY: &str = "targetx-account";
@@ -154,7 +171,18 @@ height:100vh;background:#f7f6f2;color:#1c1b19}p{margin:.4em}\
/// the browser arrives. The thread ends after one request either way, so an
/// abandoned sign-in leaks a thread for at most `BROWSER_TIMEOUT`.
fn listen_once() -> Result<(u16, Receiver), String> {
- let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
+ // First free candidate wins. Bound to 127.0.0.1 and never 0.0.0.0: this
+ // accepts an authorization code, and it has no business being reachable
+ // from the campus network the machine is sitting on.
+ let listener = CALLBACK_PORTS
+ .iter()
+ .find_map(|p| TcpListener::bind(("127.0.0.1", *p)).ok())
+ .ok_or_else(|| {
+ format!(
+ "Could not open a port to finish signing in. Something else is using all of {:?}.",
+ CALLBACK_PORTS
+ )
+ })?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let (tx, rx) = mpsc::channel();
@@ -557,6 +585,19 @@ mod tests {
assert_eq!(urlencode("aZ0-._~"), "aZ0-._~");
}
+ #[test]
+ fn every_candidate_port_is_bindable_and_distinct() {
+ // A duplicate here would silently shrink the fallback list, and the
+ // registered redirect URIs in the provider would no longer match what
+ // the app can actually open.
+ let mut seen = CALLBACK_PORTS.to_vec();
+ seen.sort_unstable();
+ seen.dedup();
+ assert_eq!(seen.len(), CALLBACK_PORTS.len());
+ // Dynamic/private range, so none of these is a registered service.
+ assert!(CALLBACK_PORTS.iter().all(|p| *p >= 49152));
+ }
+
#[test]
fn a_callback_with_neither_code_nor_error_is_not_a_result() {
let q = parse_query("/favicon.ico");
diff --git a/app/src/state/auth.ts b/app/src/state/auth.ts
index 38f9bf0..12af468 100644
--- a/app/src/state/auth.ts
+++ b/app/src/state/auth.ts
@@ -40,9 +40,24 @@ export interface Session {
const ISSUER = String(import.meta.env.VITE_CLERK_ISSUER ?? "").trim();
const CLIENT_ID = String(import.meta.env.VITE_CLERK_CLIENT_ID ?? "").trim();
-/** Scopes. `offline_access` is what earns a refresh token; without it, a
- * student is signed out the moment the access token expires. */
-const SCOPES = "openid profile email offline_access";
+/**
+ * Scopes, kept to the two that are actually load-bearing.
+ *
+ * `openid` is what makes this an identity request at all, and `offline_access`
+ * is what earns a refresh token - without it the student is signed out the
+ * moment the access token expires, on every launch, forever.
+ *
+ * `profile` and `email` are deliberately NOT requested. Nothing in this app
+ * reads them: the Worker rate-limits on the token's `sub` and the account menu
+ * shows the student's own semester and CGPA, which are computed here. Asking
+ * for a name and an email address we would never look at means a consent
+ * screen that overstates what this does, and a token that carries more than it
+ * needs to. Add them the day something actually displays them.
+ *
+ * `public_metadata` and `private_metadata` are offered by the instance and are
+ * never appropriate here.
+ */
+const SCOPES = "openid offline_access";
const [session, setSession] = createSignal(null);
const [authBusy, setAuthBusy] = createSignal(false);
From a8102792c1c5d4bccf5a13d5a6f4f93bd4f455c2 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:11:09 +0530
Subject: [PATCH 11/32] chore(worker): point CLERK_ISSUER at the real instance
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
worker/wrangler.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/worker/wrangler.toml b/worker/wrangler.toml
index 3c62ff3..e381719 100644
--- a/worker/wrangler.toml
+++ b/worker/wrangler.toml
@@ -8,7 +8,7 @@ compatibility_flags = ["nodejs_compat"]
# is reproducible from the repo. The Gemini key is NOT here; it is set with
# `wrangler secret put GEMINI_KEY` and never touches version control.
[vars]
-CLERK_ISSUER = "https://REPLACE-ME.clerk.accounts.dev"
+CLERK_ISSUER = "https://prompt-elf-6362.clerk.accounts.dev"
[[durable_objects.bindings]]
name = "QUOTA"
From ec6d5cea56248e511750a58f7a0fbc47cfb1bd48 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:18:12 +0530
Subject: [PATCH 12/32] fix(ui): the sign-in button rendered as a wrapped
swatch
`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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/styles/app.css | 33 +++++++++++++++++++++++++++++++++
app/src/ui/App.tsx | 2 +-
2 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index 951dca4..748e91e 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -904,3 +904,36 @@ tr.row:hover .del { opacity: 1; }
}
.pop-x:hover { background: var(--surface-2); color: var(--text); }
.pop-x:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
+
+/* --- actions inside a popover ---------------------------------------------
+ *
+ * `.link primary` was reused here from the launch banner, where `.launch-notice
+ * .link.primary` scopes it back to a link. Unscoped it matches `button.primary`
+ * as well, so the sign-in control rendered as a solid swatch with link metrics
+ * and wrapped "Sign in" onto two lines inside it.
+ */
+.pop-row > span { flex: 1 1 auto; min-inline-size: 0; }
+
+.pop-action {
+ appearance: none;
+ background: var(--brand);
+ border: 1px solid transparent;
+ border-radius: var(--radius-lg);
+ color: oklch(0.99 0 0);
+ cursor: pointer;
+ flex: none;
+ font: inherit;
+ font-size: var(--text-sm);
+ font-weight: 600;
+ padding: var(--s2) var(--s4);
+ /* The row is `space-between` and the copy beside it is two lines long. With
+ nothing stopping it, the action is the thing that gives up its width. */
+ white-space: nowrap;
+}
+.pop-action:hover:not(:disabled) { filter: brightness(1.1); }
+.pop-action:disabled { cursor: default; opacity: 0.6; }
+.pop-action:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
+
+/* The quiet counterpart: signing out and opening Data are not the thing the
+ row is recommending, so they stay text. */
+.pop-row > .link { flex: none; white-space: nowrap; }
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index 9020cde..d332c1a 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -502,7 +502,7 @@ function Profile() {
- { void signIn(); }}>
{authBusy() ? "Signing in…" : "Sign in"}
From 04e0278bb0d6da338622fad48000e87c140a311a Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:26:13 +0530
Subject: [PATCH 13/32] fix(auth): a new sign-in releases the abandoned one's
port
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src-tauri/src/oauth.rs | 51 ++++++++++++++++++++++++++++++++------
1 file changed, 44 insertions(+), 7 deletions(-)
diff --git a/app/src-tauri/src/oauth.rs b/app/src-tauri/src/oauth.rs
index eea81e5..a32586e 100644
--- a/app/src-tauri/src/oauth.rs
+++ b/app/src-tauri/src/oauth.rs
@@ -43,8 +43,9 @@ use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
+use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
-use std::sync::Mutex;
+use std::sync::{Arc, Mutex};
use std::time::Duration;
/// How long the browser half of the flow may take before we give the port back.
@@ -170,7 +171,7 @@ height:100vh;background:#f7f6f2;color:#1c1b19}p{margin:.4em}\
/// before the browser is opened - and a receiver that yields the callback when
/// the browser arrives. The thread ends after one request either way, so an
/// abandoned sign-in leaks a thread for at most `BROWSER_TIMEOUT`.
-fn listen_once() -> Result<(u16, Receiver), String> {
+fn listen_once() -> Result<(u16, Receiver, Arc), String> {
// First free candidate wins. Bound to 127.0.0.1 and never 0.0.0.0: this
// accepts an authorization code, and it has no business being reachable
// from the campus network the machine is sitting on.
@@ -185,6 +186,8 @@ fn listen_once() -> Result<(u16, Receiver), String> {
})?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let (tx, rx) = mpsc::channel();
+ let cancelled = Arc::new(AtomicBool::new(false));
+ let flag = Arc::clone(&cancelled);
std::thread::spawn(move || {
// `incoming` blocks; one iteration is all this listener is for. A
@@ -192,6 +195,14 @@ fn listen_once() -> Result<(u16, Receiver), String> {
// single accept, so anything without a `code` or `error` is ignored and
// the loop waits for the real callback.
for stream in listener.incoming() {
+ // A sign-in that was abandoned and started again wakes this thread
+ // by connecting to the port; the flag is how it tells the difference
+ // between that and a browser arriving. Without it an abandoned
+ // attempt held its port for the full five-minute timeout, and four
+ // retries in five minutes exhausted every candidate.
+ if flag.load(Ordering::SeqCst) {
+ break;
+ }
let Ok(mut stream) = stream else { continue };
let mut line = String::new();
@@ -230,12 +241,14 @@ fn listen_once() -> Result<(u16, Receiver), String> {
}
});
- Ok((port, rx))
+ Ok((port, rx, cancelled))
}
// --- pending flow state -----------------------------------------------------
struct Pending {
+ port: u16,
+ cancelled: Arc,
verifier: String,
state: String,
redirect_uri: String,
@@ -293,7 +306,7 @@ pub async fn oauth_begin(
// that does not carry the state this process generated is not ours.
let state = random_urlsafe(24);
- let (port, rx) = listen_once()?;
+ let (port, rx, cancelled) = listen_once()?;
let redirect_uri = format!("http://127.0.0.1:{port}/callback");
let authorize_url = format!(
@@ -307,7 +320,16 @@ pub async fn oauth_begin(
urlencode(&challenge),
);
- *oauth.pending.lock().map_err(|_| "sign-in state was poisoned")? = Some(Pending {
+ let mut slot = oauth.pending.lock().map_err(|_| "sign-in state was poisoned")?;
+ // Pressing Sign in again abandons the previous attempt. Releasing its port
+ // here rather than letting it time out is what keeps a run of failed
+ // attempts from walking through every candidate port.
+ if let Some(previous) = slot.take() {
+ release(&previous);
+ }
+ *slot = Some(Pending {
+ port,
+ cancelled,
verifier,
state,
redirect_uri,
@@ -341,8 +363,12 @@ pub async fn oauth_finish(oauth: tauri::State<'_, Oauth>) -> Result bool {
// --- helpers ----------------------------------------------------------------
+/// Release a pending attempt's loopback port.
+///
+/// Setting the flag alone is not enough: the listener thread is parked inside a
+/// blocking `accept`, and nothing wakes it until a connection arrives. So one
+/// is made, to itself, purely to let the loop come round and see the flag. The
+/// connection is dropped immediately and carries no request.
+fn release(pending: &Pending) {
+ pending.cancelled.store(true, Ordering::SeqCst);
+ let _ = std::net::TcpStream::connect(("127.0.0.1", pending.port));
+}
+
/// Hand a URL to the operating system's default browser.
///
/// Never through a shell. `cmd /c start` would parse the URL as a command line,
From 987e1377c5744aff5c136e2275e766fa8bb48404 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:30:43 +0530
Subject: [PATCH 14/32] feat(auth): show the signed-in account, picture and all
`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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src-tauri/src/oauth.rs | 107 ++++++++++++++++++++++++++++++++++---
app/src/state/auth.ts | 41 ++++++++++----
app/src/styles/app.css | 29 ++++++++++
app/src/ui/App.tsx | 42 +++++++++++----
4 files changed, 191 insertions(+), 28 deletions(-)
diff --git a/app/src-tauri/src/oauth.rs b/app/src-tauri/src/oauth.rs
index a32586e..fd0da8f 100644
--- a/app/src-tauri/src/oauth.rs
+++ b/app/src-tauri/src/oauth.rs
@@ -275,6 +275,15 @@ struct TokenResponse {
access_token: String,
refresh_token: Option,
expires_in: Option,
+ id_token: Option,
+}
+
+/// The claims worth showing. Everything else in the token is ignored.
+#[derive(Deserialize, Default)]
+struct Claims {
+ name: Option,
+ email: Option,
+ picture: Option,
}
/// What the frontend is told about the signed-in account.
@@ -286,6 +295,17 @@ pub struct Session {
pub access_token: String,
/// Unix seconds. `None` when the provider declined to say.
pub expires_at: Option,
+ pub name: Option,
+ pub email: Option,
+ /// The avatar as a `data:` URI, already fetched and inlined.
+ ///
+ /// NOT the provider's URL. Handing the webview an https URL would mean a
+ /// request to Google's CDN every time the header rendered - telling them
+ /// this machine's IP and that the app is open, repeatedly, in an
+ /// application whose whole position is that it works without a network. It
+ /// is fetched once here and inlined, which also means it still draws
+ /// offline and needs no widening of the app's `img-src`.
+ pub avatar: Option,
}
// --- commands ---------------------------------------------------------------
@@ -421,10 +441,7 @@ pub async fn oauth_finish(oauth: tauri::State<'_, Oauth>) -> Result bool {
// --- helpers ----------------------------------------------------------------
+/// Read the claims out of an ID token, without verifying its signature.
+///
+/// Safe here and only here: this token came back on the direct, TLS-protected
+/// response to our own PKCE exchange, which OpenID Connect Core 3.1.3.7 says
+/// need not be validated - there is no third party in the path who could have
+/// substituted it. The claims are used for a name and a picture and for
+/// nothing that grants access; the WORKER verifies signatures properly,
+/// because there the token arrives from a client we do not trust.
+fn claims_of(id_token: &str) -> Claims {
+ let Some(payload) = id_token.split('.').nth(1) else {
+ return Claims::default();
+ };
+ let Ok(bytes) = URL_SAFE_NO_PAD.decode(payload) else {
+ return Claims::default();
+ };
+ serde_json::from_slice(&bytes).unwrap_or_default()
+}
+
+/// The largest avatar worth inlining.
+///
+/// A cap rather than trust: the URL comes from a token, the response comes from
+/// somebody else's CDN, and this ends up in a `data:` URI held in memory. A
+/// provider that served a 40MB image should cost us nothing.
+const MAX_AVATAR_BYTES: usize = 512 * 1024;
+
+/// Fetch an avatar and inline it as a `data:` URI.
+///
+/// Best-effort throughout. Every failure returns `None`, because an account
+/// with no picture and an account whose picture would not load are the same
+/// thing to a student, and neither is worth an error.
+async fn inline_avatar(url: &str) -> Option {
+ // https only. A token claim is not a reason to make a plaintext request.
+ if !url.starts_with("https://") {
+ return None;
+ }
+
+ let res = reqwest::Client::new().get(url).send().await.ok()?;
+ if !res.status().is_success() {
+ return None;
+ }
+
+ // Trust the declared type only far enough to name it, and only if it is an
+ // image at all - this string goes into a `data:` URI.
+ let mime = res
+ .headers()
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ .map(|v| v.split(';').next().unwrap_or("").trim().to_string())
+ .filter(|v| matches!(v.as_str(),
+ "image/jpeg" | "image/png" | "image/webp" | "image/gif"))?;
+
+ let bytes = res.bytes().await.ok()?;
+ if bytes.is_empty() || bytes.len() > MAX_AVATAR_BYTES {
+ return None;
+ }
+
+ Some(format!("data:{mime};base64,{}",
+ base64::engine::general_purpose::STANDARD.encode(&bytes)))
+}
+
+/// Build the session handed to the frontend, resolving the avatar if there is one.
+async fn session_from(token: TokenResponse) -> Session {
+ let claims = token.id_token.as_deref().map(claims_of).unwrap_or_default();
+ let avatar = match claims.picture.as_deref() {
+ Some(url) => inline_avatar(url).await,
+ None => None,
+ };
+
+ Session {
+ access_token: token.access_token,
+ expires_at: token.expires_in.map(|s| now_secs() + s),
+ name: claims.name,
+ email: claims.email,
+ avatar,
+ }
+}
+
/// Release a pending attempt's loopback port.
///
/// Setting the flag alone is not enough: the listener thread is parked inside a
diff --git a/app/src/state/auth.ts b/app/src/state/auth.ts
index 12af468..915992d 100644
--- a/app/src/state/auth.ts
+++ b/app/src/state/auth.ts
@@ -27,6 +27,17 @@ export interface Session {
accessToken: string;
/** Unix seconds, or null when the provider declined to say. */
expiresAt: number | null;
+ name: string | null;
+ email: string | null;
+ /**
+ * The avatar, already inlined as a `data:` URI by the Rust side.
+ *
+ * Never the provider's https URL. The app's `img-src` is `'self' data:` and
+ * stays that way: pointing the webview at Google's CDN would mean a request
+ * to them every time the header drew, from an application that otherwise
+ * needs no network at all.
+ */
+ avatar: string | null;
}
/**
@@ -41,23 +52,21 @@ const ISSUER = String(import.meta.env.VITE_CLERK_ISSUER ?? "").trim();
const CLIENT_ID = String(import.meta.env.VITE_CLERK_CLIENT_ID ?? "").trim();
/**
- * Scopes, kept to the two that are actually load-bearing.
+ * Scopes. Every one of these is here because something uses it.
*
* `openid` is what makes this an identity request at all, and `offline_access`
* is what earns a refresh token - without it the student is signed out the
* moment the access token expires, on every launch, forever.
*
- * `profile` and `email` are deliberately NOT requested. Nothing in this app
- * reads them: the Worker rate-limits on the token's `sub` and the account menu
- * shows the student's own semester and CGPA, which are computed here. Asking
- * for a name and an email address we would never look at means a consent
- * screen that overstates what this does, and a token that carries more than it
- * needs to. Add them the day something actually displays them.
+ * `profile` and `email` were dropped for a while because nothing displayed
+ * them, and are back because the account menu now does: the signed-in name,
+ * address and picture. That is the standard for the scope - ask for a claim
+ * when something shows it, not in case something might.
*
* `public_metadata` and `private_metadata` are offered by the instance and are
* never appropriate here.
*/
-const SCOPES = "openid offline_access";
+const SCOPES = "openid profile email offline_access";
const [session, setSession] = createSignal(null);
const [authBusy, setAuthBusy] = createSignal(false);
@@ -84,11 +93,23 @@ export function accessToken(): string | null {
return s.accessToken;
}
-interface RawSession { access_token: string; expires_at: number | null }
+interface RawSession {
+ access_token: string;
+ expires_at: number | null;
+ name: string | null;
+ email: string | null;
+ avatar: string | null;
+}
const adopt = (raw: RawSession | null): Session | null => {
if (!raw) return null;
- const next = { accessToken: raw.access_token, expiresAt: raw.expires_at ?? null };
+ const next: Session = {
+ accessToken: raw.access_token,
+ expiresAt: raw.expires_at ?? null,
+ name: raw.name ?? null,
+ email: raw.email ?? null,
+ avatar: raw.avatar ?? null,
+ };
setSession(next);
return next;
};
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index 748e91e..afe4979 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -937,3 +937,32 @@ tr.row:hover .del { opacity: 1; }
/* The quiet counterpart: signing out and opening Data are not the thing the
row is recommending, so they stay text. */
.pop-row > .link { flex: none; white-space: nowrap; }
+
+/* --- the signed-in face ---------------------------------------------------- */
+
+/* Fills the circle it sits in. A non-square source - some providers return
+ one - would otherwise letterbox inside a round frame, which reads as broken
+ rather than as a wide photo. */
+.avatar-img {
+ inline-size: 100%;
+ block-size: 100%;
+ border-radius: 999px;
+ object-fit: cover;
+ display: block;
+}
+/* The generic figure needs the padding; a photograph needs the whole circle. */
+.avatar:has(.avatar-img) { border-color: transparent; }
+
+.who { align-items: center; display: flex; gap: var(--s3); min-inline-size: 0; }
+.who-lines { display: flex; flex-direction: column; min-inline-size: 0; }
+/* A long Google display name or a long address must not push the sign-out
+ button off the row. */
+.who-lines > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.who-face {
+ inline-size: 2.25rem;
+ block-size: 2.25rem;
+ border-radius: 999px;
+ object-fit: cover;
+ flex: none;
+}
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index d332c1a..f4bd0e5 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -21,7 +21,8 @@ import { runLaunchCheck, saveFindings } from "../state/launch";
import { autoRefresh, refreshAll, refreshFailures, refreshing } from "../state/autosync";
import type { SourceResult } from "../state/autosync";
import {
- authBusy, authConfigured, authError, resumeAccount, signIn, signOut, signedIn,
+ authBusy, authConfigured, authError, resumeAccount, session, signIn, signOut,
+ signedIn,
} from "../state/auth";
import type { Finding } from "../state/launch";
import { checkForUpdate } from "../sync/update";
@@ -456,13 +457,25 @@ function Profile() {
setOpen((o) => !o)}
aria-expanded={open()} aria-label="Account">
-
+ {/* The picture when there is one, the generic figure otherwise. Not
+ initials: a student who has not signed in has no name to take
+ them from, and inventing one from the semester would be a face
+ for an account that does not exist. */}
+
+
+
+
+ }>
+ {(src) => }
+
+ {/* The chip keeps saying what the app is about. The signed-in name
+ belongs in the menu: an academic tracker's header should lead with
+ the semester being tracked, not with whose Google account is
+ attached to it. */}
{state.activeSemester ?? "Student"}
{cgpa() === null ? "no CGPA yet" : `CGPA ${cgpa()}`}
@@ -509,10 +522,19 @@ function Profile() {
}>
-
- Signed in
-
- You can ask questions in the search box.
+
+
+ {(src) => }
+
+
+ {session()?.name ?? "Signed in"}
+ {/* The address is the thing that actually answers "which
+ account is this", which is the only question a signed-in
+ row has to be able to answer. */}
+
+ {(mail) => {mail()}}
+
+ { void signOut(); }}>Sign out
From c694d910f62e5f0032265821b68ac165aab32799 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:43:21 +0530
Subject: [PATCH 15/32] feat(ux): make the first run and the sign-in mean
something
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/auth.ts | 20 +++++++++--
app/src/styles/app.css | 59 ++++++++++++++++++++++++++++++++
app/src/styles/screens.css | 29 +++++++++++++++-
app/src/ui/App.tsx | 70 +++++++++++++++++++++++++++++++++++---
app/src/ui/Home.tsx | 38 +++++++++++++++++----
app/src/ui/Setup.tsx | 8 ++---
6 files changed, 207 insertions(+), 17 deletions(-)
diff --git a/app/src/state/auth.ts b/app/src/state/auth.ts
index 915992d..1a97ae3 100644
--- a/app/src/state/auth.ts
+++ b/app/src/state/auth.ts
@@ -71,7 +71,15 @@ const SCOPES = "openid profile email offline_access";
const [session, setSession] = createSignal(null);
const [authBusy, setAuthBusy] = createSignal(false);
const [authError, setAuthError] = createSignal(null);
-export { authBusy, authError, session };
+/**
+ * True for a moment after a sign-in the student actually performed.
+ *
+ * Deliberately NOT derived from `session()` becoming non-null. `resumeAccount`
+ * does that on every launch, and greeting somebody by name every time they
+ * open the app is a nag rather than an acknowledgement.
+ */
+const [justSignedIn, setJustSignedIn] = createSignal(false);
+export { authBusy, authError, justSignedIn, session };
/** Whether this build was given a provider to talk to at all. */
export const authConfigured = (): boolean =>
@@ -133,7 +141,14 @@ export async function signIn(): Promise {
await invoke<{ authorize_url: string }>("oauth_begin", {
issuer: ISSUER, clientId: CLIENT_ID, scopes: SCOPES,
});
- return adopt(await invoke("oauth_finish"));
+ const next = adopt(await invoke("oauth_finish"));
+ if (next) {
+ setJustSignedIn(true);
+ // Long enough for the acknowledgement to have been shown and gone. The
+ // flag is a one-shot, not a state anything else should read.
+ setTimeout(() => setJustSignedIn(false), 6000);
+ }
+ return next;
} catch (exc) {
setAuthError(exc instanceof Error ? exc.message : String(exc));
return null;
@@ -170,6 +185,7 @@ export async function resumeAccount(): Promise {
export async function signOut(): Promise {
setSession(null);
setAuthError(null);
+ setJustSignedIn(false);
try {
await invoke("oauth_sign_out");
} catch { /* nothing left to do; the session is already gone from memory */ }
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index afe4979..1ed0123 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -966,3 +966,62 @@ tr.row:hover .del { opacity: 1; }
object-fit: cover;
flex: none;
}
+
+/* An informational count, not an alarm. The empty-record finding fires on a
+ brand new install, and a red badge there is the app telling a student off for
+ having just arrived. */
+.bell-badge.info {
+ background: var(--surface-3);
+ color: var(--text-dim);
+ border: 1px solid var(--hairline-strong);
+ line-height: calc(1rem - 2px);
+}
+
+/* --- the sign-in acknowledgement -------------------------------------------
+ *
+ * Bottom-right, over the content, gone in four seconds. It confirms something
+ * that already succeeded, so it must not be dismissible - a confirmation that
+ * has to be closed is a second task handed to someone who just finished one.
+ */
+.toast {
+ position: fixed;
+ inset-block-end: var(--s5);
+ inset-inline-end: var(--s5);
+ z-index: 40;
+ display: flex;
+ align-items: center;
+ gap: var(--s4);
+ max-inline-size: 24rem;
+ padding: var(--s3) var(--s4);
+ background: var(--surface-1);
+ border: 1px solid var(--hairline-strong);
+ border-radius: var(--radius-card);
+ /* The one place a shadow is right: this floats over content that is still
+ there underneath it, and tokens.css's "elevation by lightness" rule has no
+ lightness step left above surface-1 in light mode. */
+ box-shadow: 0 8px 28px oklch(0 0 0 / 0.16);
+ animation: toast-in var(--dur) var(--ease);
+}
+
+.toast-face {
+ inline-size: 2.5rem;
+ block-size: 2.5rem;
+ border-radius: 999px;
+ object-fit: cover;
+ flex: none;
+}
+
+.toast-lines { display: flex; flex-direction: column; gap: 2px; min-inline-size: 0; }
+.toast-lines strong { font-size: var(--text-sm); }
+.toast-lines .dim { color: var(--text-faint); font-size: var(--text-xs); }
+
+@keyframes toast-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: none; }
+}
+
+/* It still appears, it just stops sliding. Removing the animation entirely
+ would make it pop into existence, which is more startling than the motion. */
+@media (prefers-reduced-motion: reduce) {
+ .toast { animation: none; }
+}
diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css
index 636b19b..6a02dd7 100644
--- a/app/src/styles/screens.css
+++ b/app/src/styles/screens.css
@@ -264,6 +264,26 @@ label { color: var(--text-dim); display: block; font-size: var(--text-sm); }
transition: border-color var(--fast) var(--ease), background var(--fast) var(--ease);
}
.route:hover { background: var(--surface-2); border-color: var(--brand-deep); }
+
+/* The recommended route has to LOOK recommended.
+ *
+ * All three cards were one drawing with a small coloured eyebrow on the first,
+ * which asks a student to read three paragraphs to discover that the app
+ * already has an opinion. It has one - portal sync brings every semester,
+ * attendance, internals and published results in a single pass, and the other
+ * two are for students who cannot use it. So the recommendation carries a
+ * brand edge and a tinted ground, and the alternatives quiet down. */
+.route.recommended {
+ background: var(--brand-wash);
+ border-color: var(--brand-deep);
+}
+.route.recommended:hover { border-color: var(--brand); }
+.route.recommended strong { font-size: var(--text-xl); }
+
+/* Not disabled-looking, just secondary. These are real choices and a student
+ with no portal login needs them to be obviously available. */
+.route.alt { background: transparent; }
+.route.alt strong { font-size: var(--text-base); }
.route strong { color: var(--text); font-size: var(--text-lg); font-weight: 600; }
.route span { color: var(--text-dim); font-size: var(--text-sm); line-height: 1.55; }
.route-tag {
@@ -830,7 +850,14 @@ label { color: var(--text-dim); display: block; font-size: var(--text-sm); }
context, not the subject. */
.home .trend svg { margin: 0 auto; max-width: 560px; }
-.empty-home { max-width: 60ch; }
+/* Centred in the space it has, not pinned to a corner of it. An empty record
+ leaves the whole screen free, and a single small card in the top-left reads
+ as the page having failed to load the rest. */
+.empty-home {
+ max-width: 60ch;
+ margin-inline: auto;
+ margin-block-start: 6vh;
+}
.empty-home h3 { font-size: var(--text-lg); font-weight: 600; margin: 0; }
/* The appearance control sits with the tabs but is not one - it changes how
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index f4bd0e5..1a26d88 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -1,4 +1,4 @@
-import { For, Show, createSignal, onCleanup, onMount } from "solid-js";
+import { For, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import {
activeCourses, addSemester, attendanceGaps, goalRequirement, hydrate, overall,
selectSemester, semesterNames, setAttendanceTarget, setGoal, state, summary,
@@ -21,8 +21,8 @@ import { runLaunchCheck, saveFindings } from "../state/launch";
import { autoRefresh, refreshAll, refreshFailures, refreshing } from "../state/autosync";
import type { SourceResult } from "../state/autosync";
import {
- authBusy, authConfigured, authError, resumeAccount, session, signIn, signOut,
- signedIn,
+ authBusy, authConfigured, authError, justSignedIn, resumeAccount, session,
+ signIn, signOut, signedIn,
} from "../state/auth";
import type { Finding } from "../state/launch";
import { checkForUpdate } from "../sync/update";
@@ -389,8 +389,14 @@ export function Bell(props: { findings: Finding[] }) {
+ {/* Red is for something wrong, and "you have not added any marks yet"
+ on a fresh install is not something wrong - it is the app meeting a
+ student who has done nothing yet and alarming them for it. The badge
+ takes the tone of the WORST finding in the list, so a warning still
+ reads as a warning. */}
0}>
- {items().length}
+ f.severity === "warn") ? "" : " info"}`}
+ aria-hidden="true">{items().length}
@@ -447,6 +453,61 @@ export function Bell(props: { findings: Finding[] }) {
* app requires an account today - every figure on every screen is computed on
* this machine from data this machine fetched.
*/
+/**
+ * The moment a sign-in lands.
+ *
+ * Signing in used to change one line of text inside a menu that the student
+ * had to still be holding open to see. They pressed a button, a browser window
+ * flashed past, and the application said nothing - so the only way to find out
+ * whether it had worked was to go and look.
+ *
+ * This is the acknowledgement. It names the person, because being greeted by
+ * name is the whole proof that the round trip actually carried an identity
+ * back, and it takes itself away - a confirmation that has to be dismissed is
+ * a second task handed to someone who just finished one.
+ */
+function SignedInToast() {
+ const [showing, setShowing] = createSignal(false);
+
+ // The signal the toast actually watches: an explicit signal set by signIn,
+ // so a launch resume can never trigger it.
+ createEffect(() => {
+ if (!justSignedIn()) return;
+ setShowing(true);
+ const t = setTimeout(() => setShowing(false), 4200);
+ onCleanup(() => clearTimeout(t));
+ });
+
+ return (
+
+ {(who) => (
+
+
+
+
+ }>
+ {(src) => }
+
+
+
+ {who().name ? `Signed in as ${who().name}` : "Signed in"}
+
+ {/* Says what it unlocked, because sign-in in this app buys exactly
+ one thing and a student who does not know that will wonder what
+ they just handed over. */}
+ You can ask questions in the search box now.
+
+
>
diff --git a/app/src/ui/Home.tsx b/app/src/ui/Home.tsx
index 090710a..6e931b1 100644
--- a/app/src/ui/Home.tsx
+++ b/app/src/ui/Home.tsx
@@ -59,7 +59,14 @@ export function Home() {
* four published results sitting in history.
*/
const onRecord = () => new Set([
- ...Object.keys(state.semesters),
+ // A semester with no subjects in it is not a semester on record. Setup
+ // creates the current one before anything is in it, so a student who had
+ // just finished onboarding was told "1 semester on record" while looking
+ // at an empty screen - the app opening with a claim it could not support,
+ // which is the one thing it is supposed never to do.
+ ...Object.entries(state.semesters)
+ .filter(([, sem]) => (sem?.courses.length ?? 0) > 0)
+ .map(([name]) => name),
...Object.keys(state.history),
]).size;
@@ -239,11 +246,26 @@ export function Home() {
Where you stand
-
- {state.activeSemester} · {onRecord()} semester
- {onRecord() === 1 ? "" : "s"} on record
-
+ {/* With nothing on record the count is not "0 semesters", it is
+ absent. A zero here is a fact about the record; the student has
+ not made one yet, and the empty state below already says so in
+ words that lead somewhere. */}
+ {/* No lede at all when the record is empty. It said "nothing
+ recorded yet" directly above a card whose heading is "Nothing
+ recorded yet", which is the same sentence twice in one eyeful and
+ makes the screen read as an error rather than as a start. */}
+ 0}>
+
+ {state.activeSemester} · {onRecord()} semester
+ {onRecord() === 1 ? "" : "s"} on record
+
+
+ {/* The header action is for a student who HAS data and wants more of
+ it. With an empty record the card below carries the same call, and
+ two identical primary buttons on one screen is a choice the student
+ has to make between two things that do the same thing. */}
+ 0}>
setView("data")}>Get your marks in
}>
@@ -251,6 +273,7 @@ export function Home() {
Synced {new Date(state.lastSync!).toLocaleDateString()}
+
@@ -586,7 +609,10 @@ function ChangesPanel(props: { at: string; items: Change[] }) {
function EmptyHome() {
return (
-
Nothing recorded yet
+ {/* "Nothing recorded yet" as a heading describes the app's problem. This
+ describes the student's next move, which is the only thing an empty
+ state is for. */}
+
Get your marks in
TargetX has no marks to work from. The fastest route is your college
portal — it brings attendance, internals and every past semester in one
diff --git a/app/src/ui/Setup.tsx b/app/src/ui/Setup.tsx
index cab0aec..13c27e0 100644
--- a/app/src/ui/Setup.tsx
+++ b/app/src/ui/Setup.tsx
@@ -22,7 +22,7 @@ type Stage = "welcome" | "data" | "goal";
const STAGES: Array<{ id: Stage; label: string }> = [
{ id: "welcome", label: "Start" },
- { id: "data", label: "Your subjects" },
+ { id: "data", label: "Your marks" },
{ id: "goal", label: "Your goal" },
];
@@ -133,7 +133,7 @@ function DataStep(props: { onBack: () => void; onNext: () => void }) {
- setRoute("sync")}>
+ setRoute("sync")}>
RecommendedSign in to your college portal
@@ -143,7 +143,7 @@ function DataStep(props: { onBack: () => void; onNext: () => void }) {
- setRoute("preset")}>
+ setRoute("preset")}>
Pick from the KTU curriculum
Choose your branch and semester and tick the subjects you
@@ -152,7 +152,7 @@ function DataStep(props: { onBack: () => void; onNext: () => void }) {
- { props.onNext(); }}>
+ { props.onNext(); }}>
Start emptyAdd subjects one at a time. Nothing is filled in for you.
From bd372f8690bba25b8244df1d86954bed12b565d8 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 18:57:28 +0530
Subject: [PATCH 16/32] feat(home): offer the target from the card that depends
on it
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/styles/screens.css | 12 ++++++++++++
app/src/ui/Home.tsx | 36 ++++++++++++++++++++++++++++++++++--
2 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css
index 6a02dd7..43e08ea 100644
--- a/app/src/styles/screens.css
+++ b/app/src/styles/screens.css
@@ -1088,3 +1088,15 @@ a.link:hover { text-decoration-thickness: 2px; }
}
.priced-row dt { color: var(--text-dim); }
.priced-row dd { margin: 0; color: var(--text); font-weight: 500; }
+
+/* The target invitation, standing where the gauge stands once there is one.
+ Same footprint, so the card does not resize the moment a target is set -
+ a layout that jumps on click reads as something having gone wrong. */
+.goal-invite {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s3);
+ align-items: flex-start;
+ min-inline-size: 280px;
+}
+.goal-chips { display: flex; flex-wrap: wrap; gap: var(--s2); }
diff --git a/app/src/ui/Home.tsx b/app/src/ui/Home.tsx
index 6e931b1..114f7ea 100644
--- a/app/src/ui/Home.tsx
+++ b/app/src/ui/Home.tsx
@@ -6,7 +6,7 @@ import {
unconfirmedNames,
} from "../engine";
import {
- dismissChanges, goalRequirement, overall, rows, state, summary, trend,
+ dismissChanges, goalRequirement, overall, rows, setGoal, state, summary, trend,
} from "../state/store";
import { setView } from "../state/nav";
import { GoalGauge, TrendChart } from "./charts";
@@ -328,7 +328,16 @@ export function Home() {
CGPA · {overall().percent.toFixed(1)}%
-
+ {/* The gauge occupies the right half of a double-width tile. With
+ no target set it rendered nothing, so the card became one
+ number and a sentence spread across the widest surface on the
+ screen - the dead half that made Home feel unfinished.
+
+ What goes there instead is the action the sentence beside it
+ is already asking for. Setting a target is the single thing
+ that changes what every other figure on this screen means, and
+ it was previously only reachable from a row on another view. */}
+ }>
+ Set a target
+
From 61f6ed28d7c9d8933c1915b10725c542dffe9577 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 19:05:28 +0530
Subject: [PATCH 17/32] feat(ask): route unmatched questions through the
deployed worker
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/ask.test.ts | 186 +++++++++++++++++++
app/src/state/ask.ts | 132 +++++++++++++
app/src/ui/Palette.tsx | 105 ++++++++++-
app/src/ui/__tests__/HeaderPopovers.test.tsx | 13 +-
app/src/ui/__tests__/PaletteAsk.test.tsx | 151 +++++++++++++++
5 files changed, 578 insertions(+), 9 deletions(-)
create mode 100644 app/src/state/ask.test.ts
create mode 100644 app/src/state/ask.ts
create mode 100644 app/src/ui/__tests__/PaletteAsk.test.tsx
diff --git a/app/src/state/ask.test.ts b/app/src/state/ask.test.ts
new file mode 100644
index 0000000..dac04ef
--- /dev/null
+++ b/app/src/state/ask.test.ts
@@ -0,0 +1,186 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * The ask client.
+ *
+ * `ENDPOINT` is read at module scope from `import.meta.env`, so every test that
+ * cares about it must stub the env BEFORE the dynamic import - a top-level
+ * import would have already frozen the value. That is why this file imports
+ * inside each test rather than at the top.
+ */
+
+const ENDPOINT = "https://worker.example.dev";
+
+async function load(endpoint: string | undefined, token: string | null) {
+ vi.resetModules();
+ if (endpoint === undefined) vi.stubEnv("VITE_ASK_ENDPOINT", "");
+ else vi.stubEnv("VITE_ASK_ENDPOINT", endpoint);
+ vi.doMock("./auth", () => ({ accessToken: () => token }));
+ return await import("./ask");
+}
+
+type FetchMock = (url: string, init: RequestInit) => Promise;
+
+const reply = (body: unknown, status = 200) =>
+ vi.fn(async () => new Response(JSON.stringify(body), {
+ status, headers: { "content-type": "application/json" },
+ }));
+
+/** The captured request, with the types the mock was declared with. */
+const sent = (f: ReturnType, i = 0) => {
+ const call = f.mock.calls[i];
+ if (!call) throw new Error("fetch was never called");
+ return { url: call[0], init: call[1], body: JSON.parse(String(call[1].body)) };
+};
+
+beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); });
+afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.doUnmock("./auth"); });
+
+describe("askConfigured", () => {
+ it("is false with no endpoint, so the palette never offers a route it cannot take", async () => {
+ const { askConfigured } = await load(undefined, "t");
+ expect(askConfigured()).toBe(false);
+ });
+
+ it("is true once an endpoint is built in", async () => {
+ const { askConfigured } = await load(ENDPOINT, "t");
+ expect(askConfigured()).toBe(true);
+ });
+});
+
+describe("parseReply", () => {
+ it("accepts a view route", async () => {
+ const { parseReply } = await load(ENDPOINT, "t");
+ expect(parseReply({ action: { kind: "view", view: "attendance" }, remaining: 7 }))
+ .toEqual({ ok: true, action: { kind: "view", view: "attendance" }, remaining: 7 });
+ });
+
+ it("rejects a view the app does not have", async () => {
+ const { parseReply } = await load(ENDPOINT, "t");
+ expect(parseReply({ action: { kind: "view", view: "settings" } })).toBeNull();
+ });
+
+ it("defaults a subject's view rather than dropping a correct subject", async () => {
+ const { parseReply } = await load(ENDPOINT, "t");
+ const out = parseReply({ action: { kind: "subject", code: "CST301" }, remaining: 3 });
+ expect(out).toEqual({
+ ok: true, action: { kind: "subject", code: "CST301", view: "attendance" }, remaining: 3,
+ });
+ });
+
+ it("narrows an unrecognised refusal reason instead of passing it through", async () => {
+ const { parseReply } = await load(ENDPOINT, "t");
+ const out = parseReply({ action: { kind: "none", reason: "because i said so" } });
+ expect(out).toEqual({ ok: true, action: { kind: "none", reason: "unclear" }, remaining: 0 });
+ });
+
+ it("rejects anything that is not an action", async () => {
+ const { parseReply } = await load(ENDPOINT, "t");
+ expect(parseReply(null)).toBeNull();
+ expect(parseReply({})).toBeNull();
+ expect(parseReply({ action: "ledger" })).toBeNull();
+ expect(parseReply({ action: { kind: "navigate", view: "ledger" } })).toBeNull();
+ });
+
+ it("treats a missing remaining as zero rather than NaN", async () => {
+ const { parseReply } = await load(ENDPOINT, "t");
+ const out = parseReply({ action: { kind: "view", view: "home" } });
+ expect(out).toMatchObject({ remaining: 0 });
+ });
+});
+
+describe("askRemote", () => {
+ it("does not call out at all when no endpoint is built in", async () => {
+ const { askRemote } = await load(undefined, "t");
+ expect(await askRemote("q", [])).toEqual({ ok: false, kind: "unconfigured" });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("does not spend a request when there is no token", async () => {
+ const { askRemote } = await load(ENDPOINT, null);
+ expect(await askRemote("q", [])).toEqual({ ok: false, kind: "signin" });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("sends only the question and the course list - never a figure", async () => {
+ const f = reply({ action: { kind: "view", view: "ledger" }, remaining: 9 });
+ vi.stubGlobal("fetch", f);
+ const { askRemote } = await load(ENDPOINT, "tok");
+ await askRemote("where are my marks", [{ code: "CST301", name: "Formal Languages" }]);
+
+ const { url, init, body } = sent(f);
+ expect(url).toBe(`${ENDPOINT}/ask`);
+ expect((init.headers as Record).authorization).toBe("Bearer tok");
+ // The exact shape matters: anything extra here is an academic record
+ // leaving the machine.
+ expect(Object.keys(body).sort()).toEqual(["question", "subjects"]);
+ expect(body.subjects).toEqual([{ code: "CST301", name: "Formal Languages" }]);
+ });
+
+ it("trims a question to the worker's cap instead of being rejected at the edge", async () => {
+ const f = reply({ action: { kind: "none", reason: "unclear" } });
+ vi.stubGlobal("fetch", f);
+ const { askRemote } = await load(ENDPOINT, "tok");
+ await askRemote("x".repeat(900), []);
+ expect(sent(f).body.question).toHaveLength(400);
+ });
+
+ it("refuses an empty question without a round trip", async () => {
+ const { askRemote } = await load(ENDPOINT, "tok");
+ expect(await askRemote(" ", [])).toEqual({ ok: false, kind: "failed" });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("does not double the slash when the endpoint has a trailing one", async () => {
+ const f = reply({ action: { kind: "view", view: "home" } });
+ vi.stubGlobal("fetch", f);
+ const { askRemote } = await load(`${ENDPOINT}/`, "tok");
+ await askRemote("q", []);
+ expect(sent(f).url).toBe(`${ENDPOINT}/ask`);
+ });
+
+ it("maps 401 to a sign-in prompt, not a failure", async () => {
+ vi.stubGlobal("fetch", reply({ error: "unauthorized" }, 401));
+ const { askRemote } = await load(ENDPOINT, "stale");
+ expect(await askRemote("q", [])).toEqual({ ok: false, kind: "signin" });
+ });
+
+ it("maps 429 to the quota state so the student is told they are out, not broken", async () => {
+ vi.stubGlobal("fetch", reply({ error: "rate_limited" }, 429));
+ const { askRemote } = await load(ENDPOINT, "tok");
+ expect(await askRemote("q", [])).toEqual({ ok: false, kind: "limit" });
+ });
+
+ it("maps a thrown fetch to offline", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("Failed to fetch"); }));
+ const { askRemote } = await load(ENDPOINT, "tok");
+ expect(await askRemote("q", [])).toEqual({ ok: false, kind: "offline" });
+ });
+
+ it("maps a 502 upstream failure to failed", async () => {
+ vi.stubGlobal("fetch", reply({ error: "upstream" }, 502));
+ const { askRemote } = await load(ENDPOINT, "tok");
+ expect(await askRemote("q", [])).toEqual({ ok: false, kind: "failed" });
+ });
+
+ it("survives a 200 that is not JSON", async () => {
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("proxy", { status: 200 })));
+ const { askRemote } = await load(ENDPOINT, "tok");
+ expect(await askRemote("q", [])).toEqual({ ok: false, kind: "failed" });
+ });
+
+ it("survives a 200 whose action the app does not recognise", async () => {
+ vi.stubGlobal("fetch", reply({ action: { kind: "delete_everything" } }));
+ const { askRemote } = await load(ENDPOINT, "tok");
+ expect(await askRemote("q", [])).toEqual({ ok: false, kind: "failed" });
+ });
+
+ it("caps the course list at the worker's limit", async () => {
+ const f = reply({ action: { kind: "view", view: "home" } });
+ vi.stubGlobal("fetch", f);
+ const { askRemote } = await load(ENDPOINT, "tok");
+ const many = Array.from({ length: 80 }, (_, i) => ({ code: `C${i}`, name: `Course ${i}` }));
+ await askRemote("q", many);
+ expect(sent(f).body.subjects).toHaveLength(60);
+ });
+});
diff --git a/app/src/state/ask.ts b/app/src/state/ask.ts
new file mode 100644
index 0000000..91da5a2
--- /dev/null
+++ b/app/src/state/ask.ts
@@ -0,0 +1,132 @@
+import { accessToken } from "./auth";
+import type { View } from "./nav";
+
+/**
+ * The remote half of the ask box.
+ *
+ * The palette already answers most questions on its own: a stop list, a
+ * subsequence match, and every figure straight out of the engine. That path is
+ * free, instant, works offline and cannot invent anything, so it stays first
+ * and this is only ever reached when it finds nothing.
+ *
+ * What crosses the wire is a question and a course list - no marks, no
+ * attendance, no CGPA, no name, no register number (see the worker's
+ * `AskRequest`). The reply is a route, never prose and never a number. The
+ * student's figures are still computed on their own machine by the engine,
+ * which is the only reason a question box can sit on top of an app whose whole
+ * position is that it never states a number it cannot show its working for.
+ */
+
+/** Mirrors the worker's `Action`. Kept in sync by the tests, not by import. */
+export type AskAction =
+ | { kind: "view"; view: View }
+ | { kind: "subject"; code: string; view: View }
+ | { kind: "none"; reason: "off_topic" | "unclear" | "no_match" };
+
+export type AskOutcome =
+ | { ok: true; action: AskAction; remaining: number }
+ /**
+ * Failures the student can do something about are separated from the ones
+ * they cannot. `signin` and `limit` are states of their account; `offline`
+ * and `failed` are ours, and the palette says so rather than blaming them.
+ */
+ | { ok: false; kind: "unconfigured" | "signin" | "limit" | "offline" | "failed" };
+
+const ENDPOINT = String(import.meta.env.VITE_ASK_ENDPOINT ?? "").trim();
+
+const VIEW_IDS = ["home", "ledger", "attendance", "history", "data"] as const;
+const isView = (v: unknown): v is View =>
+ typeof v === "string" && (VIEW_IDS as readonly string[]).includes(v);
+
+/** Whether this build has somewhere to send a question at all. */
+export const askConfigured = (): boolean => ENDPOINT !== "";
+
+/**
+ * Parse the worker's reply.
+ *
+ * Hostile to its input for the same reason the worker's own parser is: this
+ * runs on a response that passed through a model, and an `action` the app does
+ * not recognise must become nothing rather than something. A `code` is NOT
+ * checked here - the worker already rejected any code the client did not send,
+ * and the caller matches it back to a real row before navigating.
+ */
+export function parseReply(raw: unknown): AskOutcome | null {
+ if (typeof raw !== "object" || raw === null) return null;
+ const o = raw as Record;
+ const a = o.action;
+ if (typeof a !== "object" || a === null) return null;
+ const act = a as Record;
+ const remaining = typeof o.remaining === "number" ? o.remaining : 0;
+
+ if (act.kind === "view" && isView(act.view)) {
+ return { ok: true, action: { kind: "view", view: act.view }, remaining };
+ }
+ if (act.kind === "subject" && typeof act.code === "string") {
+ return {
+ ok: true,
+ action: {
+ kind: "subject",
+ code: act.code,
+ view: isView(act.view) ? act.view : "attendance",
+ },
+ remaining,
+ };
+ }
+ if (act.kind === "none") {
+ const reason = act.reason;
+ const known = reason === "off_topic" || reason === "unclear" || reason === "no_match";
+ return {
+ ok: true,
+ action: { kind: "none", reason: known ? reason : "unclear" },
+ remaining,
+ };
+ }
+ return null;
+}
+
+/**
+ * Ask the worker to route a question.
+ *
+ * Signed out is not an error state to apologise for. Every figure in the app is
+ * still readable without an account; this one box is what an account buys, and
+ * the caller says that in a sentence rather than pushing a sign-in wall.
+ */
+export async function askRemote(
+ question: string,
+ subjects: Array<{ code: string; name: string }>,
+ signal?: AbortSignal,
+): Promise {
+ if (!askConfigured()) return { ok: false, kind: "unconfigured" };
+
+ const token = accessToken();
+ if (!token) return { ok: false, kind: "signin" };
+
+ // Trimmed to the worker's own caps before sending. A request the edge will
+ // reject as malformed is a round trip spent to be told what we already knew.
+ const q = question.trim().slice(0, 400);
+ if (q === "") return { ok: false, kind: "failed" };
+
+ let res: Response;
+ try {
+ res = await fetch(`${ENDPOINT.replace(/\/+$/, "")}/ask`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+ body: JSON.stringify({ question: q, subjects: subjects.slice(0, 60) }),
+ signal,
+ });
+ } catch {
+ // No network, DNS, a campus proxy, a dead worker - indistinguishable from
+ // here and identical to the student, who is simply not getting an answer.
+ return { ok: false, kind: "offline" };
+ }
+
+ if (res.status === 401) return { ok: false, kind: "signin" };
+ if (res.status === 429) return { ok: false, kind: "limit" };
+ if (!res.ok) return { ok: false, kind: "failed" };
+
+ try {
+ return parseReply(await res.json()) ?? { ok: false, kind: "failed" };
+ } catch {
+ return { ok: false, kind: "failed" };
+ }
+}
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx
index c05e0ad..3d61fb4 100644
--- a/app/src/ui/Palette.tsx
+++ b/app/src/ui/Palette.tsx
@@ -3,6 +3,8 @@ import { courseLabel } from "../engine";
import { rows } from "../state/store";
import { VIEWS, setView } from "../state/nav";
import type { View } from "../state/nav";
+import { askConfigured, askRemote } from "../state/ask";
+import { signedIn } from "../state/auth";
/**
* The command palette.
@@ -17,8 +19,13 @@ import type { View } from "../state/nav";
* engine's own evaluation. Nothing is generated, summarised or inferred, which
* is what makes it safe to put a question box at the top of an app whose whole
* trust position is that it never states a number it cannot show its working
- * for. When a model is eventually put behind this box, it selects among these
- * same rows - it does not get to invent one.
+ * for. The model behind this box selects among these same rows - it does not
+ * get to invent one.
+ *
+ * The remote route is deliberately the SECOND thing tried, and only on Enter.
+ * Local matching is free, offline, instant and cannot hallucinate, so it answers
+ * every question it can; calling a metered API per keystroke would be an
+ * unbounded bill for answers the machine already had.
*/
interface Hit {
@@ -75,7 +82,11 @@ function matches(haystack: string, needle: string): boolean {
export function Palette(props: { open: boolean; onClose: () => void }) {
const [query, setQuery] = createSignal("");
const [cursor, setCursor] = createSignal(0);
+ const [asking, setAsking] = createSignal(false);
+ /** What the remote route had to say, when it had to say anything. */
+ const [remote, setRemote] = createSignal(null);
let input: HTMLInputElement | undefined;
+ let inflight: AbortController | undefined;
const hits = createMemo(() => {
const q = query().trim();
@@ -129,18 +140,82 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
// A filtered list whose selection stayed put would run the wrong row on
// Enter as soon as the results moved under it.
- createEffect(() => { query(); setCursor(0); });
+ createEffect(() => {
+ query();
+ setCursor(0);
+ // A verdict about the previous question is worse than no verdict at all
+ // once the question has changed under it.
+ setRemote(null);
+ });
createEffect(() => {
if (props.open) { setQuery(""); setCursor(0); queueMicrotask(() => input?.focus()); }
});
+ // A request whose palette has closed has nobody left to answer.
+ onCleanup(() => inflight?.abort());
+
const run = (hit: Hit | undefined) => {
if (!hit) return;
hit.go();
props.onClose();
};
+ /**
+ * Hand the question to the router.
+ *
+ * Only reached when local matching found nothing, so there is no risk of the
+ * model overriding an answer the engine could already give. A returned route
+ * is followed; a returned subject is matched back to a real row before the
+ * app moves, because "the worker validated the code" and "this student has
+ * that subject" are not the same claim.
+ */
+ const ask = async () => {
+ const q = query().trim();
+ if (q === "" || asking()) return;
+ inflight?.abort();
+ const ctl = new AbortController();
+ inflight = ctl;
+ setAsking(true);
+ setRemote(null);
+ try {
+ const out = await askRemote(q, rows().map((r) => ({
+ code: r.course.code ?? "",
+ name: courseLabel(r.course),
+ })), ctl.signal);
+
+ if (!out.ok) {
+ setRemote(
+ out.kind === "signin" ? "Sign in from the profile menu to ask questions."
+ : out.kind === "limit" ? "That is all the questions for today. The rest of the app is unchanged."
+ : out.kind === "offline" ? "No connection. Everything below still works offline."
+ : out.kind === "unconfigured" ? "Question routing is not set up in this build."
+ : "That did not go through. Try rephrasing it.",
+ );
+ return;
+ }
+
+ const a = out.action;
+ if (a.kind === "view") { setView(a.view); props.onClose(); return; }
+ if (a.kind === "subject") {
+ const hit = rows().find((r) => r.course.code === a.code);
+ if (hit) { setView(a.view); props.onClose(); return; }
+ setRemote("That subject is not in this semester.");
+ return;
+ }
+ setRemote(
+ a.reason === "off_topic"
+ ? "That one is outside what TargetX knows about."
+ : a.reason === "no_match"
+ ? "Nothing in your record matches that."
+ : "Not sure what that is asking. Try naming the subject.",
+ );
+ } finally {
+ if (inflight === ctl) inflight = undefined;
+ setAsking(false);
+ }
+ };
+
const onKey = (e: KeyboardEvent) => {
const list = hits();
if (e.key === "Escape") { e.preventDefault(); props.onClose(); return; }
@@ -152,7 +227,11 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
setCursor((c) => Math.max(c - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
- run(list[cursor()]);
+ const hit = list[cursor()];
+ // The engine's answer wins whenever it has one. Enter only leaves the
+ // machine when there is nothing here to press.
+ if (hit) { run(hit); return; }
+ void ask();
}
};
@@ -168,7 +247,23 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
onKeyDown={onKey} />
0} fallback={
-
Nothing matches “{query()}”.
+
+
+
Nothing here matches “{query()}”.
+ {/* The offer is only made when it can be honoured. Telling a
+ signed-out student to press Enter and then refusing them
+ is worse than not offering. */}
+
+
+ {asking() ? "Working it out…" : "Press Enter to ask."}
+
+
+ >
+ }>
+ {(said) =>
{said()}
}
+
+
}>
{(hit, i) => (
diff --git a/app/src/ui/__tests__/HeaderPopovers.test.tsx b/app/src/ui/__tests__/HeaderPopovers.test.tsx
index 22b5060..415af84 100644
--- a/app/src/ui/__tests__/HeaderPopovers.test.tsx
+++ b/app/src/ui/__tests__/HeaderPopovers.test.tsx
@@ -14,10 +14,15 @@
* broken build.
*
* The panels are queried on `document` rather than on the render container:
- * they are rendered through a Portal into `document.body`, out of the header,
- * because the header carries a backdrop-filter and WebView2 clips a filtered
- * element's descendants to its own box - which painted them and then clipped
- * them away in the shipped app while every test passed.
+ * they are rendered through a Portal into `document.body`. The Portal is there
+ * so a panel anchored to a header button is not clipped or stacked by the
+ * header's own box, which is the ordinary reason a popover is portalled.
+ *
+ * An earlier version of this comment blamed a WebView2 backdrop-filter clipping
+ * bug for blank panels in the running app. That was wrong and is recorded here
+ * so it is not re-derived: the panels were blank because a dev server that had
+ * been up for hours had silently stopped applying HMR updates. Restarting it
+ * fixed it. Nothing about backdrop-filter was involved.
*/
import { cleanup, render } from "@solidjs/testing-library";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
diff --git a/app/src/ui/__tests__/PaletteAsk.test.tsx b/app/src/ui/__tests__/PaletteAsk.test.tsx
new file mode 100644
index 0000000..15a809f
--- /dev/null
+++ b/app/src/ui/__tests__/PaletteAsk.test.tsx
@@ -0,0 +1,151 @@
+// @vitest-environment jsdom
+/**
+ * When the ask box is allowed to leave the machine.
+ *
+ * The palette answers most questions locally: a stop list, a subsequence match,
+ * and figures straight from the engine. That path is free, offline, instant and
+ * cannot invent anything. The remote router is strictly a fallback, and the two
+ * rules below are what keep it one:
+ *
+ * - Enter runs the local hit when there is one, and only reaches the network
+ * when there is nothing to press. A palette that asked on every Enter would
+ * bill a metered API for answers the machine already had.
+ * - Nothing but the question and the course list is ever sent. Marks,
+ * attendance and CGPA stay here, which is what lets an academic tracker put
+ * a question box on top of a third-party model at all.
+ *
+ * `askRemote` is mocked rather than `fetch`, because the assertion is about
+ * WHETHER the palette calls out and with what - not about how the client
+ * serialises it. That is `ask.test.ts`'s job and it is tested there.
+ */
+import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { Course } from "../../engine";
+import { addCourse, edit, updateCourse } from "../../state/store";
+
+const askRemote = vi.fn();
+vi.mock("../../state/ask", () => ({
+ askConfigured: () => true,
+ askRemote: (...args: unknown[]) => askRemote(...args),
+}));
+vi.mock("../../state/auth", () => ({ signedIn: () => true }));
+
+const { Palette } = await import("../Palette");
+
+const ML: Partial = {
+ code: "CST305", name: "Machine Learning", credits: 4, type: "TH 40/60",
+ s1: 38, s2: 34, other: 9, attended: 39, held: 50, dl: 0,
+};
+
+function open() {
+ edit((d) => {
+ d.semesters = { S5: { courses: [] } };
+ d.activeSemester = "S5";
+ d.history = {};
+ });
+ addCourse();
+ updateCourse(0, ML);
+ return render(() => {}} />);
+}
+
+const type = (value: string) => {
+ const input = screen.getByLabelText("Search subjects and views") as HTMLInputElement;
+ fireEvent.input(input, { target: { value } });
+ return input;
+};
+
+afterEach(() => { cleanup(); askRemote.mockReset(); });
+beforeEach(() => {
+ askRemote.mockResolvedValue({
+ ok: true, action: { kind: "view", view: "attendance" }, remaining: 39,
+ });
+});
+
+describe("the engine answers first", () => {
+ it("does not call out when a subject matched locally", async () => {
+ open();
+ type("machine learning");
+ // There is a row to press, so Enter presses it.
+ fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" });
+ await Promise.resolve();
+ expect(askRemote).not.toHaveBeenCalled();
+ });
+
+ it("does not call out for a question with no nameable term, which every subject answers", async () => {
+ open();
+ // "how many classes can i miss" is all stop words, so the palette treats it
+ // as a question about every subject and lists them. Nothing to ask.
+ type("how many classes can i miss");
+ fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" });
+ await Promise.resolve();
+ expect(askRemote).not.toHaveBeenCalled();
+ });
+
+ it("does not call out on an empty box", async () => {
+ open();
+ fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" });
+ await Promise.resolve();
+ expect(askRemote).not.toHaveBeenCalled();
+ });
+});
+
+describe("the router is the fallback", () => {
+ it("asks only once local matching found nothing", async () => {
+ open();
+ const input = type("zzzqqq");
+ expect(screen.getByText(/Nothing here matches/)).toBeTruthy();
+ fireEvent.keyDown(input, { key: "Enter" });
+ await vi.waitFor(() => expect(askRemote).toHaveBeenCalledTimes(1));
+ });
+
+ it("sends the question and the course list, and no figure of any kind", async () => {
+ open();
+ fireEvent.keyDown(type("zzzqqq"), { key: "Enter" });
+ await vi.waitFor(() => expect(askRemote).toHaveBeenCalled());
+
+ const [question, subjects] = askRemote.mock.calls[0] as [string, unknown[]];
+ expect(question).toBe("zzzqqq");
+ // Asserted as an exact payload rather than as "no digits appear": a course
+ // code has digits in it, so a regex over the JSON would fail on every real
+ // subject while proving nothing. What matters is that the seeded course's
+ // 39 of 50 classes and its 38/34/9 marks are not in here, and an equality
+ // check on the whole array says that and nothing weaker.
+ expect(subjects).toEqual([{ code: "CST305", name: "Machine Learning" }]);
+ });
+
+ it("tells the student what happened when the router declines", async () => {
+ askRemote.mockResolvedValue({
+ ok: true, action: { kind: "none", reason: "off_topic" }, remaining: 39,
+ });
+ open();
+ fireEvent.keyDown(type("zzzqqq"), { key: "Enter" });
+ await vi.waitFor(() =>
+ expect(screen.getByText(/outside what TargetX knows about/)).toBeTruthy());
+ });
+
+ it("says the app still works when there is no connection, rather than only reporting failure", async () => {
+ askRemote.mockResolvedValue({ ok: false, kind: "offline" });
+ open();
+ fireEvent.keyDown(type("zzzqqq"), { key: "Enter" });
+ await vi.waitFor(() => expect(screen.getByText(/still works offline/)).toBeTruthy());
+ });
+
+ it("refuses to navigate to a subject this student does not have", async () => {
+ askRemote.mockResolvedValue({
+ ok: true, action: { kind: "subject", code: "NOPE999", view: "attendance" }, remaining: 39,
+ });
+ open();
+ fireEvent.keyDown(type("zzzqqq"), { key: "Enter" });
+ await vi.waitFor(() =>
+ expect(screen.getByText(/not in this semester/)).toBeTruthy());
+ });
+
+ it("clears a stale verdict as soon as the question changes under it", async () => {
+ askRemote.mockResolvedValue({ ok: false, kind: "offline" });
+ open();
+ fireEvent.keyDown(type("zzzqqq"), { key: "Enter" });
+ await vi.waitFor(() => expect(screen.getByText(/still works offline/)).toBeTruthy());
+ type("zzzqqqw");
+ expect(screen.queryByText(/still works offline/)).toBeNull();
+ });
+});
From 2aff0dd9e7913d984a4a99ad771f8b85f5e44e04 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 19:10:54 +0530
Subject: [PATCH 18/32] fix(worker): route on gemini-flash-lite-latest, and
stop the palette 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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/ui/Palette.tsx | 45 ++++++++++++++++++++++++++----------------
worker/src/gemini.ts | 18 ++++++++++++++++-
2 files changed, 45 insertions(+), 18 deletions(-)
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx
index 3d61fb4..59f6ffd 100644
--- a/app/src/ui/Palette.tsx
+++ b/app/src/ui/Palette.tsx
@@ -90,6 +90,12 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
const hits = createMemo(() => {
const q = query().trim();
+ // An empty box lists nothing. It used to open onto every view and every
+ // subject, which is not a set of results - it is the whole app enumerated
+ // in the one place a student came to narrow it down. There is nothing to
+ // read there and nothing to choose between, so the list starts when the
+ // typing does.
+ if (q === "") return [];
// A question with nothing nameable left in it - "how many can I miss" -
// is not a failed search. It is a question about every subject, so every
// subject answers it.
@@ -247,23 +253,28 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
onKeyDown={onKey} />
0} fallback={
-
-
-
Nothing here matches “{query()}”.
- {/* The offer is only made when it can be honoured. Telling a
- signed-out student to press Enter and then refusing them
- is worse than not offering. */}
-
-
- {asking() ? "Working it out…" : "Press Enter to ask."}
-
-
- >
- }>
- {(said) =>
{said()}
}
-
-
+ // An untouched box says nothing at all. "Nothing matches" is a
+ // verdict on a search, and no search has happened yet - the
+ // placeholder in the field is already the whole instruction.
+
+
+
+
Nothing here matches “{query().trim()}”.
+ {/* The offer is only made when it can be honoured. Telling
+ a signed-out student to press Enter and then refusing
+ them is worse than not offering. */}
+
+
+ {asking() ? "Working it out…" : "Press Enter to ask."}
+
+
+ >
+ }>
+ {(said) =>
{said()}
}
+
+
+
}>
{(hit, i) => (
diff --git a/worker/src/gemini.ts b/worker/src/gemini.ts
index e06d3a9..5fe0b07 100644
--- a/worker/src/gemini.ts
+++ b/worker/src/gemini.ts
@@ -13,8 +13,24 @@
import type { AskRequest } from "./schema";
import { VIEWS } from "./schema";
+/**
+ * The routing model.
+ *
+ * `flash-lite` because this is a classification with a fixed output schema and
+ * five possible destinations - the cheapest model that can read a course list
+ * is the right one, and a larger model buys nothing a JSON enum has already
+ * decided.
+ *
+ * The floating alias rather than a pinned version, which is the opposite of the
+ * usual advice and is deliberate. `gemini-2.0-flash` was pinned here and Google
+ * retired it; the endpoint began returning 404 and every question in the app
+ * failed. A pinned version's failure mode is total and arrives without warning.
+ * The alias's failure mode is that the model behind it changes - and with
+ * temperature 0, a schema the API enforces during decoding, and `parseAction`
+ * rejecting anything outside it, there is very little for a change to break.
+ */
const ENDPOINT =
- "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent";
+ "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent";
const SYSTEM = `You route questions inside TargetX, a KTU academic tracker.
From 9b56219a926eef7cb7efeae6a135a3752192c5db Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 19:24:38 +0530
Subject: [PATCH 19/32] fix(palette): match a sentence's words literally, not
as subsequences
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/ui/Palette.tsx | 25 ++++++++++--
app/src/ui/__tests__/PaletteAsk.test.tsx | 49 +++++++++++++++++++++++-
2 files changed, 70 insertions(+), 4 deletions(-)
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx
index 59f6ffd..0cfca1b 100644
--- a/app/src/ui/Palette.tsx
+++ b/app/src/ui/Palette.tsx
@@ -65,12 +65,28 @@ function terms(q: string): string[] {
.filter((w) => w.length > 0 && !STOP.has(w));
}
-/** Subsequence match, so "cn" finds "Computer Networks". */
-function matches(haystack: string, needle: string): boolean {
+/**
+ * Match a subject label against one term.
+ *
+ * `loose` allows a subsequence, so "cn" finds "Computer Networks". That is the
+ * behaviour a student typing an abbreviation wants, and it is ONLY safe when
+ * the abbreviation is the whole query.
+ *
+ * Applied to words pulled out of a sentence it matches almost anything, and
+ * measurably did: "if i took a leave tomorrow how badly would it affect my
+ * attendance" left the terms [took, tommorw, badly, would, affect], and "took"
+ * subsequence-matched "Computer Networks" - t, o, o, k in order, scattered
+ * across three words. The student got one arbitrary subject and no answer.
+ *
+ * Worse than a bad row: a local hit stops Enter from reaching the router, so a
+ * phantom match silently disables the one path that could have answered.
+ */
+function matches(haystack: string, needle: string, loose = true): boolean {
if (!needle) return true;
const h = haystack.toLowerCase();
const n = needle.toLowerCase();
if (h.includes(n)) return true;
+ if (!loose) return false;
let i = 0;
for (const ch of h) {
if (ch === n[i]) i += 1;
@@ -100,8 +116,11 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
// is not a failed search. It is a question about every subject, so every
// subject answers it.
const words = terms(q);
+ // One term is an abbreviation and gets the loose match. Several terms are a
+ // sentence, and a sentence's words have to appear literally - see `matches`.
+ const loose = words.length === 1;
const anyTerm = (hay: string) =>
- words.length === 0 || words.some((w) => matches(hay, w));
+ words.length === 0 || words.some((w) => matches(hay, w, loose));
const out: Hit[] = [];
for (const v of VIEWS) {
diff --git a/app/src/ui/__tests__/PaletteAsk.test.tsx b/app/src/ui/__tests__/PaletteAsk.test.tsx
index 15a809f..8862e38 100644
--- a/app/src/ui/__tests__/PaletteAsk.test.tsx
+++ b/app/src/ui/__tests__/PaletteAsk.test.tsx
@@ -37,6 +37,21 @@ const ML: Partial = {
s1: 38, s2: 34, other: 9, attended: 39, held: 50, dl: 0,
};
+/**
+ * The subject that makes the phantom-match bug visible.
+ *
+ * "Computer Networks" contains o, n, e in order, so the word "one" - left
+ * behind by the stop list from "what happens if i miss one more class" -
+ * subsequence-matched it. "Machine Learning" does not, so a test seeded only
+ * with ML would pass against the broken build and prove nothing. This course
+ * is here to make the test able to fail; the revert check below confirms it
+ * does.
+ */
+const CN: Partial = {
+ code: "CST303", name: "Computer Networks", credits: 4, type: "TH 40/60",
+ s1: 30, s2: 28, other: 8, attended: 44, held: 50, dl: 0,
+};
+
function open() {
edit((d) => {
d.semesters = { S5: { courses: [] } };
@@ -45,6 +60,8 @@ function open() {
});
addCourse();
updateCourse(0, ML);
+ addCourse();
+ updateCourse(1, CN);
return render(() => {}} />);
}
@@ -81,6 +98,33 @@ describe("the engine answers first", () => {
expect(askRemote).not.toHaveBeenCalled();
});
+ it("does not let a sentence phantom-match a subject and swallow the question", async () => {
+ open();
+ // Measured against the real haystacks, not supposed: the palette matches
+ // the course NAME and the CODE as two separate strings (courseLabel is the
+ // name alone, engine/course.ts:44). The stop list leaves [happens, one],
+ // and "one" subsequence-matches "Computer Networks" - o, n, e in order.
+ //
+ // The damage is not the bad row. A local hit stops Enter from reaching the
+ // router, so a phantom match silently disables the one path that could
+ // have answered a question the engine cannot.
+ const input = type("what happens if i miss one more class");
+ expect(screen.getByText(/Nothing here matches/)).toBeTruthy();
+ fireEvent.keyDown(input, { key: "Enter" });
+ await vi.waitFor(() => expect(askRemote).toHaveBeenCalledTimes(1));
+ });
+
+ it("still resolves an abbreviation typed as the whole query", async () => {
+ open();
+ // The loose match is why it exists, and it survives: "ml" is one term, so
+ // it may match a subsequence, and it finds Machine Learning.
+ type("ml");
+ expect(screen.getByText("Machine Learning")).toBeTruthy();
+ fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" });
+ await Promise.resolve();
+ expect(askRemote).not.toHaveBeenCalled();
+ });
+
it("does not call out on an empty box", async () => {
open();
fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" });
@@ -110,7 +154,10 @@ describe("the router is the fallback", () => {
// subject while proving nothing. What matters is that the seeded course's
// 39 of 50 classes and its 38/34/9 marks are not in here, and an equality
// check on the whole array says that and nothing weaker.
- expect(subjects).toEqual([{ code: "CST305", name: "Machine Learning" }]);
+ expect(subjects).toEqual([
+ { code: "CST305", name: "Machine Learning" },
+ { code: "CST303", name: "Computer Networks" },
+ ]);
});
it("tells the student what happened when the router declines", async () => {
From c0ef7d0b91e3de69ca9c555c72f8ba272f68d0df Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 19:34:59 +0530
Subject: [PATCH 20/32] fix(history): a blank box is not a delete, and a typed
figure is not 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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/engine/history.ts | 11 +-
app/src/state/store.ts | 28 ++-
app/src/styles/screens.css | 17 ++
app/src/ui/History.tsx | 53 +++++-
app/src/ui/__tests__/HistoryEdits.test.tsx | 189 +++++++++++++++++++++
5 files changed, 292 insertions(+), 6 deletions(-)
create mode 100644 app/src/ui/__tests__/HistoryEdits.test.tsx
diff --git a/app/src/engine/history.ts b/app/src/engine/history.ts
index 982a2a7..615c6d4 100644
--- a/app/src/engine/history.ts
+++ b/app/src/engine/history.ts
@@ -19,8 +19,15 @@ export const HISTORY_RANK: Record = {
etlab: 0,
};
-/** Two SGPAs are "the same" within the precision either source prints. */
-const sameSgpa = (a: number, b: number): boolean => Math.abs(a - b) < 0.005;
+/**
+ * Two SGPAs are "the same" within the precision either source prints.
+ *
+ * Exported because `setHistory` asks the same question when it decides whether
+ * a hand edit changed the figure or only the credits beside it. A second copy
+ * of this tolerance would be a second definition of "the student changed it",
+ * and the two would drift.
+ */
+export const sameSgpa = (a: number, b: number): boolean => Math.abs(a - b) < 0.005;
/**
* Fold one incoming history figure onto whatever is already stored for that
diff --git a/app/src/state/store.ts b/app/src/state/store.ts
index 627c4aa..e34d53b 100644
--- a/app/src/state/store.ts
+++ b/app/src/state/store.ts
@@ -5,7 +5,7 @@ import {
attendanceTargetGap, cgpaFromSemesters, checkAttendanceTarget, checkGpaTarget,
courseFromCode, defaultState, evaluate, historyCredits, horizonToGraduation,
normaliseTargets, planForSgpa, reconcileSgpaTarget, requiredSgpaForCgpa,
- sgpaTargetFor, statusFor, summarise, toFloat, toOptionalFloat,
+ sameSgpa, sgpaTargetFor, statusFor, summarise, toFloat, toOptionalFloat,
} from "../engine";
import type { Course, HistorySource, MarkInput, SemesterHistory, Targets } from "../engine";
import type { AppState, Semester } from "../engine/course";
@@ -394,11 +394,33 @@ export function setHistory(name: string, sgpa: number, creditsRegistered: number
// `mergeHistory` instead would let a stored grade card discard the credits
// the student just typed, which is the opposite of the screen's job.
const prev = s.history[name];
+ // Whether the SGPA itself moved decides the provenance, and only that.
+ //
+ // Editing the credits box leaves the figure the card published, so the
+ // record is still the card's - it keeps `gradecard` and outranks a portal
+ // scrape on the next sync, which is the screen's job.
+ //
+ // Typing over the SGPA is a different act. The number is no longer the one
+ // the university published, and leaving `source: "gradecard"` on it would
+ // have the app claim a grade card said something it never said - and hold
+ // that claim at rank 3, where a later real fetch of the correct figure ties
+ // with it and can silently lose. A hand-typed figure is `manual`, rank 2:
+ // still trusted over a scrape, because it was deliberate, and correctly
+ // beaten by the card it replaced if that card is fetched again.
+ const changed = prev !== undefined && !sameSgpa(prev.sgpa, sgpa);
+ // The displaced figure is not discarded. `conflict` exists to hold a value
+ // from another source that disagrees with the stored one, which is exactly
+ // what the card's number now is - so the row goes on showing "the KTU grade
+ // card said 8.42" and the student can put it back.
+ const displaced = changed && prev.source !== "manual"
+ ? { source: prev.source, sgpa: prev.sgpa }
+ : prev?.conflict ?? null;
+
s.history[name] = {
sgpa, creditsRegistered,
creditsEarned: prev?.creditsEarned ?? null,
- source: prev?.source ?? "manual",
- conflict: prev?.conflict ?? null,
+ source: changed ? "manual" : prev?.source ?? "manual",
+ conflict: displaced,
};
});
}
diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css
index 43e08ea..26112a8 100644
--- a/app/src/styles/screens.css
+++ b/app/src/styles/screens.css
@@ -1100,3 +1100,20 @@ a.link:hover { text-decoration-thickness: 2px; }
min-inline-size: 280px;
}
.goal-chips { display: flex; flex-wrap: wrap; gap: var(--s2); }
+
+/* The per-row remove control on History.
+ Named `remove-go` rather than `danger`: `button.danger` is a filled
+ destructive button with its own background, and `class="link danger"` would
+ match both rules - the collision that once painted a sign-in control as a
+ bare swatch. Colour alone carries the difference here, so the confirmation
+ also states what is being discarded in words beside it. */
+.row-remove { text-align: right; white-space: nowrap; }
+.confirm-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s3);
+ justify-content: flex-end;
+}
+.confirm-inline .fineprint { color: var(--text-dim); }
+button.link.remove-go { color: var(--danger); }
+button.link.remove-go:hover { color: var(--danger); font-weight: 600; }
diff --git a/app/src/ui/History.tsx b/app/src/ui/History.tsx
index 7a07f8b..24b09d2 100644
--- a/app/src/ui/History.tsx
+++ b/app/src/ui/History.tsx
@@ -135,6 +135,10 @@ export function History() {
Earned
Recomputed
Cross-check
+ {/* No visible label: the column holds one control per row and
+ each already names its own semester. A header word here
+ would be read out before every cell in the table. */}
+
Remove semester
@@ -229,6 +233,14 @@ function HistoryRow(props: { row: Row }) {
props.row.published ? String(props.row.published.sgpa) : "");
const [creditDraft, setCreditDraft] = createSignal(
props.row.registered === null ? "" : String(props.row.registered));
+ /**
+ * Removal is two presses, and deliberately not a modal.
+ *
+ * The first press turns the control into the confirmation, in the row being
+ * removed, so what is about to be discarded is on screen beside the question
+ * rather than described in a dialog covering it.
+ */
+ const [confirming, setConfirming] = createSignal(false);
const commit = () => {
const sgpaValue = Number(sgpaDraft().trim());
@@ -238,7 +250,19 @@ function HistoryRow(props: { row: Row }) {
// store is still a store write, and every write re-runs the memo this
// table is built from. Tabbing across a row must cost nothing.
if (!props.row.published) return;
- edit((s) => { delete s.history[props.row.name]; });
+ // An empty box means "I do not know this", never "destroy the record".
+ //
+ // Clearing it used to delete the entire SemesterHistory - the SGPA, both
+ // credit totals, the source and the recorded conflict - with no
+ // confirmation and no undo, on blur. Select-all, Backspace, Tab was
+ // enough, and the CGPA in the header silently dropped. That is the most
+ // destructive action in the app, and it was the only one with no guard,
+ // while erasing everything two screens away has a two-step confirmation.
+ //
+ // Removing a semester is now a deliberate act with its own control. A
+ // blur just puts back what is stored, so the box cannot be used to
+ // discard a figure the university published.
+ setSgpaDraft(String(props.row.published.sgpa));
return;
}
if (!Number.isFinite(sgpaValue)) return;
@@ -314,6 +338,33 @@ function HistoryRow(props: { row: Row }) {
)}
+
+ {/* Only offered where there is something to remove. An empty row has
+ nothing to discard and the control would be a no-op wearing a
+ destructive label. */}
+
+ setConfirming(true)}>Remove
+ }>
+
+ {/* Names the figure being discarded, not just the semester. The
+ SGPA is the thing that took a semester to earn and the thing
+ the CGPA above will move without. */}
+
+ Discard {props.row.name} (
+ {props.row.published!.sgpa.toFixed(2)})?
+
+ {
+ edit((s) => { delete s.history[props.row.name]; });
+ setConfirming(false);
+ }}>Remove
+ setConfirming(false)}>Keep
+
+
+
+
);
}
diff --git a/app/src/ui/__tests__/HistoryEdits.test.tsx b/app/src/ui/__tests__/HistoryEdits.test.tsx
new file mode 100644
index 0000000..4324067
--- /dev/null
+++ b/app/src/ui/__tests__/HistoryEdits.test.tsx
@@ -0,0 +1,189 @@
+// @vitest-environment jsdom
+/**
+ * What a hand edit on the History table is allowed to do.
+ *
+ * Two rules, both of which the screen previously broke, and both of which are
+ * the same rule seen twice: the app must never hold a number it cannot account
+ * for, and must never silently discard one the university published.
+ *
+ * - An empty box means "I do not know this". It used to mean "delete the
+ * entire semester record", on blur, with no confirmation and no undo -
+ * select-all, Backspace, Tab was enough to drop an SGPA, both credit
+ * totals, the source and the recorded conflict, and to move the CGPA in
+ * the header with no explanation.
+ * - Typing over a figure changes whose figure it is. Keeping `gradecard` on
+ * a hand-typed number would have the app claim the university published
+ * something it never did, and hold that claim at the rank where a later
+ * real fetch of the correct figure ties with it and can lose.
+ */
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library";
+import { defaultTargets } from "../../engine";
+import { edit, state } from "../../state/store";
+import { History } from "../History";
+
+const reset = () => {
+ edit((s) => {
+ s.activeSemester = "S5";
+ s.semesters = { S5: { courses: [] } };
+ s.history = {};
+ s.goal = defaultTargets();
+ });
+};
+
+/** A semester the university published, which is the case with something to lose. */
+const seedCard = () => edit((s) => {
+ s.history["S3"] = {
+ sgpa: 8.42, creditsRegistered: 20, creditsEarned: 20,
+ source: "gradecard", conflict: null,
+ };
+});
+
+const sgpaBox = () =>
+ screen.getByLabelText("Published SGPA for S3") as HTMLInputElement;
+const creditBox = () =>
+ screen.getByLabelText("Registered credits for S3") as HTMLInputElement;
+
+/** Type a value and blur, which is what commits on this screen. */
+const put = (box: HTMLInputElement, value: string) => {
+ fireEvent.input(box, { target: { value } });
+ fireEvent.blur(box);
+};
+
+beforeEach(reset);
+afterEach(cleanup);
+
+describe("an empty box is not a delete", () => {
+ it("keeps the semester when the SGPA is cleared and blurred", () => {
+ seedCard();
+ render(() => );
+ put(sgpaBox(), "");
+
+ // The record survives, whole - not just the row.
+ expect(state.history["S3"]).toBeDefined();
+ expect(state.history["S3"]!.sgpa).toBe(8.42);
+ expect(state.history["S3"]!.source).toBe("gradecard");
+ expect(state.history["S3"]!.creditsRegistered).toBe(20);
+ });
+
+ it("puts the stored figure back in the box, so the screen and the store agree", () => {
+ seedCard();
+ render(() => );
+ put(sgpaBox(), "");
+ // A box left blank over a stored 8.42 would be the screen showing one
+ // thing and the CGPA above being computed from another.
+ expect(sgpaBox().value).toBe("8.42");
+ });
+
+ it("still writes nothing at all for a row that has no stored record", () => {
+ edit((s) => { s.semesters = { S3: { courses: [] }, S5: { courses: [] } }; });
+ render(() => );
+ put(sgpaBox(), "");
+ expect(state.history["S3"]).toBeUndefined();
+ });
+});
+
+describe("removing a semester is deliberate", () => {
+ it("does not remove on the first press", () => {
+ seedCard();
+ render(() => );
+ fireEvent.click(screen.getByLabelText("Remove S3 from history"));
+ expect(state.history["S3"]).toBeDefined();
+ });
+
+ it("names the figure being discarded before discarding it", () => {
+ seedCard();
+ render(() => );
+ fireEvent.click(screen.getByLabelText("Remove S3 from history"));
+ // The SGPA, not just the semester name: it is the thing that took a
+ // semester to earn and the thing the CGPA will move without.
+ //
+ // Scoped to the confirmation group rather than the whole screen - 8.42 is
+ // also sitting in the input box, so a document-wide query would pass
+ // without the confirmation naming anything at all.
+ const group = screen.getByRole("group", { name: "Remove S3?" });
+ expect(group.textContent).toMatch(/Discard S3/);
+ expect(group.textContent).toMatch(/8\.42/);
+ });
+
+ it("removes only on the confirming press", () => {
+ seedCard();
+ render(() => );
+ fireEvent.click(screen.getByLabelText("Remove S3 from history"));
+ fireEvent.click(screen.getByText("Remove"));
+ expect(state.history["S3"]).toBeUndefined();
+ });
+
+ it("keeps the record when the student backs out", () => {
+ seedCard();
+ render(() => );
+ fireEvent.click(screen.getByLabelText("Remove S3 from history"));
+ fireEvent.click(screen.getByText("Keep"));
+ expect(state.history["S3"]).toBeDefined();
+ expect(state.history["S3"]!.sgpa).toBe(8.42);
+ });
+
+ it("offers nothing to remove on a row with no record", () => {
+ edit((s) => { s.semesters = { S3: { courses: [] }, S5: { courses: [] } }; });
+ render(() => );
+ expect(screen.queryByLabelText("Remove S3 from history")).toBeNull();
+ });
+});
+
+describe("editing a figure changes whose figure it is", () => {
+ it("marks an overwritten grade-card SGPA as the student's own entry", () => {
+ seedCard();
+ render(() => );
+ put(sgpaBox(), "8.6");
+
+ expect(state.history["S3"]!.sgpa).toBe(8.6);
+ // Not `gradecard`. No grade card ever carried 8.6, and claiming one did
+ // would hold the figure at the rank that beats a real KTU fetch.
+ expect(state.history["S3"]!.source).toBe("manual");
+ });
+
+ it("keeps the displaced university figure so it can be seen and put back", () => {
+ seedCard();
+ render(() => );
+ put(sgpaBox(), "8.6");
+
+ expect(state.history["S3"]!.conflict)
+ .toEqual({ source: "gradecard", sgpa: 8.42 });
+ // And it is on screen, not merely in the store.
+ expect(screen.getByText(/KTU grade card said/i)).toBeTruthy();
+ });
+
+ it("leaves the source alone when only the credits box moved", () => {
+ seedCard();
+ render(() => );
+ put(creditBox(), "22");
+
+ // The SGPA is still exactly what the card published, so the record is
+ // still the card's - which is what lets it outrank a portal scrape.
+ expect(state.history["S3"]!.creditsRegistered).toBe(22);
+ expect(state.history["S3"]!.source).toBe("gradecard");
+ expect(state.history["S3"]!.conflict).toBeNull();
+ });
+
+ it("does not re-tag the record when the SGPA is retyped identically", () => {
+ seedCard();
+ render(() => );
+ put(sgpaBox(), "8.42");
+ expect(state.history["S3"]!.source).toBe("gradecard");
+ });
+
+ it("does not stack a conflict when a hand entry is edited again", () => {
+ edit((s) => {
+ s.history["S3"] = {
+ sgpa: 7.5, creditsRegistered: 20, creditsEarned: 20,
+ source: "manual", conflict: null,
+ };
+ });
+ render(() => );
+ put(sgpaBox(), "7.9");
+ // Replacing one of the student's own guesses with another displaces no
+ // published figure, so there is no disagreement to record.
+ expect(state.history["S3"]!.source).toBe("manual");
+ expect(state.history["S3"]!.conflict).toBeNull();
+ });
+});
From 368f98a51daac838a12f8ed28cacdf894bb1292e Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 19:39:49 +0530
Subject: [PATCH 21/32] feat(palette): match screens on the words the stop list
throws away
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/nav.ts | 35 ++++++++++++++---
app/src/ui/Palette.tsx | 22 ++++++++++-
app/src/ui/__tests__/PaletteAsk.test.tsx | 49 +++++++++++++++++++-----
3 files changed, 89 insertions(+), 17 deletions(-)
diff --git a/app/src/state/nav.ts b/app/src/state/nav.ts
index c9afdb2..86148bc 100644
--- a/app/src/state/nav.ts
+++ b/app/src/state/nav.ts
@@ -12,12 +12,35 @@ import { state } from "./store";
export type View = "home" | "ledger" | "attendance" | "history" | "data";
-export const VIEWS: Array<{ id: View; label: string; hint: string }> = [
- { id: "home", label: "Home", hint: "Where you stand and what needs doing" },
- { id: "ledger", label: "Semester", hint: "Marks, attendance and what you still need" },
- { id: "attendance", label: "Attendance", hint: "How many classes you can still miss, per subject" },
- { id: "history", label: "History", hint: "Published results from past semesters" },
- { id: "data", label: "Data", hint: "Sync, import, catalogue and backup" },
+/**
+ * `keys` are the words a student uses for a screen that are not its name.
+ *
+ * The search box strips domain words - "attendance", "marks", "classes" - out
+ * of a question before matching subjects, because otherwise every phrasing of
+ * every question matches every subject. But those same words are precisely what
+ * names a SCREEN, so "am i short on attendance" matched nothing at all: the one
+ * useful word in it had been removed before anything was compared.
+ *
+ * Views are therefore matched on these instead, against the raw question. They
+ * are an explicit list rather than the hint text because the hints are English
+ * sentences - matching "what needs doing" would have "what" open Home.
+ */
+export const VIEWS: Array<{ id: View; label: string; hint: string; keys: string[] }> = [
+ { id: "home", label: "Home", hint: "Where you stand and what needs doing",
+ keys: ["cgpa", "overall", "standing", "summary", "risk", "target", "goal"] },
+ { id: "ledger", label: "Semester", hint: "Marks, attendance and what you still need",
+ keys: ["mark", "marks", "cie", "ese", "internal", "series", "subject",
+ "subjects", "grade", "grades", "pass", "fail", "sgpa"] },
+ { id: "attendance", label: "Attendance", hint: "How many classes you can still miss, per subject",
+ keys: ["attendance", "absent", "present", "bunk", "leave", "leaves", "skip",
+ "miss", "class", "classes", "eligible", "eligibility", "debar",
+ "debarred", "condonation", "timetable", "schedule"] },
+ { id: "history", label: "History", hint: "Published results from past semesters",
+ keys: ["history", "past", "previous", "last", "result", "results",
+ "gradecard", "transcript", "semester"] },
+ { id: "data", label: "Data", hint: "Sync, import, catalogue and backup",
+ keys: ["sync", "etlab", "ktu", "import", "export", "backup", "restore",
+ "password", "login", "portal", "catalogue"] },
];
// Home is the landing screen: the ledger answers "what are my marks", which is
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx
index 0cfca1b..5fee3ad 100644
--- a/app/src/ui/Palette.tsx
+++ b/app/src/ui/Palette.tsx
@@ -86,6 +86,12 @@ function matches(haystack: string, needle: string, loose = true): boolean {
const h = haystack.toLowerCase();
const n = needle.toLowerCase();
if (h.includes(n)) return true;
+ // Initials, which work in a sentence because they cannot over-match: the
+ // initials of "Computer Networks" are exactly "cn" and nothing else. Losing
+ // this was the cost of the fix above - "what do i need to pass cn" is four
+ // terms, so the loose path is closed and "cn" stopped resolving. This gives
+ // it back without giving back "one" matching Computer Networks.
+ if (n.length >= 2 && initials(h) === n) return true;
if (!loose) return false;
let i = 0;
for (const ch of h) {
@@ -95,6 +101,15 @@ function matches(haystack: string, needle: string, loose = true): boolean {
return false;
}
+/** "Computer Networks" -> "cn". Words only; "and"/"of" are not initials. */
+const SKIP_WORD = new Set(["and", "of", "the", "for", "in", "to", "a"]);
+function initials(label: string): string {
+ return label.toLowerCase().split(/[^a-z0-9]+/)
+ .filter((w) => w.length > 0 && !SKIP_WORD.has(w))
+ .map((w) => w[0])
+ .join("");
+}
+
export function Palette(props: { open: boolean; onClose: () => void }) {
const [query, setQuery] = createSignal("");
const [cursor, setCursor] = createSignal(0);
@@ -123,8 +138,13 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
words.length === 0 || words.some((w) => matches(hay, w, loose));
const out: Hit[] = [];
+ // Views are matched on the RAW question, not on `terms`. The stop list
+ // exists to stop domain words matching every subject, and those same words
+ // - attendance, marks, results, sync - are the ones that name a screen. A
+ // question stripped of them had nothing left to match a view with.
+ const raw = new Set(q.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
for (const v of VIEWS) {
- if (!matches(v.label, q)) continue;
+ if (!matches(v.label, q) && !v.keys.some((k) => raw.has(k))) continue;
out.push({
kind: "view", label: v.label, detail: v.hint ?? "",
go: () => setView(v.id as View),
diff --git a/app/src/ui/__tests__/PaletteAsk.test.tsx b/app/src/ui/__tests__/PaletteAsk.test.tsx
index 8862e38..49ee9ca 100644
--- a/app/src/ui/__tests__/PaletteAsk.test.tsx
+++ b/app/src/ui/__tests__/PaletteAsk.test.tsx
@@ -98,20 +98,22 @@ describe("the engine answers first", () => {
expect(askRemote).not.toHaveBeenCalled();
});
- it("does not let a sentence phantom-match a subject and swallow the question", async () => {
+ it("does not let a sentence phantom-match a subject", () => {
open();
// Measured against the real haystacks, not supposed: the palette matches
// the course NAME and the CODE as two separate strings (courseLabel is the
// name alone, engine/course.ts:44). The stop list leaves [happens, one],
- // and "one" subsequence-matches "Computer Networks" - o, n, e in order.
- //
- // The damage is not the bad row. A local hit stops Enter from reaching the
- // router, so a phantom match silently disables the one path that could
- // have answered a question the engine cannot.
- const input = type("what happens if i miss one more class");
- expect(screen.getByText(/Nothing here matches/)).toBeTruthy();
- fireEvent.keyDown(input, { key: "Enter" });
- await vi.waitFor(() => expect(askRemote).toHaveBeenCalledTimes(1));
+ // and "one" subsequence-matches "Computer Networks" - o, n, e in order, so
+ // an unrelated subject was offered as the answer to a question about
+ // missing a class.
+ type("what happens if i miss one more class");
+
+ // The question is about attendance, so the Attendance view is the right
+ // row and it is there - "miss" and "class" name the screen.
+ expect(screen.getByText("Attendance")).toBeTruthy();
+ // But no subject is claimed to match, because none does.
+ expect(screen.queryByText("Computer Networks")).toBeNull();
+ expect(screen.queryByText("Machine Learning")).toBeNull();
});
it("still resolves an abbreviation typed as the whole query", async () => {
@@ -125,6 +127,33 @@ describe("the engine answers first", () => {
expect(askRemote).not.toHaveBeenCalled();
});
+ it("finds the screen a question names, even though the word was stripped", () => {
+ open();
+ // "attendance" is a stop word - it appears in every phrasing of every
+ // attendance question, so matching subjects on it would match all of them.
+ // Stripping it left "am i short on attendance" with only [short], which
+ // named nothing, and the question that the Attendance screen answers
+ // outright found no results at all. Views are matched on the raw question
+ // for exactly this reason.
+ type("am i short on attendance");
+ expect(screen.getByText("Attendance")).toBeTruthy();
+ });
+
+ it("routes a CGPA question without a screen called CGPA", () => {
+ open();
+ type("whats my cgpa");
+ expect(screen.getByText("Home")).toBeTruthy();
+ });
+
+ it("resolves an abbreviation used inside a sentence, by initials", () => {
+ open();
+ // Four terms, so the loose subsequence path is closed. Initials are what
+ // carries this: "Computer Networks" -> "cn" exactly, which cannot
+ // over-match the way a subsequence does.
+ type("what do i need in the final to pass cn");
+ expect(screen.getByText("Computer Networks")).toBeTruthy();
+ });
+
it("does not call out on an empty box", async () => {
open();
fireEvent.keyDown(screen.getByLabelText("Search subjects and views"), { key: "Enter" });
From 510c6cb1853007db55e1bd03b355998122d62754 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:29:43 +0530
Subject: [PATCH 22/32] feat(engine): price the next absence, not just the
eligibility slack
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/engine/__tests__/absenceCost.test.ts | 129 +++++++++++++++++++
app/src/engine/attendance.ts | 93 ++++++++++++-
app/src/engine/types.ts | 23 ++++
3 files changed, 244 insertions(+), 1 deletion(-)
create mode 100644 app/src/engine/__tests__/absenceCost.test.ts
diff --git a/app/src/engine/__tests__/absenceCost.test.ts b/app/src/engine/__tests__/absenceCost.test.ts
new file mode 100644
index 0000000..6164f9b
--- /dev/null
+++ b/app/src/engine/__tests__/absenceCost.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from "vitest";
+import {
+ ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN,
+ absenceCost, attendanceMarks, freeSkips,
+} from "../index";
+
+/**
+ * The cost of the next absence.
+ *
+ * Every figure asserted here is re-derived from the engine's own constants
+ * rather than copied out of a run, so a change to `ATTENDANCE_MARK_BANDS` or
+ * `ATTENDANCE_MIN` fails this file instead of leaving it quoting a rule KTU no
+ * longer has.
+ */
+
+/** The percentage at which the full attendance mark starts. */
+const FULL_AT = Math.max(...ATTENDANCE_MARK_BANDS.map(([pct]) => pct));
+
+describe("absenceCost prices a skip in marks, not only in percent", () => {
+ it("returns null without raw counts, because there is no denominator to move", () => {
+ expect(absenceCost(null, null)).toBeNull();
+ expect(absenceCost(40, 0)).toBeNull();
+ expect(absenceCost(40, null)).toBeNull();
+ });
+
+ it("moves the denominator, not just the numerator", () => {
+ // 45 of 50 is 90%. One more class held and not attended is 45 of 51.
+ const cost = absenceCost(45, 50, 0, 1);
+ expect(cost!.before).toBeCloseTo(90, 2);
+ expect(cost!.after).toBeCloseTo((45 / 51) * 100, 2);
+ });
+
+ it("costs nothing when the skip stays inside the same band", () => {
+ // Well above the top band, so one absence cannot cross a step.
+ const cost = absenceCost(49, 50, 0, 1)!;
+ expect(cost.marksBefore).toBe(ATTENDANCE_MARK_MAX);
+ expect(cost.marksAfter).toBe(ATTENDANCE_MARK_MAX);
+ expect(cost.marksLost).toBe(0);
+ });
+
+ it("reports the mark a band crossing takes, for a student every portal calls fine", () => {
+ // Sits just above the full-marks line, which no other system mentions.
+ const held = 100;
+ const attended = Math.ceil((FULL_AT / 100) * held);
+ const cost = absenceCost(attended, held, 0, 1)!;
+
+ expect(cost.before).toBeGreaterThanOrEqual(FULL_AT);
+ expect(cost.after).toBeLessThan(FULL_AT);
+ // Both sides agree with the band table itself - this test does not carry a
+ // second opinion about what a percentage is worth.
+ expect(cost.marksBefore).toBe(attendanceMarks(cost.before));
+ expect(cost.marksAfter).toBe(attendanceMarks(cost.after));
+ expect(cost.marksLost).toBeGreaterThan(0);
+ // And they are still eligible throughout, which is the whole point: the
+ // loss is invisible to any check that only asks about 75%.
+ expect(cost.eligibleBefore).toBe(true);
+ expect(cost.eligibleAfter).toBe(true);
+ });
+
+ it("reports the crossing of the eligibility floor", () => {
+ const held = 100;
+ const attended = ATTENDANCE_MIN; // exactly on the line at 100 held
+ const cost = absenceCost(attended, held, 0, 1)!;
+ expect(cost.eligibleBefore).toBe(true);
+ expect(cost.eligibleAfter).toBe(false);
+ });
+
+ it("never reports a negative loss, because missing a class cannot earn a mark", () => {
+ for (const attended of [10, 30, 44, 49, 50]) {
+ expect(absenceCost(attended, 50, 0, 3)!.marksLost).toBeGreaterThanOrEqual(0);
+ }
+ });
+
+ it("prices zero skips as no change at all", () => {
+ const cost = absenceCost(44, 50, 0, 0)!;
+ expect(cost.before).toBe(cost.after);
+ expect(cost.marksLost).toBe(0);
+ });
+
+ it("holds duty leave at today's credited value rather than growing it", () => {
+ // A larger `held` would mathematically allow more DL under the cap. The
+ // student is not acquiring duty leave by missing a class, and letting the
+ // cap rise would report the skip as cheaper than it is.
+ const withDl = absenceCost(40, 50, 5, 10)!;
+ const noDl = absenceCost(40, 50, 0, 10)!;
+ expect(withDl.after).toBeGreaterThan(noDl.after);
+ // The DL credited is bounded by today's cap, so the gap cannot widen with
+ // the extra held classes: both sides move down together.
+ expect(withDl.after).toBeLessThan(withDl.before);
+ });
+
+ it("scales with the course's own attendance maximum", () => {
+ const five = absenceCost(80, 100, 0, 20, 5)!;
+ const ten = absenceCost(80, 100, 0, 20, 10)!;
+ expect(ten.marksBefore).toBeCloseTo(five.marksBefore * 2, 2);
+ });
+});
+
+describe("freeSkips is the budget that binds first", () => {
+ it("is null without raw counts", () => {
+ expect(freeSkips(null, null)).toBeNull();
+ });
+
+ it("counts the absences that cost no mark at all", () => {
+ const n = freeSkips(45, 50)!;
+ // The claim, checked against absenceCost rather than asserted: n costs
+ // nothing and n+1 costs something.
+ expect(absenceCost(45, 50, 0, n)!.marksLost).toBe(0);
+ expect(absenceCost(45, 50, 0, n + 1)!.marksLost).toBeGreaterThan(0);
+ });
+
+ it("is zero for a student already on a band edge", () => {
+ const held = 100;
+ const attended = Math.ceil((FULL_AT / 100) * held);
+ expect(freeSkips(attended, held)).toBe(0);
+ });
+
+ it("is never more permissive than the eligibility budget it sits inside", () => {
+ // The marks line is at or above the eligibility line, so the number of
+ // absences that cost no mark can never exceed the number that keep the
+ // student eligible. If it did, the app would be telling a student a skip
+ // is free while it bars them from the exam.
+ for (const [attended, held] of [[45, 50], [80, 100], [38, 50], [90, 100]]) {
+ const free = freeSkips(attended!, held!)!;
+ const cost = absenceCost(attended!, held!, 0, free)!;
+ expect(cost.eligibleAfter).toBe(true);
+ }
+ });
+});
diff --git a/app/src/engine/attendance.ts b/app/src/engine/attendance.ts
index 5168d04..3f60387 100644
--- a/app/src/engine/attendance.ts
+++ b/app/src/engine/attendance.ts
@@ -1,7 +1,9 @@
import {
ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN, DL_CAP_PCT,
} from "./constants";
-import type { AttendanceBand, AttendancePlan, Course, MarkInput } from "./types";
+import type {
+ AbsenceCost, AttendanceBand, AttendancePlan, Course, MarkInput,
+} from "./types";
import { ceil, clamp, floor, round, toFloat, toOptionalFloat } from "./util";
/** CIE marks earned by attendance alone, per R 7.5.ii. */
@@ -208,3 +210,92 @@ export function effectiveAttendance(
const stated = toOptionalFloat(course.attendance);
return stated === null ? null : clamp(stated, 0, 100);
}
+
+/**
+ * What the next absence actually costs.
+ *
+ * The question this app exists to answer, and the one every other system
+ * refuses: not "am I above 75%" but "if I skip tomorrow, what does it take from
+ * me". A student at 86% is told by every portal that they are fine. Missing two
+ * classes puts them at 84% and costs a CIE mark under R 7.5.ii, and nothing
+ * anywhere tells them that until the mark is gone.
+ *
+ * Composition, not new arithmetic: the percentage after `n` more absences is
+ * the same effective figure the rest of the engine uses, recomputed with the
+ * classes added to `held` and none of them attended, and the marks on each side
+ * come from `attendanceMarks`. There is deliberately no second opinion here
+ * about what a percentage is worth.
+ *
+ * Duty leave is NOT re-credited against the larger denominator. `creditDutyLeave`
+ * caps DL at a fraction of held, so a bigger `held` could mathematically allow
+ * more DL to count - but the student is not acquiring new duty leave by missing
+ * a class, and letting the cap rise here would report a skip as cheaper than it
+ * is. The credited figure is held at today's value.
+ *
+ * Returns null when the portal gave no raw counts. A percentage alone cannot
+ * answer this: without `held` there is no denominator to move.
+ */
+export function absenceCost(
+ attendedIn: MarkInput, heldIn: MarkInput, dutyLeave: MarkInput = 0,
+ skips: number = 1,
+ maxMarks: number = ATTENDANCE_MARK_MAX,
+ floorPct: number = ATTENDANCE_MIN,
+ dlCapPct: number = DL_CAP_PCT,
+): AbsenceCost | null {
+ const attended = toOptionalFloat(attendedIn);
+ const held = toOptionalFloat(heldIn);
+ if (attended === null || held === null || held <= 0) return null;
+ const n = Math.max(0, Math.floor(skips));
+
+ 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);
+ };
+
+ const before = pctAt(0);
+ const after = pctAt(n);
+ const marksBefore = attendanceMarks(before, maxMarks) ?? 0;
+ const marksAfter = attendanceMarks(after, maxMarks) ?? 0;
+
+ return {
+ skips: n,
+ before, after,
+ marksBefore, marksAfter,
+ marksLost: round(Math.max(0, marksBefore - marksAfter), 2),
+ eligibleBefore: before >= floorPct,
+ eligibleAfter: after >= floorPct,
+ };
+}
+
+/**
+ * The largest number of absences that costs nothing.
+ *
+ * Distinct from `AttendancePlan.skip`, which solves for eligibility alone and
+ * so answers "before I am barred from the exam". This solves for the marks: the
+ * last skip that leaves the CIE attendance mark untouched. For most students
+ * the two are far apart, and the smaller one is the one that binds first.
+ *
+ * Bounded rather than closed-form because the band table is a list of steps,
+ * not a formula, and a loop over it cannot disagree with `attendanceMarks` the
+ * way a re-derivation could. `held` is the bound: skipping every remaining
+ * class is the most anyone can do.
+ */
+export function freeSkips(
+ attendedIn: MarkInput, heldIn: MarkInput, dutyLeave: MarkInput = 0,
+ maxMarks: number = ATTENDANCE_MARK_MAX,
+ dlCapPct: number = DL_CAP_PCT,
+): number | null {
+ const first = absenceCost(attendedIn, heldIn, dutyLeave, 0, maxMarks, ATTENDANCE_MIN, dlCapPct);
+ if (first === null) return null;
+ const held = toFloat(heldIn);
+ let n = 0;
+ while (n < held) {
+ const next = absenceCost(attendedIn, heldIn, dutyLeave, n + 1, maxMarks,
+ ATTENDANCE_MIN, dlCapPct);
+ if (next === null || next.marksAfter < first.marksBefore) break;
+ n += 1;
+ }
+ return n;
+}
diff --git a/app/src/engine/types.ts b/app/src/engine/types.ts
index 2e3dd04..583ab8d 100644
--- a/app/src/engine/types.ts
+++ b/app/src/engine/types.ts
@@ -108,6 +108,29 @@ export interface AttendanceBand {
atPct: number | null;
}
+/**
+ * What missing `skips` more classes would do to one course.
+ *
+ * Both sides are carried rather than only the difference, because the sentence
+ * a student needs is "86% and 4 marks becomes 84% and 3", not "you lose 1". A
+ * bare delta is the kind of figure that cannot show its working.
+ */
+export interface AbsenceCost {
+ /** How many further absences this priced. */
+ skips: number;
+ /** Effective attendance now, and after those absences. */
+ before: number;
+ after: number;
+ /** CIE marks earned by attendance, on each side (R 7.5.ii). */
+ marksBefore: number;
+ marksAfter: number;
+ /** Never negative: missing a class cannot earn a mark. */
+ marksLost: number;
+ /** Whether the course clears the eligibility floor, on each side. */
+ eligibleBefore: boolean;
+ eligibleAfter: boolean;
+}
+
export interface Evaluation {
cie: number;
cieMax: number;
From 782ca5a4b5765a4cf87b00db00512dbe9b73ec4a Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:34:55 +0530
Subject: [PATCH 23/32] feat(worker): log every question the router is asked,
content apart from 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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
worker/.dev.vars.example | 11 +++++
worker/src/index.ts | 25 +++++++++-
worker/src/log.ts | 103 +++++++++++++++++++++++++++++++++++++++
worker/wrangler.toml | 8 +++
4 files changed, 145 insertions(+), 2 deletions(-)
create mode 100644 worker/.dev.vars.example
create mode 100644 worker/src/log.ts
diff --git a/worker/.dev.vars.example b/worker/.dev.vars.example
new file mode 100644
index 0000000..d418cc8
--- /dev/null
+++ b/worker/.dev.vars.example
@@ -0,0 +1,11 @@
+# Local development only. Wrangler reads this when you run `npm run dev`.
+#
+# NEVER commit this file - it is gitignored, and the committed copy is this
+# .example. For the DEPLOYED worker the key is not a file at all:
+#
+# npx wrangler secret put GEMINI_KEY
+#
+# which prompts, stores the value encrypted at Cloudflare, and never writes it
+# to disk. The key must not go anywhere near app/.env: Vite inlines every
+# VITE_* value into the shipped bundle, so it would ship inside the installer.
+GEMINI_KEY=
diff --git a/worker/src/index.ts b/worker/src/index.ts
index a0ee2e0..77f8eb4 100644
--- a/worker/src/index.ts
+++ b/worker/src/index.ts
@@ -22,6 +22,7 @@ import { verify } from "./clerk";
import { claim } from "./limit";
import { route } from "./gemini";
import { parseAction, parseAskRequest } from "./schema";
+import { logAsk, outcomeOf } from "./log";
export { Quota } from "./limit";
@@ -29,6 +30,12 @@ interface Env {
GEMINI_KEY: string;
CLERK_ISSUER: string;
QUOTA: DurableObjectNamespace;
+ /**
+ * Optional. A deployment without the dataset bound still answers questions;
+ * see `logAsk`. Analytics Engine is not on every plan, and losing the log is
+ * a worse outcome than losing the feature only if the feature still works.
+ */
+ ASK_LOG?: AnalyticsEngineDataset;
}
/** No credentials, no cookies - the client sends a bearer token by hand. */
@@ -43,7 +50,7 @@ function json(body: unknown, status = 200, extra: Record = {}) {
}
export default {
- async fetch(request: Request, env: Env): Promise {
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
if (request.method === "OPTIONS") return new Response(null, { headers: CORS });
if (request.method !== "POST") return json({ error: "method" }, 405);
@@ -80,10 +87,21 @@ export default {
// worth more here than a slow success.
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), 8000);
+ const started = Date.now();
+ // Logged on every path below, including the failures. The set of questions
+ // that reach this Worker at all is the set the app could not answer by
+ // itself, which is the only evidence there is about what to teach it next.
+ const record = (outcome: Parameters[1]["outcome"]) =>
+ ctx.waitUntil(logAsk(env.ASK_LOG, {
+ outcome, question: ask.question,
+ subjectCount: ask.subjects.length,
+ latencyMs: Date.now() - started,
+ }));
try {
const raw = await route(ask, env.GEMINI_KEY, abort.signal);
const action = parseAction(raw, new Set(ask.subjects.map((s) => s.code)));
+ record(outcomeOf(action));
// The model returned something outside its own schema. That is not an
// action the app is allowed to act on, and it is not an error the student
@@ -96,7 +114,10 @@ export default {
} catch {
// Nothing about the upstream failure is echoed back. The error text could
// carry a URL, a key fragment or a project id, and none of that is the
- // student's business or safe to leak.
+ // student's business or safe to leak. It IS recorded, because a run of
+ // upstream errors is the difference between "nobody asked" and "everybody
+ // asked and got nothing".
+ record("upstream_error");
return json({ error: "upstream" }, 502);
} finally {
clearTimeout(timer);
diff --git a/worker/src/log.ts b/worker/src/log.ts
new file mode 100644
index 0000000..977cdab
--- /dev/null
+++ b/worker/src/log.ts
@@ -0,0 +1,103 @@
+/**
+ * What the router was asked, and what it did about it.
+ *
+ * The point of this log is one specific blind spot. Local matching in the app
+ * now answers almost every question - the keyword list routes attendance,
+ * marks, results and sync questions without ever 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
+ * without a log it is discarded the instant a response is returned.
+ *
+ * Refusals are logged as carefully as successes, and are the more valuable
+ * half: a question the model declined is a student who wanted something the app
+ * would not give them, and nothing else in the system records that.
+ *
+ * WHAT IS DELIBERATELY NOT LOGGED
+ *
+ * No `sub`, no token, no name, no email. The caller's identity is verified on
+ * every request and used to count quota - it just never reaches this file, and
+ * the quota counter needs no content to do its job. Keeping the two apart is
+ * the whole design: with both, this is a profile of a named student's questions
+ * over time, and holding that carries obligations under the DPDP Act that a
+ * routing corpus does not.
+ *
+ * No course codes or names either. A course list is not sensitive on its own,
+ * but a distinctive question plus a specific semester's course list re-identifies
+ * a student inside a small cohort. The COUNT is kept, because the only thing the
+ * router's behaviour depends on is whether it had a course list to work with.
+ *
+ * The question text itself IS kept, because it is the thing being studied and
+ * there is no useful anonymised form of it. It is free text a student typed, so
+ * it is treated as the sensitive field it is: nothing is joined to it.
+ */
+
+/** Every way a request can end. The refusals are the interesting rows. */
+export type Outcome =
+ | "routed_view"
+ | "routed_subject"
+ | "declined_off_topic"
+ | "declined_unclear"
+ | "declined_no_match"
+ /** The model returned something outside its own schema. */
+ | "unparseable"
+ /** Gemini failed, timed out, or was aborted. */
+ | "upstream_error";
+
+export interface AskLog {
+ outcome: Outcome;
+ question: string;
+ /** How many courses the client sent. Never which ones. */
+ subjectCount: number;
+ /** Milliseconds spent in the model call. */
+ latencyMs: number;
+}
+
+/**
+ * A stable id for a question, so repeats can be counted without grouping by the
+ * raw string in every query. Truncated: this identifies a phrasing, and a
+ * full-width digest would invite treating it as a key for something else.
+ */
+async function digest(text: string): Promise {
+ const bytes = new TextEncoder().encode(text.toLowerCase().trim());
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
+ return Array.from(new Uint8Array(hash).slice(0, 8))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+/**
+ * Write one point, or quietly do nothing.
+ *
+ * Never throws and never awaits into the response path. A logging failure -
+ * an unbound dataset, a quota, a malformed point - must not turn a working
+ * answer into an error for the student. The binding is optional for the same
+ * reason: a deployment without it should serve questions, not refuse them.
+ */
+export async function logAsk(
+ dataset: AnalyticsEngineDataset | undefined, entry: AskLog,
+): Promise {
+ if (!dataset) return;
+ try {
+ dataset.writeDataPoint({
+ // `indexes` is what the dataset is sampled and grouped by, so it carries
+ // the outcome - the dimension every question about this log starts from.
+ indexes: [entry.outcome],
+ blobs: [entry.outcome, entry.question, await digest(entry.question)],
+ doubles: [entry.subjectCount, entry.latencyMs],
+ });
+ } catch {
+ // Nothing to do and nobody to tell. The request has already been answered.
+ }
+}
+
+/** The outcome an Action maps to, so the caller does not spell it out twice. */
+export function outcomeOf(
+ action: { kind: string; reason?: string } | null,
+): Outcome {
+ if (!action) return "unparseable";
+ if (action.kind === "view") return "routed_view";
+ if (action.kind === "subject") return "routed_subject";
+ if (action.reason === "off_topic") return "declined_off_topic";
+ if (action.reason === "no_match") return "declined_no_match";
+ return "declined_unclear";
+}
diff --git a/worker/wrangler.toml b/worker/wrangler.toml
index e381719..aabaa07 100644
--- a/worker/wrangler.toml
+++ b/worker/wrangler.toml
@@ -17,3 +17,11 @@ class_name = "Quota"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Quota"]
+
+# The router's question log. Content only - no `sub`, no token, no course codes;
+# see src/log.ts for why identity and content are kept apart. The binding is
+# optional in the Worker, so removing this block degrades to no logging rather
+# than to a broken endpoint.
+[[analytics_engine_datasets]]
+binding = "ASK_LOG"
+dataset = "targetx_ask_log"
From 5b16526a49955c417c4491e1f8f06d0ee227b5de Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:43:12 +0530
Subject: [PATCH 24/32] feat(ask): answer the question in the box instead of
pointing at a screen
"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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/answers.ts | 224 ++++++++++++++++++++
app/src/styles/app.css | 28 +++
app/src/ui/Palette.tsx | 43 ++++
app/src/ui/__tests__/PaletteAnswer.test.tsx | 193 +++++++++++++++++
4 files changed, 488 insertions(+)
create mode 100644 app/src/state/answers.ts
create mode 100644 app/src/ui/__tests__/PaletteAnswer.test.tsx
diff --git a/app/src/state/answers.ts b/app/src/state/answers.ts
new file mode 100644
index 0000000..92e27c7
--- /dev/null
+++ b/app/src/state/answers.ts
@@ -0,0 +1,224 @@
+import { absenceCost, courseLabel, freeSkips } from "../engine";
+import type { Course } from "../engine";
+import { rows, state } from "./store";
+import type { View } from "./nav";
+
+/**
+ * Answers, computed here, said in the box.
+ *
+ * The search box used to route: a question got you a screen, and the screen had
+ * the number on it somewhere. For "where are my marks" that is the right answer.
+ * For "if I skip tomorrow, how badly does it hurt" it is not an answer at all -
+ * it is a filing cabinet, and the student still has to do the arithmetic the app
+ * exists to do for them.
+ *
+ * THE MODEL STILL NEVER STATES A FIGURE. It names which question this is and
+ * which subject it is about - a topic and a code, both from fixed enums - and
+ * every number in the sentence below is computed by the engine on this machine
+ * from this student's own records. That is the same guarantee as before; what
+ * changed is that the answer is now shown where it was asked instead of two
+ * clicks away.
+ *
+ * A topic is only added here when the engine can already answer it exactly.
+ * There is deliberately no "general" topic and no free-text field: a question
+ * shape that has no solver behind it must fall through to a route, because the
+ * alternative is a sentence nobody can show the working for.
+ */
+
+/** The question shapes the engine can answer outright. */
+export const TOPICS = [
+ "skip_cost", "budget", "eligibility", "tomorrow",
+] as const;
+export type Topic = (typeof TOPICS)[number];
+
+export interface Answer {
+ /** The sentence. Every figure in it came from the engine. */
+ headline: string;
+ /** Supporting lines, one per subject where the question spans several. */
+ lines: string[];
+ /** Where to go for the full working. The answer is not a substitute for it. */
+ view: View;
+}
+
+const pct = (value: number) => `${value.toFixed(0)}%`;
+/** "1 mark", "2 marks" - and "half a mark" never appears, marks are integral. */
+const marks = (value: number) => `${value} ${value === 1 ? "mark" : "marks"}`;
+
+/**
+ * What one more absence costs a single course, in a sentence.
+ *
+ * Both sides of the change, never a bare delta: "86% and 4 marks becomes 84%
+ * and 3" is a figure a student can check against their own portal. "You lose a
+ * mark" is one they have to take on trust.
+ */
+function skipLine(course: Course): string | null {
+ const label = courseLabel(course);
+ const cost = absenceCost(course.attended, course.held, course.dl ?? 0, 1);
+ if (cost === null) return `${label} — attendance not recorded`;
+
+ if (!cost.eligibleBefore) {
+ return `${label} — already below the line at ${pct(cost.before)}`;
+ }
+ if (!cost.eligibleAfter) {
+ // The one case where a single class is not a matter of marks at all.
+ return `${label} — one more takes you to ${pct(cost.after)} and you lose exam eligibility`;
+ }
+ if (cost.marksLost > 0) {
+ return `${label} — ${pct(cost.before)} becomes ${pct(cost.after)}, costs ${marks(cost.marksLost)}`;
+ }
+ const free = freeSkips(course.attended, course.held, course.dl ?? 0);
+ return free === null || free <= 1
+ ? `${label} — free, but the next one is not`
+ : `${label} — free (${free} to spare before it costs a mark)`;
+}
+
+/** The classes a student can still miss for nothing, per course. */
+function budgetLine(course: Course): string {
+ const label = courseLabel(course);
+ const free = freeSkips(course.attended, course.held, course.dl ?? 0);
+ const cost = absenceCost(course.attended, course.held, course.dl ?? 0, 1);
+ if (cost === null || free === null) return `${label} — attendance not recorded`;
+ if (!cost.eligibleBefore) return `${label} — below 75%, nothing to spare`;
+ return free === 0
+ ? `${label} — none free, the next one costs ${marks(Math.max(1, cost.marksLost))}`
+ : `${label} — ${free} free before it costs a mark`;
+}
+
+/**
+ * Tomorrow's classes, priced individually.
+ *
+ * The timetable and the attendance budget have always both been in the app,
+ * two hundred pixels apart, and nothing multiplied them. This is that join: the
+ * subjects that actually run tomorrow, each with what missing it would take.
+ *
+ * The subject strings come from the portal as printed, so they are matched back
+ * to the student's own courses by code first and by name second. A period that
+ * matches nothing is dropped rather than guessed at - naming a subject the
+ * student does not have would be worse than a shorter list.
+ */
+const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
+
+export function tomorrowCourses(now: Date = new Date()): Course[] {
+ const grid = state.timetable?.grid ?? [];
+ if (grid.length === 0) return [];
+ const wanted = DAYS[(now.getDay() + 1) % 7]!;
+ const day = grid.find((d) => d.day.trim().toLowerCase().startsWith(wanted.slice(0, 3)));
+ if (!day) return [];
+
+ const out: Course[] = [];
+ const seen = new Set();
+ for (const period of day.periods) {
+ const printed = period.subject?.trim().toLowerCase();
+ if (!printed) continue;
+ const hit = rows().find((r) => {
+ const code = (r.course.code ?? "").trim().toLowerCase();
+ const name = courseLabel(r.course).trim().toLowerCase();
+ return (code !== "" && printed.includes(code))
+ || (name !== "" && printed.includes(name));
+ });
+ if (!hit) continue;
+ const key = hit.course.code ?? courseLabel(hit.course);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(hit.course);
+ }
+ return out;
+}
+
+/**
+ * Answer a topic, or decline.
+ *
+ * Null when the engine cannot answer it for this student - no courses, no
+ * attendance recorded, no timetable. Declining is the correct outcome there,
+ * and the caller falls back to routing: a screen that shows nothing is more
+ * honest than a sentence built from nothing.
+ */
+export function answerFor(topic: Topic, code?: string): Answer | null {
+ const all = rows();
+ if (all.length === 0) return null;
+
+ const picked = code
+ ? all.filter((r) => r.course.code === code)
+ : all;
+ if (picked.length === 0) return null;
+
+ if (topic === "tomorrow") {
+ const courses = tomorrowCourses();
+ if (courses.length === 0) return null;
+ const lines = courses.map(skipLine).filter((l): l is string => l !== null);
+ if (lines.length === 0) return null;
+ const free = courses.every((c) => {
+ const cost = absenceCost(c.attended, c.held, c.dl ?? 0, 1);
+ return cost !== null && cost.marksLost === 0 && cost.eligibleAfter;
+ });
+ return {
+ headline: free
+ ? `Tomorrow is free — missing any of ${courses.length} would cost nothing.`
+ : `Tomorrow costs you something.`,
+ lines, view: "attendance",
+ };
+ }
+
+ if (topic === "eligibility") {
+ const short = picked.filter((r) => {
+ const cost = absenceCost(r.course.attended, r.course.held, r.course.dl ?? 0, 0);
+ return cost !== null && !cost.eligibleBefore;
+ });
+ return {
+ headline: short.length === 0
+ ? `Eligible in all ${picked.length} — every subject is above 75%.`
+ : `${short.length} of ${picked.length} below 75%.`,
+ lines: (short.length === 0 ? picked : short).map((r) => budgetLine(r.course)),
+ view: "attendance",
+ };
+ }
+
+ const line = topic === "skip_cost" ? skipLine : budgetLine;
+ const lines = picked
+ .map((r) => line(r.course))
+ .filter((l): l is string => l !== null);
+ if (lines.length === 0) return null;
+
+ // With one subject named, the sentence IS the answer and repeating it as a
+ // heading says the same thing twice.
+ if (picked.length === 1) {
+ return { headline: lines[0]!, lines: [], view: "attendance" };
+ }
+ return {
+ headline: topic === "skip_cost"
+ ? "What one more absence costs, per subject:"
+ : "Classes you can still miss for free:",
+ lines, view: "attendance",
+ };
+}
+
+/**
+ * Recognise a question shape without asking anybody.
+ *
+ * Cheap keyword detection, run before the model and before the subject matcher.
+ * It exists because the questions worth answering are asked in a small number
+ * of ways, and a student typing "can i skip tomorrow" should not wait on a
+ * network round trip for a sentence this machine can produce immediately.
+ *
+ * Only fires on an unambiguous phrase. Anything it is not sure about falls
+ * through to the router, which is allowed to be slower and is allowed to say no.
+ */
+export function detectTopic(query: string): Topic | null {
+ const q = ` ${query.toLowerCase().replace(/[^a-z0-9 ]+/g, " ")} `;
+ const has = (...words: string[]) => words.some((w) => q.includes(` ${w} `));
+
+ if (has("tomorrow", "tmrw", "tommorow", "tommorw")) return "tomorrow";
+ if (has("eligible", "eligibility", "debar", "debarred", "condonation")) {
+ return "eligibility";
+ }
+ // "one more", "another class", "if i miss" - all the ways of asking what the
+ // NEXT absence costs, as opposed to how many are left.
+ if ((has("one", "another", "1") && has("miss", "skip", "bunk", "cut", "leave"))
+ || q.includes(" one more ") || q.includes(" what happens if ")) {
+ return "skip_cost";
+ }
+ if (has("miss", "skip", "bunk", "cut", "leave", "leaves", "absent")) {
+ return "budget";
+ }
+ return null;
+}
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index 1ed0123..9adc50a 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -1025,3 +1025,31 @@ tr.row:hover .del { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; }
}
+
+/* The answer, above the results it is not a substitute for.
+ Elevation by lightness, per the constraint in tokens.css: this sits on the
+ palette surface and lifts by one step, never by a shadow. */
+.palette-answer {
+ padding: var(--s4) var(--s5);
+ background: var(--brand-wash);
+ border-bottom: 1px solid var(--hairline);
+}
+.palette-answer-head {
+ margin: 0;
+ font-size: var(--text-base);
+ line-height: 1.45;
+ color: var(--text);
+}
+.palette-answer-lines {
+ margin: var(--s3) 0 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+}
+.palette-answer-lines li {
+ font-size: var(--text-sm);
+ color: var(--text-dim);
+}
+.palette-answer > button.link { margin-top: var(--s3); }
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx
index 5fee3ad..bdc8c5d 100644
--- a/app/src/ui/Palette.tsx
+++ b/app/src/ui/Palette.tsx
@@ -4,6 +4,8 @@ import { rows } from "../state/store";
import { VIEWS, setView } from "../state/nav";
import type { View } from "../state/nav";
import { askConfigured, askRemote } from "../state/ask";
+import { answerFor, detectTopic } from "../state/answers";
+import type { Topic } from "../state/answers";
import { signedIn } from "../state/auth";
/**
@@ -119,6 +121,29 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
let input: HTMLInputElement | undefined;
let inflight: AbortController | undefined;
+ /**
+ * The answer, when the question is one the engine can answer outright.
+ *
+ * Computed before routing and shown above the results, because it IS the
+ * answer - the rows below it are where to go for the working. Detection is
+ * local keywords, so "can i skip tomorrow" is answered without a network
+ * round trip for a sentence this machine can produce immediately.
+ */
+ const answer = createMemo(() => {
+ const q = query().trim();
+ if (q === "") return null;
+ const topic = detectTopic(q);
+ if (topic === null) return null;
+ // A subject named in the question narrows the answer to it; otherwise the
+ // question is about every subject and every subject answers.
+ const words = terms(q);
+ const loose = words.length === 1;
+ const named = words.length === 0 ? undefined : rows().find((r) =>
+ words.some((w) => matches(courseLabel(r.course), w, loose)
+ || matches(r.course.code ?? "", w, loose)));
+ return answerFor(topic, named?.course.code);
+ });
+
const hits = createMemo(() => {
const q = query().trim();
// An empty box lists nothing. It used to open onto every view and every
@@ -291,6 +316,24 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
onInput={(e) => setQuery(e.currentTarget.value)}
onKeyDown={onKey} />
+
+ {(said) => (
+
+
{said().headline}
+ 0}>
+
+ {(line) =>
{line}
}
+
+
+ {/* The answer is not a substitute for the working. */}
+ {
+ setView(said().view);
+ props.onClose();
+ }}>See the full breakdown
+
+ )}
+
+
0} fallback={
// An untouched box says nothing at all. "Nothing matches" is a
// verdict on a search, and no search has happened yet - the
diff --git a/app/src/ui/__tests__/PaletteAnswer.test.tsx b/app/src/ui/__tests__/PaletteAnswer.test.tsx
new file mode 100644
index 0000000..29536f7
--- /dev/null
+++ b/app/src/ui/__tests__/PaletteAnswer.test.tsx
@@ -0,0 +1,193 @@
+// @vitest-environment jsdom
+/**
+ * The box answers, instead of pointing at a screen that answers.
+ *
+ * "If I take a leave tomorrow, how badly does it affect my attendance" used to
+ * open the Attendance screen, which shows a static budget - how many classes
+ * are left before 75% - and never what the NEXT absence costs. Those are
+ * different facts, and the second one is the question.
+ *
+ * Every figure asserted here is re-derived from the engine in the test, never
+ * copied from a run: if `absenceCost` or the band table changes, this file
+ * fails rather than going on quoting a number the app no longer computes.
+ */
+import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ATTENDANCE_MARK_BANDS, absenceCost, freeSkips } from "../../engine";
+import type { Course } from "../../engine";
+import { addCourse, edit, updateCourse } from "../../state/store";
+
+vi.mock("../../state/ask", () => ({
+ askConfigured: () => false,
+ askRemote: vi.fn(),
+}));
+vi.mock("../../state/auth", () => ({ signedIn: () => false }));
+
+const { Palette } = await import("../Palette");
+
+const FULL_AT = Math.max(...ATTENDANCE_MARK_BANDS.map(([p]) => p));
+
+/** Sits exactly on the full-marks line, so one absence crosses a band. */
+const EDGE: Partial = {
+ code: "CST303", name: "Computer Networks", credits: 4, type: "TH 40/60",
+ s1: 30, s2: 28, other: 8, dl: 0,
+ held: 100, attended: Math.ceil((FULL_AT / 100) * 100),
+};
+
+/** Comfortably clear, so an absence costs nothing. */
+const CLEAR: Partial = {
+ code: "CST305", name: "Machine Learning", credits: 4, type: "TH 40/60",
+ s1: 38, s2: 34, other: 9, dl: 0, held: 100, attended: 99,
+};
+
+function open(...courses: Partial[]) {
+ edit((d) => {
+ d.semesters = { S5: { courses: [] } };
+ d.activeSemester = "S5";
+ d.history = {};
+ d.timetable = undefined;
+ });
+ courses.forEach((c, i) => { addCourse(); updateCourse(i, c); });
+ return render(() => {}} />);
+}
+
+const type = (value: string) => {
+ const input = screen.getByLabelText("Search subjects and views") as HTMLInputElement;
+ fireEvent.input(input, { target: { value } });
+ return input;
+};
+
+afterEach(cleanup);
+beforeEach(() => { edit((d) => { d.timetable = undefined; }); });
+
+describe("the cost of the next absence is stated, not routed to", () => {
+ it("names both sides of the change, so the student can check it", () => {
+ open(EDGE);
+ type("what happens if i miss one more class in computer networks");
+
+ const cost = absenceCost(EDGE.attended!, EDGE.held!, 0, 1)!;
+ // Derived, not copied: the sentence must carry the engine's own figures.
+ expect(cost.marksLost).toBeGreaterThan(0);
+ const said = screen.getByText(new RegExp(
+ `${cost.before.toFixed(0)}%.*${cost.after.toFixed(0)}%`));
+ expect(said).toBeTruthy();
+ expect(said.textContent).toMatch(new RegExp(`costs ${cost.marksLost} mark`));
+ });
+
+ it("says free when it is free, and how much room is left", () => {
+ open(CLEAR);
+ type("what happens if i miss one more class in machine learning");
+
+ const free = freeSkips(CLEAR.attended!, CLEAR.held!, 0)!;
+ expect(absenceCost(CLEAR.attended!, CLEAR.held!, 0, 1)!.marksLost).toBe(0);
+ expect(screen.getByText(new RegExp(`free \\(${free} to spare`))).toBeTruthy();
+ });
+
+ it("answers about every subject when none is named", () => {
+ open(EDGE, CLEAR);
+ type("what happens if i miss one more class");
+ expect(screen.getByText(/What one more absence costs, per subject/)).toBeTruthy();
+ expect(screen.getByText(/Computer Networks/)).toBeTruthy();
+ expect(screen.getByText(/Machine Learning/)).toBeTruthy();
+ });
+
+ it("still offers the working, because the sentence is not a substitute for it", () => {
+ open(EDGE);
+ type("what happens if i miss one more class");
+ expect(screen.getByText("See the full breakdown")).toBeTruthy();
+ });
+
+ it("says nothing at all when attendance was never recorded", () => {
+ open({ ...EDGE, attended: null, held: null, attendance: null });
+ type("what happens if i miss one more class");
+ // A sentence built from nothing is worse than no sentence.
+ expect(screen.queryByText(/costs/)).toBeNull();
+ });
+});
+
+describe("tomorrow joins the timetable to the budget", () => {
+ const withTimetable = () => edit((d) => {
+ // Every weekday carries the same two subjects, so the test does not depend
+ // on which day it is run.
+ d.timetable = {
+ grid: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+ .map((day) => ({
+ day,
+ periods: [
+ { subject: "CST303 Computer Networks", teacher: null },
+ { subject: "CST305 Machine Learning", teacher: null },
+ ],
+ })),
+ substitutions: [],
+ };
+ });
+
+ it("prices each subject that actually runs tomorrow", () => {
+ open(EDGE, CLEAR);
+ withTimetable();
+ type("can i skip tomorrow");
+
+ expect(screen.getByText(/Tomorrow costs you something/)).toBeTruthy();
+ // The one that crosses a band is priced; the one that does not is free.
+ const cost = absenceCost(EDGE.attended!, EDGE.held!, 0, 1)!;
+ expect(screen.getByText(new RegExp(`costs ${cost.marksLost} mark`))).toBeTruthy();
+ expect(screen.getByText(/Machine Learning.*free/)).toBeTruthy();
+ });
+
+ it("says tomorrow is free when nothing on it costs a mark", () => {
+ open(CLEAR);
+ edit((d) => {
+ d.timetable = {
+ grid: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+ .map((day) => ({
+ day, periods: [{ subject: "CST305 Machine Learning", teacher: null }],
+ })),
+ substitutions: [],
+ };
+ });
+ type("can i skip tomorrow");
+ expect(screen.getByText(/Tomorrow is free/)).toBeTruthy();
+ });
+
+ it("declines rather than guessing when there is no timetable", () => {
+ open(EDGE, CLEAR);
+ type("can i skip tomorrow");
+ // No timetable means the app does not know what runs tomorrow. Listing
+ // every subject would be answering a question it was not asked.
+ expect(screen.queryByText(/Tomorrow/)).toBeNull();
+ });
+
+ it("drops a timetable subject the student does not have, rather than naming it", () => {
+ open(CLEAR);
+ edit((d) => {
+ d.timetable = {
+ grid: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+ .map((day) => ({
+ day,
+ periods: [
+ { subject: "CST305 Machine Learning", teacher: null },
+ { subject: "MAT101 Some Elective Nobody Registered", teacher: null },
+ ],
+ })),
+ substitutions: [],
+ };
+ });
+ type("can i skip tomorrow");
+ expect(screen.queryByText(/Some Elective/)).toBeNull();
+ expect(screen.getByText(/Machine Learning/)).toBeTruthy();
+ });
+});
+
+describe("eligibility is answered from the record", () => {
+ it("does not claim every subject is fine when one is not", () => {
+ open(EDGE, { ...CLEAR, attended: 60, held: 100 });
+ type("am i eligible");
+ expect(screen.getByText(/1 of 2 below 75%/)).toBeTruthy();
+ });
+
+ it("says so plainly when all of them are", () => {
+ open(EDGE, CLEAR);
+ type("am i eligible");
+ expect(screen.getByText(/Eligible in all 2/)).toBeTruthy();
+ });
+});
From 8c8448c7a6a865aa47ee0bbd576687cf162850ac Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:53:02 +0530
Subject: [PATCH 25/32] feat(attendance): show the counts every percentage is
computed from
`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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/engine/__tests__/daywise.test.ts | 88 +++++++++
app/src/engine/attendance.ts | 44 ++++-
app/src/styles/app.css | 10 +
app/src/styles/calendar.css | 12 ++
app/src/styles/screens.css | 9 +
app/src/ui/Attendance.tsx | 187 ++++++++++++++++--
app/src/ui/Ledger.tsx | 22 ++-
.../ui/__tests__/AttendanceCounts.test.tsx | 120 +++++++++++
8 files changed, 472 insertions(+), 20 deletions(-)
create mode 100644 app/src/engine/__tests__/daywise.test.ts
create mode 100644 app/src/ui/__tests__/AttendanceCounts.test.tsx
diff --git a/app/src/engine/__tests__/daywise.test.ts b/app/src/engine/__tests__/daywise.test.ts
new file mode 100644
index 0000000..a260108
--- /dev/null
+++ b/app/src/engine/__tests__/daywise.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import { daywiseBySubject } from "../index";
+import type { AttendanceStatus, DaywiseAttendance } from "../types";
+
+/**
+ * The day-by-day log, counted per subject.
+ *
+ * This is the second opinion on a figure the app previously took on trust. The
+ * portal publishes a percentage AND a period log; they should agree, and until
+ * now nothing compared them.
+ */
+
+const day = (...periods: Array<[AttendanceStatus, string | null]>): DaywiseAttendance[number] => ({
+ label: "1st",
+ periods: periods.map(([status, subject]) => ({ status, subject })),
+});
+
+describe("daywiseBySubject counts the way the regulation counts", () => {
+ it("is empty for an empty log", () => {
+ expect(daywiseBySubject([]).size).toBe(0);
+ });
+
+ it("counts a present period as attended and as held", () => {
+ const out = daywiseBySubject([day(["present", "CST303 Computer Networks"])]);
+ expect(out.get("CST303 Computer Networks")).toEqual({ attended: 1, held: 1 });
+ });
+
+ it("counts an absence as held but not attended", () => {
+ const out = daywiseBySubject([day(["absent", "CST303"])]);
+ expect(out.get("CST303")).toEqual({ attended: 0, held: 1 });
+ });
+
+ it("excludes holidays and empty periods from held, because they never happened", () => {
+ const out = daywiseBySubject([day(
+ ["present", "CST303"], ["holiday", "CST303"], ["none", "CST303"],
+ )]);
+ // One class ran. A holiday is not a class anybody missed.
+ expect(out.get("CST303")).toEqual({ attended: 1, held: 1 });
+ });
+
+ it("credits on-duty and duty leave as attended, the way the portal does", () => {
+ for (const status of ["od", "dutyleave", "duty"] as AttendanceStatus[]) {
+ const out = daywiseBySubject([day([status, "CST303"])]);
+ // The class ran and the student was excused: held, and not against them.
+ expect(out.get("CST303")).toEqual({ attended: 1, held: 1 });
+ }
+ });
+
+ it("counts a plain leave as an absence", () => {
+ // An approved leave arrives as one of the credited statuses. A bare
+ // "leave" is the student not being there, and treating it as neutral would
+ // report a rosier figure than the portal's own.
+ const out = daywiseBySubject([day(["leave", "CST303"])]);
+ expect(out.get("CST303")).toEqual({ attended: 0, held: 1 });
+ });
+
+ it("ignores periods with no subject named", () => {
+ const out = daywiseBySubject([day(["present", null], ["absent", " "])]);
+ expect(out.size).toBe(0);
+ });
+
+ it("accumulates one subject across many days", () => {
+ const log = [
+ day(["present", "CST303"], ["absent", "CST305"]),
+ day(["present", "CST303"], ["present", "CST305"]),
+ day(["absent", "CST303"], ["holiday", "CST305"]),
+ ];
+ const out = daywiseBySubject(log);
+ expect(out.get("CST303")).toEqual({ attended: 2, held: 3 });
+ expect(out.get("CST305")).toEqual({ attended: 1, held: 2 });
+ });
+
+ it("trims the printed subject so one subject is not counted twice", () => {
+ const out = daywiseBySubject([day(["present", " CST303 "], ["absent", "CST303"])]);
+ expect(out.size).toBe(1);
+ expect(out.get("CST303")).toEqual({ attended: 1, held: 2 });
+ });
+
+ it("never reports more attended than held", () => {
+ const statuses: AttendanceStatus[] =
+ ["present", "absent", "od", "dutyleave", "duty", "leave", "holiday", "none"];
+ const out = daywiseBySubject([day(
+ ...statuses.map((s): [AttendanceStatus, string] => [s, "CST303"]),
+ )]);
+ const tally = out.get("CST303")!;
+ expect(tally.attended).toBeLessThanOrEqual(tally.held);
+ });
+});
diff --git a/app/src/engine/attendance.ts b/app/src/engine/attendance.ts
index 3f60387..12253b6 100644
--- a/app/src/engine/attendance.ts
+++ b/app/src/engine/attendance.ts
@@ -2,7 +2,8 @@ import {
ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN, DL_CAP_PCT,
} from "./constants";
import type {
- AbsenceCost, AttendanceBand, AttendancePlan, Course, MarkInput,
+ AbsenceCost, AttendanceBand, AttendancePlan, Course, DaywiseAttendance,
+ MarkInput,
} from "./types";
import { ceil, clamp, floor, round, toFloat, toOptionalFloat } from "./util";
@@ -299,3 +300,44 @@ export function freeSkips(
}
return n;
}
+
+/**
+ * Attendance counted per subject from the day-by-day record.
+ *
+ * The portal publishes two things that should agree: a percentage per subject,
+ * and a period-by-period log of what happened. TargetX has always shown both
+ * and never compared them - so a portal that miscounts, or a student who was
+ * marked absent for a class they attended, produced a figure with no way to
+ * check it. This is the second opinion.
+ *
+ * Counted the way the regulation counts: `held` excludes holidays and empty
+ * periods, because a class that never happened is not one anybody missed.
+ * Credited statuses (on duty, duty leave) count as HELD and as attended, since
+ * the class ran and the student was excused - which is exactly how the portal's
+ * own percentage treats them, so the two figures stay comparable.
+ *
+ * `leave` is deliberately counted as an absence. A leave a college has approved
+ * arrives as one of the credited statuses; a plain "leave" is the student not
+ * being there, and treating it as neutral would report a rosier figure than the
+ * portal's own.
+ */
+export function daywiseBySubject(
+ days: DaywiseAttendance,
+): Map {
+ const out = new Map();
+ for (const day of days) {
+ for (const period of day.periods) {
+ const subject = period.subject?.trim();
+ if (!subject) continue;
+ if (period.status === "holiday" || period.status === "none") continue;
+ const row = out.get(subject) ?? { attended: 0, held: 0 };
+ row.held += 1;
+ if (period.status === "present" || period.status === "od"
+ || period.status === "dutyleave" || period.status === "duty") {
+ row.attended += 1;
+ }
+ out.set(subject, row);
+ }
+ }
+ return out;
+}
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index 9adc50a..950b3a6 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -1053,3 +1053,13 @@ tr.row:hover .del { opacity: 1; }
color: var(--text-dim);
}
.palette-answer > button.link { margin-top: var(--s3); }
+
+/* The raw attendance counts on a ledger row. Sits after the percentage as its
+ working, quieter than it, and never wraps - a fraction broken across two
+ lines reads as two numbers. */
+.att-raw {
+ margin-left: var(--s2);
+ font-size: var(--text-xs);
+ color: var(--text-faint);
+ white-space: nowrap;
+}
diff --git a/app/src/styles/calendar.css b/app/src/styles/calendar.css
index a29d3f7..66fd5a0 100644
--- a/app/src/styles/calendar.css
+++ b/app/src/styles/calendar.css
@@ -270,3 +270,15 @@
font-size: var(--text-sm);
text-align: center;
}
+
+/* The status glyph inside a calendar block. Centring only - the block already
+ carries its size and colour; this makes the shape legible without hue. */
+.cal-block {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ line-height: 1;
+ font-weight: 700;
+ color: var(--bg);
+}
diff --git a/app/src/styles/screens.css b/app/src/styles/screens.css
index 26112a8..171c1ec 100644
--- a/app/src/styles/screens.css
+++ b/app/src/styles/screens.css
@@ -1117,3 +1117,12 @@ a.link:hover { text-decoration-thickness: 2px; }
.confirm-inline .fineprint { color: var(--text-dim); }
button.link.remove-go { color: var(--danger); }
button.link.remove-go:hover { color: var(--danger); font-weight: 600; }
+
+/* The counts a subject's percentage is computed from. Quiet, because it is
+ evidence rather than a finding - but present, because a percentage with no
+ visible numerator and denominator is a number the student cannot check. */
+.att-counts {
+ margin: 0 0 var(--s3);
+ font-size: var(--text-sm);
+ color: var(--text-dim);
+}
diff --git a/app/src/ui/Attendance.tsx b/app/src/ui/Attendance.tsx
index 6516e29..fe5e38b 100644
--- a/app/src/ui/Attendance.tsx
+++ b/app/src/ui/Attendance.tsx
@@ -1,7 +1,7 @@
import { For, Show, createMemo } from "solid-js";
import {
ATTENDANCE_FULL_MARKS_PCT, ATTENDANCE_MARK_MAX, ATTENDANCE_MIN,
- attendanceMarks, courseLabel,
+ attendanceMarks, courseLabel, daywiseBySubject, toOptionalFloat,
} from "../engine";
import type {
AttendancePlan, AttendanceStatus, DaywiseDay, TimetableDay,
@@ -30,6 +30,17 @@ interface Line {
/** The subject as the student knows it. See `courseLabel`. */
label: string;
plan: AttendancePlan | null;
+ /**
+ * The counts every other figure on the card is derived from.
+ *
+ * They were only ever reachable as editable inputs inside one expanded
+ * Ledger row, so this screen stated a percentage and a miss budget and never
+ * showed the two numbers behind them - an app whose whole position is that
+ * it never states a number it cannot show its working for, not showing the
+ * working for its loudest number.
+ */
+ attended: number | null;
+ held: number | null;
}
export function Attendance() {
@@ -38,6 +49,8 @@ export function Attendance() {
index: row.index,
label: courseLabel(row.course),
plan: row.ev.plan,
+ attended: toOptionalFloat(row.course.attended),
+ held: toOptionalFloat(row.course.held),
})));
return (
@@ -71,6 +84,7 @@ export function Attendance() {
+
@@ -87,15 +101,25 @@ export function Attendance() {
* spelled-out status for the cell's tooltip, so the meaning colour carries is
* also available to a pointer and a screen reader, not colour alone.
*/
-const STATUS_META: Record = {
- present: { cls: "present", word: "Present" },
- absent: { cls: "absent", word: "Absent" },
- od: { cls: "credited", word: "On duty" },
- dutyleave: { cls: "credited", word: "Duty leave" },
- duty: { cls: "credited", word: "Duty" },
- leave: { cls: "leave", word: "Leave" },
- holiday: { cls: "holiday", word: "Holiday" },
- none: { cls: "none", word: "No class" },
+/**
+ * `glyph` is what keeps this grid readable without colour.
+ *
+ * The blocks were empty divs distinguished only by hue, and the three that
+ * matter are close in lightness on both themes - dark --good L0.76, --warn
+ * L0.80, --danger L0.73 - so under a red-green deficiency they collapse into
+ * three identical squares. The shape carries the meaning; the colour reinforces
+ * it. The Ledger already solved this two files away and the pattern was simply
+ * not applied here.
+ */
+const STATUS_META: Record = {
+ present: { cls: "present", word: "Present", glyph: "·" },
+ absent: { cls: "absent", word: "Absent", glyph: "×" },
+ od: { cls: "credited", word: "On duty", glyph: "~" },
+ dutyleave: { cls: "credited", word: "Duty leave", glyph: "~" },
+ duty: { cls: "credited", word: "Duty", glyph: "~" },
+ leave: { cls: "leave", word: "Leave", glyph: "~" },
+ holiday: { cls: "holiday", word: "Holiday", glyph: "" },
+ none: { cls: "none", word: "No class", glyph: "" },
};
/**
@@ -107,6 +131,110 @@ const STATUS_META: Record = {
* the page was never synced the whole thing is one quiet line, never an empty
* grid pretending the student has perfect attendance.
*/
+/**
+ * The day-by-day record, rolled up per subject and checked against the counts
+ * the portal published.
+ *
+ * Two independent figures for the same fact: what the period log says happened,
+ * and what the subject page says the totals are. They should agree. When they
+ * do not, one of them is wrong and the student is the only person who can say
+ * which - so the app states the disagreement rather than silently preferring
+ * the number it happens to store.
+ *
+ * Subjects are matched from the portal's printed period string back to the
+ * student's own courses; anything that matches nothing is left out rather than
+ * shown under a name they would not recognise.
+ */
+function BySubjectSection() {
+ const counted = createMemo(() => {
+ const log = daywiseBySubject(state.daywiseAttendance ?? []);
+ if (log.size === 0) return [];
+ return rows().map((row) => {
+ const code = (row.course.code ?? "").trim().toLowerCase();
+ const name = courseLabel(row.course).trim().toLowerCase();
+ let found: { attended: number; held: number } | null = null;
+ for (const [printed, tally] of log) {
+ const key = printed.toLowerCase();
+ if ((code !== "" && key.includes(code)) || (name !== "" && key.includes(name))) {
+ found = tally;
+ break;
+ }
+ }
+ return {
+ label: courseLabel(row.course),
+ logged: found,
+ storedAttended: toOptionalFloat(row.course.attended),
+ storedHeld: toOptionalFloat(row.course.held),
+ };
+ }).filter((r) => r.logged !== null);
+ });
+
+ return (
+ 0}>
+
+
+
+
Counted from the day-by-day record
+
+ What the period log adds up to, per subject, beside the totals the
+ portal published. They should agree — where they do not, one of
+ them is wrong.
+
+ no published total to compare
+ }>
+
+ log says {logged.held - (r.storedHeld ?? 0) > 0 ? "more" : "fewer"} classes
+
+ }>
+ matches
+
+
+
+
+ );
+ }}
+
+
+
+
+
+
+ );
+}
+
function CalendarSection() {
const days = createMemo(() => state.daywiseAttendance ?? []);
// Every day has the same number of periods after parsing, but a defensive max
@@ -131,12 +259,15 @@ function CalendarSection() {
0} fallback={
Sync to see your day-by-day attendance.
}>
-
- Present
- Absent
- Credited (on duty / duty leave)
- Leave
- Holiday / no class
+ {/* Not aria-hidden. It was, which meant the only explanation of what
+ the grid's colours mean was hidden from the readers least able to
+ infer it from the colours. */}
+
+ Present
+ Absent
+ Credited (on duty / duty leave)
+ Leave
+ Holiday / no class
@@ -169,7 +300,14 @@ function CalendarSection() {
};
return (
-
+ {/* `title` is hover-only: unreachable by keyboard,
+ unreliable on a td for screen readers, and
+ invisible on touch. It stays for the pointer,
+ and the status is also stated in text. */}
+
+ {meta().glyph}
+ {title()}
+
);
}}
@@ -348,6 +486,21 @@ function SubjectCard(props: { line: Line }) {
+ {/* The working for the percentage beside it. Deliberately does NOT repeat
+ the percentage - it is one line up, and saying it twice would make the
+ counts read as a second opinion rather than as the source. */}
+
+
+ {props.line.attended} of {props.line.held} attended
+ 0}>
+ {" · "}{plan()!.dlCredited} duty leave credited
+
+ 0}>
+ {" · "}{plan()!.dlWasted} over the cap
+
+
+
+
No attended and held classes on record for this subject, so there is no
diff --git a/app/src/ui/Ledger.tsx b/app/src/ui/Ledger.tsx
index dfb440b..0e83132 100644
--- a/app/src/ui/Ledger.tsx
+++ b/app/src/ui/Ledger.tsx
@@ -1,7 +1,7 @@
import { For, Show, createMemo, createSignal } from "solid-js";
import {
ATTENDANCE_CONDONE, ATTENDANCE_MIN, COURSE_TYPES, TARGET_CHOICES, TYPE_KEYS,
- isIncomplete, requiredEseCell, toOptionalFloat,
+ isIncomplete, requiredEseCell, specFor, toOptionalFloat,
} from "../engine";
import type {
AttendanceTargetGap, Course, Evaluation, Letter, RequiredEse, TypeKey,
@@ -560,9 +560,27 @@ export function Ledger() {
: ` below the ${ATTENDANCE_MIN.toFixed(0)}% eligibility line`}
+ {/* The counts the percentage is computed from. They existed
+ only as editable inputs inside the expanded row, so the
+ table stated a percentage on every row and showed the
+ working for none of them - and a student checking their
+ own figure had to open seven rows one at a time. */}
+
+
+ {toOptionalFloat(row.course.attended)}/{toOptionalFloat(row.course.held)}
+
+
- {show(row.ev.attMarks)}/5
+ {/* `attMax`, not a literal 5. `CourseSpec.attMax` exists so
+ it can vary by course type, and constants.ts explicitly
+ instructs the next maintainer to spell out per-type
+ values - on that day a hardcoded 5 here would print
+ "4/5" for a course the engine scores out of 8. */}
+ {show(row.ev.attMarks)}
+
+ /{specFor(row.course.type).attMax}
+
= {
+ code: "CST303", name: "Computer Networks", credits: 4, type: "TH 40/60",
+ attended: 39, held: 50, dl: 0,
+};
+
+function open(...courses: Partial[]) {
+ edit((d) => {
+ d.semesters = { S5: { courses: [] } };
+ d.activeSemester = "S5";
+ d.daywiseAttendance = undefined;
+ d.timetable = undefined;
+ });
+ courses.forEach((c, i) => { addCourse(); updateCourse(i, c); });
+ return render(() => );
+}
+
+afterEach(cleanup);
+
+describe("the counts behind the percentage are on the card", () => {
+ it("states attended of held", () => {
+ open(CN);
+ expect(screen.getByText(/39 of 50 attended/)).toBeTruthy();
+ });
+
+ it("says how much duty leave was credited, since it moves the percentage", () => {
+ open({ ...CN, dl: 3 });
+ // Credited DL is why the shown percentage differs from attended/held, so
+ // omitting it would leave the working not actually adding up.
+ expect(screen.getByText(/duty leave credited/)).toBeTruthy();
+ });
+
+ it("does not mention duty leave when there is none", () => {
+ open(CN);
+ expect(screen.queryByText(/duty leave credited/)).toBeNull();
+ });
+
+ it("says nothing rather than zeroes when attendance was never recorded", () => {
+ open({ ...CN, attended: null, held: null });
+ expect(screen.queryByText(/of .* attended/)).toBeNull();
+ });
+});
+
+describe("the day-by-day log is a second opinion, not decoration", () => {
+ const seedLog = (statuses: Array<[string, string]>) => edit((d) => {
+ d.daywiseAttendance = [{
+ label: "1st",
+ periods: statuses.map(([status, subject]) => ({
+ status: status as never, subject,
+ })),
+ }];
+ });
+
+ it("says the two figures match when they do", () => {
+ open({ ...CN, attended: 1, held: 1 });
+ seedLog([["present", "CST303 Computer Networks"]]);
+ expect(screen.getByText("matches")).toBeTruthy();
+ });
+
+ it("states the disagreement rather than preferring the stored number", () => {
+ open({ ...CN, attended: 39, held: 50 });
+ seedLog([
+ ["present", "CST303 Computer Networks"],
+ ["absent", "CST303 Computer Networks"],
+ ]);
+ // The log says 1/2 and the portal says 39/50. One of them is wrong, and
+ // the student is the only person who can say which.
+ expect(screen.getByText(/log says fewer classes/)).toBeTruthy();
+ });
+
+ it("shows nothing at all when there is no day-by-day record", () => {
+ open(CN);
+ expect(screen.queryByText(/Counted from the day-by-day record/)).toBeNull();
+ });
+
+ it("leaves out a logged subject the student does not have", () => {
+ open(CN);
+ seedLog([["present", "MAT101 Some Elective"]]);
+ // With nothing matched, the roll-up has nothing to say and does not
+ // render - a comparison table with no rows is a heading promising a
+ // cross-check it cannot perform.
+ expect(screen.queryByText("Counted from the day-by-day record")).toBeNull();
+ // The calendar below it still shows the period, because that is the RAW
+ // log and it is not the roll-up's job to censor it. A screen-wide query
+ // for "Some Elective" would match there and assert the opposite of this.
+ expect(screen.getAllByText(/Some Elective/).length).toBeGreaterThan(0);
+ });
+});
+
+describe("calendar status is not carried by colour alone", () => {
+ it("states each period's status in text for a screen reader", () => {
+ open(CN);
+ edit((d) => {
+ d.daywiseAttendance = [{
+ label: "1st",
+ periods: [{ status: "absent", subject: "CST303 Computer Networks" }],
+ }];
+ });
+ // The block was an empty div whose only carrier was a `title` - hover-only,
+ // unreachable by keyboard, invisible on touch.
+ expect(screen.getAllByText(/Absent — CST303 Computer Networks/).length)
+ .toBeGreaterThan(0);
+ });
+});
From 0f82f569009f2bd00468534c43cbf8c261ee2d7f Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 22:18:19 +0530
Subject: [PATCH 26/32] feat(ask): answer marks, standing and plain attendance
questions too
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/__tests__/detectTopic.test.ts | 90 ++++++++++++
app/src/state/answers.ts | 146 +++++++++++++++++++-
app/src/ui/__tests__/PaletteAnswer.test.tsx | 56 ++++++++
3 files changed, 287 insertions(+), 5 deletions(-)
create mode 100644 app/src/state/__tests__/detectTopic.test.ts
diff --git a/app/src/state/__tests__/detectTopic.test.ts b/app/src/state/__tests__/detectTopic.test.ts
new file mode 100644
index 0000000..c7fab41
--- /dev/null
+++ b/app/src/state/__tests__/detectTopic.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from "vitest";
+import { detectTopic } from "../answers";
+
+/**
+ * Which question shape a phrasing is, decided locally.
+ *
+ * This runs before the model and before the subject matcher, and it is allowed
+ * to say nothing. Saying nothing routes the student to a screen, which is a
+ * worse answer but never a wrong one - so every case here is judged against the
+ * standard that a WRONG topic is worse than no topic.
+ */
+
+describe("a question about the rule is not a question about the student", () => {
+ it.each([
+ "how is sgpa calculated",
+ "explain cgpa",
+ "what is the pass mark",
+ "how does attendance affect marks",
+ "how do they calculate the percentage",
+ ])("declines %j", (q) => {
+ // "how is sgpa calculated" contains "sgpa" and was answered with the
+ // student's own CGPA - a confident reply to a question nobody asked. A
+ // route at least lands somewhere the real answer might be.
+ expect(detectTopic(q)).toBeNull();
+ });
+
+ it("still answers the same words when they are about this student", () => {
+ // The test is possessive, not topical: the regulation question and the
+ // personal one share almost all their vocabulary.
+ expect(detectTopic("how badly would it affect my attendance")).not.toBeNull();
+ expect(detectTopic("whats my cgpa")).toBe("standing");
+ });
+});
+
+describe("the forward-looking attendance questions", () => {
+ it.each([
+ ["can i skip tomorrow", "tomorrow"],
+ ["if i take a leave tommorow", "tomorrow"],
+ ["what happens if i miss one more", "skip_cost"],
+ ["how many classes can i miss", "budget"],
+ ["am i eligible for the exam", "eligibility"],
+ ["will i get debarred", "eligibility"],
+ ])("reads %j as %s", (q, topic) => {
+ expect(detectTopic(q)).toBe(topic);
+ });
+});
+
+describe("the plainest question, which used to fall through", () => {
+ it.each([
+ "whats my attendance in cn",
+ "how many classes have i attended",
+ "my attendance",
+ ])("answers %j rather than routing it", (q) => {
+ // The detector only fired on forward-looking words, so "can i miss one"
+ // was answered outright and "what is my attendance" opened a screen.
+ expect(detectTopic(q)).toBe("attendance_now");
+ });
+});
+
+describe("marks and standing", () => {
+ it.each([
+ ["what do i need in the final to pass cn", "need_to_pass"],
+ ["how many marks do i need for an A", "need_to_pass"],
+ ["am i failing anything", "need_to_pass"],
+ ["whats my cgpa", "standing"],
+ ["what sgpa do i need this sem", "standing"],
+ ["will i get a first class", "standing"],
+ ])("reads %j as %s", (q, topic) => {
+ expect(detectTopic(q)).toBe(topic);
+ });
+
+ it("prefers the exam question over the standing one when both words appear", () => {
+ // "what sgpa do i need to pass" is about the target, not about one paper;
+ // the ordering in `detectTopic` is what decides this and it is asserted
+ // here so a reorder does not silently change the answer.
+ expect(detectTopic("what do i need to pass")).toBe("need_to_pass");
+ });
+});
+
+describe("it says nothing rather than guessing", () => {
+ it.each([
+ "what is the capital of france",
+ "write my assignment",
+ "who teaches this",
+ "",
+ " ",
+ ])("declines %j", (q) => {
+ expect(detectTopic(q)).toBeNull();
+ });
+});
diff --git a/app/src/state/answers.ts b/app/src/state/answers.ts
index 92e27c7..d5659d3 100644
--- a/app/src/state/answers.ts
+++ b/app/src/state/answers.ts
@@ -1,6 +1,6 @@
-import { absenceCost, courseLabel, freeSkips } from "../engine";
-import type { Course } from "../engine";
-import { rows, state } from "./store";
+import { absenceCost, courseLabel, freeSkips, requiredEseCell } from "../engine";
+import type { Course, Evaluation } from "../engine";
+import { goalPlan, goalRequirement, overall, rows, state, summary, targets } from "./store";
import type { View } from "./nav";
/**
@@ -28,6 +28,7 @@ import type { View } from "./nav";
/** The question shapes the engine can answer outright. */
export const TOPICS = [
"skip_cost", "budget", "eligibility", "tomorrow",
+ "attendance_now", "need_to_pass", "standing",
] as const;
export type Topic = (typeof TOPICS)[number];
@@ -84,6 +85,54 @@ function budgetLine(course: Course): string {
: `${label} — ${free} free before it costs a mark`;
}
+/**
+ * Where a subject's attendance stands right now.
+ *
+ * The plainest question a student can ask, and the one that fell through: the
+ * detector only fired on forward-looking words - skip, miss, eligible - so
+ * "what is my attendance in CN" was routed to a screen while "can I miss one"
+ * was answered outright. The counts are included because a percentage without
+ * its numerator and denominator is a figure the student cannot check.
+ */
+function standingLine(course: Course): string {
+ const label = courseLabel(course);
+ const cost = absenceCost(course.attended, course.held, course.dl ?? 0, 0);
+ if (cost === null) return `${label} — attendance not recorded`;
+ const counts = `${course.attended} of ${course.held}`;
+ const mark = `${marks(cost.marksBefore)} of the attendance CIE`;
+ return cost.eligibleBefore
+ ? `${label} — ${pct(cost.before)} (${counts}), earning ${mark}`
+ : `${label} — ${pct(cost.before)} (${counts}), below 75% and not eligible`;
+}
+
+/**
+ * What this subject still needs in the end-semester exam.
+ *
+ * `requiredEseCell` is the engine's own answer and is already what the Ledger
+ * prints; this states it in a sentence instead of a table cell. `bound` means
+ * the passing mark is not what binds - the student's own target grade is - and
+ * saying which is the difference between "you need 28" and "you need 28 for the
+ * grade you asked for, 19 to pass".
+ */
+function needLine(course: Course, ev: Evaluation): string {
+ const label = courseLabel(course);
+ if (ev.grade !== null && ev.ese !== null && ev.ese !== undefined) {
+ // Already sat and marked: there is nothing left to need.
+ return `${label} — already graded ${ev.grade}`;
+ }
+ const pass = requiredEseCell(ev.needPass, ev.needPassBest);
+ if (!pass.shown.possible) {
+ return `${label} — cannot pass on the marks recorded so far`;
+ }
+ const target = requiredEseCell(ev.needTarget, ev.needTargetBest);
+ const base = `${label} — needs ${pass.shown.value} of ${ev.eseMax} in the final to pass`;
+ // Only mention the target when it asks for MORE than passing does; a target
+ // already satisfied is not a thing to go and do.
+ return target.shown.possible && target.shown.value > pass.shown.value
+ ? `${base}, ${target.shown.value} for your target`
+ : base;
+}
+
/**
* Tomorrow's classes, priced individually.
*
@@ -173,7 +222,57 @@ export function answerFor(topic: Topic, code?: string): Answer | null {
};
}
- const line = topic === "skip_cost" ? skipLine : budgetLine;
+ if (topic === "standing") {
+ // The one topic that is not per-subject: a CGPA is a fact about the whole
+ // record, and listing it per course would be answering a different question.
+ const cgpa = overall();
+ if (cgpa.credits <= 0) return null;
+ const need = goalRequirement();
+ const plan = goalPlan();
+ const lines: string[] = [
+ `${cgpa.credits} registered credits counted, ${cgpa.percent.toFixed(1)}%`,
+ ];
+ const target = targets().cgpa;
+ if (target !== null && target !== undefined && need !== null) {
+ lines.push(need.possible && need.required !== null
+ ? `To reach ${target.toFixed(1)}, you need ${need.required.toFixed(2)} SGPA from here on`
+ : `${target.toFixed(1)} is no longer reachable in the semesters left`);
+ // Three states, not two. `reachable: false` explicitly does NOT mean
+ // impossible - goals.ts:395 says it means "this route does not guarantee
+ // it", and `conditional` is the case where the target is still open on a
+ // harder route or on marks the plan cannot price yet. Collapsing those
+ // into "not reachable" would tell a student their target is gone when it
+ // is not.
+ if (plan !== null) {
+ lines.push(plan.reachable
+ ? "This semester's subjects can carry it"
+ : plan.conditional
+ ? "Still open, but today's marks do not guarantee it"
+ : "This semester's subjects cannot carry it");
+ }
+ }
+ const sem = summary();
+ if (sem.assessed > 0) {
+ lines.push(`This semester projects to ${sem.sgpaProjected.toFixed(2)} SGPA`);
+ }
+ return { headline: `CGPA ${cgpa.cgpa.toFixed(2)}`, lines, view: "home" };
+ }
+
+ if (topic === "need_to_pass") {
+ const lines = picked.map((r) => needLine(r.course, r.ev));
+ if (lines.length === 0) return null;
+ if (picked.length === 1) {
+ return { headline: lines[0]!, lines: [], view: "ledger" };
+ }
+ return {
+ headline: "What each subject needs in the final:",
+ lines, view: "ledger",
+ };
+ }
+
+ const line = topic === "skip_cost" ? skipLine
+ : topic === "attendance_now" ? standingLine
+ : budgetLine;
const lines = picked
.map((r) => line(r.course))
.filter((l): l is string => l !== null);
@@ -187,7 +286,9 @@ export function answerFor(topic: Topic, code?: string): Answer | null {
return {
headline: topic === "skip_cost"
? "What one more absence costs, per subject:"
- : "Classes you can still miss for free:",
+ : topic === "attendance_now"
+ ? "Where your attendance stands:"
+ : "Classes you can still miss for free:",
lines, view: "attendance",
};
}
@@ -207,7 +308,38 @@ export function detectTopic(query: string): Topic | null {
const q = ` ${query.toLowerCase().replace(/[^a-z0-9 ]+/g, " ")} `;
const has = (...words: string[]) => words.some((w) => q.includes(` ${w} `));
+ /**
+ * A question about the RULE is not a question about this student.
+ *
+ * "How is SGPA calculated" contains "sgpa" and was being answered with the
+ * student's own CGPA - a confident reply to a question nobody asked, which is
+ * worse than routing, because a route at least lands somewhere the answer
+ * might be. These fall through so the caller can send them to the glossary.
+ *
+ * The test is possessive: "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.
+ */
+ const personal = has("my", "i", "me", "mine", "im") || q.includes(" am i ");
+ const explains = has("calculated", "calculate", "computed", "compute",
+ "explain", "explained", "works", "work", "mean", "means",
+ "definition", "formula", "affect", "affects", "why")
+ || q.includes(" pass mark ") || q.includes(" pass marks ");
+ if (explains && !personal) return null;
+
if (has("tomorrow", "tmrw", "tommorow", "tommorw")) return "tomorrow";
+
+ // Ordered before the attendance branches: "what do i need to pass" contains
+ // no attendance word, but "how many marks do i need" and "am i failing" would
+ // otherwise be caught by nothing at all.
+ if (has("pass", "passing", "fail", "failing", "final", "ese", "endsem")
+ || (has("need", "needed") && has("marks", "mark", "exam", "grade"))) {
+ return "need_to_pass";
+ }
+ if (has("cgpa", "sgpa", "gpa", "percentage") || q.includes(" first class ")
+ || q.includes(" where do i stand ") || has("standing", "distinction")) {
+ return "standing";
+ }
if (has("eligible", "eligibility", "debar", "debarred", "condonation")) {
return "eligibility";
}
@@ -220,5 +352,9 @@ export function detectTopic(query: string): Topic | null {
if (has("miss", "skip", "bunk", "cut", "leave", "leaves", "absent")) {
return "budget";
}
+ // The plainest question there is, and the one that used to fall through: 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.
+ if (has("attendance", "attended", "present")) return "attendance_now";
return null;
}
diff --git a/app/src/ui/__tests__/PaletteAnswer.test.tsx b/app/src/ui/__tests__/PaletteAnswer.test.tsx
index 29536f7..fe3fe20 100644
--- a/app/src/ui/__tests__/PaletteAnswer.test.tsx
+++ b/app/src/ui/__tests__/PaletteAnswer.test.tsx
@@ -178,6 +178,62 @@ describe("tomorrow joins the timetable to the budget", () => {
});
});
+describe("the plain attendance question is answered, not routed", () => {
+ it("states the percentage with the counts behind it", () => {
+ open(EDGE);
+ type("whats my attendance in computer networks");
+ // Percentage AND counts, both derived from the fixture rather than typed
+ // in: a percentage on its own is a figure the student cannot check against
+ // their own portal, and a literal here would stop tracking the band table.
+ const cost = absenceCost(EDGE.attended!, EDGE.held!, 0, 0)!;
+ expect(screen.getByText(new RegExp(
+ `${cost.before.toFixed(0)}%.*\(${EDGE.attended} of ${EDGE.held}\)`))).toBeTruthy();
+ });
+
+ it("says what the attendance is currently earning in CIE marks", () => {
+ open(EDGE);
+ type("whats my attendance in computer networks");
+ expect(screen.getByText(/of the attendance CIE/)).toBeTruthy();
+ });
+});
+
+describe("what a subject needs in the final", () => {
+ it("states the mark required to pass, out of the paper's own maximum", () => {
+ open(EDGE);
+ type("what do i need to pass computer networks");
+ expect(screen.getByText(/needs \d+ of \d+ in the final to pass/)).toBeTruthy();
+ });
+
+ it("answers for every subject when none is named", () => {
+ open(EDGE, CLEAR);
+ type("what do i need to pass");
+ expect(screen.getByText(/What each subject needs in the final/)).toBeTruthy();
+ });
+});
+
+describe("standing is a fact about the record, not about one subject", () => {
+ it("says nothing when no semester has been published", () => {
+ open(EDGE);
+ edit((d) => { d.history = {}; });
+ type("whats my cgpa");
+ // A CGPA computed from nothing is the 0.00 this app refuses to print.
+ expect(screen.queryByText(/CGPA \d/)).toBeNull();
+ });
+
+ it("states the CGPA once published, with the credits it is weighted by", () => {
+ open(EDGE);
+ edit((d) => {
+ d.history = {
+ S3: { sgpa: 8.2, creditsRegistered: 20, creditsEarned: 20,
+ source: "gradecard", conflict: null },
+ };
+ });
+ type("whats my cgpa");
+ expect(screen.getByText("CGPA 8.20")).toBeTruthy();
+ expect(screen.getByText(/20 registered credits counted/)).toBeTruthy();
+ });
+});
+
describe("eligibility is answered from the record", () => {
it("does not claim every subject is fine when one is not", () => {
open(EDGE, { ...CLEAR, attended: 60, held: 100 });
From f1f50b5270dd78bd2bb6719f38fa30e836ee9dfd Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 22:35:58 +0530
Subject: [PATCH 27/32] feat(ask): name the assistant Tex, and let it answer
for itself
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/__tests__/glossary.test.ts | 163 +++++++++++++++
app/src/state/answers.ts | 43 ++++
app/src/state/glossary.ts | 221 ++++++++++++++++++++
app/src/styles/app.css | 12 ++
app/src/ui/Drawer.tsx | 24 +--
app/src/ui/Palette.tsx | 61 ++++--
app/src/ui/__tests__/PaletteAnswer.test.tsx | 5 +-
app/src/ui/__tests__/PaletteAsk.test.tsx | 5 +-
app/src/ui/__tests__/PaletteSignIn.test.tsx | 149 +++++++++++++
worker/src/gemini.ts | 18 +-
10 files changed, 667 insertions(+), 34 deletions(-)
create mode 100644 app/src/state/__tests__/glossary.test.ts
create mode 100644 app/src/state/glossary.ts
create mode 100644 app/src/ui/__tests__/PaletteSignIn.test.tsx
diff --git a/app/src/state/__tests__/glossary.test.ts b/app/src/state/__tests__/glossary.test.ts
new file mode 100644
index 0000000..ae6d8e3
--- /dev/null
+++ b/app/src/state/__tests__/glossary.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from "vitest";
+import {
+ ATTENDANCE_CONDONE, ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX,
+ ATTENDANCE_MIN, DL_CAP_PCT, ESE_PASS_FRACTION,
+} from "../../engine";
+import { ALL_FACTS, CAPABILITIES, TERMS, lookupCapability, lookupTerm } from "../glossary";
+import { defineFor, detectTopic } from "../answers";
+
+/**
+ * The definitions, and the misfires they replaced.
+ *
+ * "What is condonation" contains no explainer verb, so the topic detector read
+ * it as an eligibility question and answered with the student's own attendance
+ * budget - a confident reply to a question nobody asked. These assert both
+ * halves: the definition is returned, and the wrong figure is not.
+ */
+
+describe("a definition is returned for a question about a word", () => {
+ it.each([
+ ["what is condonation", "Condonation"],
+ ["what is duty leave", "Duty leave"],
+ ["what does ese mean", "ESE"],
+ ["what is cie", "CIE"],
+ ["whats the difference between sgpa and cgpa", "SGPA and CGPA"],
+ ["explain debarred", "Debarred"],
+ ])("answers %j with %s", (q, name) => {
+ expect(defineFor(q)?.headline).toBe(name);
+ });
+
+ it("prefers the longest matching alias", () => {
+ // "duty leave" must not lose to "dl", and "sgpa and cgpa" must not lose to
+ // the bare "sgpa" entry.
+ expect(lookupTerm("what is duty leave")?.name).toBe("Duty leave");
+ expect(lookupTerm("what is the difference between sgpa and cgpa")?.name)
+ .toBe("SGPA and CGPA");
+ });
+
+ it("marks a definition as one, so it is not read as a computed figure", () => {
+ // "Condonation is available down to 60%" is the regulation. Presented like
+ // the student's own numbers it would read as something worked out for them.
+ expect(defineFor("what is condonation")?.isDefinition).toBe(true);
+ });
+});
+
+describe("a question about the student is not a definition", () => {
+ it.each([
+ "what is my attendance",
+ "whats my cgpa",
+ "what do i need to pass",
+ "how many classes can i miss",
+ ])("declines %j so the engine answers it instead", (q) => {
+ // One word apart from a definitional question, and a completely different
+ // kind of answer.
+ expect(defineFor(q)).toBeNull();
+ expect(detectTopic(q)).not.toBeNull();
+ });
+
+ it("returns nothing for a word the glossary does not hold", () => {
+ expect(defineFor("what is a placement drive")).toBeNull();
+ expect(defineFor("what is the capital of france")).toBeNull();
+ });
+});
+
+describe("the misfires are dead", () => {
+ it.each([
+ "what is duty leave",
+ "what is condonation",
+ ])("no longer answers %j with a figure about the student", (q) => {
+ // Both used to return an attendance budget. The definition path claims
+ // them first, and the topic detector must not be reached.
+ expect(defineFor(q)).not.toBeNull();
+ });
+});
+
+describe("every figure quoted is the constant the engine calculates with", () => {
+ const body = (name: string) => TERMS.find((t) => t.name === name)!.body;
+
+ it("quotes the eligibility floor rather than a literal", () => {
+ expect(body("Shortage")).toContain(`${ATTENDANCE_MIN}%`);
+ expect(body("Condonation")).toContain(`${ATTENDANCE_CONDONE}%`);
+ });
+
+ it("quotes the duty leave cap rather than a literal", () => {
+ expect(body("Duty leave")).toContain(`${DL_CAP_PCT}%`);
+ });
+
+ it("quotes the attendance band table rather than a literal", () => {
+ const text = body("Attendance marks");
+ expect(text).toContain(`${ATTENDANCE_MARK_MAX} CIE marks`);
+ // The top band's percentage and its mark, read off the table itself - the
+ // drawer used to write "85% earns 5" out by hand in the one panel whose
+ // job is teaching the rule.
+ expect(text).toContain(`${ATTENDANCE_MARK_BANDS[0]![0]}% earns ${ATTENDANCE_MARK_BANDS[0]![1]}`);
+ });
+
+ it("quotes the separate ESE minimum rather than a literal", () => {
+ const pct = Math.round(ESE_PASS_FRACTION * 100);
+ expect(TERMS.some((t) => t.name.includes(`${pct}%`))).toBe(true);
+ });
+
+ it("has no term whose aliases are empty, since it could never be found", () => {
+ for (const term of TERMS) expect(term.aliases.length).toBeGreaterThan(0);
+ });
+});
+
+/**
+ * Questions about the app, which are the ones nothing could answer.
+ *
+ * These shipped once with no test and were dead on arrival: a heredoc turned
+ * every `\b` in the HOWTO patterns into a literal backspace byte, so the regexes
+ * read as `/^Hhow (?:do|can|does|often)^H/` and matched nothing at all. The file
+ * looked correct in every editor, because a terminal renders 0x08 as nothing.
+ * Six capability entries, reachable by no phrasing, and the only thing that
+ * would have caught it is an assertion that a real question finds one.
+ */
+describe("a question about the app is answered by the app", () => {
+ it.each([
+ ["what can this app do", "What TargetX does"],
+ ["where does this data come from", "Where the data comes from"],
+ ["how do i import my grade card", "Importing a grade card"],
+ ["is my password stored", "Whether your password is stored"],
+ ["how do i back up my data", "Backing up"],
+ ["how often does it sync", "How often it syncs"],
+ ])("answers %j with %s", (q, name) => {
+ expect(defineFor(q)?.headline).toBe(name);
+ });
+
+ it("is reached by a possessive phrasing, which the term lookup refuses", () => {
+ // "How do I import MY grade card" is first-person and is still not a
+ // question about this student's record. `lookupTerm` rejects it twice over
+ // - not definitional, and possessive - which is why capabilities need
+ // their own matcher rather than a looser guard on the shared one.
+ expect(lookupTerm("how do i import my grade card")).toBeNull();
+ expect(lookupCapability("how do i import my grade card")).not.toBeNull();
+ });
+
+ it("every capability is reachable by at least one of its own aliases", () => {
+ // The assertion the backspace bug needed. A phrasing built from the entry
+ // itself, so adding a capability with an unmatchable alias fails here.
+ for (const cap of CAPABILITIES) {
+ const alias = cap.aliases[0]!;
+ expect(lookupCapability(`what is ${alias}`), cap.name).not.toBeNull();
+ }
+ });
+
+ it("declines a question that is not about the app", () => {
+ expect(lookupCapability("what is the capital of france")).toBeNull();
+ expect(lookupCapability("import")).toBeNull(); // No question in it at all.
+ });
+
+ it("does not let a capability claim a question about the student", () => {
+ // "what is my attendance" reads as a HOWTO phrasing and must still reach
+ // the engine, or a student asking for a figure gets a paragraph.
+ expect(defineFor("what is my attendance")).toBeNull();
+ expect(detectTopic("what is my attendance")).toBe("attendance_now");
+ });
+
+ it("has no fact whose body is shorter than a sentence", () => {
+ for (const fact of ALL_FACTS) {
+ expect(fact.body.length, fact.name).toBeGreaterThan(40);
+ }
+ });
+});
diff --git a/app/src/state/answers.ts b/app/src/state/answers.ts
index d5659d3..bec24e6 100644
--- a/app/src/state/answers.ts
+++ b/app/src/state/answers.ts
@@ -2,6 +2,21 @@ import { absenceCost, courseLabel, freeSkips, requiredEseCell } from "../engine"
import type { Course, Evaluation } from "../engine";
import { goalPlan, goalRequirement, overall, rows, state, summary, targets } from "./store";
import type { View } from "./nav";
+import { lookupCapability, lookupTerm } from "./glossary";
+
+/**
+ * What the assistant is called.
+ *
+ * It needed a name because it now says things rather than only opening screens,
+ * and an answer that appears with no attribution reads as the app asserting a
+ * fact rather than as something having worked it out.
+ *
+ * Tex is already inside TargetX, which is why it does not need explaining, and
+ * it is one syllable so it fits a button label without being shortened again.
+ * Every user-facing use of the name reads this constant - renaming it is a one
+ * line change, and no string anywhere spells it out.
+ */
+export const ASSISTANT = "Tex";
/**
* Answers, computed here, said in the box.
@@ -35,6 +50,15 @@ export type Topic = (typeof TOPICS)[number];
export interface Answer {
/** The sentence. Every figure in it came from the engine. */
headline: string;
+ /**
+ * True when this is a definition rather than a figure about this student.
+ *
+ * Rendered differently because it is a different KIND of claim: a definition
+ * is the regulation, and a figure is this student's record. Presenting them
+ * identically would let "condonation is available down to 60%" read as
+ * something computed about them.
+ */
+ isDefinition?: boolean;
/** Supporting lines, one per subject where the question spans several. */
lines: string[];
/** Where to go for the full working. The answer is not a substitute for it. */
@@ -304,6 +328,25 @@ export function answerFor(topic: Topic, code?: string): Answer | null {
* Only fires on an unambiguous phrase. Anything it is not sure about falls
* through to the router, which is allowed to be slower and is allowed to say no.
*/
+/**
+ * A definition, when the question is asking what a word means.
+ *
+ * Tried BEFORE the topic detector and works with an empty record, because "what
+ * is CIE" has the same answer for a student who has synced nothing. It is also
+ * what stops the misfires: "what is condonation" used to match the eligibility
+ * topic and reply with the student's own miss budget.
+ */
+export function defineFor(query: string): Answer | null {
+ // A definition first, then a fact about the app. Definitions are the
+ // narrower test, so trying them first means "what is CIE" is never mistaken
+ // for a question about the product.
+ const term = lookupTerm(query) ?? lookupCapability(query);
+ if (!term) return null;
+ return {
+ headline: term.name, lines: [term.body], view: "ledger", isDefinition: true,
+ };
+}
+
export function detectTopic(query: string): Topic | null {
const q = ` ${query.toLowerCase().replace(/[^a-z0-9 ]+/g, " ")} `;
const has = (...words: string[]) => words.some((w) => q.includes(` ${w} `));
diff --git a/app/src/state/glossary.ts b/app/src/state/glossary.ts
new file mode 100644
index 0000000..2230f1c
--- /dev/null
+++ b/app/src/state/glossary.ts
@@ -0,0 +1,221 @@
+import {
+ ATTENDANCE_CONDONE, ATTENDANCE_MARK_BANDS, ATTENDANCE_MARK_MAX,
+ ATTENDANCE_MIN, DL_CAP_PCT, ESE_PASS_FRACTION, COURSE_TYPES, DEFAULT_TYPE,
+} from "../engine";
+
+/**
+ * What the words mean, stated once.
+ *
+ * Two problems this fixes, which turned out to be the same problem.
+ *
+ * The ask box could not answer "what is condonation" and did something worse
+ * than failing: "condonation" contains no explainer verb, so the topic detector
+ * matched 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 rather than a question shape that has to be
+ * guarded against.
+ *
+ * And the drawer's glossary hardcoded every figure it quoted - "85% earns 5,
+ * then 4, 3, 2, 1 down to 60%", "at most 10% of classes held", "at least 40% of
+ * the ESE paper". Those are the engine's constants written out as English, in
+ * the one panel whose entire job is teaching the rules, with nothing failing if
+ * they drift. Every number below is interpolated from the constant the
+ * calculation itself uses, so a change to the regulation cannot leave this
+ * screen confidently teaching the old one.
+ */
+
+export interface Term {
+ /** What it is called on screen. */
+ name: string;
+ /** Everything a student might type to mean it. Lower case, matched whole. */
+ aliases: string[];
+ body: string;
+}
+
+/** The top band and its mark, read off the table rather than assumed. */
+const TOP_BAND = ATTENDANCE_MARK_BANDS[0]!;
+/** The lowest band that still earns anything. */
+const LAST_BAND = ATTENDANCE_MARK_BANDS[ATTENDANCE_MARK_BANDS.length - 1]!;
+/** The marks in the table, high to low, as "5, 4, 3, 2, 1". */
+const BAND_MARKS = ATTENDANCE_MARK_BANDS.map(([, m]) => m).join(", ");
+const DEFAULT_SPEC = COURSE_TYPES[DEFAULT_TYPE];
+const ESE_PASS_PCT = Math.round(ESE_PASS_FRACTION * 100);
+
+export const TERMS: Term[] = [
+ {
+ name: "CIE",
+ aliases: ["cie", "internal", "internals", "continuous internal evaluation"],
+ body: `Continuous Internal Evaluation — the marks your college gives during the semester: series exams, assignments, and attendance. Out of ${DEFAULT_SPEC.cieMax} for most theory courses.`,
+ },
+ {
+ name: "ESE",
+ aliases: ["ese", "end semester", "endsem", "end semester examination", "university exam"],
+ body: `End Semester Examination — the university exam at the end. Out of ${DEFAULT_SPEC.eseMax} for most theory courses.`,
+ },
+ {
+ name: `The ${ESE_PASS_PCT}% rule`,
+ aliases: ["40 rule", "40% rule", "ese minimum", "separate minimum", "ese pass"],
+ body: `You must score at least ${ESE_PASS_PCT}% of the ESE paper on its own, whatever your CIE is. A strong internal cannot buy a pass.`,
+ },
+ {
+ name: "Attendance marks",
+ aliases: ["att mk", "attendance mark", "attendance marks", "r7.5", "r 7.5"],
+ body: `Attendance is worth up to ${ATTENDANCE_MARK_MAX} CIE marks under Regulations 2024, R 7.5.ii: ${TOP_BAND[0]}% earns ${TOP_BAND[1]}, then ${BAND_MARKS} down to ${LAST_BAND[0]}%. This is the part no other KTU calculator shows — being at ${ATTENDANCE_MIN + 1}% is not "fine", it is marks already gone.`,
+ },
+ {
+ name: "Shortage",
+ aliases: ["shortage", "short", "attendance shortage"],
+ body: `Below ${ATTENDANCE_MIN}% attendance. Condonation may be possible down to ${ATTENDANCE_CONDONE}%, for at most two semesters, against a fee.`,
+ },
+ {
+ name: "Condonation",
+ aliases: ["condonation", "condone", "condoned"],
+ body: `Paying to be allowed to sit an exam you are short of attendance for. Available between ${ATTENDANCE_CONDONE}% and ${ATTENDANCE_MIN}%, for at most two semesters across the programme, against a fee. Below ${ATTENDANCE_CONDONE}% there is no appeal path under R 6.2.`,
+ },
+ {
+ name: "Debarred",
+ aliases: ["debarred", "debar", "barred"],
+ body: `Below ${ATTENDANCE_CONDONE}% attendance. You cannot sit the exam and there is no appeal path under R 6.2.`,
+ },
+ {
+ name: "Duty leave",
+ aliases: ["duty leave", "dl", "od", "on duty"],
+ body: `Approved absence for NSS, sports, fests or placement drives. It counts as present, but only up to ${DL_CAP_PCT}% of classes held (R 6.3.ii) — anything beyond that is wasted, and this app says so.`,
+ },
+ {
+ name: "Incomplete",
+ aliases: ["incomplete", "withdrawn", "withdrawal", "grade i", "grade w"],
+ body: "Published as I or W — withdrawn, or not completed. KTU leaves it out of the SGPA entirely, credits included, until you complete it. It is not a fail and is not scored as one.",
+ },
+ {
+ name: "Unreachable",
+ aliases: ["unreachable"],
+ body: "Even a full ESE paper cannot get this course to a pass. Better to know now.",
+ },
+ {
+ name: "SGPA and CGPA",
+ aliases: ["sgpa", "cgpa", "gpa", "sgpa and cgpa", "difference between sgpa and cgpa"],
+ body: "SGPA is one semester's grade point average, weighted by the credits you registered for that semester. CGPA is the same average across every semester published so far. A failed course still counts in the denominator; one marked I or W does not.",
+ },
+];
+
+/**
+ * Whether a question is asking what something IS.
+ *
+ * Deliberately narrow. A definitional phrasing plus a term the glossary holds
+ * is a high bar, and anything short of it falls through to the topic detector -
+ * because "what is my attendance" is a question about the student, not about
+ * the word "attendance", and the two are one word apart.
+ */
+const DEFINITIONAL = [
+ /\bwhat (?:is|are|does|do)\b/, /\bwhats\b/, /\bexplain\b/, /\bmeaning of\b/,
+ /\bdefine\b/, /\bmeans?\b/, /\bdifference between\b/,
+];
+
+/** A question about the student, not about a word. One word decides it. */
+const POSSESSIVE = /\b(my|mine|i|me|im)\b/;
+
+/**
+ * Find the term a question is asking about, or null.
+ *
+ * Longest alias first, so "duty leave" is not beaten by "dl" and "sgpa and
+ * cgpa" is not beaten by "sgpa".
+ */
+const normalise = (query: string) =>
+ ` ${query.toLowerCase().replace(/[^a-z0-9% ]+/g, " ").replace(/\s+/g, " ")} `;
+
+export function lookupTerm(query: string): Term | null {
+ const q = normalise(query);
+ if (!DEFINITIONAL.some((re) => re.test(q))) return null;
+ // "what is my attendance" is asking for a figure, not a definition.
+ if (POSSESSIVE.test(q)) return null;
+
+ return longestAlias(q, TERMS);
+}
+
+/** The longest alias any of `pool` matches in an already-normalised query. */
+function longestAlias(q: string, pool: Term[]): Term | null {
+ let best: { term: Term; length: number } | null = null;
+ for (const term of pool) {
+ for (const alias of term.aliases) {
+ if (!q.includes(` ${alias} `)) continue;
+ if (best === null || alias.length > best.length) {
+ best = { term, length: alias.length };
+ }
+ }
+ }
+ return best?.term ?? null;
+}
+
+/**
+ * A question about the APP, which needs its own matcher.
+ *
+ * "How do I import my grade card" is possessive and is not definitional, so
+ * `lookupTerm` refuses it twice over - correctly, since those guards exist to
+ * stop "what is my attendance" being answered with a dictionary entry. But a
+ * question about the product is naturally phrased in the first person, and it
+ * is still not a question about the student's record. Different question,
+ * different test.
+ */
+const HOWTO = [
+ /\bhow (?:do|can|does|often)\b/, /\bcan i\b/, /\bwhere (?:is|are|do|does)\b/,
+ /\bwhat can\b/, /\bis my\b/, /\bare my\b/, /\bwhat (?:is|are|does|do)\b/,
+ /\bwhats\b/,
+];
+
+export function lookupCapability(query: string): Term | null {
+ const q = normalise(query);
+ if (!HOWTO.some((re) => re.test(q))) return null;
+ return longestAlias(q, CAPABILITIES);
+}
+
+/**
+ * What the app itself does.
+ *
+ * Nothing anywhere described this. Not the app, and not the Worker's system
+ * prompt - which lists five view names and nothing else - so "how do I import
+ * my grade card", "where does this data come from" and "is my password stored"
+ * could not be answered by either half. A student asking what the thing in
+ * front of them does is asking the most reasonable question there is.
+ *
+ * Kept beside the regulation terms deliberately: they are the same kind of
+ * claim. Neither is computed from the student's record, and both are things
+ * TargetX can state because they are true of TargetX, not of them.
+ */
+export const CAPABILITIES: Term[] = [
+ {
+ name: "What TargetX does",
+ aliases: ["targetx", "this app", "the app", "it do", "you do", "tex"],
+ body: `Tracks your KTU marks and attendance so you do not have to open etlab or the KTU portal. It prices attendance in CIE marks (R 7.5.ii), says what each subject still needs in the final, and tracks your CGPA against a target you set. Every figure is computed on this machine from your own records.`,
+ },
+ {
+ name: "Where the data comes from",
+ aliases: ["data come from", "data comes from", "where the data", "source of the data",
+ "sync", "syncing", "etlab", "ktu portal"],
+ body: `Two sources. Your college's etlab portal supplies this semester's marks, attendance and timetable; the KTU portal supplies published grade cards for past semesters. Where the two disagree about a semester, the KTU grade card wins and the disagreement is shown rather than hidden.`,
+ },
+ {
+ name: "Importing a grade card",
+ aliases: ["import", "importing", "grade card", "gradecard", "import a grade card",
+ "paste", "pdf"],
+ body: `On the Data screen. You can fetch it from the KTU portal directly, paste the table from a grade card, or open the PDF. Any of the three fills in a past semester's SGPA and credits.`,
+ },
+ {
+ name: "Whether your password is stored",
+ aliases: ["password", "password stored", "passwords", "credentials", "secure", "safe"],
+ body: `Your portal password is used to sign in and is not written to disk in readable form, is never included in an export, and is never written to a log. Signing in to TargetX itself is separate and optional - it unlocks the question box and nothing else.`,
+ },
+ {
+ name: "Backing up",
+ aliases: ["backup", "back up", "backing up", "export", "restore"],
+ body: `Export backup on the Data screen writes a single file holding every semester, subject and published result. Restore replaces the whole record with a file, so export first if there is anything you want to keep.`,
+ },
+ {
+ name: "How often it syncs",
+ aliases: ["how often", "sync", "refresh", "refreshes", "automatic"],
+ body: `On its own, at most once every 45 minutes, and only when the record is stale. The refresh button in the header pulls both portals immediately and ignores that gap.`,
+ },
+];
+
+/** Both are facts about TargetX rather than about the student. */
+export const ALL_FACTS: Term[] = [...TERMS, ...CAPABILITIES];
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index 950b3a6..cba8858 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -1063,3 +1063,15 @@ tr.row:hover .del { opacity: 1; }
color: var(--text-faint);
white-space: nowrap;
}
+
+/* Who is answering, and on what authority. A definition is the regulation; a
+ figure is this student's record. Same block, different ground, so the two
+ kinds of claim are never mistaken for each other. */
+.palette-answer-who {
+ margin: 0 0 var(--s2);
+ font-size: var(--text-xs);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-faint);
+}
+.palette-answer.definition { background: var(--surface-2); }
diff --git a/app/src/ui/Drawer.tsx b/app/src/ui/Drawer.tsx
index 7f1ab34..b9aa69d 100644
--- a/app/src/ui/Drawer.tsx
+++ b/app/src/ui/Drawer.tsx
@@ -2,6 +2,7 @@ import { For, Show, createSignal } from "solid-js";
import { goalRequirement, rows, summary } from "../state/store";
import { AttendanceScatter, GoalGauge } from "./charts";
import { TargetsTab } from "./Targets";
+import { TERMS } from "../state/glossary";
/**
* Column glossary.
@@ -10,19 +11,14 @@ import { TargetsTab } from "./Targets";
* and the old build hid this behind a modal - so the explanation and the thing
* being explained were never on screen together. It lives in the drawer now
* and stays open while the table is read.
+ *
+ * The text comes from `state/glossary`, which is also what the ask box answers
+ * definitions from. It used to be a second copy here with every figure written
+ * out by hand - "85% earns 5, then 4, 3, 2, 1 down to 60%", "at most 10% of
+ * classes held" - in the one panel whose entire job is teaching the rules, with
+ * nothing failing if the regulation moved under it.
*/
-const GLOSSARY: Array<[string, string]> = [
- ["CIE", "Continuous Internal Evaluation - the marks your college gives during the semester: series exams, assignments, and attendance. Out of 40 for most theory courses."],
- ["ESE", "End Semester Examination - the university exam at the end. Out of 60 for most theory courses."],
- ["The 40% rule", "You must score at least 40% of the ESE paper on its own, whatever your CIE is. A brilliant internal cannot buy a pass. Where this is the binding constraint, the required mark is starred."],
- ["Att mk", "Attendance is worth up to 5 CIE marks under Regulations 2024, R 7.5.ii: 85% earns 5, then 4, 3, 2, 1 down to 60%. This is the part no other KTU calculator shows - being at 76% is not 'fine', it is two marks already gone."],
- ["Pass / Need", "The ESE mark required to pass, and to reach your target grade. Blank means the course has not been assessed yet, not that you scored zero."],
- ["SHORTAGE", "Below 75% attendance. Condonation may be possible down to 60%, for at most two semesters, against a fee."],
- ["DEBARRED", "Below 60%. There is no appeal path under R 6.2."],
- ["UNREACHABLE", "Even a full ESE paper cannot get this course to a pass. Better to know now."],
- ["INCOMPLETE", "Published as I or W - withdrawn, or not completed. KTU leaves it out of the SGPA entirely, credits included, until you complete it. It is not a fail and is not scored as one."],
- ["Duty leave", "Approved absence for NSS, sports, fests or placement drives. It counts as present, but only up to 10% of classes held (R 6.3.ii) - anything beyond that is wasted, and this app says so."],
-];
+
export function Drawer() {
const [tab, setTab] = createSignal<"analytics" | "targets" | "legend">("analytics");
@@ -63,8 +59,8 @@ export function Drawer() {
- {([term, text]) => (
- <>
{term}
{text}
>
+ {(term) => (
+ <>
{term.name}
{term.body}
>
)}
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx
index bdc8c5d..5900d6f 100644
--- a/app/src/ui/Palette.tsx
+++ b/app/src/ui/Palette.tsx
@@ -4,9 +4,9 @@ import { rows } from "../state/store";
import { VIEWS, setView } from "../state/nav";
import type { View } from "../state/nav";
import { askConfigured, askRemote } from "../state/ask";
-import { answerFor, detectTopic } from "../state/answers";
+import { ASSISTANT, answerFor, defineFor, detectTopic } from "../state/answers";
import type { Topic } from "../state/answers";
-import { signedIn } from "../state/auth";
+import { authBusy, authConfigured, signIn, signedIn } from "../state/auth";
/**
* The command palette.
@@ -132,6 +132,12 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
const answer = createMemo(() => {
const q = query().trim();
if (q === "") return null;
+ // Definitions first, and they work with an empty record: "what is CIE" has
+ // the same answer for a student who has synced nothing. This is also what
+ // stops the misfires - "what is condonation" used to match the eligibility
+ // topic and reply with the student's own miss budget.
+ const defined = defineFor(q);
+ if (defined) return defined;
const topic = detectTopic(q);
if (topic === null) return null;
// A subject named in the question narrows the answer to it; otherwise the
@@ -311,14 +317,21 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
+ {/* Attributed, because an answer with no author reads as the
+ app asserting a fact rather than as something that was
+ worked out - and the two deserve different trust. */}
+
+ {said().isDefinition ? `${ASSISTANT} · from the KTU regulations`
+ : `${ASSISTANT} · from your own record`}
+
{/* The answer is not a substitute for the working. */}
- {
- setView(said().view);
- props.onClose();
- }}>See the full breakdown
+
+ {
+ setView(said().view);
+ props.onClose();
+ }}>See the full breakdown
+
- {/* The offer is only made when it can be honoured. Telling
- a signed-out student to press Enter and then refusing
- them is worse than not offering. */}
-
-
- {asking() ? "Working it out…" : "Press Enter to ask."}
-
+ {/* Signed out, this said nothing at all - so a student who
+ had just been shown that the box answers questions hit
+ the one question it could not answer and was told
+ nothing about why, or what to do. The nudge belongs
+ exactly here: at the moment the assistant would have
+ been used, not as a banner over a box that mostly works
+ without an account. */}
+
+
+
+ { void signIn(); }}>
+ {authBusy() ? "Opening your browser…" : `Sign in to ask ${ASSISTANT}`}
+
+ {" "}— everything else in TargetX works without an account.
+
+
+ }>
+
+ {asking() ? "Working it out…" : `Press Enter to ask ${ASSISTANT}.`}
+
+
>
}>
diff --git a/app/src/ui/__tests__/PaletteAnswer.test.tsx b/app/src/ui/__tests__/PaletteAnswer.test.tsx
index fe3fe20..ba09007 100644
--- a/app/src/ui/__tests__/PaletteAnswer.test.tsx
+++ b/app/src/ui/__tests__/PaletteAnswer.test.tsx
@@ -21,7 +21,10 @@ vi.mock("../../state/ask", () => ({
askConfigured: () => false,
askRemote: vi.fn(),
}));
-vi.mock("../../state/auth", () => ({ signedIn: () => false }));
+vi.mock("../../state/auth", () => ({
+ signedIn: () => false, authConfigured: () => true,
+ authBusy: () => false, signIn: vi.fn(),
+}));
const { Palette } = await import("../Palette");
diff --git a/app/src/ui/__tests__/PaletteAsk.test.tsx b/app/src/ui/__tests__/PaletteAsk.test.tsx
index 49ee9ca..0e4fa8c 100644
--- a/app/src/ui/__tests__/PaletteAsk.test.tsx
+++ b/app/src/ui/__tests__/PaletteAsk.test.tsx
@@ -28,7 +28,10 @@ vi.mock("../../state/ask", () => ({
askConfigured: () => true,
askRemote: (...args: unknown[]) => askRemote(...args),
}));
-vi.mock("../../state/auth", () => ({ signedIn: () => true }));
+vi.mock("../../state/auth", () => ({
+ signedIn: () => true, authConfigured: () => true,
+ authBusy: () => false, signIn: vi.fn(),
+}));
const { Palette } = await import("../Palette");
diff --git a/app/src/ui/__tests__/PaletteSignIn.test.tsx b/app/src/ui/__tests__/PaletteSignIn.test.tsx
new file mode 100644
index 0000000..696e0bf
--- /dev/null
+++ b/app/src/ui/__tests__/PaletteSignIn.test.tsx
@@ -0,0 +1,149 @@
+// @vitest-environment jsdom
+/**
+ * The nudge at the moment the assistant would have been used.
+ *
+ * The offer to press Enter was gated on `askConfigured() && signedIn()`, on the
+ * reasoning that offering something you will then refuse is worse than offering
+ * nothing. True as far as it went - but the else branch was silence. A student
+ * who typed a real question, matched nothing locally, and had no account was
+ * shown "Nothing here matches" and no reason and no way forward, at the exact
+ * point they were reaching for the one feature an account unlocks.
+ *
+ * So: still never promise Enter to someone it would refuse, but say what the
+ * account is for and put the button there. And still say nothing when there is
+ * genuinely nothing to offer - no endpoint, or no Clerk to sign in against -
+ * because a button that cannot work is the same bug wearing a coat.
+ */
+import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { Course } from "../../engine";
+import { addCourse, edit, updateCourse } from "../../state/store";
+import { ASSISTANT } from "../../state/answers";
+
+const state = { configured: true, signed: false, authOk: true, busy: false };
+const signIn = vi.fn();
+
+vi.mock("../../state/ask", () => ({
+ askConfigured: () => state.configured,
+ askRemote: vi.fn(),
+}));
+vi.mock("../../state/auth", () => ({
+ signedIn: () => state.signed,
+ authConfigured: () => state.authOk,
+ authBusy: () => state.busy,
+ signIn: (...args: unknown[]) => signIn(...args),
+}));
+
+const { Palette } = await import("../Palette");
+
+const ML: Partial = {
+ code: "CST305", name: "Machine Learning", credits: 4, type: "TH 40/60",
+ s1: 38, s2: 34, other: 9, attended: 39, held: 50, dl: 0,
+};
+
+/** Matches no subject, no view and no local answer, so the box falls through. */
+const UNMATCHED = "zzq qqz";
+
+function open() {
+ edit((d) => {
+ d.semesters = { S5: { courses: [] } };
+ d.activeSemester = "S5";
+ d.history = {};
+ d.timetable = undefined;
+ });
+ addCourse();
+ updateCourse(0, ML);
+ return render(() => {}} />);
+}
+
+const type = (value: string) => {
+ const input = screen.getByLabelText("Search subjects and views") as HTMLInputElement;
+ fireEvent.input(input, { target: { value } });
+ return input;
+};
+
+afterEach(() => {
+ cleanup();
+ signIn.mockClear();
+ Object.assign(state, { configured: true, signed: false, authOk: true, busy: false });
+});
+
+describe("a signed-out student is told what the account is for", () => {
+ it("offers sign-in instead of the silence that was there", () => {
+ open();
+ type(UNMATCHED);
+ // Not "sign in" in the abstract: named, so it is clear what is unlocked.
+ expect(screen.getByRole("button", { name: `Sign in to ask ${ASSISTANT}` })).toBeTruthy();
+ });
+
+ it("says the rest of the app does not need an account, so it does not read as a wall", () => {
+ open();
+ type(UNMATCHED);
+ expect(screen.getByText(/everything else in TargetX works without an account/)).toBeTruthy();
+ });
+
+ it("starts sign-in when pressed", () => {
+ open();
+ type(UNMATCHED);
+ fireEvent.click(screen.getByRole("button", { name: `Sign in to ask ${ASSISTANT}` }));
+ expect(signIn).toHaveBeenCalled();
+ });
+
+ it("does not promise Enter to someone it would refuse", () => {
+ open();
+ type(UNMATCHED);
+ expect(screen.queryByText(/Press Enter/)).toBeNull();
+ });
+
+ it("says the browser is opening rather than looking dead while it is", () => {
+ state.busy = true;
+ open();
+ type(UNMATCHED);
+ expect(screen.getByRole("button", { name: /Opening your browser/ })).toBeTruthy();
+ expect((screen.getByRole("button", { name: /Opening your browser/ }) as HTMLButtonElement)
+ .disabled).toBe(true);
+ });
+});
+
+describe("nothing is offered that could not be honoured", () => {
+ it("stays silent when there is no ask endpoint at all", () => {
+ state.configured = false;
+ open();
+ type(UNMATCHED);
+ expect(screen.queryByRole("button", { name: /Sign in to ask/ })).toBeNull();
+ expect(screen.queryByText(/Press Enter/)).toBeNull();
+ });
+
+ it("stays silent when there is nothing to sign in against", () => {
+ // No Clerk client id in the build. The button would open nothing.
+ state.authOk = false;
+ open();
+ type(UNMATCHED);
+ expect(screen.queryByRole("button", { name: /Sign in to ask/ })).toBeNull();
+ });
+
+ it("offers Enter, not sign-in, once signed in", () => {
+ state.signed = true;
+ open();
+ type(UNMATCHED);
+ expect(screen.getByText(new RegExp(`Press Enter to ask ${ASSISTANT}`))).toBeTruthy();
+ expect(screen.queryByRole("button", { name: /Sign in to ask/ })).toBeNull();
+ });
+});
+
+describe("the nudge appears where the assistant was reached for, not before", () => {
+ it("is absent while the box is still matching things locally", () => {
+ open();
+ type("machine");
+ // A subject matched. Nothing was asked of the assistant, so nothing about
+ // accounts belongs on screen.
+ expect(screen.queryByRole("button", { name: /Sign in to ask/ })).toBeNull();
+ });
+
+ it("is absent for a question answered locally while signed out", () => {
+ open();
+ type("whats my attendance in machine learning");
+ // The engine answered it on this machine. Being signed out cost nothing.
+ expect(screen.queryByRole("button", { name: /Sign in to ask/ })).toBeNull();
+ });
+});
diff --git a/worker/src/gemini.ts b/worker/src/gemini.ts
index 5fe0b07..e678a90 100644
--- a/worker/src/gemini.ts
+++ b/worker/src/gemini.ts
@@ -46,12 +46,24 @@ Views:
- history: published results from past semesters
- data: sync with etlab and KTU, import, backup
+What TargetX holds, so that "we do not have that" and "that is not our subject"
+are told apart. It has: this semester's marks per component, attendance counts
+and percentages, the CIE marks attendance is worth under KTU Regulations 2024
+R 7.5.ii, the weekly timetable, published SGPA and credits for past semesters,
+and a CGPA target. It does NOT have: fee or hostel records, exam hall tickets,
+seating, question papers, syllabus content, faculty contact details, or anything
+about another student. A question about the first list is "no_match" at worst.
+A question about the second is on the subject of college and still "off_topic",
+because there is no screen here that could ever answer it.
+
Choose "subject" when the question names one of the given courses, and set
"view" to where that question is answered - attendance questions to attendance,
mark questions to ledger. Choose "view" when the question is about the student
-overall. Choose "none" with reason "off_topic" for anything not about this
-student's academic record, "unclear" when the question cannot be understood,
-and "no_match" when it is on topic but nothing here answers it.`;
+overall, and "data" when it is about where the numbers come from, syncing,
+importing a grade card, backup, or whether a password is stored. Choose "none"
+with reason "off_topic" for anything not about this student's academic record,
+"unclear" when the question cannot be understood, and "no_match" when it is on
+topic but nothing here answers it.`;
/** Mirrors `Action`. Gemini enforces this during decoding. */
function responseSchema(codes: string[]) {
From f5f39b53891cb3b461bc2c16c01b01752410ec04 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 22:48:15 +0530
Subject: [PATCH 28/32] feat(palette): the search pill grows into the bar, and
shrinks back
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/styles/app.css | 7 ++
app/src/ui/App.tsx | 12 ++-
app/src/ui/Palette.tsx | 57 +++++++++-
app/src/ui/__tests__/PaletteMorph.test.tsx | 117 +++++++++++++++++++++
app/src/ui/morph.ts | 111 +++++++++++++++++++
5 files changed, 298 insertions(+), 6 deletions(-)
create mode 100644 app/src/ui/__tests__/PaletteMorph.test.tsx
create mode 100644 app/src/ui/morph.ts
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index cba8858..5c156b6 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -863,6 +863,13 @@ tr.row:hover .del { opacity: 1; }
.ask:hover { background: var(--surface-2); color: var(--text); }
.ask:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
+/* While the palette is open it IS this control, grown. Two copies on screen
+ would give the morph something to visibly separate from, which is the exact
+ impression the morph exists to remove. Opacity, not display: the box has to
+ keep its size, because the palette measures it to know where to shrink back
+ to - and a hidden element has no rect. */
+:root[data-palette="open"] .ask { opacity: 0; }
+
.ask > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ask svg { flex: none; opacity: 0.7; }
/* Hard to the far end, so the shortcut reads as the field's affordance rather
diff --git a/app/src/ui/App.tsx b/app/src/ui/App.tsx
index 1a26d88..4377518 100644
--- a/app/src/ui/App.tsx
+++ b/app/src/ui/App.tsx
@@ -5,6 +5,7 @@ import {
targets,
} from "../state/store";
import { VIEWS, needsSetup, setView, view } from "../state/nav";
+import { ASSISTANT } from "../state/answers";
import { appearance, setTheme, startTheme, theme } from "../state/theme";
import { Data } from "./Data";
import { WindowChrome } from "./WindowChrome";
@@ -830,14 +831,19 @@ export function App() {
{/* Search sits in the header rather than inside a view because it is
not a view: it crosses all of them. Labelled with its shortcut so
- the keyboard route is discoverable without a tour. */}
+ the keyboard route is discoverable without a tour.
+
+ It names Tex, because this control and the palette it grows into
+ are now visibly one object - and an object that said "Ask
+ anything" at one size and "Ask Tex" at the other was telling the
+ student it was two. */}
setPaletteOpen(true)}
- aria-label="Ask about your subjects, marks and attendance. Press Control K.">
+ aria-label={`Ask ${ASSISTANT} about your subjects, marks and attendance. Press Control K.`}>
- Ask anything — how many classes can I miss?
+ Ask {ASSISTANT} — how many classes can I miss?Ctrl K
diff --git a/app/src/ui/Palette.tsx b/app/src/ui/Palette.tsx
index 5900d6f..675d850 100644
--- a/app/src/ui/Palette.tsx
+++ b/app/src/ui/Palette.tsx
@@ -7,6 +7,7 @@ import { askConfigured, askRemote } from "../state/ask";
import { ASSISTANT, answerFor, defineFor, detectTopic } from "../state/answers";
import type { Topic } from "../state/answers";
import { authBusy, authConfigured, signIn, signedIn } from "../state/auth";
+import { morph } from "./morph";
/**
* The command palette.
@@ -120,6 +121,19 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
const [remote, setRemote] = createSignal(null);
let input: HTMLInputElement | undefined;
let inflight: AbortController | undefined;
+ let shell: HTMLDivElement | undefined;
+ let scrim: HTMLDivElement | undefined;
+
+ /**
+ * Mounted, which is not the same as open.
+ *
+ * The palette has to outlive `props.open` by the length of the shrink, or
+ * there is nothing left on screen to animate back into the pill. `alive` is
+ * a plain variable rather than a second signal because the effect below both
+ * reads and writes this state, and a signal would make that a loop.
+ */
+ const [mounted, setMounted] = createSignal(props.open);
+ let alive = props.open;
/**
* The answer, when the question is one the engine can answer outright.
@@ -228,6 +242,43 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
if (props.open) { setQuery(""); setCursor(0); queueMicrotask(() => input?.focus()); }
});
+ /**
+ * Grow on open, shrink on close, and only then unmount.
+ *
+ * The header pill is hidden for as long as the palette stands in for it -
+ * the two are one object, and showing both would give the morph something
+ * to visibly separate from. It comes back at the end of the shrink, not the
+ * start of it.
+ */
+ createEffect(() => {
+ if (props.open) {
+ alive = true;
+ setMounted(true);
+ document.documentElement.dataset.palette = "open";
+ // After the element exists and has a box to measure.
+ queueMicrotask(() => {
+ if (shell && scrim) morph(shell, scrim, "in");
+ });
+ return;
+ }
+ if (!alive) return;
+ alive = false;
+
+ const gone = () => {
+ setMounted(false);
+ delete document.documentElement.dataset.palette;
+ };
+ const out = shell && scrim ? morph(shell, scrim, "out") : null;
+ // No animation to wait for is not a failure - it is every case where the
+ // pill is off screen, motion is off, or this is a test. Unmount now.
+ if (!out) { gone(); return; }
+ out.finished.then(gone, gone);
+ });
+
+ // A palette torn down mid-shrink would leave the header pill invisible for
+ // the rest of the session.
+ onCleanup(() => { delete document.documentElement.dataset.palette; });
+
// A request whose palette has closed has nobody left to answer.
onCleanup(() => inflight?.abort());
@@ -312,9 +363,9 @@ export function Palette(props: { open: boolean; onClose: () => void }) {
};
return (
-
-
-
+
+
e.stopPropagation()}>
({
+ askConfigured: () => false, askRemote: vi.fn(),
+}));
+vi.mock("../../state/auth", () => ({
+ signedIn: () => false, authConfigured: () => false,
+ authBusy: () => false, signIn: vi.fn(),
+}));
+
+const { Palette } = await import("../Palette");
+
+const box = () => screen.queryByRole("dialog", { name: "Search" });
+const flag = () => document.documentElement.dataset.palette ?? null;
+
+afterEach(() => { cleanup(); delete document.documentElement.dataset.palette; });
+
+describe("the palette's own lifecycle", () => {
+ it("mounts on open and unmounts on close", async () => {
+ const [open, setOpen] = createSignal(false);
+ render(() => setOpen(false)} />);
+ expect(box()).toBeNull();
+
+ setOpen(true);
+ expect(box()).toBeTruthy();
+
+ setOpen(false);
+ // No animation to wait for, so the close is not deferred into a state
+ // where a stuck promise would leave the dialog on screen forever.
+ expect(box()).toBeNull();
+ });
+
+ it("puts the pill back when it closes", () => {
+ const [open, setOpen] = createSignal(false);
+ render(() => setOpen(false)} />);
+
+ setOpen(true);
+ expect(flag()).toBe("open");
+
+ setOpen(false);
+ // The one that would be invisible to every other test: the header's search
+ // control is hidden by this flag, so a flag that outlives the palette is a
+ // control the student can never see again without a reload.
+ expect(flag()).toBeNull();
+ });
+
+ it("puts the pill back when the palette is torn down mid-flight", () => {
+ const [open, setOpen] = createSignal(false);
+ const r = render(() => setOpen(false)} />);
+ setOpen(true);
+ expect(flag()).toBe("open");
+
+ // A route change or a reload during the shrink. Nothing will ever call the
+ // close path again, so cleanup has to be the one that clears it.
+ r.unmount();
+ expect(flag()).toBeNull();
+ });
+
+ it("does not mount closed, and does not run a close it never opened", () => {
+ render(() => {}} />);
+ expect(box()).toBeNull();
+ expect(flag()).toBeNull();
+ });
+});
+
+describe("morph declines rather than guessing", () => {
+ const el = () => {
+ const d = document.createElement("div");
+ document.body.append(d);
+ return d;
+ };
+
+ it("returns null where there is no Web Animations API", () => {
+ // The whole reason the component has a no-animation path.
+ const shell = el();
+ expect(typeof (shell as HTMLElement).animate).not.toBe("function");
+ expect(morph(shell, el(), "in")).toBeNull();
+ });
+
+ it("returns null when the header pill is not on screen", () => {
+ const shell = el();
+ // Give it an API so the pill is the only thing missing.
+ (shell as HTMLElement).animate = vi.fn() as unknown as HTMLElement["animate"];
+ expect(document.querySelector(".ask")).toBeNull();
+ expect(morph(shell, el(), "in")).toBeNull();
+ });
+
+ it("returns null on a zero-sized box rather than flashing", () => {
+ const shell = el();
+ (shell as HTMLElement).animate = vi.fn() as unknown as HTMLElement["animate"];
+ const pill = el();
+ pill.className = "ask";
+ // jsdom lays nothing out, so every rect is 0x0 - which is exactly the
+ // case this guard exists for. Animating from nothing to nothing is a
+ // flash, and a flash is worse than the hard cut this replaced.
+ expect(morph(shell, el(), "in")).toBeNull();
+ });
+});
diff --git a/app/src/ui/morph.ts b/app/src/ui/morph.ts
new file mode 100644
index 0000000..84444ad
--- /dev/null
+++ b/app/src/ui/morph.ts
@@ -0,0 +1,111 @@
+/**
+ * The pill becomes the bar.
+ *
+ * The palette used to hard-cut: the header pill stayed where it was and a
+ * 38rem dialog appeared 12vh down the screen, unrelated to it. Two objects,
+ * and the student has to work out that the second one came from the first.
+ * They are one object, so it moves like one - the pill grows into the bar on
+ * open and shrinks back into the pill on close.
+ *
+ * FLIP, with one correction that matters. The naive version animates `width`
+ * on an element the scrim is centring, so every frame re-centres the box and
+ * the translate delta - computed once, against the FINAL layout - is wrong for
+ * the whole flight. The element drifts. So the shell is pinned to its measured
+ * rect for the duration and released after: identical position, no layout left
+ * to fight.
+ *
+ * Duration comes from `--med`, which the tokens collapse to 0ms under
+ * prefers-reduced-motion. Inheriting the accessibility behaviour beats
+ * restating it, and it is the same rule motion.css already follows.
+ */
+
+/** The header control this grows out of. Absent on some screens; then no morph. */
+const PILL = ".ask";
+
+/**
+ * A duration token in milliseconds.
+ *
+ * `parseFloat` on a custom property returns NaN whenever the value is not what
+ * you assumed, and `|| fallback` would quietly swallow that - along with 0ms,
+ * which is not a missing value but the single most important one this function
+ * can return, because it is what reduced motion sets.
+ */
+function ms(name: string, fallback: number): number {
+ const raw = getComputedStyle(document.documentElement)
+ .getPropertyValue(name).trim();
+ const n = Number.parseFloat(raw);
+ if (!Number.isFinite(n)) return fallback;
+ return raw.endsWith("ms") ? n : n * 1000;
+}
+
+/**
+ * Animate `shell` between the header pill's box and its own.
+ *
+ * Returns the animation so the caller can wait for it, or null when there is
+ * nothing to animate against - no pill on screen, a zero-sized box, or a test
+ * environment with no Web Animations API. A null return is a complete answer:
+ * the caller shows or hides the palette immediately, which is what it did
+ * before any of this existed.
+ */
+export function morph(
+ shell: HTMLElement, scrim: HTMLElement, dir: "in" | "out",
+): Animation | null {
+ // jsdom has no `animate`, and a component that only works in a browser is
+ // a component that cannot be tested.
+ if (typeof shell.animate !== "function") return null;
+
+ const pill = document.querySelector(PILL);
+ if (!(pill instanceof HTMLElement)) return null;
+
+ const from = pill.getBoundingClientRect();
+ const to = shell.getBoundingClientRect();
+ // A zero box means the element is not laid out yet. Animating from nothing
+ // to nothing produces a flash, which is worse than the hard cut it replaced.
+ if (from.width === 0 || to.width === 0) return null;
+
+ const style = getComputedStyle(shell);
+ const duration = ms("--med", 200);
+ const easing = style.getPropertyValue("--ease").trim() || "ease";
+
+ // Out of the scrim's flex centring for the flight. Set from the measured
+ // rect, so releasing it lands on the same pixel it was already on.
+ const pinned = shell.style.cssText;
+ shell.style.position = "fixed";
+ shell.style.insetInlineStart = `${to.left}px`;
+ shell.style.insetBlockStart = `${to.top}px`;
+ shell.style.margin = "0";
+
+ const asPill: Keyframe = {
+ transform: `translate(${from.left - to.left}px, ${from.top - to.top}px)`,
+ width: `${from.width}px`,
+ height: `${from.height}px`,
+ // The pill's own radius, so the corners round off as it collapses rather
+ // than staying square until the last frame.
+ borderRadius: "999px",
+ };
+ const asBar: Keyframe = {
+ transform: "translate(0px, 0px)",
+ width: `${to.width}px`,
+ height: `${to.height}px`,
+ borderRadius: style.borderRadius,
+ };
+
+ scrim.animate(
+ dir === "in" ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0 }],
+ { duration, easing, fill: "both" },
+ );
+
+ const anim = shell.animate(dir === "in" ? [asPill, asBar] : [asBar, asPill], {
+ duration, easing,
+ // Going out, the shell must stay collapsed until it unmounts - dropping
+ // to its natural size for one frame is a flash of the thing that just
+ // shrank away.
+ fill: dir === "out" ? "forwards" : "none",
+ });
+
+ if (dir === "in") {
+ const release = () => { shell.style.cssText = pinned; };
+ anim.finished.then(release, release);
+ }
+ return anim;
+}
From 4aa2e4c1aa872d36e05cc2c8b349c69bf58ea293 Mon Sep 17 00:00:00 2001
From: Rishi Praseeth Krishnan <113823551+CodedRichy@users.noreply.github.com>
Date: Tue, 1 Sep 2026 23:15:26 +0530
Subject: [PATCH 29/32] fix(home): stop reporting a confirmed score of zero as
an all-clear
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
Claude-Session: https://claude.ai/code/session_01A9xWgnoVmp3ZFuEu2c3SeF
---
app/src/state/actions.ts | 8 ++-
app/src/ui/Home.tsx | 30 +++++++++-
app/src/ui/__tests__/HomeVerdicts.test.tsx | 68 ++++++++++++++++++++++
3 files changed, 103 insertions(+), 3 deletions(-)
diff --git a/app/src/state/actions.ts b/app/src/state/actions.ts
index cd2155b..74e6706 100644
--- a/app/src/state/actions.ts
+++ b/app/src/state/actions.ts
@@ -394,8 +394,14 @@ export function reportText(rows: Array<{ course: Course; ev: Record 0
+ ? String(summary["sgpaConfirmed"]) : "-";
lines.push("",
- `Confirmed SGPA ${summary["sgpaConfirmed"]} Projected ${summary["sgpaProjected"]}`,
+ `Confirmed SGPA ${confirmed} Projected ${summary["sgpaProjected"]}`,
`Credits ${summary["creditsConfirmed"]} of ${summary["credits"]}`);
return lines.join("\n");
}
diff --git a/app/src/ui/Home.tsx b/app/src/ui/Home.tsx
index 114f7ea..92fe7e6 100644
--- a/app/src/ui/Home.tsx
+++ b/app/src/ui/Home.tsx
@@ -503,7 +503,16 @@ export function Home() {
Confirmed
- {summary().sgpaConfirmed.toFixed(2)}
+ {/* An average over nothing is not zero, it is absent - and
+ `sgpa()` returns 0 for an empty register, so printing it
+ raw put "0.00" under the word Confirmed on the first screen
+ of a semester where nothing has been graded yet. That is
+ the one number this app is not allowed to state: it reads
+ as a score, and there is no working to show behind it. */}
+ 0}
+ fallback={—}>
+ {summary().sgpaConfirmed.toFixed(2)}
+
- Stays on this computer. No account, no server, no telemetry —{" "}
+ Your marks stay on this computer, and there is no telemetry. Four
+ things touch the network, and the fourth only if you sign in to ask
+ a question —{" "}
the privacy statement{" "}
- names the only three things that touch the network.
+ target="_blank" rel="noreferrer"
+ onClick={(e) => openExternal(e, e.currentTarget.href)}>the privacy statement{" "}
+ names all of them.
diff --git a/app/src/ui/SyncPanel.tsx b/app/src/ui/SyncPanel.tsx
index c26cb3d..ba71d0a 100644
--- a/app/src/ui/SyncPanel.tsx
+++ b/app/src/ui/SyncPanel.tsx
@@ -4,6 +4,7 @@ import type { SyncResult } from "../sync/etlab";
import { applySync } from "../state/actions";
import { edit, state } from "../state/store";
import { canRemember, deleteCreds, loadCreds, saveCreds } from "../state/creds";
+import { openExternal } from "../state/external";
/**
* Portal sign-in and sync.
@@ -267,7 +268,8 @@ export function SyncPanel(props: { onDone?: () => void; compact?: boolean }) {
{" · "}
Open an issue
+ target="_blank" rel="noreferrer"
+ onClick={(e) => openExternal(e, e.currentTarget.href)}>Open an issue
diff --git a/docs/index.html b/docs/index.html
index 914a45a..0921066 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -74,7 +74,8 @@
"Classes that can still be missed, with the Regulation 6.3.ii duty-leave cap",
"CGPA target solved backwards to per-subject moves",
"Portal sync, grade-card import and paste",
- "Runs locally: no account, no server, no telemetry"
+ "Ask in plain words: every figure computed on your own machine",
+ "Runs locally: no telemetry, and no account needed to use any of it"
]
}
@@ -120,7 +121,8 @@
Every other calculator tells you what you already scored.
Free for students, forever — written into the licence.
- No account. No server. Your record is a file on your own machine.
+ Your record is a file on your own machine, and no account is needed
+ to use any of it.
@@ -382,11 +384,10 @@
Download
On your machine. That is the whole answer.
- There is no account, no sign-up and no server belonging to us. Your
- marks are a file in your own user profile. Nothing about them is
+ Your marks are a file in your own user profile. Nothing about them is
transmitted anywhere, and there is no telemetry, no analytics
and no crash reporting — the app makes network requests to
- exactly three places:
+ exactly four places, and the fourth only if you choose to sign in:
@@ -402,6 +403,10 @@
On your machine. That is the whole answer.
Your college portal
Only when you press Sync, and only to the address you typed. Your password is held for the length of that one request — never written to disk, never put into the saved record, never in the export, never in a log.
+
+
The question box
+
Only if you sign in, and only for a question the app could not answer by itself — most are answered on your machine and never leave it. What is sent is your question and the code and title of your subjects. No marks, no attendance, no CGPA, no name, no register number. What comes back is a destination, not an answer: one of five screens, or one of your own subjects, and anything else is rejected. Every figure you then read was computed here.
- TargetX runs entirely on the machine it is installed on. There is
- no TargetX account, no TargetX server and no telemetry.
- Every claim below can be checked against the source, and where a claim is
- about code, the file is named — so it can be read rather than believed.
+ TargetX runs on the machine it is installed on. Every figure it shows is
+ computed there, and there is no telemetry, and no account needed
+ to use any of it. One optional feature — the question box — has
+ a server behind it and asks you to sign in; it is set out in full below,
+ and it never receives a mark, an attendance figure or a CGPA. Every claim
+ here can be checked against the source, and where a claim is about code,
+ the file is named — so it can be read rather than believed.
@@ -124,8 +127,8 @@
Privacy
What issent
- TargetX makes network requests in exactly three situations, and no
- others.
+ TargetX makes network requests in exactly four situations, and no
+ others. The fourth happens only if you choose to sign in.
@@ -149,6 +152,33 @@
Privacy
information about you beyond what any file download carries.
app/src/sync/update.ts.
+
+ When you sign in and ask a question the app could not
+ answer by itself. This is the only request that reaches a
+ server of ours, and the only one that needs an account. Most
+ questions never get here: the app answers attendance, marks,
+ standing and the regulations on your own machine, offline, and only
+ a phrasing it does not recognise is forwarded.
+
+ What is sent is your question and the code and title of
+ the subjects you are registered for — the course list, so the
+ question can be matched to a subject. No marks, no attendance, no
+ CGPA, no name, no register number, and no password.
+
+ What comes back is not an answer. It is a destination, drawn from a
+ fixed list: one of five screens, or one of the subjects you sent.
+ Anything outside that list is rejected before the app acts on it, so
+ it cannot state a figure about you even in principle — the reply has
+ no field that could carry one.
+ app/src/state/ask.ts, worker/src/schema.ts.
+
+ The question text is logged, so the app can learn which phrasings it
+ failed to answer. That log holds the question, the outcome, how many
+ subjects were sent and how long it took. It does not
+ hold who asked: no account id, no token, and not the course codes
+ themselves. Identity and content are kept apart on purpose.
+ worker/src/log.ts.
+
Your marks are never sent anywhere, by anyone, for any