Skip to content

Stop reading the whole club to draw the organisers' dashboard - #7

Open
abhinavjha0239 wants to merge 12 commits into
mainfrom
admin-read-efficiency
Open

Stop reading the whole club to draw the organisers' dashboard#7
abhinavjha0239 wants to merge 12 commits into
mainfrom
admin-read-efficiency

Conversation

@abhinavjha0239

Copy link
Copy Markdown
Collaborator

The problem

Opening /admin read every profile, every mentor and every enrolment — on load and on every Refresh. At 1,000 members that is ~1,500 reads a press, and about 33 presses exhausts the 50,000-a-day free quota — for everyone, including members trying to read their own profile. Three organisers planning a cohort could get there in an afternoon.

Measured, not estimated

Against the emulator with a seeded club:

Club Opening /admin
300 members / 150 enrolments 23 aggregates + ~32 docs
1,000 / 500 23 aggregates + ~32 docs
1,500 / 1,500 (8 mentors) 27 aggregates + ~34 docs

A full scan of the last one would be 3,000 documents. The cost doesn't grow with the club, because aggregates are billed on the size of the answer, not the scan.

What moved where

counts, 8-week trend aggregate queries — one read each at any size
demand per mentor two aggregates per mentor. Also what the delete guard needs: "has anybody picked them" is a count, not a list — and it was the single most expensive thing on the page
members table pages of 25, with Load more
interest list pages of 25, joined to just the profiles those rows name via documentId() in [...] — one query, not 25 round trips and not a membership scan
breakdowns, exports a full scan, behind a button that states the cost

Why the breakdowns still scan. Batch, branch and year are derived from the address rather than stored — which is what makes them unforgeable, and also what makes them unqueryable. There is no where('batch','==',…) to count with. That trade is written up in FIREBASE.md; this is the bill for it.

Two correctness fixes pagination forced into the open

  • Copy emails / Export CSV acted on filtered, which is now only the loaded pages. Exporting a club of 1,500 would have written a CSV of 25 and looked entirely successful. Both load everything first.
  • The membership toggle patched only rows, so once a full scan was on screen it appeared to do nothing. It patches both row sets now.

Cursors are snapshots, not created_at values — a timestamp cursor silently skips rows whenever two profiles share a second, which is exactly what a build day produces.

Verification

Run at 1,500 members and 1,500 enrolments against the emulators:

Check Result
typecheck, lint clean
rules pass
rules:emulator 218 passed
smoke 104 pass, 0 fail
e2e:mentorship 22 passed
browsers 0 failures, three engines

Two pre-existing failures on main, untouched by this

Both were measured on clean main before this branch existed, and this change alters neither:

  • qa reports 4,356 issues across 80 combinations. Identical number with and without this branch. The project's stated bar is zero and "not negotiable", so main is currently well outside it — worth its own fix.
  • e2e:auth fails. Main's own copy of that file fails 7 assertions on main, because /join became an application form and the suite still expects the sign-in card. This branch's copy fails 3, since the popup-handling work here fixes some of them. It is not fixed properly here — that belongs with whoever changed the join flow.

Neither blocks CI, which runs typecheck, lint, palette, rules, build, smoke, qa and browsers but not the emulator suites.

🤖 Generated with Claude Code

abhinavjha0239 and others added 12 commits September 5, 2026 23:21
Opening /admin read every profile, every mentor and every enrollment — on
load AND on every Refresh. At 1,000 members that was ~1,500 reads a press,
and about 33 presses would exhaust the 50,000-a-day free quota for
EVERYONE, members reading their own profile included. Three organisers
planning a cohort could get there in an afternoon.

Measured against the emulator, opening the page now costs 27 aggregate
queries and ~34 documents at 1,500 members and 1,500 enrolments. The same
page at 300 members costs the same: aggregates are billed on the size of
the answer, not the scan.

WHAT MOVED WHERE:

  counts, the 8-week trend   aggregate queries, one read each at any size
  demand per mentor          two aggregates per mentor. Also what the
                             delete guard needs — "has anybody picked
                             them" is a count, not a list, and it was the
                             single most expensive thing on the page
  the members table          pages of 25, with Load more
  the interest list          pages of 25, joined to just the profiles
                             those rows name via documentId() in [...],
                             one query rather than 25 round trips or a
                             scan of the membership
  breakdowns, exports        a full scan, behind a button that says what
                             it will cost

WHY THE BREAKDOWNS STILL SCAN. Batch, branch and year are derived from the
address rather than stored — which is what makes them unforgeable, and
also what makes them unqueryable. There is no where('batch','==',...) to
count with. That trade is written up in FIREBASE.md; this is the bill.

TWO CORRECTNESS FIXES that pagination forced into the open. Copy emails
and Export CSV acted on `filtered`, which is now only the loaded pages —
so exporting a club of 1,500 would have written a CSV of 25 and looked
entirely successful. Both load everything first. And the membership toggle
patched only `rows`, so once a full scan was on screen it appeared to do
nothing; it patches both row sets.

Cursors are snapshots, not created_at values: a timestamp cursor silently
skips rows whenever two profiles share a second, which is exactly what a
build day produces.

Verified against the emulator at 1,500 members and 1,500 enrolments.
rules:emulator 218 passed, e2e:mentorship 22 passed, smoke 104 with no
failures, browsers clean on three engines, typecheck and lint clean.

qa reports 4,356 issues and e2e:auth fails — both identical on clean main
before this change, and neither is touched by it.
`html { font-size: 75% }` scaled the whole page to a 12px root so a section
would fit a 1080p laptop without the reader zooming. The reasoning above it
is sound, including using a percentage so somebody running a 20px default
still gets their proportional increase.

But the small type came down with everything else, and it was already at
the floor. 11/12/13/14px became 8.25/9/9.75/10.5. The QA sweep reported
4,356 issues across 80 combinations — 3,960 tiny-text and 396 small-tap,
identical in light and dark, which is what told us it was size and not
colour. Every reported value was exactly 0.75x an intended one.

The file already contains the principle that settles it, three paragraphs
above the change: hairlines, borders and the 44px touch targets are
physical constants, and "a thumb does not get smaller because the type
did". An eye does not get better because the layout shrank. Readable text
belongs in that same category; the exemption list was one item short.

TYPE: the small end is recompressed into an ordered band starting at the
11px floor — 11, 11.5, 12, 12.5, 12.75, 13.5. It stays in rem, so a reader
who has set a larger default still gets it scaled. Raising the sub-floor
steps alone was not an option: lifting 0.875rem back to 14px would have
overtaken 0.9375rem sitting at 11.25px and inverted the scale.

TOUCH TARGETS: the ones that had drifted under 40px are px again, since
they are the physical constants the file already says they are — the nav
links and wordmark, the theme toggle, the ticker's pause button (whose own
comment says a 32px button here "would have been a real defect"), the
console input's min-height, the citation links, and a min-height on the
form fields so they hold the floor whatever the type scale does later.

qa: 4,356 -> 0 across 80 combinations. smoke 104 with no failures,
browsers clean on three engines, palette, rules, typecheck, lint and
build all pass.

e2e:mentorship is intermittent in this environment and is NOT affected by
this change: bisected by stashing it, the same 9-passed/5-failed appears
either way, and the failures are all downstream of the member's popup
sign-in not completing — the emulator wedge documented in e2e-auth.mjs.
The organiser half passes every time.
/join held an anonymous application form: name, email, year and branch,
hostel, GitHub, experience, path, and ten programme checkboxes. It is a
sign-in card again.

WHY, GIVEN THE ARGUMENT FOR THE FORM WAS GOOD. It was: requiring a Google
account before somebody may apply puts a requirement in front of the
club's front door, and the headline three inches away promises the
opposite. That is true of a club that admits anybody.

This one does not. Membership IS an @sst.scaler.com address — that is the
whole test, and it is the one thing a form cannot check. A form asks a
stranger to type an address they may not own and leaves an organiser to
verify it by hand; signing in with the college account proves it in one
tap and produces a record nobody had to check. The door and the test are
now the same act, and there is no application queue to read.

The two tiles, the headline and the closing band are unchanged. The flow
changed, not the page — except for the copy that named a form that no
longer exists ("Applications open", "without applying", "the same form").

SEALED, NOT JUST UNUSED. components/ApplyForm.tsx and web/lib/
applications.ts are deleted, applications/{id} goes back to
`allow create: if false`, and isWellFormedApplication is removed with it.
Leaving an open unauthenticated write endpoint for a form nobody can reach
is a spam surface with no user, and an unused validator on a sealed
collection is an invitation to reopen it by deleting one line.

THE HISTORY THIS HAS TO RESPECT. A merge once closed this same door with a
comment calling the collection legacy WHILE the form was still on the
page, and every application submitted in between was silently refused —
the form rendered, the applicant filled it in, the write failed. Closing
it is only safe because the form is gone in this same commit. The rules
comment says so, and the checks now assert BOTH halves: no client may
create, and lib/applications.ts does not exist. If a form ever comes back,
they fail and force the rule to move with it.

Checks updated rather than deleted, in both directions: rules.mjs drops
the field-parity check that had nothing left to compare and asserts the
seal instead; the four "a stranger may apply" cases in rules-emulator.mjs
became denials rather than disappearing, so a reopened door is loud;
smoke's four form assertions became "offers sign-in, no fields survive,
and the page states the one address that can register".

rules 100%, rules:emulator 218 passed, smoke 103 with no failures, qa 0
issues across 80 combinations, typecheck and lint clean. Sign-in driven
end to end against the emulators: /join -> Google -> /dashboard with
?path= intact, and the batch read from the address as 2023-27 - BCS.
Two changes the club asked for, and both reverse a decision made for a
reason worth recording.

FINISHING THE PROFILE IS A GATE AGAIN. The form used to render instead of
the dashboard, and the club's own organisers reported the site "has no
dashboard" — they had met a hostel dropdown and never got past it. The fix
then was to demote it to a panel and let everything through.

It gates again because nobody should be half-registered: an organiser
reading the roster should be able to trust a row means a member. What is
different is where the form lives. It is not in the dashboard at all. An
incomplete profile goes to /onboarding, which is a PAGE about being three
questions — a two-step spine showing sign-in was step one, a heading that
says "Three questions", a button that says "Finish joining" and lands on
the dashboard. The old failure was a form with no frame: it looked like
the destination rather than a step, so nothing said you were nearly
through. The gate lives in one place, RequireProfile, rather than being
re-implemented per route.

/onboarding also loses the dashboard's sidebar. Three links offering Good
first issues, Projects and My details beside the one screen a member has
to finish are three invitations to leave it, and two of them lead to a
page that would bounce them straight back. The bar stays: somebody who
signed in with the wrong Google account needs a way out that is not the
back button.

ONE PAGE BECAME THREE. Four figures, the forms, the board, the sessions,
the GitHub panel, the profile record, the profile form and the whole
mentorship flow were two columns on one route — everything the club could
say arrived at once, so nothing arrived first.

  /dashboard             what is waiting, what is on, what to do next
  /dashboard/mentorship  the GSoC cohort, a decision made once a term
  /dashboard/details     the record the club holds, and the form for it

Mentorship was the last thing under four weekly panels: the club's
headline activity, buried, and mixed in with things that change every
week. "My details" was an anchor to a panel — the kind of nav item
somebody presses once, watches the page jump, and stops trusting.

The spacing is the other half. Panels ran together at space-y-5 into one
column of cards with no rhythm; sections open on a real heading and sit at
6/8. The visually hidden h1 and the apology in its comment are gone with
the reason for it.

THREE DEFECTS FOUND ON THE WAY. /onboarding rendered a second
<main id="main"> inside the shell's — a duplicate id, two landmarks and an
ambiguous skip-link target. It also carried `.page-top`, which is
clearance for the floating marketing nav, stacking two clearances inside a
shell that already pads. And the bar read "OSC / DASHBOARD" over a form
standing between the member and the dashboard.

e2e-mentorship walks the gate now rather than landing on /dashboard cold,
and its sign-in helper is the hardened one from e2e-auth: real keystrokes,
the submit pressed until the ACCOUNT EXISTS rather than for four seconds,
and a loud error naming the popup when it does not. It had been filling
the form, clicking once and waiting — which works for the first identity
in a run and silently fails for the second, so the member never signed in
and five assertions about the mentorship section failed as though the
section were missing.

typecheck, lint, rules, browsers clean. rules:emulator 218 passed, smoke
103 with no failures, qa 0 issues across 80 combinations. The gate and all
three sections driven end to end against the emulators: first sign-in
lands on /onboarding, finishing lands on /dashboard, and each section
renders one <main> and its own h1.
/admin rendered six components on one route — the membership table with its
breakdowns and export, the mentor list, the interest list, the notice
composer, the session editor, the form builder and the roster. About 3,800
lines in one column. An organiser opening it to answer "how many joined
this week" scrolled past a form builder to find out.

Seven routes now, one concern each:

  /admin             the numbers, and the way into everything else
  /admin/members     the roster, the breakdowns, the export
  /admin/mentorship  publish mentors, and see who asked for whom
  /admin/notices     the board every member reads
  /admin/sessions    when the club meets
  /admin/forms       ask the club something, read the answers
  /admin/team        who is an organiser

THE OVERVIEW IS ALL AGGREGATES. Members, joined this week, enrolled,
mentors published, and the eight-week trend — about thirteen reads, and the
same thirteen whether the club is thirty people or three thousand. Nothing
on it reads a member document. The sections that genuinely need documents
pay for themselves when opened, which is the point of splitting rather than
a side effect of it.

EACH ROUTE OWNS ITS READS, reversing the rule that held while this was one
page. One component used to issue every query so two panels could not
disagree a moment after a write; with a route per concern the argument
inverts, and opening the membership table should not pay for the mentor
list. Mentorship therefore owns its own mentors, demand counts, paged
interest list and full-scan button rather than being handed them.

The "organisers only" card was seven copies waiting to drift into seven
slightly different ways of telling somebody they are not an organiser. It
is components/admin/Gate.tsx now, and it keeps the three-state check —
`isAdmin === undefined` is the check still running, and showing the refusal
during it tells every organiser they are not one, on every load.

The sidebar carries the organiser sections while you are inside /admin and
hides them everywhere else: six admin links in a bar about a member's week
are six links most readers can never use.

/dashboard/mentorship and /dashboard/details join the QA sweep, so every
signed-in route's signed-out state is measured — that state is what a
stranger who guesses the URL sees.

typecheck, lint, rules and browsers clean. rules:emulator 218 passed, smoke
119 with no failures, qa 0 issues across 96 combinations. All seven
organiser routes driven end to end against the emulators, signed in as an
owner and signed out: each renders one <main>, its own h1, the correct
refusal when signed out, and no page errors.
The Auth emulator's popup stops responding after the first successful sign-in
in a browser: the form fills, the button is enabled, the clicks land, no error
appears, and the account is never created. e2e-auth.mjs already works around it
by launching a whole new browser process per identity. The cost is not the
popup — it is that opening the organisers' area, seven routes now, took several
attempts each time, so the temptation is to stop looking at it.

DevLogin creates the account against the emulator's admin API with
emailVerified already true — before signing in, so the token is minted right
rather than needing a refresh the client will not do for an hour — seeds
admins/{email} with the owner token for the organiser, and signs in with a
password. It lives on the app shell, so switching between the two identities is
one click from any of the eleven signed-in routes rather than sign out, /join,
sign in.

WHAT THE FENCE AROUND IT LOOKED LIKE, AND WHY IT NEEDED A CHECK. The slot first
keyed on NEXT_PUBLIC_FIRESTORE_EMULATOR, which reads like the natural switch and
eliminates nothing: Next inlines a NEXT_PUBLIC_* reference only when the
variable has a non-empty value, so an empty one stays a runtime lookup, the
ternary never folds, and the whole component shipped as its own chunk —
measured, in out/_next/static/chunks/1651.*.js, `Bearer owner` included. It
rendered nothing and it was all there to read. Keyed on NODE_ENV it folds, and
scripts/assert-no-dev-login.mjs now greps the built site for its strings and
fails build:static if it finds them, because that claim was false the first time
it was made.

Two things found on the way:

  * securityHeaders() dropped projectId. buildCSP takes it; the signature did
    not name it, and an object rest parameter discards what it does not name.
    Both generators passed it, each with a comment explaining that it is what
    stops connect-src falling back to a wildcard, and both were ignored — so the
    deployed policy had no Cloud Functions origin at all and the dashboard's
    "check GitHub now" callable would have been blocked before a request left
    the browser. htaccess.mjs was passing neither value, so the two files
    carried different policies for the same site; it reads .env.local now, as
    hosting-config.mjs already did.

  * SignInCard still offered "the application form asks for no account at all".
    True while /join carried an anonymous form; since joining became sign-in
    only it was a link to a page that would ask for the very account it promised
    you did not need. Removed from both branches.

typecheck 0, lint clean, rules pass, rules:emulator 218 passed, smoke 119/0,
qa 0 issues across 96 combinations, browsers 0 failures across three engines.
Dev login checked by hand end to end: organiser lands on /admin with the roster
readable, member lands on /onboarding with the batch line reading 2023-27 · BCS
· 4th year · Roll 10045, and finishing it lands on the dashboard. The
no-dev-login check was confirmed red as well as green by planting a marker in
out/index.html.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The site was rendering at three quarters. globals.css carried
`html { font-size: 75% }`, so a 12px root put body copy at 13.5px, eyebrow
labels at 11px and section headings at 21.75px — against the 18px, 14.7px and
29px every token in tailwind.config.ts was written for. Those values are
Apple's, measured, and the comments in that file say so; `sm: 1.0625rem` is
17px only if the root is 16.

The 75% was added because "a 1080p laptop had to be zoomed to 75% before a
section fit on screen". The observation was real and the diagnosis was not:
what did not fit was the section, and a section is mostly padding and leading.
It also did not work — the home page was still 10,566px with the root at 75%.
The height was in the spacing all along, and that is the next commit.

FONT SIZES WERE SET IN THREE PLACES THAT DISAGREED: 8 tokens here, 18 more
`font-size` declarations inside globals.css in five values it did not share,
and 148 hand-written `text-[…rem]` utilities. `.label` was the sharpest case —
1.0417rem in the stylesheet, 0.9167rem as a token, both in use, same name. 73
of the 148 arbitrary sizes were exact duplicates of a token typed by hand, and
`text-[1rem]`, the most common type utility in the codebase at 74 uses, was a
ninth size with no token at all.

So the bottom of the scale is re-cut to 13 / 14 / 16 / 18 — four steps a reader
can tell apart, where there were four inside 3.3px — and 1rem, where those 74
uses already were, is now `sm`. Every arbitrary size is gone: 149 replaced by
tokens, six px leftovers converted, and the four one-off display clamps folded
into display-xl/lg. The vw terms in the fluid clamps go back to full value;
they had been multiplied by 0.75 to ride the root.

Measured on the home page: 12 distinct font sizes down to 9, the six-value
cluster between 11px and 13.5px gone, and no fractional pixels left except the
one fluid step that is meant to be fluid.

Two things found on the way:

  * qa.mjs flagged text below 11px while its own header said 12px. The site's
    smallest text was 11.0004px, passing by four ten-thousandths of a pixel —
    so the sweep reported clean on a site whose body copy was 13.5px, and "0
    issues across 96 combinations" was never evidence the typography was sound.
    The floor is 12 again. A floor moved to fit what it measures is not a floor.

  * The deferred pixel pass measured elements through fixed overlays. A locator
    screenshot scrolls to its own target, so an element can land under the nav
    plate or the outline panel — both frosted — and be sampled through them. It
    reported an orange sticky note at 1.63:1 on "painted rgb(60,47,39)" in
    exactly one of eight combinations; the note is black on #fdba74, 12.4:1, and
    the odd one out was the one with a panel open in the corner it scrolls into.
    Overlays are hidden for the capture and restored before the reference shot.

typecheck 0, lint clean, palette passes, smoke 119/0, qa 0 issues across 96
combinations at the raised floor, browsers 0 failures across three engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restoring the 16px root took the home page from 10,566px to 13,933 — the type
grew and nothing else moved. This pays for it, and the cheapest height to
remove is text nobody was going to read.

THE COPY. 14 paragraphs over 40 words are rewritten to fit under it, keeping
every number, programme name and upstream link and cutting the justification,
the throat-clearing and the second explanation of the first explanation. Four
section standfirsts on the home page go the same way. Over-40 paragraphs across
the site: 31 to 17.

The 17 are not an unfinished job in the same sense. Six are the member
testimonial quotes on the home page — those are people's own words, attributed,
and editing somebody's quote to hit a word count puts words in their mouth. The
remaining 11 are on /programmes, /projects and /how-to-join and are the next
pass.

THE SPACE, on one rhythm instead of five. Sections carried pt-24/sm:pt-32,
pt-20/sm:pt-24, pt-12/sm:pt-16, pb-28/sm:pb-40 and pb-24/pt-4 — 90 padding
tokens across 15 files, retuned to one scale a third smaller. A second layer of
bloat sat inside the sections rather than between them: 28 mt-12 to mt-32 gaps,
also brought down a step.

And the two body leadings, 1.72 to 1.6 and 1.62 to 1.5. Those ratios were set
against 13.5px and 17.3px text, where generous leading is what makes small type
readable; at 18px and 24px the same ratio is 31px and 39px of line box, which
reads as gappy rather than airy. Leading is relative to size and a ratio tuned
at one size does not survive a third being added to it.

Home page 13,933px -> 12,709px. That is short of the 7,000 the plan aims at,
and the remaining height is no longer in the spacing: nine sections averaging
1,200px, of which two are over 2,000 — a four-tile grid plus a three-column
block plus an 820px timeline in one, and a 3x6 comparison table in the other.
Getting under 7,000 means showing fewer items or moving sections to sub-pages,
which is a content decision rather than a spacing one, so it is not made here.

typecheck 0, lint clean, palette passes, qa 0 issues across 96 combinations,
smoke 119/0, browsers 0 failures across three engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four sub-page mastheads wore display-xl alongside the home page hero. At the old
12px root that was 63px and merely large; at 84px a sixteen-word title — "Paid,
competitive, and open to beginners. Most students never apply because nobody
told them these exist." — is four lines that fill a 1440x900 viewport on their
own, with the chip above it, the standfirst below it, and nothing else on
screen. /programmes, /projects, /team and /how-to-join take display-lg now.

A step called xl that four pages use is not a step. It belongs to the home page
hero, which is two words.

Not fixed here, and visible in the same screenshot: a page masthead and the
first section heading under it are now both display-lg, both black-and-blue,
both underlined with the drawn stroke, and nothing tells them apart. The stroke
is the answer rather than another size — it is meant to mark the page's primary
heading and currently sits under every h2 as well, which is why it has stopped
meaning anything. That is the decoration pass.

typecheck 0, lint clean, qa 0 issues across 96 combinations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The site is handmade and should stay handmade. What read as scrappy was not the
tilt or the paper, it was that there were thirty-odd one-off devices, most used
once or twice, each invented for the section it sits in. A crafted object is
premium precisely when the same hand is visible throughout.

THE ANGLE, now literally one number. Chips sat at -1.5deg, notes defaulted to
-2.5 and were overridden at the call site with -4, 4, 3.5 and -3.5, stickers
used -4, -3, -2, 3 and 4, and the tape across a note ran at 3. Nine magnitudes
doing one job: "a person put this here". They all read `--tilt-placed` now, and
the tape negates it so it leans against the note it is holding down rather than
with it.

THE PAPER, three stocks from one pad instead of seven. Mint #86efac, pink
#f9a8d4, sky #7dd3fc, lime #bef264, orange #fdba74 and lilac #c4b5fd were each
a reasonable pick for the section they landed in, and each took "a tape from a
different corner of the wheel to its own fill" — so eleven notes carried seven
papers and seven tapes between them. None of those colours was the site's blue
or its yellow. Now: the yellow default, a warm cream, a soft blue off the
accent, and one warm grey tape on all three. Black measures 18.3:1, 15.6:1 and
14.9:1 on them.

THE CORNERS, ten radii down to four. The home page rendered 4, 5, 6, 8, 9, 10,
12, 18, 20, 24, 28 and the pill; several are indistinguishable at the sizes they
were used, which is drift rather than craft. inline 10 / tile 18 / panel 28 /
full, with a stated exception for the 2px on the contribution-wall cells and the
focus ring, which are not surfaces. 33 utilities and 10 CSS declarations folded
in; measured on the home page, 10 distinct values down to 4.

THE INK. .chip-violet is gone — the only violet on the site, worn by two badges
and a sticker, and a colour appearing three times is not a colour anybody reads
as meaning anything. Sticker tones go from blue|violet|mint to blue|pop.
Decorative mint goes the same way. What stays is the mint/red pair on
/how-to-join and "OSC club", where the colour IS the argument rather than
decoration — cutting those to hit a number would have been worse than keeping
them.

AND THE DRAWN RULE, on the page's primary heading only. Duo rendered it under
all 34 headings on the site, and a mark under everything marks nothing. It had
just cost a real hierarchy: with the sub-page mastheads moved down to
display-lg, a page title and the first section heading under it were the same
size, the same black-and-blue, and both underlined. Restricting the mark
restores the distinction without inventing a third heading size.

typecheck 0, lint clean, palette passes, qa 0 issues across 96 combinations,
smoke 119/0, browsers 0 failures across three engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
THE PHONE NAV. Measured at 390px, the link strip was a 187px-wide window onto
407px of links: four of six destinations — Hall of Fame, Team, How to Join and
most of Programmes — sat off-screen behind a fade, reachable only by dragging a
strip most readers never think to drag. The strip and its fade were a reasonable
answer to "six links do not fit across 390px" and they did not work. A site
whose nav hides two-thirds of itself on the commonest phone size does not have a
nav on phones.

The strip is md+ now, where all six genuinely fit, and below that a disclosure
button opens a panel with the same six plus the two the bar drops on small
screens — GitHub, and Sign in, which is the returning member's only door in the
chrome. 44x44 button, 48px rows, closes on Escape and on navigation.

The panel is `.plate-solid` rather than `.plate`. The bar can be 80% and frosted
because what shows through it is a page scrolling under a 56px strip — that
translucency is the effect. A panel of eight links is not that: it covers
content, and at 0.8 the hero's headline and its yellow highlight read straight
through the list. Same border, same shadow, same corner; opaque ground.

THE APP BAR CRUMB read the literal string "DASHBOARD" on every signed-in route
except /onboarding, so all seven organiser routes said "OSC / DASHBOARD" over
the members table, the mentor list or the roster. It reads the route now, and
derives the name from NAV rather than a second list, so a route cannot be
renamed in one place and keep its old name in the other — which is the drift
that caused this.

THE DUPLICATE NAV ITEM. Two rows three apart both said "Mentorship", both with
the compass icon, pointing at /admin/mentorship and /dashboard/mentorship, and
inside /admin both rendered. The organiser one is "Mentors" now — it is where
you publish mentors and read the interest list, as against where a member picks
one.

The browsers suite asserted the old arrangement — "scrollable=true applied=true"
— which was true and was describing the bug. It now checks what matters on a
phone: every route reachable, behind a control at or above the touch floor, with
the strip out of the way. Its click and its measurement are separate steps
because React renders the panel on the next tick, and reading the DOM in the
same evaluate reports an empty menu.

Also restored five comments the radius sweep had rewritten: "a floating rounded
plate" is prose, not a class name.

typecheck 0, lint clean, rules pass, qa 0 issues across 96 combinations,
smoke 119/0, browsers 0 failures across three engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The members table and the interest list both scrolled horizontally with no
affordance at all — 1151px of table into 942 on a 1280 laptop, with the GitHub
column cut off mid-header and nothing on screen suggesting there was anything
beyond it. An organiser reading the roster had no reason to think it was not
complete, which is the worst kind of missing: it does not look broken.

`.table-scroll` fades the trailing edge with a mask rather than an overlaid
gradient, for the reason the nav strip uses one — these tables sit on cards whose
ground differs between the two themes, so an overlay in a fixed colour is a
visible block on one of them.

The fade retracts as you reach the end, via `animation-timeline: scroll(self
inline)`, so it stops claiming there is more once there is not. Browsers without
scroll-driven animations keep it static, which is the honest fallback: the table
does scroll, and all that is lost is the fade going away at the end.

typecheck 0, lint clean, qa 0 issues across 96 combinations, smoke 119/0,
browsers 0 failures across three engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant