diff --git a/firebase.json b/firebase.json index 81b9542..aa60b34 100644 --- a/firebase.json +++ b/firebase.json @@ -31,7 +31,7 @@ "headers": [ { "key": "Content-Security-Policy", - "value": "default-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://apis.google.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; style-src 'self' 'unsafe-inline'; connect-src 'self' https://firestore.googleapis.com https://*.googleapis.com; frame-src 'self' https://www.google.com https://accounts.google.com https://scaleropensourcelabs.com; form-action 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; upgrade-insecure-requests" + "value": "default-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline' https://apis.google.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; style-src 'self' 'unsafe-inline'; connect-src 'self' https://firestore.googleapis.com https://*.googleapis.com https://asia-south1-osc-website-610b9.cloudfunctions.net; frame-src 'self' https://www.google.com https://accounts.google.com https://scaleropensourcelabs.com; form-action 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; upgrade-insecure-requests" }, { "key": "Strict-Transport-Security", diff --git a/firestore.rules b/firestore.rules index 8c9ce2b..2e4917c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -662,63 +662,10 @@ service cloud.firestore { * LEVELS array with LEVEL_LABEL. Deployed as it was, it would have refused every real * application while looking perfectly correct. `npm run rules` now diffs all four of * these sets against the content. */ - function isWellFormedApplication(d) { - return - d.keys().hasOnly([ - 'name', 'email', 'year_branch', 'hostel', 'github', - 'level', 'path', 'programs', 'programs_other', 'submitted_at' - ]) - && d.keys().hasAll([ - 'name', 'email', 'year_branch', 'hostel', 'level', 'path', - 'programs', 'submitted_at' - ]) - - // Required strings, bounded. The form's maxlength is a courtesy to the reader; - // these are the real limits, because a direct SDK call never sees the form. - && d.name is string && d.name.size() > 0 && d.name.size() <= 120 - && d.email is string && d.email.size() > 3 && d.email.size() <= 200 - // NOT restricted to the college domain, deliberately, and this is the one place - // that differs from every other rule here. Somebody applying from a personal - // address is an applicant to talk to, not a forgery to reject — the domain rule - // belongs on MEMBERSHIP rather than on the act of asking. - && d.email.matches('^[^@\\s]+@[^@\\s]+[.][^@\\s]+$') - && d.year_branch is string && d.year_branch.size() > 0 && d.year_branch.size() <= 120 - - && (!('github' in d) || (d.github is string && d.github.size() > 0 && d.github.size() <= 100)) - - && d.hostel in ['uniworld-1', 'uniworld-2'] - && d.level in ['beginner', 'intermediate'] - && d.path in ['build-day', 'first-contribution', 'fast-track', 'program-track'] - - // Programmes are REQUIRED and must be non-empty, which is the form's rule too — - // the browser refuses the submit without a tick. Enforced here as well because a - // direct SDK call never sees the form, and an application with an empty list would - // read as a UI bug to whoever opens it rather than as the forgery it is. - && d.programs is list - && d.programs.size() > 0 - && d.programs.size() <= 10 - && d.programs.hasOnly([ - 'gsoc', 'lfx', 'outreachy', 'sok', 'hacktoberfest', 'sob', - 'gssoc', 'ssoc', 'esoc', 'other' - ]) - // 'other' and its free text are a pair, checked in BOTH directions: no bare - // 'other' with nothing to explain it, and no stray text without the tick that is - // supposed to have produced it. - && (!d.programs.hasAny(['other']) - || ('programs_other' in d - && d.programs_other is string - && d.programs_other.size() > 0)) - && (!('programs_other' in d) - || (d.programs_other is string - && d.programs_other.size() > 0 - && d.programs_other.size() <= 120 - && d.programs.hasAny(['other']))) - - // The server's clock, not the submitter's. Everything else in this document was - // supplied by a stranger; this one field cannot be forged, so submission ORDER - // stays trustworthy even when nothing else does. - && d.submitted_at == request.time; - } + // isWellFormedApplication WAS HERE. It validated the anonymous application form's + // shape and was the entire boundary on a collection strangers could write to. With + // the form gone and create denied, it guarded nothing — and an unused validator on a + // sealed collection is an invitation to reopen the collection by deleting one line. // ---------------------------------------------------------------- members @@ -1087,30 +1034,31 @@ service cloud.firestore { // ---------------------------------------------------- legacy applications match /applications/{id} { - // NOT LEGACY, AND THE COMMENT THAT SAID SO COST THE CLUB ITS FRONT DOOR. An upstream - // merge took this block as "nothing writes here any more; the profile replaced it" - // and denied create — but /join still renders components/ApplyForm.tsx, which writes - // exactly here. Every application submitted between that merge and this line was - // refused. The form rendered, the applicant filled it in, and the write failed. + // SEALED, AND THE COMMENT THIS REPLACES IS THE REASON TO READ CAREFULLY BEFORE + // CHANGING IT BACK. It said: "NOT LEGACY, AND THE COMMENT THAT SAID SO COST THE CLUB + // ITS FRONT DOOR" — an upstream merge had denied create while /join still rendered + // an application form, so every application submitted in between was refused. The + // form rendered, the applicant filled it in, the write failed, and nothing in the UI + // could have told anybody. // - // Nothing in the UI could have told anybody: the rules were correct in git, correct - // in review, and wrong about which features existed. + // Denying create is correct NOW because the form is gone in this same change: + // components/ApplyForm.tsx and web/lib/applications.ts are deleted and /join renders + // the sign-in gate instead. Membership is an @sst.scaler.com address, which is the + // one thing a form could not check — signing in proves it in a tap and leaves a + // record nobody had to verify by hand. // - // CREATE IS OPEN TO STRANGERS, on purpose — see isWellFormedApplication above for - // why applying must not require sign-in, and for why that function is therefore the - // whole boundary. - allow create: if isWellFormedApplication(request.resource.data); - - // NOBODY READS THIS FROM A CLIENT, INCLUDING ADMINS. Organisers read applications in - // the Firebase console. That is a deliberate floor rather than a missing feature: - // these rows hold names, addresses and hostels belonging to people who are not - // members yet and never agreed to appear in anything, so the smallest surface that - // still lets the club act on them is the right one. An organisers' view would need - // its own admin-only rule AND a decision about retention, not a loosened read. + // THE TEST BEFORE YOU REOPEN THIS: does anything still WRITE here? If a form comes + // back, this line has to move with it, in the same commit. That coupling is the + // whole lesson of the incident above. + allow create: if false; + + // NOBODY READS THIS FROM A CLIENT, INCLUDING ADMINS. The rows already here hold + // names, addresses and hostels belonging to people who were not members and never + // agreed to appear in anything. Organisers read them in the Firebase console. That + // is a deliberate floor rather than a missing feature. allow read: if false; - // Immutable once sent, so the history is intact and an applicant cannot be edited - // into somebody else after an organiser has read them. + // Immutable, so the history stays intact. allow update, delete: if false; } diff --git a/web/app/(app)/admin/forms/page.tsx b/web/app/(app)/admin/forms/page.tsx new file mode 100644 index 0000000..6f4d496 --- /dev/null +++ b/web/app/(app)/admin/forms/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import AdminGate from "@/components/admin/Gate"; +import SectionHead from "@/components/dashboard/SectionHead"; +import FormBuilder from "@/components/FormBuilder"; + +// One organiser concern, one route. /admin was six of these on one page — an organiser +// opening it to post a notice scrolled past the membership table and a form builder. +// NOT a privilege gate; see components/admin/Gate.tsx. +export const metadata: Metadata = { + title: "Forms", + description: "Ask the club something and read the answers.", + robots: { index: false, follow: false }, +}; + +export default function Page() { + return ( + + + Ask the club something and read the answers — sign-ups, and the odd “which Saturday suits everyone”. + + + + ); +} diff --git a/web/app/(app)/admin/members/page.tsx b/web/app/(app)/admin/members/page.tsx new file mode 100644 index 0000000..846e5be --- /dev/null +++ b/web/app/(app)/admin/members/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import AdminGate from "@/components/admin/Gate"; +import SectionHead from "@/components/dashboard/SectionHead"; +import AdminDashboard from "@/components/AdminDashboard"; + +export const metadata: Metadata = { + title: "Members", + description: "The roster, the breakdowns, and the export.", + robots: { index: false, follow: false }, +}; + +export default function MembersPage() { + return ( + + + Everyone registered, and the breakdowns most often asked for. Batch, branch and year + are read from each member's college address rather than asked for, so they + cannot drift — and cannot be queried, which is why the breakdowns load on request. + + + + ); +} diff --git a/web/app/(app)/admin/mentorship/page.tsx b/web/app/(app)/admin/mentorship/page.tsx new file mode 100644 index 0000000..ce7b1e2 --- /dev/null +++ b/web/app/(app)/admin/mentorship/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import MentorshipAdmin from "@/components/admin/MentorshipAdmin"; + +export const metadata: Metadata = { + title: "Mentorship", + description: "Publish mentors, and see who has asked for whom.", + robots: { index: false, follow: false }, +}; + +export default function AdminMentorshipPage() { + return ; +} diff --git a/web/app/(app)/admin/notices/page.tsx b/web/app/(app)/admin/notices/page.tsx new file mode 100644 index 0000000..36c4e65 --- /dev/null +++ b/web/app/(app)/admin/notices/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import AdminGate from "@/components/admin/Gate"; +import SectionHead from "@/components/dashboard/SectionHead"; +import Composer from "@/components/Composer"; + +// One organiser concern, one route. /admin was six of these on one page — an organiser +// opening it to post a notice scrolled past the membership table and a form builder. +// NOT a privilege gate; see components/admin/Gate.tsx. +export const metadata: Metadata = { + title: "Notices", + description: "The board every member reads on their dashboard. Post something and it lands there first.", + robots: { index: false, follow: false }, +}; + +export default function Page() { + return ( + + + The board every member reads on their dashboard. Post something and it lands there first. + + + + ); +} diff --git a/web/app/(app)/admin/page.tsx b/web/app/(app)/admin/page.tsx index dd84f5f..58d8df3 100644 --- a/web/app/(app)/admin/page.tsx +++ b/web/app/(app)/admin/page.tsx @@ -1,49 +1,24 @@ import type { Metadata } from "next"; -import AdminDashboard from "@/components/AdminDashboard"; -import AudienceBackfill from "@/components/AudienceBackfill"; -import Composer from "@/components/Composer"; -import FormBuilder from "@/components/FormBuilder"; -import Roster from "@/components/Roster"; -import Sessions from "@/components/Sessions"; +import AdminOverview from "@/components/admin/Overview"; -// THE ORGANISERS' PAGE. Same shell as the member dashboard — it comes from -// (app)/layout.tsx, so this file is only the content. +// THE ORGANISERS' OVERVIEW. This route used to render everything: the membership table, +// the mentor list, the interest list, the notice composer, the session editor, the form +// builder and the roster — six components and about 3,800 lines, in one column. +// +// It is the numbers and a way in now. Each concern has its own route under /admin, and the +// sidebar switches to them while you are in here. // // NOT A PRIVILEGE GATE. The page ships to anybody who asks for it, because the site is a // static export with no server to refuse them. What refuses them is the `list` rule on -// users/{uid} and the admin-only writes on every collection below, none of which any -// client can talk its way past. A non-admin who loads this URL gets a page whose every -// panel renders its own "not for you" state. -// -// THE ORDER IS BY HOW OFTEN AN ORGANISER DOES THE THING: membership is the question the -// page is opened with, notices and sessions are weekly, forms every few weeks, and the -// roster once a term — which is why it is last, where nobody reaches it by accident. +// users/{uid} and the admin-only writes on every collection these pages touch. See +// components/admin/Gate.tsx. export const metadata: Metadata = { title: "Organisers", - description: "Club membership, sessions, notices and forms.", + description: "Club membership, mentorship, sessions, notices and forms.", robots: { index: false, follow: false }, }; export default function Admin() { - return ( -
-
-

- Admin dashboard -

-

- Who is in the club, what they have been told, and what you have asked them. -

-
- - {/* Renders only while there is something to migrate — see the component. */} - - - - - - -
- ); + return ; } diff --git a/web/app/(app)/admin/sessions/page.tsx b/web/app/(app)/admin/sessions/page.tsx new file mode 100644 index 0000000..c8ab622 --- /dev/null +++ b/web/app/(app)/admin/sessions/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import AdminGate from "@/components/admin/Gate"; +import SectionHead from "@/components/dashboard/SectionHead"; +import Sessions from "@/components/Sessions"; + +// One organiser concern, one route. /admin was six of these on one page — an organiser +// opening it to post a notice scrolled past the membership table and a form builder. +// NOT a privilege gate; see components/admin/Gate.tsx. +export const metadata: Metadata = { + title: "Sessions", + description: "When the club meets, and what is on. A session is the most time-bound thing a member sees.", + robots: { index: false, follow: false }, +}; + +export default function Page() { + return ( + + + When the club meets, and what is on. A session is the most time-bound thing a member sees. + + + + ); +} diff --git a/web/app/(app)/admin/team/page.tsx b/web/app/(app)/admin/team/page.tsx new file mode 100644 index 0000000..49a2fc7 --- /dev/null +++ b/web/app/(app)/admin/team/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import AdminGate from "@/components/admin/Gate"; +import SectionHead from "@/components/dashboard/SectionHead"; +import Roster from "@/components/Roster"; + +// One organiser concern, one route. /admin was six of these on one page — an organiser +// opening it to post a notice scrolled past the membership table and a form builder. +// NOT a privilege gate; see components/admin/Gate.tsx. +export const metadata: Metadata = { + title: "Team", + description: "Who is an organiser, and what the public site says about them.", + robots: { index: false, follow: false }, +}; + +export default function Page() { + return ( + + + Who is an organiser, and what the public site says about them. + + + + ); +} diff --git a/web/app/(app)/dashboard/details/page.tsx b/web/app/(app)/dashboard/details/page.tsx new file mode 100644 index 0000000..6947b7a --- /dev/null +++ b/web/app/(app)/dashboard/details/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import DetailsSection from "@/components/dashboard/DetailsSection"; + +export const metadata: Metadata = { + title: "Your details", + description: "The record the club holds about you.", + robots: { index: false, follow: false }, +}; + +export default function DetailsPage() { + return ; +} diff --git a/web/app/(app)/dashboard/mentorship/page.tsx b/web/app/(app)/dashboard/mentorship/page.tsx new file mode 100644 index 0000000..f6b63b8 --- /dev/null +++ b/web/app/(app)/dashboard/mentorship/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from "next"; +import MentorshipSection from "@/components/dashboard/MentorshipSection"; + +// `noindex`, like every signed-in route: a page that means nothing without a session has +// no business in a search result. NOT a privilege gate — see RequireProfile.tsx. +export const metadata: Metadata = { + title: "Mentorship", + description: "The club's GSoC cohort, and the mentors you can ask for.", + robots: { index: false, follow: false }, +}; + +export default function MentorshipPage() { + return ; +} diff --git a/web/app/(app)/onboarding/page.tsx b/web/app/(app)/onboarding/page.tsx index b976f67..f20d1aa 100644 --- a/web/app/(app)/onboarding/page.tsx +++ b/web/app/(app)/onboarding/page.tsx @@ -24,8 +24,8 @@ export const metadata: Metadata = { export default function Onboarding() { return ( -
-
+ <> +
{/* CENTRED AND NARROW, which the dashboard is not. This is a single task with one control at the end of it, and a form column stretched across a 1400px page is the layout that makes a sign-up feel like paperwork — the name and GitHub @@ -58,6 +58,6 @@ export default function Onboarding() {
-
+ ); } diff --git a/web/app/(site)/hall-of-fame/page.tsx b/web/app/(site)/hall-of-fame/page.tsx index cd3dd6d..6a6b589 100644 --- a/web/app/(site)/hall-of-fame/page.tsx +++ b/web/app/(site)/hall-of-fame/page.tsx @@ -111,8 +111,7 @@ export default function HallOfFame() { the chip beside it is short at every width there is. */} @@ -154,7 +153,7 @@ export default function HallOfFame() { paper="ruled" title="Start early" body="These names were contributing months before they applied." - tilt={-4} + tilt={-2.5} className="-left-40 top-44" />

@@ -181,14 +180,14 @@ export default function HallOfFame() { measure as every other section. The `pb` is inherited work, not decoration. A "Every selection" - table used to close this section and carried `pb-28 sm:pb-40` as + table used to close this section and carried `pb-20 sm:pb-28` as the hall's bottom breathing room; removing the table took that with it and left 25 cards ending flush, with only the ticker's own `pt-12` under them. Kept here at the same values so the boundary below the densest block on the page still reads as a boundary. Roster.tsx is unmounted, not deleted — it has uncommitted changes in it. */} -

+
diff --git a/web/app/(site)/how-to-join/page.tsx b/web/app/(site)/how-to-join/page.tsx index 4ecaf68..c95b0ba 100644 --- a/web/app/(site)/how-to-join/page.tsx +++ b/web/app/(site)/how-to-join/page.tsx @@ -86,7 +86,7 @@ export default function HowToJoin() {

The way in

@@ -104,8 +104,8 @@ export default function HowToJoin() { id={level === "beginner" ? "beginner-paths" : "intermediate-paths"} className={ levelIndex === 1 - ? "band section pb-24 pt-24 sm:pb-32 sm:pt-32" - : "section pt-20 sm:pt-24" + ? "band section pb-16 pt-16 sm:pb-24 sm:pt-24" + : "section pt-14 sm:pt-20" } aria-label={`${LEVEL_LABEL[level]} — entry paths`} data-reveal-group @@ -122,7 +122,7 @@ export default function HowToJoin() { />
-
+
{PATHS.filter((p) => p.level === level).map((p, i) => (
{p.tagline}

{p.bring && ( -

+

Bring: {p.bring}

)} @@ -196,7 +196,7 @@ export default function HowToJoin() { aside rather than a highlighted callout — it is a clarification about how the thing runs, not a selling point. */} {p.note && ( -

+

@@ -241,7 +241,7 @@ export default function HowToJoin() { across lines is ambiguous about whether the break is a newline — and 24rem is 384px, so it was silently clipped on desktop with no scrollbar visible to say there was more. Half of 1152px is 576px, which fits it. */} -

+
@@ -290,7 +290,7 @@ export default function HowToJoin() { moment it occurs, rather than in an FAQ nobody scrolls to. */}
@@ -396,7 +395,7 @@ export default function HowToJoin() { // 11px, not 10: the QA sweep flags anything under 11px as // too small to read on a phone, and a decorative glyph is // not a reason to make an exception nobody can see. - className="mt-0.5 flex h-[1.15rem] w-[1.15rem] shrink-0 items-center justify-center rounded-full border border-haze/40 text-[0.8125rem] leading-none text-haze" + className="mt-0.5 flex h-[1.15rem] w-[1.15rem] shrink-0 items-center justify-center rounded-full border border-haze/40 text-sm leading-none text-haze" > ✕ @@ -436,7 +435,7 @@ export default function HowToJoin() { {/* ---- The path. Numbered because it genuinely is a sequence. ------- */}
{/* A FLOW note rather than a gutter one, and this is the section that @@ -456,12 +455,12 @@ export default function HowToJoin() { from that step and pointing at nothing. */} @@ -471,8 +470,7 @@ export default function HowToJoin() { end — see scripts and the placement note in Note.tsx. */} @@ -536,7 +534,7 @@ export default function HowToJoin() { {/* ---- How the club actually runs ----------------------------------- */}
{/* The sticky note, in the left gutter beside this section's heading. @@ -635,7 +633,7 @@ export default function HowToJoin() { // small to read on a phone, and a decorative frame is no reason // to make an exception. The comment strings were shortened to // suit, rather than the frame widened into the sentence. - className="hidden w-44 shrink-0 self-start overflow-hidden rounded-xl border border-white/10 p-3 font-mono text-[0.8125rem] leading-relaxed lg:block" + className="hidden w-44 shrink-0 self-start overflow-hidden rounded-tile border border-white/10 p-3 font-mono text-sm leading-relaxed lg:block" style={{ background: "#0F172A" }} >

@@ -658,7 +656,7 @@ export default function HowToJoin() { join makes the invitation read as selective rather than desperate. */}

{/* Placed low, level with the end of the list rather than its start — @@ -672,20 +670,19 @@ export default function HowToJoin() { 40px off it. */} {/* Head of the same column, where the note is at the foot of it. */} @@ -708,7 +705,7 @@ export default function HowToJoin() { {/* ---- FAQ ---------------------------------------------------------- */}
{/* Beside the answers, where somebody who has run out of them is @@ -723,13 +720,13 @@ export default function HowToJoin() { paper="ruled" title="Not on the list?" body="Ask us. If we answer it twice, it ends up here." - tilt={3.5} + tilt={2.5} anchor={52} className="top-64" /> diff --git a/web/app/(site)/join/page.tsx b/web/app/(site)/join/page.tsx index 3838a45..eab918d 100644 --- a/web/app/(site)/join/page.tsx +++ b/web/app/(site)/join/page.tsx @@ -1,9 +1,8 @@ import type { Metadata } from "next"; import Link from "next/link"; -import ApplyForm from "@/components/ApplyForm"; +import JoinGate from "@/components/JoinGate"; import Duo from "@/components/Duo"; import Note from "@/components/fx/Note"; -import { DASHBOARD_HREF } from "@/content/site"; // THE APPLICATION FORM. One route, one job. // @@ -30,7 +29,7 @@ import { DASHBOARD_HREF } from "@/content/site"; export const metadata: Metadata = { title: "Join", description: - "Apply to the Scaler Open Source Club. No fee, no interview, no prior experience — a laptop and a GitHub account.", + "Join the Scaler Open Source Club. Sign in with your college account — no fee, no interview, no prior experience needed.", }; export default function Join() { @@ -66,7 +65,7 @@ export default function Join() { and the only reason it ever looked otherwise was that `top-14` landed inside this section's 96px of top padding. A later spacing pass cut that padding to 48/64px and the sticker came to rest - exactly on the "Applications open" eyebrow, at EVERY width from + exactly on the eyebrow above the headline, at EVERY width from 1024 up. So a flush sticker needs a band that is empty in both axes, not just a corner. */} @@ -113,12 +112,12 @@ export default function Join() { tracks sound like tiers. */} {/* An even 1fr/1fr split, up from 1fr/26rem. The form was a fixed @@ -135,7 +134,7 @@ export default function Join() { header lives a level down inside this two-column grid, so the group goes here and the section keeps its ordinary settle. */}
-

Applications open

+

Open to every SST student

{/* h1, not the default h2 — same reason as on /hall-of-fame. This was a mid-page band and is now the whole route. */}
- {/* THE WAY BACK IN. The nav carries a "Sign in" link of its own now, so - this is no longer the only thing between a returning member and their - dashboard — but it stays, because that link is sm+ only and because - this is the page somebody lands on when they press "Join" out of habit. - It is the sentence that stops them filling in an application they have - already sent. - - BEFORE THE FORM, NOT AFTER IT. Under the fields it would be found by - somebody who had already filled them in, which is the one moment the - sentence is no longer useful — a second application is exactly what it - exists to prevent. - - Small and quiet on purpose: almost nobody reading this page is a - member, and a sign-in prompt with equal weight to the form would ask - every first-time reader to work out which of two things they are. - - SAME TAB, AND IT USED TO OPEN A NEW ONE. `target="_blank"` on an - internal route left this application page open behind the reader as a - stale, signed-out copy of a site they had just signed into, and left - the back button — the thing somebody presses the moment they realise - they are in the wrong place — doing nothing at all. Signing in is not - a reference you consult beside the form; it is where you were going - instead of filling it in. */} -

- Already joined?{" "} - - Sign in to your dashboard - {" "} - — no need to apply twice. -

- {/* THE ANONYMOUS APPLICATION FORM, and it is back here after a spell as a - sign-in gate. For a while this column held : register with - a college Google account first, then fill a profile. That put an - account requirement in front of the club's front door — a stranger - could not apply without already holding the thing that membership - grants — and it made the headline three inches to the left false at - the exact moment somebody acted on it. + {/* SIGN-IN, NOT AN APPLICATION FORM, and this reverses the previous change + here rather than drifting from it. + + The argument for the anonymous form was real and is worth stating: making + somebody hold a Google account before they may apply puts a requirement in + front of the club's front door, and the headline three inches to the left + promises the opposite. That is true of a club that admits anybody. - Sign-in did not go away; it stopped being this page's business. It - lives on /dashboard now (components/SignInCard.tsx), which is the one - place that genuinely needs to know who you are. This page asks, that - page identifies, and neither has to care about the other. + This one does not. Membership IS an @sst.scaler.com address — that is the + whole test, and it is the one thing an application 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. So the door and + the test are the same act now, and there is no application to read. - The column this sits in, the copy beside it and the two tiles above - are unchanged — the flow changed, not the page. */} - + The two tiles above and the copy beside this are unchanged. The flow + changed, not the page. */} +
@@ -231,7 +202,7 @@ export default function Join() { the decision already on the screen and then stops, so the only thing to do with it is scroll back up and finish. - NOT INSIDE ApplyForm. It lived in the card for one revision and the + NOT INSIDE THE SIGN-IN CARD. It lived in the card for one revision and the card is the wrong container: at 15px inside a 7-unit padded tile it read as a third disclaimer under the two grey notes about data, and disclaimers are what people skip. On the page at display-md it is @@ -246,7 +217,7 @@ export default function Join() { colour and every custom class in globals.css is declared after @tailwind utilities, so a text-* utility on it silently does nothing. See the note on .page-top there. */} -
+
{/* One column until lg, and lg rather than sm because these are 30-word paragraphs at display size — the point where two of them fit side by side without either dropping to four words a line is @@ -256,9 +227,9 @@ export default function Join() { data-reveal-group >
-

If you close this tab without applying

+

If you close this tab

- You'll open it again in February. The same form, one semester + You'll open it again in February. The same one tap, one semester less, and a batch of students who already know how to review your code.

@@ -268,9 +239,9 @@ export default function Join() { edge below that, so the band never loses the mark that says which of the two futures the page is pointing at. */}
-

If you hit the button

+

If you sign in

- Someone reads it this week. You show up regularly. By November + Somebody messages you this week. You show up regularly. By November you're the one answering the questions.

diff --git a/web/app/(site)/page.tsx b/web/app/(site)/page.tsx index 90aa207..366c3fb 100644 --- a/web/app/(site)/page.tsx +++ b/web/app/(site)/page.tsx @@ -96,7 +96,7 @@ function Sources({ cell }: { cell: Cell }) { href={s.url} target="_blank" rel="noreferrer" - className="py-3.5 font-mono text-xs text-accent link-u hover:brightness-125" + className="py-[14px] font-mono text-xs text-accent link-u hover:brightness-125" > {s.label} ↗ @@ -131,7 +131,7 @@ export default function Home() { marked phrase — which were reaching four sections out of fourteen. */}
@@ -145,16 +145,14 @@ export default function Home() { trail="Nobody told you that you could change it." />

- Open source is software written in public, by anyone, for everyone to use. - Not a niche category — the things below are four of the most widely used - pieces of software on earth, and you can read every line of all of them - right now. + Software written in public, by anyone, for everyone. The four below are + among the most used on earth — and you can read every line right now.

{/* A group of its own so the four tiles deal themselves out rather than arriving as one slab. Nested inside the section's group, which skips it as an item — see Reveal.tsx — so the grid does not also slide. */} -
    +
      {EVERYDAY.map((e) => (
    • {/* Same reason as the build-day cards: shrink-0 on text from a data file is a viewport overflow waiting for a longer value. */} - + {e.language}
@@ -200,7 +198,7 @@ export default function Home() { {/* The definition, arriving after the examples have done the work. */} -
+
{WHAT_IT_IS.map((w) => ( // .rise is the hover for a block whose only furniture is its own // hairline: the rule goes accent and the block lifts 2px. There is no @@ -215,14 +213,14 @@ export default function Home() { {/* And the mechanic, drawn. This is the one idea that is genuinely hard to say in a sentence, which is the test for whether a diagram earns space. */} -
+

How a change actually gets in

- +
@@ -235,7 +233,7 @@ export default function Home() { as a consequence rather than as the point. */}
@@ -250,7 +248,7 @@ export default function Home() { before somebody's first contribution.

-
+
{MAINTAINERS.map((m) => (

{m.title}

@@ -279,7 +277,7 @@ export default function Home() { says "rebase onto upstream/main and squash before we triage". */}
@@ -296,9 +294,9 @@ export default function Home() { that silently goes wrong the first time somebody adds a thirteenth term — the same class of drift the numbers strip is built to avoid. */}

- {GLOSSARY.length} words that get used constantly and explained never. Not - knowing them is the most common reason a capable person never opens their - first pull request, and every one of us had to work them out{" "} + {GLOSSARY.length} words used constantly and explained never. Not knowing + them is the commonest reason a capable person never opens a pull request. + We all worked them out{" "} by being confused in public.

@@ -307,7 +305,7 @@ export default function Home() { {/* Twelve terms, so the stagger's eight-step cap does the work it was added for: the last four share the eighth delay and come up together rather than the twelfth waiting 1.2s. See MAX_STAGGER_STEPS. */} -
+
{GLOSSARY.map((g) => (
{g.term}
@@ -318,7 +316,7 @@ export default function Home() {
{/* ---- Thesis ------------------------------------------------------ */} -
+

What this is

@@ -373,13 +371,12 @@ export default function Home() { trail="They will read your commits." />

- This is the part that sounds like a pitch and is not. Every line below is a - mechanism you can trace, and the reason it works is boring: open source is - the only part of your CV that a stranger has already{" "} + Every line below is a mechanism you can trace. Open source is the only part + of your CV a stranger has already{" "} checked for you.

-
+
{IMPACT.map((i) => (

@@ -408,7 +405,7 @@ export default function Home() { the half that cites its cells AND stays legible at a glance. */}
{/* The table below is the argument; this is the one line of it a @@ -429,11 +426,11 @@ export default function Home() { through a headline on a 1180px laptop. */} @@ -566,7 +563,7 @@ export default function Home() { filling in. The figures inside count instead. See NumbersStrip. */}
@@ -576,9 +573,8 @@ export default function Home() { lead="Small, new, and counting honestly." />

- Everything here is derived from the other four pages rather than typed in - by hand, so it cannot say more than the evidence does. Click through and - check any of it. + Derived from the other four pages, not typed in by hand — so it cannot say + more than the evidence does.

@@ -590,7 +586,7 @@ export default function Home() { time somebody adds or pulls a story. */}
diff --git a/web/app/(site)/privacy/page.tsx b/web/app/(site)/privacy/page.tsx index d4ea4f0..115119b 100644 --- a/web/app/(site)/privacy/page.tsx +++ b/web/app/(site)/privacy/page.tsx @@ -73,7 +73,7 @@ export default function Privacy() {

-
+

Signing in tells us three things, and Google is the one that tells us: the @@ -184,7 +184,7 @@ export default function Privacy() {

-

+

Something here wrong, or out of date against the code? This site is one of the club's own repositories —{" "} diff --git a/web/app/(site)/programmes/page.tsx b/web/app/(site)/programmes/page.tsx index 96c8439..9cc18ee 100644 --- a/web/app/(site)/programmes/page.tsx +++ b/web/app/(site)/programmes/page.tsx @@ -91,7 +91,7 @@ function ProgrammeField({ p }: { p: ProgrammeInfo }) { {/* The tier, stated in words as well as carried by the colour. The colour is never the only signal. */}

Paid open source

@@ -238,7 +238,7 @@ export default function Programmes() { for them and stops. The two things they can do this month go at the top. */}
@@ -256,7 +256,7 @@ export default function Programmes() { fork, branch, review, merge — somewhere the stakes are zero.

-
-

+

Percentages are of students enrolled, and a student holds two preferences — so the two demand charts add up past 100%. Nothing on this page is an allocation: preferences are what students asked for, and pairing them is still a decision diff --git a/web/components/AppFooter.tsx b/web/components/AppFooter.tsx index 746db70..389980e 100644 --- a/web/components/AppFooter.tsx +++ b/web/components/AppFooter.tsx @@ -20,7 +20,7 @@ export default function AppFooter() {

{/* `.tap` on each link and gap-y-4 to pay for it. The QA sweep measures these under the 44px touch floor otherwise, on both themes at mobile. */} -

+

Privacy @@ -40,7 +40,7 @@ export default function AppFooter() { {/* The club, not the university. SST is where its members study; signing the university's name to a student project would claim an endorsement nobody gave. */} -

+

© {new Date().getFullYear()} Scaler Open Source Club

diff --git a/web/components/AppHeader.tsx b/web/components/AppHeader.tsx index 9144bb5..f9bb433 100644 --- a/web/components/AppHeader.tsx +++ b/web/components/AppHeader.tsx @@ -87,7 +87,7 @@ export default function AppHeader() { {name} {batch && ( - + {batch.label} · {batch.branch} )} diff --git a/web/components/ApplyForm.tsx b/web/components/ApplyForm.tsx deleted file mode 100644 index 7e2e0d3..0000000 --- a/web/components/ApplyForm.tsx +++ /dev/null @@ -1,472 +0,0 @@ -"use client"; - -// THE APPLICATION FORM. Anonymous, one-shot, and independent of sign-in. -// -// WHY THIS EXISTS AGAIN. For a while this route WAS the sign-in flow: register with a -// college Google account, then fill a profile. That collapsed two different things into -// one screen and got the order wrong. Applying is how a stranger asks to join; signing -// in is how a member proves who they are. Requiring the second before the first meant -// the club's front door needed the key you get by walking through it — and it -// contradicted the headline six inches to the left, which promises the reader they need -// nothing but a laptop and a GitHub account. -// -// So the two are separate features now: -// -// /join this form. No account, no sign-in, no session. Writes one immutable row -// to applications/{id} and says thank you. -// /dashboard sign in, fill a profile, come back to it. See components/SignInCard.tsx. -// -// They deliberately DO NOT share a component. The field lists are nearly identical and -// that is exactly the trap: a shared form would need a prop for "is there a user", and -// every branch behind that prop is a place where an applicant's path and a member's path -// can silently swap. Two forms that never surprise anybody beat one that needs a -// diagram. What they DO share is the option lists in content/join.ts, which is the part -// that actually must not drift — and firestore.rules checks both against them. -// -// FIVE THINGS THIS FORM WILL NOT DO. -// -// 1. No sign-in, ever. See above. If a future change wants the address verified, that is -// a mail loop or an organiser reading the row — not an auth gate on the one page -// whose whole argument is that you need nothing to start. -// -// 2. No countdown timer. The reference this layout came from counts down to a real dated -// admissions deadline; a club timer that silently resets is a dark pattern, and on a -// site whose entire argument is "every claim here is checkable" it would be the one -// self-inflicted wound. The deadline below renders ONLY when a real future date is -// configured, and disappears once it passes. -// -// 3. No required GitHub field. The site tells beginners repeatedly that they are welcome -// with no experience; a required GitHub profile would call that a lie at the last -// possible moment, to exactly the person the club most wants. -// -// 4. No silent failure. With no Firebase project configured the form still RENDERS and -// still VALIDATES, and says so when you press the button rather than pretending to -// submit. That is the documented promise in web/.env.example, and it is the default -// for every contributor: the repo ships no credentials, so a local checkout gets an -// honest message instead of writing test rows into the organisers' real collection. -// It is also why the unconfigured state is NOT a card that replaces the form — a -// contributor fixing the copy or the spacing here needs to see the fields. -// -// 5. No hand-rolled validation where the browser's is better. `required`, `type` and -// `maxLength` work before hydration and behave the way the reader's browser has -// taught them. -// -// PATH PRESELECTION. Every page's closing action links here with ?path=, so a reader -// who clicked "join the program track" arrives with that already chosen. It is a -// default, not a lock — the whole point of showing four paths is that people reclassify -// themselves while reading, and the field stays editable. - -import { Suspense, useEffect, useRef, useState } from "react"; -import { useSearchParams } from "next/navigation"; -import { - HOSTELS, - LEVEL_LABEL, - PATHS, - PROGRAMS, - PROGRAM_OTHER, -} from "@/content/join"; -import { LINKS } from "@/content/site"; -import { NotConfiguredError, TimeoutError, submitApplication } from "@/lib/applications"; -import { celebrate } from "@/components/fx/celebrate"; - -/** ISO date. Renders only while genuinely in the future — see point 2 above. */ -const DEADLINE = process.env.NEXT_PUBLIC_COHORT_DEADLINE ?? ""; - -function deadlineLabel(): string | null { - if (!DEADLINE) return null; - const d = new Date(DEADLINE); - if (Number.isNaN(d.getTime()) || d.getTime() < Date.now()) return null; - return d.toLocaleDateString("en-GB", { - day: "numeric", - month: "long", - year: "numeric", - }); -} - -// One string, applied to every text control, so the form cannot drift field by field. -// Identical to ProfileForm's — the two forms are separate but they are the same system, -// and a field that looked different on the two screens would read as a different site. -// -// bg-sunk, not bg-bg: on the light theme --bg and --raise are both #FFFFFF, so a white -// field on a white card is distinguished only by its 1px border. --sunk is the recessed -// fill and exists for exactly this. -// -// The focus halo is the same 3px accent ring at 18% that `.card` wears on hover, so a -// focused field and a hovered tile are visibly the same system saying the same thing. It -// rides the bare `transition` already here — Tailwind's `transition` covers box-shadow — -// and it is ADDITIVE to the border recolour rather than a replacement, so the affordance -// survives a forced-colours mode that flattens shadows. -const field = - "w-full rounded-md border border-seam bg-sunk px-3.5 py-2.5 text-sm text-ink placeholder:text-dust outline-none transition focus:border-accent focus:shadow-[0_0_0_3px_rgb(var(--sky)/0.18)]"; - -/** The fields, split out for one mechanical reason: `useSearchParams` needs a Suspense - * boundary or `next build` refuses to prerender this route — at BUILD time rather than - * at runtime, which is the good version of that error. */ -function Fields() { - const params = useSearchParams(); - // Validated against the real list rather than trusted. A hand-edited ?path=anything - // would otherwise become the select's value and submit a path that does not exist, - // which the rules reject — presenting as a broken form rather than as a bad link. - const requested = params.get("path"); - const preselected = PATHS.some((p) => p.id === requested) ? requested! : ""; - - // The programmes group is the only control here React has to hold state for, and it - // holds it for two reasons rather than one: to reveal the "which one" field when Other - // is ticked, and to enforce "at least one" — see the comment on the fieldset. - const [programs, setPrograms] = useState([]); - const firstProgram = useRef(null); - - // setCustomValidity rather than a banner of our own. `required` on a checkbox means - // "this box must be ticked", not "one of this group", so the browser has no native - // check for "pick at least one" — and rather than invent one, this borrows the - // browser's, including the scroll-into-view and the focus we would otherwise - // reimplement badly. Cleared the moment something is ticked, or the form stays - // permanently unsubmittable. - useEffect(() => { - firstProgram.current?.setCustomValidity( - programs.length === 0 - ? "Pick at least one programme — or Other, and tell us which." - : "", - ); - }, [programs]); - - return ( - <> -
-
- - -
-
- {/* ASKED HERE, UNLIKE ON THE PROFILE FORM, and this is the one field the two - genuinely differ on. A profile takes the address from the signed-in Google - account, because letting somebody type it would let them type somebody - else's. An applicant has no account to take it from, so it is a field — - and `type="email"` plus the regex in the rules is the whole check. */} - - -
-
- -
-
- - -
-
- - {/* The empty first option is what makes `required` bite: a select whose default - is already a real hostel can never be "unanswered", so the browser would let - a wrong-by-default answer through. */} - -
-
- -
- - -
- -
- Where you are right now -
- {/* UPSTREAM REPLACED THE `LEVELS` ARRAY WITH `LEVEL_LABEL`, a Record keyed by the - stored value. Object.entries gives back the same [value, label] pairs the - array used to hold, so the markup below is unchanged apart from the names. */} - {Object.entries(LEVEL_LABEL).map(([value, label]) => ( - - ))} -
-
- -
- - -
- -
- - Open source programs you are interested in{" "} - - (pick at least one) - - -
- {PROGRAMS.map((p, i) => ( - - ))} -
- {/* Ticking Other reveals a REQUIRED free-text field rather than accepting a bare - "other" — an unqualified "other" is the one answer that would change nobody's - first conversation, which is the test every field here has to pass. */} - {programs.includes(PROGRAM_OTHER) && ( -
- - -
- )} -
- - ); -} - -export default function ApplyForm() { - const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle"); - const [message, setMessage] = useState(""); - const deadline = deadlineLabel(); - - async function onSubmit(e: React.FormEvent) { - e.preventDefault(); - if (state === "sending") return; - - // Read the fields BEFORE the first await. `e.currentTarget` is null by the time an - // async handler resumes, so building FormData afterwards throws — in the one code - // path a test that never submits would not cover. - const data = new FormData(e.currentTarget); - setState("sending"); - setMessage(""); - - const str = (k: string) => String(data.get(k) ?? "").trim(); - - try { - await submitApplication({ - name: str("name"), - email: str("email"), - year_branch: str("year_branch"), - hostel: str("hostel"), - level: str("level"), - path: str("path"), - // Required and never empty — the browser refused the submit otherwise, and the - // rules refuse an empty list on the way in as well. - programs: data.getAll("programs").map(String), - programs_other: str("programs_other"), - github: str("github"), - }); - - setState("done"); - // The confetti, and it fires HERE rather than anywhere earlier — after the write - // has been confirmed, not when the button is pressed. A celebration over a request - // that is still in flight and might yet fail is the one moment on this site where - // a bit of delight would become a lie. - // - // Deliberately not awaited, and the void is the point rather than tidiness: - // celebrate() dynamically imports canvas-confetti, so it can reject on a slow or - // blocked network. Awaited, a failed confetti chunk would throw into the catch - // below and tell somebody whose application HAD been saved that it had not. - void celebrate(); - } catch (err) { - setState("error"); - // The raw Firebase message is never shown. "Missing or insufficient permissions" - // tells an applicant nothing and reads as though they did something wrong; it goes - // to the console for whoever is debugging instead. - console.error("[osc] application submit failed", err); - // THREE CASES, THREE DIFFERENT SENTENCES, and the distinctions are not pedantry. - // On a timeout the queued write may still reach Firestore later, so claiming - // "nothing was saved" could be false and could produce a duplicate if they - // resubmit. And an unconfigured deployment is not a failure of theirs or of the - // network — telling them to try again would be telling them to fail again. - setMessage( - err instanceof NotConfiguredError - ? "This form is not connected to anything yet, so submitting would send your application nowhere. Email us instead and it will actually reach somebody:" - : err instanceof TimeoutError - ? "We could not confirm that went through — it may be our end or the network. Rather than have you send it twice, email us and we'll check:" - : "That did not go through, and the fault is ours rather than yours. Nothing was saved, so please email us and we'll pick it up:", - ); - } - } - - if (state === "done") { - return ( -
-

Application received

-

- You're in the queue. -

-

- Somebody will message you before the next session. There is nothing else to do - and nothing to prepare — bring a laptop. -

-
- ); - } - - return ( -
-

Open to all years, no experience needed

-

- Apply to join -

- {/* NAMES THE ABSENT STEPS, because the reader's question at a form is not "what do - I fill in" but "what happens after I do". No account to make and no interview is - the unusual half, and it is the half that decides whether somebody starts. */} -

- One form, about a minute. No account to make, no interview, and nothing to - prepare — the organisers read it and message you before the next session. -

- -
- {/* The fallback is a plain height reservation so the card does not jump on - hydration — see the note on Fields for why the boundary is mandatory. */} - }> - - - - {deadline && ( -

- Applications for this cohort close {deadline}. -

- )} - - - - {state === "error" && ( -

- {message}{" "} - - {LINKS.email} - -

- )} - - {/* WHAT HAPPENS TO THE DATA, placed where it is read before submitting rather - than in a policy page nobody opens. A form that quietly began keeping names, - emails and hostels without saying so would be the exact behaviour this site - criticises elsewhere, and it is the applicant's information, not ours. */} -

- What we do with this: your answers go to the club organisers and nowhere else. - Nothing here is published on the site — the names on it are only there because - those people were asked and said yes. -

- -

- Not ready to apply? Turn up to a build day instead — no signup, no form, and - nobody will ask whether you have contributed before. -

-
-
- ); -} diff --git a/web/components/CommitGraph.tsx b/web/components/CommitGraph.tsx index 22bbe3f..e3f2579 100644 --- a/web/components/CommitGraph.tsx +++ b/web/components/CommitGraph.tsx @@ -145,7 +145,7 @@ export default function CommitGraph({ className = "" }: { className?: string }) data-reveal-group >
  • -

    +

    The grey line

    @@ -154,7 +154,7 @@ export default function CommitGraph({ className = "" }: { className?: string })

  • -

    +

    The blue line

    @@ -163,7 +163,7 @@ export default function CommitGraph({ className = "" }: { className?: string })

  • -

    +

    The filled dot

    diff --git a/web/components/CommunityBanner.tsx b/web/components/CommunityBanner.tsx index 7694210..f7cdaed 100644 --- a/web/components/CommunityBanner.tsx +++ b/web/components/CommunityBanner.tsx @@ -43,9 +43,9 @@ export default function CommunityBanner() { const faces = people.slice(0, FACES); return ( -

    +
    {stats.total} selected - + this cohort

    diff --git a/web/components/Composer.tsx b/web/components/Composer.tsx index 6c861af..8a3ca5a 100644 --- a/web/components/Composer.tsx +++ b/web/components/Composer.tsx @@ -41,7 +41,7 @@ import { fmtDate } from "@/lib/profile"; /** One string for both inputs and the textarea, so three controls cannot drift apart a * class at a time. Lifted from AdminDashboard's `ctl` for exactly that reason. */ const ctl = - "w-full rounded-md border border-seam bg-sunk px-3.5 py-2.5 text-sm text-ink placeholder:text-dust outline-none transition focus:border-accent"; + "w-full rounded-inline border border-seam bg-sunk px-3.5 py-2.5 text-sm text-ink placeholder:text-dust outline-none transition focus:border-accent"; export default function Composer() { const { user, isAdmin } = useAuth(); @@ -194,7 +194,7 @@ export default function Composer() { as a limit somebody is about to hit; one that appears at 1000 characters is information at the moment it becomes useful. */} {body.length > 1000 && ( -

    +

    {2000 - body.length} characters left

    )} @@ -286,12 +286,12 @@ export default function Composer() {

    {post.pinned && Pinned} {post.archived && Archived} - + {CATEGORIES.find((c) => c.value === (post.category ?? "general"))?.label} {post.title}

    -

    +

    {fmtDate(post.created_at)} · {post.author_email}

    diff --git a/web/components/Duo.tsx b/web/components/Duo.tsx index 0b78c6d..c752764 100644 --- a/web/components/Duo.tsx +++ b/web/components/Duo.tsx @@ -42,13 +42,20 @@ export default function Duo({ trail, as = "h2", className = "", + rule, }: { lead: string; trail?: string; as?: "h1" | "h2" | "h3"; className?: string; + /** The drawn rule. Defaults to ON for the page's own h1 and OFF for everything + * else — see the note above the below for why that is the whole point + * of the mark. Overridable, because a section that genuinely is the page's + * subject may want it, but the default is the rule. */ + rule?: boolean; }) { const Tag = as; + const showRule = rule ?? as === "h1"; return ( <> {lead} {trail ? {trail} : null} - {/* The hand-drawn rule under every section title. - Rendered HERE rather than at each call site, so no section can forget it + {/* THE HAND-DRAWN RULE, ON THE PAGE'S PRIMARY HEADING AND NOTHING ELSE. + It used to render under all 34 of these — every page title and every + section heading on the site — and a mark that appears under everything + marks nothing. It cost a real hierarchy: with the sub-page mastheads + moved down to display-lg, a page title and the first section heading + beneath it were the same size, the same black-and-blue, and both + underlined, so there was no way to tell which was the page. + Restricting it restores the distinction without a third heading size. + It also gives the mark back its meaning: one drawn line per page, under + the sentence the page is actually about. + + Rendered HERE rather than at each call site, so no page can forget it and none can drift to a different width — the same argument that put the caps rule in this component. @@ -113,11 +130,13 @@ export default function Duo({ stroke to 2.88px, which lands on their 3px by construction. The LENGTH is still fixed rather than matched to the text — see the note above for why that distinction is the whole point of the device. */} - + {showRule && ( + + )} ); } diff --git a/web/components/Eyebrow.tsx b/web/components/Eyebrow.tsx index 3e47d9e..ef8e775 100644 --- a/web/components/Eyebrow.tsx +++ b/web/components/Eyebrow.tsx @@ -26,7 +26,7 @@ export default function Eyebrow({ }) { return (

    {children}

    diff --git a/web/components/Footer.tsx b/web/components/Footer.tsx index 35f6bd4..9fcaf4f 100644 --- a/web/components/Footer.tsx +++ b/web/components/Footer.tsx @@ -36,8 +36,8 @@ import { DASHBOARD_HREF, INSTITUTIONAL, JOIN_HREF, LINKS, PAGES } from "@/conten export default function Footer() { return ( -
  • ))} - {footnote &&

    {footnote}

    } + {footnote &&

    {footnote}

    }
    ); } @@ -99,7 +99,9 @@ export function Counts({ rows, loading, }: { - rows: [string, number][]; + /** A string value renders as-is — used for "—", the honest answer for a figure that + * needs a full collection scan nobody has asked for yet. */ + rows: [string, number | string][]; loading?: boolean; }) { // LITERAL CLASS NAMES, not `sm:grid-cols-${n}`. Tailwind scans source text for whole diff --git a/web/components/dashboard/Board.tsx b/web/components/dashboard/Board.tsx index 4ecbb12..f28449d 100644 --- a/web/components/dashboard/Board.tsx +++ b/web/components/dashboard/Board.tsx @@ -108,7 +108,7 @@ export default function Board() {
    - + {CATEGORIES.find((c) => c.value === (post.category ?? "general"))?.label}

    {post.title}

    @@ -127,7 +127,7 @@ export default function Board() { Open the link )} -

    +

    {fmtDate(post.created_at)} · {post.author_email}

    diff --git a/web/components/dashboard/Contributions.tsx b/web/components/dashboard/Contributions.tsx index 53241ed..d8bd306 100644 --- a/web/components/dashboard/Contributions.tsx +++ b/web/components/dashboard/Contributions.tsx @@ -74,7 +74,7 @@ function StatePill({ state }: { state: string }) { const merged = state === "merged"; return ( @@ -209,7 +209,7 @@ export default function Contributions({ {/* The handle is stated first, because the whole panel is only true OF that handle — see the note at the top about what it does and does not prove. */} -

    @{handle}

    +

    @{handle}

    {error && (

    @@ -267,10 +267,10 @@ export default function Contributions({ className="tap flex items-center justify-between gap-3 rounded-tile bg-sunk px-4 py-3 transition-colors hover:bg-accent-soft" > - + {pr.title} - + {pr.repo} @@ -281,7 +281,7 @@ export default function Contributions({ )} -

    +

    Checked {ago(synced)}

    diff --git a/web/components/dashboard/DetailsSection.tsx b/web/components/dashboard/DetailsSection.tsx new file mode 100644 index 0000000..73dbac9 --- /dev/null +++ b/web/components/dashboard/DetailsSection.tsx @@ -0,0 +1,90 @@ +"use client"; + +// "My details" — the record the club holds about a member, and the form to change it. +// +// IT WAS A PANEL IN THE DASHBOARD'S RIGHT COLUMN, and the sidebar's "My details" was an +// anchor to it (`/dashboard#details`) rather than a page. That is the kind of nav item a +// reader presses once, watches the page jump, and stops trusting — and it meant the form, +// when opened, expanded inside a column sized for a summary card. +// +// It is a route now, so editing gets the width it needs and the sidebar link goes +// somewhere. The record is the default; the form is a state you enter deliberately. + +import { Suspense, useState } from "react"; +import { useRouter } from "next/navigation"; +import RequireProfile from "@/components/dashboard/RequireProfile"; +import SectionHead from "@/components/dashboard/SectionHead"; +import ProfileCard from "@/components/ProfileCard"; +import ProfileForm from "@/components/ProfileForm"; + +export default function DetailsSection() { + const [editing, setEditing] = useState(false); + const router = useRouter(); + + return ( + + {({ user, profile, reload }) => ( +
    + setEditing(false)} + className="tap font-mono text-label uppercase tracking-wider text-haze underline decoration-seam underline-offset-4 transition-colors hover:text-ink" + > + Cancel + + ) : ( + + ) + } + > + {editing + ? "Change anything and save. Your address stays as it is on your college account — it is what your membership hangs on." + : "This is everything the club holds about you, and it is visible to you and the organisers only. Nothing here is published on the site."} + + + {editing ? ( + // SUSPENSE IS REQUIRED, not tidiness: ProfileForm reads useSearchParams for the + // ?path= preselect, and an unwrapped useSearchParams fails the static export + // build outright — at build time, which is the good version of that error. + }> + { + setEditing(false); + // Re-read rather than trusting the local echo, so the card shows the + // server's timestamps rather than a client clock. + reload(); + }} + /> + + ) : ( + <> + setEditing(true)} /> + {/* THE WAY OUT OF THE SECTION, because a route with no exit but the sidebar + is a dead end on a phone, where the sidebar is not on screen. */} + + + )} +
    + )} +
    + ); +} diff --git a/web/components/dashboard/Forms.tsx b/web/components/dashboard/Forms.tsx index f11818d..945c8e3 100644 --- a/web/components/dashboard/Forms.tsx +++ b/web/components/dashboard/Forms.tsx @@ -33,7 +33,7 @@ import { } from "@/lib/forms"; const ctl = - "w-full rounded-md border border-seam bg-sunk px-3.5 py-2.5 text-sm text-ink placeholder:text-dust outline-none transition focus:border-accent"; + "w-full rounded-inline border border-seam bg-sunk px-3.5 py-2.5 text-sm text-ink placeholder:text-dust outline-none transition focus:border-accent"; /** The counts, as bars. Same treatment as the organisers' breakdowns — one dimension, * a handful of rows, and the token colours already carry the meaning, so a chart diff --git a/web/components/dashboard/MentorshipSection.tsx b/web/components/dashboard/MentorshipSection.tsx new file mode 100644 index 0000000..bfa3be2 --- /dev/null +++ b/web/components/dashboard/MentorshipSection.tsx @@ -0,0 +1,32 @@ +"use client"; + +// The mentorship section, on its own route. +// +// IT WAS THE LAST THING ON THE OVERVIEW, below four panels and a two-column grid, and that +// was the wrong place for it twice over. It is the club's headline activity — the reason +// most people join — and it is a decision made once a term rather than something read +// weekly, so it was both buried and mixed in with things that change every week. +// +// On its own route it gets the whole column, which the picker genuinely needs: choosing a +// mentor means reading several descriptions side by side, and that is a page, not a panel. + +import RequireProfile from "@/components/dashboard/RequireProfile"; +import SectionHead from "@/components/dashboard/SectionHead"; +import MentorPicker from "@/components/MentorPicker"; + +export default function MentorshipSection() { + return ( + + {({ user }) => ( + <> + + The club runs a Google Summer of Code cohort: weekly sessions, proposal review, + and a mentor who has been through it recently. Enrolling records a preference — + an organiser pairs the cohort by hand once it closes. + + + + )} + + ); +} diff --git a/web/components/dashboard/NextSessions.tsx b/web/components/dashboard/NextSessions.tsx index 892dd56..5936ed0 100644 --- a/web/components/dashboard/NextSessions.tsx +++ b/web/components/dashboard/NextSessions.tsx @@ -67,14 +67,14 @@ export default function NextSessions() { {/* The date as its own block, in the accent, so a member scanning the panel reads WHEN before what — which is the question they opened it with. */} - + {when.day} - {when.time} + {when.time} {s.title} - + {[s.speaker, s.location].filter(Boolean).join(" · ") || "Details to come"} diff --git a/web/components/dashboard/NextUp.tsx b/web/components/dashboard/NextUp.tsx index d236b43..1796528 100644 --- a/web/components/dashboard/NextUp.tsx +++ b/web/components/dashboard/NextUp.tsx @@ -121,7 +121,7 @@ export default function NextUp({ profile }: { profile: Profile }) { <> {d.label} - {d.hint} + {d.hint} diff --git a/web/components/dashboard/Panel.tsx b/web/components/dashboard/Panel.tsx index a7ffe24..aa110f8 100644 --- a/web/components/dashboard/Panel.tsx +++ b/web/components/dashboard/Panel.tsx @@ -88,7 +88,7 @@ export default function Panel({ inside one). The size is set here rather than inherited because this is the mono face doing a heading's job. */}

    diff --git a/web/components/dashboard/RequireProfile.tsx b/web/components/dashboard/RequireProfile.tsx new file mode 100644 index 0000000..9fb88c9 --- /dev/null +++ b/web/components/dashboard/RequireProfile.tsx @@ -0,0 +1,111 @@ +"use client"; + +// The gate every signed-in section sits behind, in one place. +// +// THREE STATES BEFORE THE PAGE, and the order matters: +// +// still checking a neutral card. `user === undefined` means the session is being +// restored, and treating that as "signed out" flashes a sign-in prompt +// at every returning member on every load. +// signed out the sign-in card. +// no profile yet sent to /onboarding, and NOTHING of the section renders. +// +// WHY THE GATE EXISTS, AND WHY IT ONCE DID NOT. The profile form used to render instead of +// the dashboard, inside the dashboard component, and the club's organisers reported that +// the site "has no dashboard" — they had met a hostel dropdown and never got past it. The +// fix at the time was to demote the form to a panel and let everything else through. +// +// It is a gate again because the club asked for it: nobody should be half-registered, and +// an organiser reading the roster should be able to trust that a row means a member. What +// is different is where the form lives. It is not here. An incomplete profile goes to +// /onboarding, which is a PAGE about being three questions — a two-step spine showing that +// signing in was step one, a heading that says "Three questions", and 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 told anybody they were +// nearly through. +// +// IT IS NOT A PRIVILEGE BOUNDARY. Every one of these routes is static HTML on a CDN and +// anybody can fetch it; what refuses to hand over data is firestore.rules. See the note at +// the top of lib/auth.tsx before concluding that a redirect makes anything safe. + +import { useCallback, useEffect, useState, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import type { User } from "firebase/auth"; +import SignInCard from "@/components/SignInCard"; +import { useAuth } from "@/lib/auth"; +import { isComplete, readProfile, type Profile } from "@/lib/profile"; + +export default function RequireProfile({ + /** Named so the waiting card can say what is being waited for. A blank card on a slow + * connection is on screen long enough to read. */ + loading = "Finding your things…", + children, +}: { + loading?: string; + children: (ctx: { user: User; profile: Profile; reload: () => void }) => ReactNode; +}) { + const { user } = useAuth(); + const router = useRouter(); + const [profile, setProfile] = useState(undefined); + const [loadError, setLoadError] = useState(""); + + const load = useCallback(async (uid: string) => { + setLoadError(""); + try { + setProfile(await readProfile(uid)); + } catch (e) { + // A denied read here is almost always an off-domain address, which lib/auth should + // already have signed out — so this is genuinely unexpected and says so rather than + // pretending there is no profile. + console.error("[osc] could not read profile", e); + setProfile(null); + setLoadError("We could not load your details. Reload the page, or email us."); + } + }, []); + + useEffect(() => { + if (user) void load(user.uid); + else if (user === null) setProfile(null); + }, [user, load]); + + // `loadError` is in the condition on purpose: a profile we FAILED to read is not a + // profile that does not exist, and bouncing somebody into onboarding on a dropped + // connection would ask a member who joined in August to register a second time. + const needsOnboarding = + Boolean(user) && profile !== undefined && !isComplete(profile) && !loadError; + + useEffect(() => { + if (needsOnboarding) router.replace("/onboarding"); + }, [needsOnboarding, router]); + + if (user === undefined || (user && profile === undefined) || needsOnboarding) { + return ( +
    + {/* THE HIDDEN H1 IS HERE TOO, because this is a state the route can be LOADED IN + rather than a flicker between two states that have one. Without it the document + has no name for as long as the check takes. */} +

    Your dashboard

    +

    One moment

    +

    + {needsOnboarding ? "Just three questions first…" : loading} +

    +
    + ); + } + + if (!user) return ; + + if (!profile) { + return ( +
    +

    Your dashboard

    +

    Something went wrong

    +

    + {loadError || "We could not load your details."} +

    +
    + ); + } + + return <>{children({ user, profile, reload: () => void load(user.uid) })}; +} diff --git a/web/components/dashboard/SectionHead.tsx b/web/components/dashboard/SectionHead.tsx new file mode 100644 index 0000000..ac845ee --- /dev/null +++ b/web/components/dashboard/SectionHead.tsx @@ -0,0 +1,52 @@ +// The heading every signed-in section opens with. +// +// THE DASHBOARD WAS ONE PAGE AND IS NOW SEVERAL, which is what this exists for. Four +// panels and a form stacked in two columns meant the page answered every question at once +// and none of them first — "asked of you", "from the organisers", "your open source", +// "your details" and the whole mentorship flow, none of which are read at the same moment. +// Split, each section can open on a heading that says what it is and then breathe. +// +// ONE COMPONENT RATHER THAN A HEADING PER PAGE, because the spacing IS the design here. +// Three pages that each invent their own gap between title and content read as three +// pages; the same rhythm on each reads as one product with sections in it. +// +// THE h1 IS REAL AND VISIBLE. The old dashboard carried a visually hidden one because the +// design opened straight on figures — a page with no h1 hands a screen-reader user a +// document with no name. With sections, each one genuinely has a title worth showing, so +// the hidden heading and the apology in its comment both go. + +import type { ReactNode } from "react"; + +export default function SectionHead({ + eyebrow, + title, + children, + action, +}: { + /** The small mono label above the title. Names the area, not the page. */ + eyebrow: string; + title: string; + /** One sentence on what this section is for. Optional — a section whose title says + * everything should not be padded with a line that repeats it. */ + children?: ReactNode; + /** A single control, right-aligned on wide screens and wrapping under the title on a + * phone rather than squeezing it. */ + action?: ReactNode; +}) { + return ( +
    +
    +
    +

    {eyebrow}

    +

    + {title} +

    +
    + {action} +
    + {children && ( +

    {children}

    + )} +
    + ); +} diff --git a/web/components/dashboard/Shell.tsx b/web/components/dashboard/Shell.tsx index f76b6ea..0a4ef09 100644 --- a/web/components/dashboard/Shell.tsx +++ b/web/components/dashboard/Shell.tsx @@ -25,13 +25,17 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import Icon from "@/components/Icon"; import ThemeToggle from "@/components/ThemeToggle"; +import DevLoginSlot from "@/components/dev/DevLoginSlot"; import { useAuth } from "@/lib/auth"; import { LINKS } from "@/content/site"; type NavItem = { label: string; href: string; - icon: "grid" | "folder" | "settings" | "megaphone"; + icon: "grid" | "folder" | "settings" | "megaphone" | "compass"; + /** Shown only while the reader is inside /admin. Six organiser links in a sidebar about + * a member's week would be six links most readers can never use. */ + adminArea?: boolean; /** Only rendered for an admin. A convenience, never a gate: /admin ships its markup to * anybody who asks for it, and what refuses a non-admin is firestore.rules, which * denies every read the page depends on. */ @@ -52,18 +56,41 @@ const NAV: NavItem[] = [ // breaks nothing, fails no test, and is only found by asking "how does an organiser // actually get there". { label: "Organisers", href: "/admin", icon: "megaphone", adminOnly: true }, + // THE ORGANISER SECTIONS, listed only while an organiser is inside them. /admin was one + // route with six panels; splitting it into six means the sidebar has to be the way + // between them, and a member — or an organiser reading their own dashboard — has no use + // for six admin links in a bar about their week. + { label: "Members", href: "/admin/members", icon: "grid", adminOnly: true, adminArea: true }, + // "MENTORS", NOT "MENTORSHIP". There was a second nav item three rows down with + // that exact label, the same compass icon and a different destination — the member's + // own mentorship page — and inside /admin both rendered, adjacent, identical. A + // reader had no way to tell which was which except by clicking. This one is where an + // organiser publishes mentors and reads the interest list; the other is where a + // member picks one. Naming them for what they do makes the collision impossible. + { label: "Mentors", href: "/admin/mentorship", icon: "megaphone", adminOnly: true, adminArea: true }, + { label: "Notices", href: "/admin/notices", icon: "megaphone", adminOnly: true, adminArea: true }, + { label: "Sessions", href: "/admin/sessions", icon: "grid", adminOnly: true, adminArea: true }, + { label: "Forms", href: "/admin/forms", icon: "folder", adminOnly: true, adminArea: true }, + { label: "Team", href: "/admin/team", icon: "settings", adminOnly: true, adminArea: true }, // PULL REQUESTS IS GONE FOR NOW. It anchored to the GitHub panel, which cannot say // anything until the contribution sync is deployed and members have handles on their // profiles — so it was a nav item leading to a card that reads "tell us where to look". // The panel itself stays, and still carries id="open-source", so restoring this is one // line when there is something behind it. { label: "Projects", href: "/projects", icon: "folder" }, + // MENTORSHIP IS A ROUTE NOW rather than the last panel on the overview. It is the club's + // headline activity and the reason most people join, and it was below four weekly panels + // on a page about the week — buried, and mixed in with things it has nothing to do with. + { label: "Mentorship", href: "/dashboard/mentorship", icon: "compass" }, // "MY DETAILS", NOT "SETTINGS". The design's word promised a settings page — notification // preferences, account options — and there are none: the only thing a member can change // about themselves is their profile. A label that names a page which does not exist is // the kind of thing a reader clicks once, finds nothing, and stops trusting the nav over. // If anything genuinely settings-shaped ever arrives, it earns the name back. - { label: "My details", href: "/dashboard#details", icon: "settings", anchor: true }, + // AN ANCHOR NO LONGER. `/dashboard#details` scrolled to a panel in the right-hand + // column, which is the kind of nav item somebody presses once, watches the page jump, + // and stops trusting. It is a page, so the link goes somewhere. + { label: "My details", href: "/dashboard/details", icon: "settings" }, ]; /** The sidebar's link styling. @@ -101,10 +128,35 @@ const NAV_CLASS = export default function Shell({ children }: { children: React.ReactNode }) { const { user, isAdmin, signOut } = useAuth(); const pathname = usePathname(); + /** ONBOARDING GETS THE BAR AND THE FOOTER AND NOTHING ELSE. + * + * Finishing the profile is a gate: an incomplete one is sent here and the dashboard is + * not reachable until it is done. A sidebar offering Good first issues, Projects and My + * details next to that form is three invitations to leave the one screen the member has + * to finish — and two of them lead to a dashboard that would bounce them straight back. + * + * The bar stays, because sign-out has to remain reachable from every signed-in page. + * Somebody who lands here with the wrong Google account needs a way out that is not the + * back button. */ + const bare = pathname === "/onboarding"; const handle = user?.email?.split("@")[0] ?? ""; const onAdmin = pathname.startsWith("/admin"); + /** What the app bar says after "OSC /". + * + * DERIVED FROM THE 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 exactly the drift that + * left every /admin route claiming to be the dashboard. The two roots that are not + * in NAV under their own label are named here; everything else finds itself. */ + const crumb = + bare + ? "FINISH JOINING" + : pathname === "/dashboard" + ? "DASHBOARD" + : (NAV.find((n) => n.href === pathname)?.label ?? (onAdmin ? "ORGANISERS" : "DASHBOARD")) + .toUpperCase(); + return (
    {/* ------------------------------------------------------------- top bar @@ -118,9 +170,15 @@ export default function Shell({ children }: { children: React.ReactNode }) {
    - OSC / DASHBOARD + {/* THE CRUMB NAMES THE PAGE, and it now reads the route to do it. + It was the literal "DASHBOARD" on everything except /onboarding, so all + seven organiser routes said "OSC / DASHBOARD" while showing the members + table, the mentor list or the roster. A breadcrumb that names the wrong + page is worse than no breadcrumb: it is the one piece of chrome a reader + trusts to tell them where they are. */} + OSC / {crumb}
    {/* THE VIEW SWITCH, AND IT LIVES IN THE BAR RATHER THAN ONLY IN THE SIDEBAR. @@ -169,6 +227,7 @@ export default function Shell({ children }: { children: React.ReactNode }) { below that the dashboard is simply one column — which is what the content wants anyway, since every panel is full width there. The sign-out and theme controls live in the bar above, so nothing is lost by its absence. */} + {!bare && (
    + )}
    -
    {children}
    +
    + {children} + {/* THE DEV LOGIN LIVES ON THE SHELL, not on the cards that refuse you, and + that is the difference between a shortcut and a switch. Put on the sign-in + card alone it got you IN as somebody; here it is on all eleven signed-in + routes in every state, so swapping from the test member to the test + organiser and back is one click from wherever you already are rather than + sign out, /join, sign in. + Renders nothing unless an emulator is configured, and is not in the bundle + at all when one is not — see components/dev/DevLoginSlot.tsx. */} + +
    diff --git a/web/components/dev/DevLogin.tsx b/web/components/dev/DevLogin.tsx new file mode 100644 index 0000000..46fdf93 --- /dev/null +++ b/web/components/dev/DevLogin.tsx @@ -0,0 +1,233 @@ +"use client"; + +// Sign in as a test member or a test organiser, without the Google popup. +// +// WHY THIS EXISTS. Every signed-in screen on this site is behind Google sign-in, and +// locally that means the Auth emulator's popup — which is the least reliable thing in the +// whole development setup. It stops responding after the first successful sign-in in a +// browser: the form fills, the button is enabled, the clicks land, no console error +// appears, and the account is simply never created. It is documented at length in +// scripts/e2e-auth.mjs, which works around it by launching a whole new browser process per +// identity, because a fresh context is not enough. +// +// The cost of that 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. On a +// project whose own notes say "check it in both themes, it has caught a bug every time", a +// jammed door to the thing you are meant to be looking at is a real problem. +// +// So this creates the account directly against the emulator's admin API and signs in with a +// password. No popup, no chooser, no wedge. +// +// ────────────────────────────────────────────────────────────────────────────────────── +// WHY IT CANNOT REACH PRODUCTION, which is the only thing that matters about a control that +// mints an organiser session. Four independent reasons: +// +// 1. Nothing imports this file directly. Call sites render DevLoginSlot, which compiles +// to a literal `null` component in any production build — so the code below is not in +// a deployed bundle at all. That claim was FALSE the first time it was made here; read +// the note in DevLoginSlot.tsx about why, it is a trap worth knowing. +// 2. scripts/assert-no-dev-login.mjs greps the built site for the strings below and fails +// the build if it finds them, which is what turns reason 1 from a belief into a check. +// 3. scripts/preflight-deploy.mjs REFUSES TO BUILD a deployable site with +// NEXT_PUBLIC_FIRESTORE_EMULATOR set. It runs before next build, because NEXT_PUBLIC_* +// values are inlined and by the time a bundle exists the mistake is already baked in. +// 4. Everything it writes goes to emulator REST endpoints on 127.0.0.1, which do not +// exist in production — and it renders nothing at all unless that variable is set. +// +// Those endpoints take `Authorization: Bearer owner` and bypass firestore.rules entirely. +// That is correct here and would be a catastrophic thing to copy anywhere else: it is how +// the emulator lets a developer act as the database owner, and it is why this file is +// fenced the way it is. Nothing in the shipped app writes `admins` — that collection being +// unwritable by every client is the one privilege escalation the rules exist to prevent. + +import { useState } from "react"; + +/** Empty in any real deployment, and the switch this whole file hangs on. */ +const EMULATOR = process.env.NEXT_PUBLIC_FIRESTORE_EMULATOR ?? ""; +const PROJECT = process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ?? ""; + +/** Both emulators, derived from the one variable that configures Firestore's — the same + * derivation lib/firebase.ts makes for Auth. The ports are the firebase.json defaults. */ +const HOST = EMULATOR.split(":")[0] || "127.0.0.1"; +const IDENTITY = `http://${HOST}:9099/identitytoolkit.googleapis.com/v1/projects/${PROJECT}`; +const FIRESTORE = `http://${HOST}:8080/v1/projects/${PROJECT}/databases/(default)/documents`; +const OWNER = { Authorization: "Bearer owner", "Content-Type": "application/json" }; + +/** REAL-SHAPED ADDRESSES, not `dev@sst.scaler.com`. Batch, branch and roll are parsed out + * of the local part by lib/batch.ts and never stored, so an address that does not match + * `name.YYbcsNNNNN` exercises none of that and renders an em dash everywhere a real + * member shows "2023–27". Two different batches so the admin breakdowns have more than + * one bar. */ +const PEOPLE = { + member: { + email: "dev.23bcs10045@sst.scaler.com", + name: "Dev Member", + admin: false, + to: "/dashboard", + }, + organiser: { + email: "dev.22bcs10002@sst.scaler.com", + name: "Dev Organiser", + admin: true, + to: "/admin", + }, +} as const; + +type Who = keyof typeof PEOPLE; + +/** Fixed, and it does not matter: the Auth emulator stores it in clear as + * `fakeHash:...:password=...` and never talks to a real identity provider. */ +const PASSWORD = "dev-password-emulator-only"; + +/** Create the account verified, or make an existing one match. + * + * VERIFIED BEFORE SIGN-IN, WHICH IS THE WHOLE ORDERING. isMember() in firestore.rules + * requires `email_verified == true` on the token, and a password signup is unverified by + * default. Setting the flag AFTER signing in would leave the client holding a token minted + * from the old record for up to an hour, so every read would be refused — which presents + * as a dashboard that loads and then shows nothing, the exact failure shape this project + * keeps producing. Setting it first means the sign-in mints a token that is already right. + * + * Returns nothing; throws with the emulator's own message if it refuses. */ +async function ensureAccount(email: string, name: string) { + const created = await fetch(`${IDENTITY}/accounts`, { + method: "POST", + headers: OWNER, + body: JSON.stringify({ + email, + password: PASSWORD, + emailVerified: true, + displayName: name, + }), + }); + if (created.ok) return; + + const why = (await created.json())?.error?.message ?? `HTTP ${created.status}`; + // Anything but "you already made this one" is a real failure and must not be swallowed. + if (why !== "EMAIL_EXISTS") throw new Error(`could not create ${email}: ${why}`); + + // It exists — from an earlier press, or from a Google sign-in in this same emulator. Reset + // it to known values rather than assuming, because an account created through the popup + // has no password at all and signing in would fail with a code that reads like our bug. + const found = await fetch(`${IDENTITY}/accounts:lookup`, { + method: "POST", + headers: OWNER, + body: JSON.stringify({ email: [email] }), + }); + const localId = (await found.json())?.users?.[0]?.localId; + if (!localId) throw new Error(`${email} exists but could not be looked up`); + + const updated = await fetch(`${IDENTITY}/accounts:update`, { + method: "POST", + headers: OWNER, + body: JSON.stringify({ localId, password: PASSWORD, emailVerified: true }), + }); + if (!updated.ok) throw new Error(`could not reset ${email}: HTTP ${updated.status}`); +} + +/** Put the address in `admins`, exactly as a human does it in the Firebase console. + * + * KEYED BY LOWERCASE EMAIL, because that is the id lib/auth.tsx reads back + * (`doc(db, ADMINS, user.email.toLowerCase())`). `role: owner` rather than a plain admin + * row so the roster UI — the owner-only part of the organisers' area — is reachable too; + * a dev login that can see six of the seven screens would leave the seventh unlooked-at, + * which is the problem this file is here to solve. */ +async function ensureAdmin(email: string) { + const r = await fetch(`${FIRESTORE}/admins/${encodeURIComponent(email)}`, { + method: "PATCH", + headers: OWNER, + body: JSON.stringify({ + fields: { + role: { stringValue: "owner" }, + active: { booleanValue: true }, + added_by: { stringValue: "dev login" }, + }, + }), + }); + if (!r.ok) throw new Error(`could not seed admins/${email}: HTTP ${r.status}`); +} + +export default function DevLogin() { + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + + // THE GUARD, first, because everything below assumes 127.0.0.1. It answers a different + // question from the slot's: the slot asks "could this build be deployed" and decides what + // ships, this asks "is there an emulator to talk to" and decides what renders. So a dev + // server with the real project's config in .env.local shows no dev login, which is right + // — those buttons would be trying to create accounts in the club's live Firebase. + if (!EMULATOR) return null; + + async function enter(which: Who) { + const who = PEOPLE[which]; + setBusy(which); + setError(""); + try { + const { getAuthClient } = await import("@/lib/firebase"); + const auth = await getAuthClient(); + if (!auth) throw new Error("Firebase is not configured — check web/.env.local"); + + await ensureAccount(who.email, who.name); + if (who.admin) await ensureAdmin(who.email); + + const { signInWithEmailAndPassword } = await import("firebase/auth"); + await signInWithEmailAndPassword(auth, who.email, PASSWORD); + + // A HARD NAVIGATION, NOT router.push. The admins row this may have just written is + // read once per session by AuthProvider, and there is no reason to make the app + // re-resolve state it only reads at boot when the whole point is to arrive on a clean + // page. It also lands you where you were going: /admin for the organiser, and + // /dashboard for the member — which bounces itself on to /onboarding the first time, + // since a fresh account has no profile yet, and that is the correct first screen. + window.location.assign(who.to); + } catch (e) { + console.error("[osc] dev login failed", e); + setError( + `${e instanceof Error ? e.message : String(e)}. Are the emulators running? ` + + `npx firebase-tools emulators:start --only firestore,auth --project ${PROJECT}`, + ); + setBusy(""); + } + } + + return ( + // DASHED, AND DELIBERATELY NOT A `.card`. It should be obvious at a glance that this + // panel is scaffolding rather than part of the product — the one thing worse than a dev + // control on screen is a dev control that looks like it belongs there. +
    +

    Local development only

    +

    + Sign in against the emulator without the Google popup, which stops responding after + the first sign-in in a browser. None of this is in a deployed build: the slot around + it compiles away under next build, and the build then greps its own + output to prove it. +

    +
    + {(Object.keys(PEOPLE) as Who[]).map((which) => ( + + ))} +
    + {/* The addresses, because which account you are on decides what every screen shows, + and "test organiser" does not tell you which row to look for in the roster. */} +

    + {PEOPLE.member.email} · {PEOPLE.organiser.email} +

    + {error && ( + // The emulator's own message, not a friendly rewrite of it. The reader here is + // whoever is running the emulators, and EMAIL_EXISTS or ECONNREFUSED tells them + // what to do; "something went wrong" does not. +

    + {error} +

    + )} +
    + ); +} diff --git a/web/components/dev/DevLoginSlot.tsx b/web/components/dev/DevLoginSlot.tsx new file mode 100644 index 0000000..6ff127c --- /dev/null +++ b/web/components/dev/DevLoginSlot.tsx @@ -0,0 +1,57 @@ +"use client"; + +// The hole DevLogin sits in, and the reason it is a separate file from DevLogin itself. +// +// A component that returns null in production still SHIPS in production — the browser +// downloads it, and anybody reading the bundle finds a function that seeds `admins` with an +// owner token. That is harmless, because the endpoints it calls exist only on 127.0.0.1, +// and it is still not something to leave lying in a deployed artefact. +// +// The ternary below is what removes it. `process.env.NODE_ENV` is substituted with a +// literal at build time, so `next build` compiles this to `"production" === "production" ? +// NoDevLogin : …`, webpack marks the second branch dead, never creates the `import()` +// dependency, and no line of DevLogin.tsx reaches the bundle. Written INLINE rather than +// through a `const`, because the substitution has to be the thing being tested. +// +// ────────────────────────────────────────────────────────────────────────────────────── +// WHY NODE_ENV AND NOT THE EMULATOR VARIABLE, which is what this keyed on first and is the +// obvious choice — it is the switch DevLogin's own render guard uses, so keying the slot on +// it too would have made one condition instead of two. +// +// IT DOES NOT ELIMINATE. Next inlines a `process.env.NEXT_PUBLIC_*` reference only for a +// variable that is set to a NON-EMPTY value; an empty or absent one is left as a runtime +// property lookup on a `process` shim. So in a production build the test is not a constant, +// nothing folds, and the whole of DevLogin.tsx ships as its own chunk — measured, in +// out/_next/static/chunks/1651.*.js, complete with the `Bearer owner` header and the test +// addresses. The panel still rendered nothing, because the lookup is undefined at runtime +// and the guard held; it was simply all there to read. +// +// This is worth knowing beyond this file: `if (process.env.NEXT_PUBLIC_THING)` around +// anything you believe is stripped from production is stripping nothing whenever THING is +// empty, which is exactly the case where you assumed it was. +// +// THE TWO CONDITIONS NOW DO DIFFERENT JOBS, which is why there are two. +// NODE_ENV here — is this a build that could be deployed? Decides what SHIPS. +// the emulator var — is there an emulator to talk to? Decides what RENDERS. +// A `next build` run locally against the emulator therefore has no dev login, which is +// correct: `next dev` is where the work happens, and a deployable bundle should not carry +// this whatever it was built against. +// +// CHECKED, NOT ASSUMED — that is the whole lesson above. scripts/assert-no-dev-login.mjs +// greps the built site for DevLogin's own strings and fails the build if it finds them. It +// runs in `npm run build:static`, after next build, because this claim was false the first +// time it was made and nothing but the output settles it. + +import dynamic from "next/dynamic"; +import type { ComponentType } from "react"; + +const DevLoginSlot: ComponentType = + process.env.NODE_ENV === "production" + ? // Not `null`: the call site renders , so it has to be a component. + // The minifier inlines it there and it costs nothing. + function NoDevLogin() { + return null; + } + : dynamic(() => import("@/components/dev/DevLogin")); + +export default DevLoginSlot; diff --git a/web/components/fx/Console.tsx b/web/components/fx/Console.tsx index 9c5003d..298f052 100644 --- a/web/components/fx/Console.tsx +++ b/web/components/fx/Console.tsx @@ -146,11 +146,11 @@ export default function Console() { // A real input rather than a keydown listener on the document: it can // be tapped on a phone, it raises a keyboard, and it does not fight // the rest of the page for keystrokes. - // min-h-10 clears the 40px touch floor. A bare inline input in a + // min-h-[40px] clears the 40px touch floor. A bare inline input in a // mono row is 20px tall, which is a fine mouse target and half of a // usable one on a phone — the height is invisible here because the // background is transparent, so it costs nothing to make it tappable. - className="min-h-10 min-w-0 flex-1 bg-transparent outline-none placeholder:text-[#64748B]" + className="min-h-[40px] min-w-0 flex-1 bg-transparent outline-none placeholder:text-[#64748B]" style={{ color: "#E2E8F0", caretColor: "#4ADE80" }} placeholder="help" /> diff --git a/web/components/fx/Note.tsx b/web/components/fx/Note.tsx index c0aaa0a..cd2f042 100644 --- a/web/components/fx/Note.tsx +++ b/web/components/fx/Note.tsx @@ -106,14 +106,13 @@ // hearing. Keep it that way when adding one — if a note has nothing to say // beyond decoration, it should be a sticker instead. +/** THREE PAPERS, FROM ONE PAD. There were seven; see the block in globals.css for + * why that read as a stationery catalogue rather than as somebody's working wall. + * `yellow` is the site's own --pop and stays the default. */ const TONE_CLASS = { yellow: "", - mint: "note-mint", - pink: "note-pink", + warm: "note-warm", sky: "note-sky", - lime: "note-lime", - orange: "note-orange", - lilac: "note-lilac", } as const; const PAPER_CLASS = { @@ -257,9 +256,9 @@ export default function Note({ {title}

    {body && ( -

    {body}

    +

    {body}

    )} - {children &&
    {children}
    } + {children &&
    {children}
    } {fold && }

    diff --git a/web/components/fx/Sticker.tsx b/web/components/fx/Sticker.tsx index 968bfce..fc0512a 100644 --- a/web/components/fx/Sticker.tsx +++ b/web/components/fx/Sticker.tsx @@ -35,14 +35,20 @@ export default function Sticker({ rotate: number; /** "blue" is the bare `.chip` — was called "lime" until the badge itself turned electric blue, and a tone named after a colour it no longer is costs more - than a rename. No call site passed it explicitly; all three stickers on the - page either take the default or ask for violet/mint. */ - tone?: "blue" | "violet" | "mint"; + than a rename. + + VIOLET AND MINT ARE GONE. A sticker is decoration, and decoration is where a + palette leaks: those two were the only violet and the only decorative mint on + the site, so three stickers carried two colours nothing else used. Blue is the + ink here and yellow is the one thing louder than it; a sticker gets one of + those or it gets the default. The mint that remains is semantic — the "OSC + way" / "Old way" pair and "OSC club" — and it stays because it is saying + something rather than decorating. */ + tone?: "blue" | "pop"; effect?: "wobble" | "bounce" | "none"; className?: string; }) { - const toneClass = - tone === "violet" ? "chip-violet" : tone === "mint" ? "chip-mint" : ""; + const toneClass = tone === "pop" ? "chip-pop" : ""; return ( +

    The wall

    {/* Not a disclaimer any more — a hint. It tells you what to do with the @@ -163,16 +163,19 @@ export default function ContribWall() { grid was the line least likely to be seen. A hint that goes unread is just clutter. - Mint specifically, out of the four fills, because it is the wall's own - green — the caption and the thing it is about are visibly the same - object, which no amount of extra size would have achieved. 8.3:1. - - And TILTED, so no .chip-true here. Per the note on .chip-violet, the - tilt is what separates a label the site applied to itself from a badge - asserting a fact; a straight pill next to a grid of green squares is - exactly the "this is data" reading the whole component is built to - avoid. */} -

    close your eyes 70%

    + IT WAS MINT, matching the wall's own green so that the caption and the + thing it is about read as one object. That argument was sound and the + colour did not survive the palette pass: decorative mint was one of the + fills that made the site's stationery drawer wider than its ink, and it + is now reserved for the places where the colour is the argument — the + "OSC way" / "Old way" pair. The default blue chip carries this instead, + and the tilt below is doing the work the colour was. + + TILTED, so no .chip-true here: the tilt is what separates a label the + site applied to itself from a badge asserting a fact, and a straight pill + next to a grid of green squares is exactly the "this is data" reading the + whole component is built to avoid. */} +

    close your eyes 70%

    {/* FLUID CELLS, which is what makes the wall run the whole length of its diff --git a/web/components/hall/Hall.tsx b/web/components/hall/Hall.tsx index 27f43b0..e72f561 100644 --- a/web/components/hall/Hall.tsx +++ b/web/components/hall/Hall.tsx @@ -85,7 +85,7 @@ export default function Hall() { as two unrelated elements sharing a row. */}

    - + {stats.total} @@ -150,7 +150,7 @@ export default function Hall() { window resizes. Mobile stays at one, unchanged: two 155px cards side by side is where the name and the work sentence stop being readable at all. */}

      {people.map((p, i) => { @@ -269,7 +269,7 @@ export default function Hall() { then the row is carrying a fact worth a second line, not a note saying there is nothing to show. */}

      - + {PROGRAMME_SHORT[p.programme]} {p.year} {p.org ? ( @@ -386,7 +386,7 @@ export default function Hall() { reading as an image that failed to load. */} {/* A plus at the same scale as Portrait's monogram, in the same container-query unit, so the glyph in this cell is the same size as diff --git a/web/components/hall/Roster.tsx b/web/components/hall/Roster.tsx index d61ec13..631921a 100644 --- a/web/components/hall/Roster.tsx +++ b/web/components/hall/Roster.tsx @@ -34,7 +34,7 @@ export default function Roster() { ); return ( -

      +

      Every selection

      @@ -95,7 +95,7 @@ export default function Roster() { {h} @@ -137,7 +137,7 @@ export default function Roster() { and a fifth column of two-character values would widen the table's min-width for very little. */} {s.studyYear && ( - + {s.studyYear} )} @@ -169,7 +169,7 @@ export default function Roster() {

      -

      +

      Programme names are trademarks of their respective organisations. Listing a selection is a statement of fact about our members, not an endorsement by{" "} {Object.values(PROGRAMME_NAME).slice(0, 3).join(", ")} or any other diff --git a/web/components/hero/Hero.tsx b/web/components/hero/Hero.tsx index a3ed60d..7f5e799 100644 --- a/web/components/hero/Hero.tsx +++ b/web/components/hero/Hero.tsx @@ -91,7 +91,7 @@ function FloatingBadges() { aria-hidden className="absolute -top-5 right-4 z-10 hidden animate-float lg:block" > - + 🟣 {merged.label} @@ -133,7 +133,7 @@ export default function Hero() { // Deliberately the utility rather than `.page-top` itself: that class is declared // after @tailwind utilities, so it would beat `lg:pt-40` at equal specificity and // silently flatten the large-screen air. See the note over .page-top. - className="section relative pb-10 pt-24 sm:pb-14 sm:pt-28 lg:pb-32 lg:pt-40" + className="section relative pb-10 pt-16 sm:pb-14 sm:pt-28 lg:pb-32 lg:pt-40" aria-label="Scaler Open Source Club" > {/* The ambient lighting. Two orbs rather than one, placed off the diagonal @@ -215,7 +215,7 @@ export default function Hero() { only where the type never wraps — and this wraps to two lines below about 1150px, which is most phones. At 0.95 "OPEN" and "SOURCE" stacked with the O's very nearly touching. */} -

      +

      Open Source

      diff --git a/web/components/hero/Terminal.tsx b/web/components/hero/Terminal.tsx index 1b6b207..0fca853 100644 --- a/web/components/hero/Terminal.tsx +++ b/web/components/hero/Terminal.tsx @@ -244,7 +244,7 @@ export default function Terminal() { ))} your-first-contribution — bash diff --git a/web/content/club.ts b/web/content/club.ts index f3c1060..50b1988 100644 --- a/web/content/club.ts +++ b/web/content/club.ts @@ -118,7 +118,7 @@ export const TRACKS: Track[] = [ name: { lead: "Mentored", trail: "contribution" }, summary: "Where almost everyone starts.", detail: - "A mentor who has already landed work upstream helps you pick a project that genuinely needs help, find an issue sized for a first attempt, and review the patch before a maintainer ever sees it. The goal is your second contribution — once you know a codebase, the next one is much faster.", + "A mentor who has already landed work upstream helps you pick a project that needs help, find an issue sized for a first attempt, and review the patch before a maintainer sees it. The goal is your second contribution.", tint: "blue", // "Beginner" rather than "Beginner friendly": three pills have to hold ONE line // at a third of an 80rem grid, and the longer phrase wrapped — which pushed this @@ -139,7 +139,7 @@ export const TRACKS: Track[] = [ name: { lead: "AI", trail: "security" }, summary: "Higher difficulty. The work most likely to get you noticed.", detail: - "Open-source AI tooling shipped fast and is now load-bearing. Members find real weaknesses — credentials committed into model configs, checkpoints that execute code on load, agent frameworks letting untrusted input reach a shell — and land the fix upstream through the project's own coordinated disclosure process.", + "Open-source AI tooling shipped fast and is now load-bearing. Members find real weaknesses — credentials in model configs, checkpoints that execute code on load, agent frameworks letting untrusted input reach a shell — and land the fix upstream.", tint: "mint", tags: ["Disclosure", "Model configs", "Harder"], preview: { @@ -638,7 +638,7 @@ export const PROGRAMMES: ProgrammeInfo[] = [ pays: "No stipend. Certificates, swag and a leaderboard — plus mentors, which is the part that is actually worth having.", weDo: - "Nothing to prepare. Register when it opens and pick a project in a language you can already run. Use it to learn the mechanics — fork, branch, PR, review, merge — so the paid programmes below are not your first time using Git in anger.", + "Nothing to prepare. Register when it opens and pick a project in a language you can already run. Use it to learn the mechanics — fork, branch, PR, review, merge — before the paid programmes below.", ours: "30+ students participated in GSSoC '26. Top contributor from SST: Bhumi N Deshpande.", url: "https://gssoc.girlscript.tech/", @@ -687,7 +687,7 @@ export const OPEN_ENTRY = PROGRAMMES.filter((p) => p.tier === "open"); export const OUTCOMES: { title: string; body: string }[] = [ { title: "A maintainer who knows your name", - body: "You spend a summer being reviewed by someone senior at a real project. They remember who ships. That relationship does not expire when the programme ends — it is the single most valuable thing here, and it is not the money.", + body: "You spend a summer being reviewed by someone senior at a real project. They remember who ships, and that relationship does not expire when the programme ends. It is worth more than the money.", }, { title: "A public record an employer can read", @@ -781,7 +781,7 @@ export const COMPARISON: Comparison[] = [ axis: "How many can win", cp: { stat: "3", - line: "Only one team from a given institution may advance to the World Finals. Three students, per college, per year. Ten Indian teams reached Baku in 2025 — thirty students, for the entire country. They earned every place; the door is simply that narrow by design.", + line: "Only one team per institution may advance to the World Finals: three students, per college, per year. Ten Indian teams reached Baku in 2025 — thirty students for the entire country. The door is that narrow by design.", sources: [ { label: "ICPC Regional Rules", @@ -860,7 +860,7 @@ export const COMPARISON: Comparison[] = [ line: "A metric on a held-out set. Objective and immediate, and indifferent to everything a number cannot see — including whether anybody but you can run the code.", }, osc: { - line: "A maintainer who has to read your patch, push back on it, and then live with it for years. The slowest signal of the three — a pull request can sit for three weeks — and the only one where a working engineer reviews your code the way your future colleagues will.", + line: "A maintainer who has to read your patch, push back on it, then live with it for years. The slowest of the three, and the only one where a working engineer reviews you the way a colleague will.", }, }, { diff --git a/web/content/essence.ts b/web/content/essence.ts index 70f4d52..3e9617b 100644 --- a/web/content/essence.ts +++ b/web/content/essence.ts @@ -112,7 +112,7 @@ export const WHAT_IT_IS: { title: string; body: string }[] = [ export const MAINTAINERS: { title: string; body: string }[] = [ { title: "Often not paid for it", - body: "The person who reviews your first pull request is frequently doing it in the evening, after the job that does pay them, because they care about the project. This is the single most useful thing to understand before you open an issue.", + body: "The person who reviews your first pull request is often doing it in the evening, after the job that does pay them, because they care about the project.", }, { // The original draft of this said the census was run "because nobody could say who @@ -120,7 +120,7 @@ export const MAINTAINERS: { title: string; body: string }[] = [ // which packages are most used, not who maintains them — so the claim now matches // the source, and the inference that follows is marked as an inference. title: "Nobody had even mapped it", - body: "The Linux Foundation, the OpenSSF and Harvard's Laboratory for Innovation Science ran a census just to establish which open-source packages the world's software actually depends on. That question needed a research project to answer, which tells you how little of this is centrally organised — nobody is in charge of making sure it keeps working.", + body: "The Linux Foundation, the OpenSSF and Harvard ran a census just to establish which packages the world's software depends on. That the question needed research tells you nobody is in charge of keeping it working.", }, { title: "Which is why review feels slow", @@ -223,20 +223,20 @@ export const GLOSSARY: { term: string; meaning: string }[] = [ export const IMPACT: { title: string; body: string; aside?: string }[] = [ { title: "Your GitHub stops being empty", - body: "Right now it holds semester projects nobody asked for. After one merged pull request it holds a change that a maintainer of a real project read, argued about, and accepted. Those are not the same artefact, and anyone technical can tell the difference in about nine seconds.", + body: "Right now it holds semester projects nobody asked for. After one merged pull request it holds a change a real maintainer read, argued about and accepted. Anyone technical can tell the difference in nine seconds.", }, { title: "Engineers at global companies review your code, for free", - body: "The person reviewing your patch to a Kubernetes-adjacent project may well do that work at Google or Red Hat. You do not have to get hired there first to have them read your code and tell you why it is wrong — which is, bluntly, better feedback than most of us get in a semester.", + body: "The person reviewing your patch to a Kubernetes-adjacent project may well do that work at Google or Red Hat. You do not have to be hired there to have them read your code and say why it is wrong.", }, { title: "It is the most honest signal you can send a recruiter", - body: "A certificate says you attended. A CGPA says you did well at exams somebody else set. A merged pull request says a stranger with no reason to be kind to you looked at your work and let it into software other people depend on. That one cannot be bought, padded, or group-projected.", + body: "A certificate says you attended. A CGPA says you passed exams somebody else set. A merged pull request says a stranger with no reason to be kind let your work into software other people depend on.", aside: "It is also harder to fake than anything else on a resume, which is exactly why it counts.", }, { title: "Some of it pays, in your second year", - body: "Google Summer of Code, LFX Mentorship and Outreachy pay stipends to people with no professional experience. Not a competition prize — a stipend, for spending a summer being mentored on a real codebase. Most students never apply because nobody told them it existed.", + body: "Google Summer of Code, LFX Mentorship and Outreachy pay stipends to people with no professional experience — for a summer being mentored on a real codebase. Most students never apply because nobody told them.", }, ]; @@ -282,7 +282,7 @@ export const POSITIONING: Claim[] = [ }, }, { - line: "There is no rule capping how many people from your college can get code merged into Kubernetes. Competitive programming is a sport with a fixed number of podium places. Open source is a backlog with an unbounded number of open issues.", + line: "No rule caps how many people from your college get code merged into Kubernetes. Competitive programming is a sport with a fixed number of podium places. Open source is an unbounded backlog.", }, { stat: "8.4%", @@ -290,7 +290,7 @@ export const POSITIONING: Claim[] = [ // statistics post — which is what we link — says 1,280. Citing one figure while // linking a source stating another is the exact failure this section exists to // avoid, so the number matches the page it points at. - line: "GSoC accepted 1,280 people from 15,240 applicants in 2025. This is not the soft option and we will not pretend it is. The difference is what you are left holding if you do not get in — a rating graph, or commits with your name on them.", + line: "GSoC accepted 1,280 of 15,240 applicants in 2025 — not the soft option. The difference is what you are left holding if you do not get in: a rating graph, or commits with your name on them.", source: { label: "Google Open Source Blog", url: "https://opensource.googleblog.com/2025/08/google-summer-of-code-2025-contributor-statistics.html", diff --git a/web/lib/applications.ts b/web/lib/applications.ts deleted file mode 100644 index 985094a..0000000 --- a/web/lib/applications.ts +++ /dev/null @@ -1,131 +0,0 @@ -// THE APPLICATION: one anonymous submission, fired once into applications/{id}. -// -// SEPARATE FROM lib/profile.ts ON PURPOSE, and the separation is the whole point of -// this file rather than an accident of history. They look similar — near-identical -// field lists, the same closed sets — and the temptation is to share one module and -// one form. What makes them different is not the shape of the data but WHO IS ASKING: -// -// an application is written by a stranger. There is no uid, no verified address and -// nothing to prove ownership with, so it is create-only, immutable, -// and validated entirely by firestore.rules on the way in. -// a profile is written by a signed-in member against their own uid, and can be -// read back and edited for as long as they are a member. -// -// Collapsing those two into one code path is what produced the state this replaced, -// where you could not apply at all without first having a college Google account — -// the club's front door required the key you get by walking through it. -// -// APPLYING DOES NOT REQUIRE SIGN-IN AND MUST NEVER START TO. If a future change wants -// the applicant's email verified, the answer is a mail loop or an organiser reading the -// row, not an auth gate: the site's own headline promises the reader they need nothing -// but a laptop and a GitHub account, and a Google-account wall on the apply form makes -// that sentence false at the exact moment somebody acts on it. -// -// NOBODY CAN READ THIS COLLECTION FROM THE CLIENT, including admins — see the match -// block in firestore.rules. Organisers read applications in the Firebase console. That -// is a deliberate floor rather than a missing feature: these rows hold names, addresses -// and hostels belonging to people who are not members yet and never agreed to appear -// in anything, so the smallest surface that still lets the club act on them is the -// right one. If an organisers' view is ever wanted, it needs its own admin-only read -// rule and a decision about retention, not a loosened `allow read`. - -import { APPLICATIONS, getDb, isConfigured } from "@/lib/firebase"; - -/** Everything the form collects. Field names are the form's own, so a stored document - * reads the same as the markup that produced it, and `isWellFormedApplication` in - * firestore.rules validates this exact shape — `npm run rules` keeps the two honest. */ -export type Application = { - name: string; - /** Asked, unlike on a profile, because there is no signed-in account to take it from. - * NOT restricted to the college domain: somebody applying from a personal address is - * an applicant to talk to, not a forgery to reject, and the domain rule belongs on - * membership rather than on the act of asking. */ - email: string; - year_branch: string; - hostel: string; - level: string; - path: string; - programs: string[]; - programs_other?: string; - github?: string; -}; - -/** Distinguishes "we gave up waiting" from "Firestore said no", because the two need - * different words in front of an applicant — see the race in `submitApplication`. A - * named class rather than a string match on the message, so it cannot be confused with - * a Firebase error that happens to mention time. */ -export class TimeoutError extends Error { - constructor() { - super("Timed out waiting for the application store"); - this.name = "TimeoutError"; - } -} - -/** Thrown when there is no Firebase project to write to. Separate from every other - * failure because it is not a failure of the applicant's or of the network — it is - * this deployment not being wired up, which needs different words and no retry. */ -export class NotConfiguredError extends Error { - constructor() { - super("Firebase is not configured"); - this.name = "NotConfiguredError"; - } -} - -/** How long to wait before telling the applicant we could not confirm the write. - * - * 12s is chosen to be longer than a slow-but-working submit on campus wifi and short - * enough that nobody assumes the page is broken. A rejected permission or a validation - * failure still arrives in well under a second and takes the caller's catch instead. */ -const TIMEOUT_MS = 12_000; - -/** Submit one application. Resolves when Firestore has confirmed the write. - * - * Throws `NotConfiguredError` when this deployment has no Firebase project, - * `TimeoutError` when the write could not be confirmed, and whatever Firestore threw - * otherwise. The caller is expected to say something different for each. */ -export async function submitApplication(data: Application): Promise { - // Checked before touching the SDK so an unconfigured deployment fails instantly and - // by name, rather than after a dynamic import and a queued write that never settles. - if (!isConfigured()) throw new NotConfiguredError(); - - const db = await getDb(); - // Belt and braces: isConfigured() already returned true, so this is only reachable - // if the SDK import itself failed. - if (!db) throw new NotConfiguredError(); - - const { addDoc, collection, serverTimestamp } = await import("firebase/firestore"); - - const doc: Record = { - name: data.name, - email: data.email, - year_branch: data.year_branch, - hostel: data.hostel, - level: data.level, - path: data.path, - programs: data.programs, - // The SERVER's clock. The rules require `submitted_at == request.time`, so a - // client-supplied Date is rejected — which is the point: submission order cannot be - // forged even though every other field here is supplied by a stranger. - submitted_at: serverTimestamp(), - }; - - // Optional fields are OMITTED rather than written empty, matching the - // present-or-absent shape the rules allow. An absent `github` then means "not given" - // rather than "gave an empty string", and the stored shape stays predictable for - // whoever reads these rows later. - if (data.github) doc.github = data.github; - // Only ever present when Other is ticked, because that is the only state in which the - // input exists to be read. The rules enforce the pairing in BOTH directions, so a - // hand-rolled SDK call cannot send one without the other. - if (data.programs_other) doc.programs_other = data.programs_other; - - // RACED AGAINST A TIMEOUT, because addDoc does not reject when the backend is - // unreachable — it queues the write and retries the channel indefinitely. Found by - // pointing the client at a project that does not exist: six retries went out, the - // promise never settled, and the button said "Sending…" forever with no message. An - // applicant would sit there, then leave. - await Promise.race([ - addDoc(collection(db, APPLICATIONS), doc), - new Promise((_, reject) => setTimeout(() => reject(new TimeoutError()), TIMEOUT_MS)), - ]); -} diff --git a/web/lib/mentorship.ts b/web/lib/mentorship.ts index d1d0dfb..a3e6bbe 100644 --- a/web/lib/mentorship.ts +++ b/web/lib/mentorship.ts @@ -214,7 +214,53 @@ export async function withdrawEnrollment(uid: string): Promise { await deleteDoc(doc(db, ENROLLMENTS, uid)); } -/** Every enrollment. Admins only — the rules refuse a list to anybody else. */ +/** How many members have enrolled, without reading them. One read, not one per member. */ +export async function countEnrollments(): Promise { + const db = await getDb(); + if (!db) throw new Error("Firebase is not configured"); + const { collection, getCountFromServer } = await import("firebase/firestore"); + return (await getCountFromServer(collection(db, ENROLLMENTS))).data().count; +} + +/** Demand per mentor, counted on the server. + * + * THIS IS THE ONE THAT SCALES. `pickCounts` below does the same arithmetic over an array + * the caller has already read — fine when the array is in memory for another reason, and + * ruinous as a reason to read 500 enrollments on every dashboard load. Two aggregate + * queries per mentor is 20 reads for ten mentors and stays 20 reads at ten thousand + * members, because an aggregate is billed on the size of its result. + * + * It is also what the delete guard needs: AdminMentors must know whether ANYBODY picked + * a mentor before offering to delete them, and that is a count, not a list. + * + * Every mentor is counted in parallel. Ten mentors is twenty round trips issued at once, + * not twenty in sequence. */ +export async function countDemand( + mentorIds: string[], +): Promise> { + const db = await getDb(); + if (!db) throw new Error("Firebase is not configured"); + const { collection, getCountFromServer, query, where } = await import("firebase/firestore"); + const col = collection(db, ENROLLMENTS); + + const rows = await Promise.all( + mentorIds.map(async (id) => { + const [first, second] = await Promise.all([ + getCountFromServer(query(col, where("mentor_1", "==", id))), + getCountFromServer(query(col, where("mentor_2", "==", id))), + ]); + const f = first.data().count; + const s = second.data().count; + return [id, { first: f, second: s, total: f + s }] as const; + }), + ); + return new Map(rows); +} + +/** Every enrollment, in one go. One read each. + * + * NOT CALLED ON PAGE LOAD. The interest list pages through `readEnrollmentPage`; this + * backs the CSV export and the batch breakdown, which need every row by definition. */ export async function readAllEnrollments(): Promise { const db = await getDb(); if (!db) throw new Error("Firebase is not configured"); @@ -225,6 +271,32 @@ export async function readAllEnrollments(): Promise { return snap.docs.map((d) => ({ ...(d.data() as Enrollment), uid: d.id })); } +/** One page of enrollments, newest first. Same snapshot-cursor reasoning as + * readProfilePage — see the note there about ties on created_at. */ +export async function readEnrollmentPage( + pageSize = 25, + cursor: unknown = null, +): Promise<{ rows: Enrollment[]; cursor: unknown; more: boolean }> { + const db = await getDb(); + if (!db) throw new Error("Firebase is not configured"); + const { collection, getDocs, limit, orderBy, query, startAfter } = await import( + "firebase/firestore" + ); + const parts = [collection(db, ENROLLMENTS), orderBy("created_at", "desc")] as const; + const q = cursor + ? query(...parts, startAfter(cursor as never), limit(pageSize + 1)) + : query(...parts, limit(pageSize + 1)); + const snap = await getDocs(q); + + const more = snap.docs.length > pageSize; + const docs = more ? snap.docs.slice(0, pageSize) : snap.docs; + return { + rows: docs.map((d) => ({ ...(d.data() as Enrollment), uid: d.id })), + cursor: docs.length ? docs[docs.length - 1] : null, + more, + }; +} + // ----------------------------------------------------------------------- shared /** How many members picked each mentor, in each position. diff --git a/web/lib/profile.ts b/web/lib/profile.ts index 44ef7d1..384d089 100644 --- a/web/lib/profile.ts +++ b/web/lib/profile.ts @@ -219,19 +219,177 @@ export async function setMembership( ); } -/** Read every profile. Admins only — the rules refuse a list to anybody else. +// READING THE MEMBERSHIP, AND WHAT IT COSTS. +// +// This used to be one function that read every profile on every dashboard load, and the +// comment defending it said the club was a few hundred people and pagination would be +// machinery with no user. That was true and is no longer: at 1,000 members one page load +// was ~1,000 reads, every Refresh was another 1,000, and about 33 of them would exhaust +// 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. +// +// So the page now pays for what it actually shows: +// +// countProfiles() 1 read per 1,000 documents. Aggregates are billed on the size +// of the RESULT, not the scan, so the headline count is ~1 read. +// readProfilePage() one page of rows, 25 reads. +// readAllProfiles() still here, still a full scan — but nothing calls it on load. +// The breakdowns, the CSV export and search-across-everybody need +// every document by definition, so they are behind a control that +// says what it will cost. +// +// WHY THE BREAKDOWNS CANNOT BE AGGREGATED AWAY. Batch, branch and year are derived from +// the address by lib/batch.ts and are not fields — which is what makes them impossible to +// forge, and also what makes them impossible to query. `where('batch','==',...)` has +// nothing to match. That trade was made deliberately and is written up in FIREBASE.md; +// this is the bill for it. Hostel and path COULD be counted with aggregates, but a +// breakdown where three of five rows need a full scan anyway would be reading everything +// regardless, so they ride along. + +/** How many members there are, without reading them. * - * Unpaginated on purpose: the club is a few hundred people, one read per member per - * dashboard load, against a free quota of 50,000 reads a day. Paginating that would be - * machinery with no user. If the club ever passes a few thousand members this needs - * revisiting, and the dashboard says so on screen rather than degrading quietly. */ -export async function readAllProfiles(): Promise { + * `getCountFromServer` is billed at one read per 1,000 documents counted, so this is one + * read for the whole club rather than one per member. */ +export async function countProfiles(): Promise { const db = await getDb(); if (!db) throw new Error("Firebase is not configured"); - const { collection, getDocs, orderBy, query } = await import("firebase/firestore"); + const { collection, getCountFromServer } = await import("firebase/firestore"); + return (await getCountFromServer(collection(db, USERS))).data().count; +} + +/** How many members joined in a window. One read, whatever the answer is. + * + * This is what the eight-week trend is built from: eight of these is eight reads, where + * computing the same chart from the documents was one read per member. `end` is + * exclusive so consecutive buckets cannot both claim a profile written on the boundary. */ +export async function countProfilesBetween(start: Date, end: Date): Promise { + const db = await getDb(); + if (!db) throw new Error("Firebase is not configured"); + const { collection, getCountFromServer, query, where } = await import("firebase/firestore"); + return ( + await getCountFromServer( + query( + collection(db, USERS), + where("created_at", ">=", start), + where("created_at", "<", end), + ), + ) + ).data().count; +} + +/** How many members gave a GitHub handle. + * + * `> ""` rather than `!= null`, and the difference matters: an optional field is OMITTED + * when not given rather than written empty (see saveProfile), and Firestore excludes + * documents missing the field from any inequality. So this counts exactly the profiles + * that have a non-empty handle, which is the question being asked. */ +export async function countProfilesWithGithub(): Promise { + const db = await getDb(); + if (!db) throw new Error("Firebase is not configured"); + const { collection, getCountFromServer, query, where } = await import("firebase/firestore"); + return ( + await getCountFromServer(query(collection(db, USERS), where("github", ">", ""))) + ).data().count; +} + +/** An opaque cursor. It is really a QueryDocumentSnapshot, and it is deliberately not + * typed as one: callers pass it back and never look inside it, and threading Firestore's + * types through the components is how a "no Firebase import outside lib/" rule dies. */ +export type Cursor = unknown; + +export type ProfilePage = { + rows: Profile[]; + /** null when there is nothing after this page. */ + cursor: Cursor | null; + /** False when the last page has been reached, so the caller can hide "Load more" + * rather than offering a button that returns nothing. */ + more: boolean; +}; + +/** One page of members, newest first. + * + * THE CURSOR IS A SNAPSHOT, NOT A TIMESTAMP. `startAfter(lastCreatedAt)` looks simpler + * and silently skips rows whenever two profiles share a created_at — which happens + * whenever two people finish the form in the same second, i.e. exactly during a + * build day. A snapshot cursor is positional and cannot tie. */ +export async function readProfilePage( + pageSize = 25, + cursor: Cursor | null = null, +): Promise { + const db = await getDb(); + if (!db) throw new Error("Firebase is not configured"); + const { collection, getDocs, limit, orderBy, query, startAfter } = await import( + "firebase/firestore" + ); // Ordered newest first. Members who predate created_at would be dropped by this // orderBy, which is acceptable only because the field has existed since the first // profile ever written — there are no such rows. + // + // One extra row is fetched and then discarded: it is how you know whether a next page + // exists without a second query, and it costs one read rather than a round trip. + const parts = [collection(db, USERS), orderBy("created_at", "desc")] as const; + const q = cursor + ? query(...parts, startAfter(cursor as never), limit(pageSize + 1)) + : query(...parts, limit(pageSize + 1)); + const snap = await getDocs(q); + + const more = snap.docs.length > pageSize; + const docs = more ? snap.docs.slice(0, pageSize) : snap.docs; + return { + rows: docs.map((d) => ({ ...(d.data() as Profile), uid: d.id })), + cursor: docs.length ? docs[docs.length - 1] : null, + more, + }; +} + +/** The profiles for a specific set of uids, in as few queries as possible. + * + * WHY THIS EXISTS. The organisers' interest list is a join: one row per enrollment, but + * the NAME on that row lives on the member's profile. Done naively that is either a + * getDoc per row — 25 round trips for a page — or a full scan of the membership to build + * a lookup, which is what it used to do and what costs one read per member. + * + * `documentId() in [...]` fetches them in one query per chunk instead, and the reads are + * exactly the documents wanted. THIRTY IS FIRESTORE'S LIMIT for an `in` clause, not a + * round number picked here — a page of 25 fits in one query, and the chunking is for + * callers that ask for more. + * + * Missing uids are simply absent from the map. An enrollment whose member has no profile + * is a real possibility (the rules do not couple the two collections) and the caller + * renders it rather than dropping the row — an enrolment nobody can see is the worst + * outcome here. */ +export async function readProfilesByIds(uids: string[]): Promise> { + const out = new Map(); + const wanted = [...new Set(uids)].filter(Boolean); + if (wanted.length === 0) return out; + + const db = await getDb(); + if (!db) throw new Error("Firebase is not configured"); + const { collection, documentId, getDocs, query, where } = await import("firebase/firestore"); + + const chunks: string[][] = []; + for (let i = 0; i < wanted.length; i += 30) chunks.push(wanted.slice(i, i + 30)); + + await Promise.all( + chunks.map(async (chunk) => { + const snap = await getDocs( + query(collection(db, USERS), where(documentId(), "in", chunk)), + ); + for (const d of snap.docs) out.set(d.id, { ...(d.data() as Profile), uid: d.id }); + }), + ); + return out; +} + +/** Every profile, in one go. One read per member. + * + * NOT CALLED ON PAGE LOAD ANY MORE. It backs the breakdowns, the CSV export and + * search-across-the-whole-club — all of which genuinely need every document — and the + * dashboard states the cost before spending it. */ +export async function readAllProfiles(): Promise { + const db = await getDb(); + if (!db) throw new Error("Firebase is not configured"); + const { collection, getDocs, orderBy, query } = await import("firebase/firestore"); const snap = await getDocs(query(collection(db, USERS), orderBy("created_at", "desc"))); return snap.docs.map((d) => ({ ...(d.data() as Profile), uid: d.id })); } diff --git a/web/lib/security-headers.js b/web/lib/security-headers.js index 9b66f4b..c76b6ef 100644 --- a/web/lib/security-headers.js +++ b/web/lib/security-headers.js @@ -131,10 +131,20 @@ function buildCSP({ dev = isDev, authDomain = "", projectId = "" } = {}) { /** The full header set as {key, value} pairs, in the shape next.config.js wants. * `dev` is a parameter rather than read from the environment so the .htaccess - * generator can force the production policy regardless of how it was invoked. */ -function securityHeaders({ dev = isDev, authDomain = "" } = {}) { + * generator can force the production policy regardless of how it was invoked. + * + * `projectId` WAS MISSING FROM THIS SIGNATURE, and buildCSP takes it. Both generators + * passed it in — scripts/hosting-config.mjs and scripts/htaccess.mjs, each with a comment + * explaining that it is what stops connect-src falling back to a `*.cloudfunctions.net` + * wildcard — and an object rest parameter drops what it does not name, silently, so the + * value went nowhere and the wildcard shipped anyway. Nothing failed: the policy is + * looser, not broken, so no check and no page could notice. + * + * Anything buildCSP learns to take has to be added here in the same commit, or it is + * ignored in exactly this way. */ +function securityHeaders({ dev = isDev, authDomain = "", projectId = "" } = {}) { return [ - { key: "Content-Security-Policy", value: buildCSP({ dev, authDomain }) }, + { key: "Content-Security-Policy", value: buildCSP({ dev, authDomain, projectId }) }, // Two years, subdomains included. Safe here: the domain serves only this site and // there is no plaintext service to break. { diff --git a/web/package.json b/web/package.json index e7d2dfb..4a4dfb0 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,7 @@ "palette": "node scripts/palette.mjs", "rules": "node scripts/rules.mjs", "rules:emulator": "node scripts/rules-emulator.mjs", - "build:static": "node scripts/preflight-deploy.mjs && STATIC_EXPORT=1 next build && node scripts/htaccess.mjs && node scripts/hosting-config.mjs", + "build:static": "node scripts/preflight-deploy.mjs && STATIC_EXPORT=1 next build && node scripts/assert-no-dev-login.mjs && node scripts/htaccess.mjs && node scripts/hosting-config.mjs", "e2e:auth": "node scripts/e2e-auth.mjs", "e2e:mentorship": "node scripts/e2e-mentorship.mjs", "team:sync": "node scripts/team-roster.mjs" diff --git a/web/scripts/assert-no-dev-login.mjs b/web/scripts/assert-no-dev-login.mjs new file mode 100644 index 0000000..eb17e48 --- /dev/null +++ b/web/scripts/assert-no-dev-login.mjs @@ -0,0 +1,88 @@ +// Refuse to ship a build containing the emulator dev login. +// +// components/dev/DevLogin.tsx signs you in as a test member or a test organiser by creating +// the account against the Auth emulator's admin API and writing `admins/{email}` with an +// owner token. It is fenced three ways — a render guard on NEXT_PUBLIC_FIRESTORE_EMULATOR, +// a build-time slot that compiles to null in production, and a deploy preflight that +// refuses an emulator-configured build — and none of that is worth anything unless somebody +// checks the output. +// +// WHICH IS THE POINT, because the fence leaked the first time it was built. The slot +// originally keyed on `process.env.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 ships as its own chunk. It rendered nothing and it +// was all there to read, `Bearer owner` included. The fix was to key on NODE_ENV; this +// script is what would have caught the mistake, and what will catch the next one. +// +// It greps the built site rather than reasoning about the graph, because "is this string in +// the artefact we are about to upload" is the only question that actually matters and it is +// the one question a bundler change cannot quietly re-answer. +// +// Runs in `npm run build:static`, after next build. Milliseconds on a few hundred files. + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const OUT = join(here, "..", "out"); + +/** Strings that exist ONLY in the dev login, chosen so a match is unambiguous. + * + * Each is a literal in components/dev/DevLogin.tsx, and each would survive minification — + * a minifier renames identifiers and folds expressions but does not rewrite string + * contents. Do not add anything generic here: a false positive on a deploy is expensive, + * and the way this check dies is somebody disabling it after it cried wolf. */ +const FORBIDDEN = [ + "Local development only", + "dev-password-emulator-only", + "dev.23bcs10045@sst.scaler.com", + "Bearer owner", +]; + +if (!statSync(OUT, { throwIfNoEntry: false })?.isDirectory()) { + console.error( + `\n No build output at ${OUT}. This runs after next build, inside build:static.\n`, + ); + process.exit(1); +} + +/** Every file in out/, flat. Not just .js: the strings would be just as visible baked into + * a prerendered .html or a source map, and those are uploaded too. */ +function files(dir) { + const out = []; + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) out.push(...files(p)); + else out.push(p); + } + return out; +} + +const hits = []; +for (const f of files(OUT)) { + let text; + try { + text = readFileSync(f, "utf8"); + } catch { + continue; // a binary asset; nothing to match + } + for (const needle of FORBIDDEN) { + if (text.includes(needle)) hits.push([f.slice(OUT.length + 1), needle]); + } +} + +if (hits.length) { + console.error("\n REFUSING TO SHIP: the emulator dev login is in the build output.\n"); + for (const [file, needle] of hits) console.error(` - ${file} contains ${JSON.stringify(needle)}`); + console.error( + "\n components/dev/DevLoginSlot.tsx is supposed to compile to a null component in a\n" + + " production build, so none of DevLogin.tsx should be reachable. Check that its\n" + + " condition is still something the bundler can fold to a literal — NEXT_PUBLIC_*\n" + + " variables cannot be, when they are empty, which is how this leaked before.\n", + ); + process.exit(1); +} + +console.log(`\n no dev login in the build output — checked ${FORBIDDEN.length} markers\n`); diff --git a/web/scripts/assert-site.mjs b/web/scripts/assert-site.mjs index 0548cad..5b95acf 100644 --- a/web/scripts/assert-site.mjs +++ b/web/scripts/assert-site.mjs @@ -48,6 +48,11 @@ export const ROUTES = [ // "the dashboard works"; it means the door to it is not broken. { path: "/onboarding", name: "onboarding", inNav: false, app: true }, { path: "/dashboard", name: "dashboard", inNav: false, app: true }, + // THE SIGNED-IN AREA IS SEVERAL ROUTES NOW, and each one's signed-out state is what a + // stranger who guesses the URL sees — so each has to meet the same contrast, tap-target + // and overflow bar as everything else. Swept signed out, like the two above. + { path: "/dashboard/mentorship", name: "dash-mentorship", inNav: false, app: true }, + { path: "/dashboard/details", name: "dash-details", inNav: false, app: true }, ]; const MARKER = "Scaler Open Source Club"; diff --git a/web/scripts/browsers.mjs b/web/scripts/browsers.mjs index 0a1f4ff..b363bcd 100644 --- a/web/scripts/browsers.mjs +++ b/web/scripts/browsers.mjs @@ -154,17 +154,32 @@ for (const [name, engine] of Object.entries(ENGINES)) { }; }); - // The nav fade, checked at a width where it must be active. + // THE PHONE NAV. This used to check that the link strip scrolled and that the fade + // marking it as scrollable was applied. Both were true and the arrangement was still + // broken: measured at 390px the strip was a 187px window onto 407px of links, so four + // of six destinations sat off-screen behind that fade, reachable only by dragging a + // strip most readers never think to drag. + // + // The strip is md+ now and a disclosure button takes its place below that, so what + // this asserts is the thing that actually matters on a phone: every route is + // reachable, and the control that reaches them is a real touch target. await pg.setViewportSize({ width: 390, height: 844 }); await pg.waitForTimeout(400); + // The click and the measurement are separate steps because React renders the panel + // on the next tick — reading the DOM in the same evaluate that clicks reports zero + // links, which looks like the menu is empty rather than like the check is early. + await pg.locator('button[aria-controls="nav-menu"]').click().catch(() => {}); + await pg.waitForTimeout(200); const fade = await pg.evaluate(() => { - const ul = document.querySelector('nav[aria-label="Main"] ul'); - if (!ul) return null; - const cs = getComputedStyle(ul); - const mask = cs.maskImage && cs.maskImage !== "none" ? cs.maskImage : cs.webkitMaskImage; + const btn = document.querySelector('button[aria-controls="nav-menu"]'); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + const panel = document.getElementById("nav-menu"); + const strip = document.querySelector('nav[aria-label="Main"] ul'); return { - scrollable: ul.scrollWidth > ul.clientWidth + 1, - applied: !!mask && mask !== "none" && mask.includes("gradient"), + target: Math.min(Math.round(r.width), Math.round(r.height)), + links: panel ? panel.querySelectorAll("a").length : 0, + stripHidden: !strip || getComputedStyle(strip).display === "none", }; }); await pg.setViewportSize({ width: 1440, height: 900 }); @@ -214,12 +229,13 @@ for (const [name, engine] of Object.entries(ENGINES)) { : hall.emptyPanel, ); line( - "nav fade at 390px", - fade ? `scrollable=${fade.scrollable} applied=${fade.applied}` : "MISSING", - // Only meaningful while the strip actually overflows; if it does, the fade must - // be there, because it is the only thing telling a phone user there are more - // pages. - fade ? !fade.scrollable || fade.applied : false, + "nav menu at 390px", + fade + ? `target=${fade.target}px links=${fade.links} strip-hidden=${fade.stripHidden}` + : "MISSING", + // 6 routes + Sign in + GitHub, behind a control at or above the 44px touch floor, + // and the horizontal strip out of the way so it cannot hide anything. + fade ? fade.target >= 44 && fade.links >= 8 && fade.stripHidden : false, ); if (errs.length) { if (counts) failures++; console.log(` ${counts ? "FAIL" : "warn"} page errors: ${errs.slice(0,2).join(" | ")}`); } await b.close(); diff --git a/web/scripts/e2e-auth.mjs b/web/scripts/e2e-auth.mjs index 1c26ff8..bca452b 100644 --- a/web/scripts/e2e-auth.mjs +++ b/web/scripts/e2e-auth.mjs @@ -153,12 +153,30 @@ for (const url of [FS, AUTH]) await fetch(url, { method: "DELETE" }).catch(() => } } +/** Addresses the Auth emulator currently holds. The clearing block above uses the same + * endpoint; this hoists it so settle() can ask whether a sign-in actually landed. */ +async function authAccounts() { + try { + const r = await fetch( + `http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/projects/${PROJECT}/accounts:query`, + { method: "POST", headers: OWNER, body: "{}" }, + ); + return ((await r.json())?.userInfo ?? []).map((u) => (u.email ?? "").toLowerCase()); + } catch { + return []; + } +} + /** Sign in through the emulator's popup as a brand-new account. */ async function signIn(pg, email, name) { const [pop] = await Promise.all([ pg.waitForEvent("popup", { timeout: 30000 }), pg.getByRole("button", { name: /continue with google/i }).click(), ]); + const popErrs = []; + pop.on("console", (m) => { if (m.type() === "error") popErrs.push(m.text().slice(0, 160)); }); + pop.on("pageerror", (e) => popErrs.push("pageerror: " + String(e).slice(0, 160))); + pop.__errs = popErrs; await pop.waitForLoadState("domcontentloaded"); // A DOM CLICK, AND SCROLLED INTO VIEW FIRST. The emulator's picker lists every account // created earlier in the run, so by the time the organiser signs in "Add new account" @@ -176,80 +194,107 @@ async function signIn(pg, email, name) { }); if (!added) throw new Error("emulator picker: could not find 'Add new account'"); await pop.waitForTimeout(700); - await pop.locator("#email-input").fill(email); - await pop.locator("#display-name-input").fill(name); - await settle(pop, email, () => - pop.evaluate(() => { - const b = [...document.querySelectorAll("button")].find((n) => - /sign in with google/i.test(n.innerText), - ); - b?.click(); - return Boolean(b); - }), - ); + // pressSequentially rather than fill: real keystrokes fire an input event per character, + // which removes any question of whether Angular's model has caught up before the form is + // submitted. It costs milliseconds on a thirty-character address. + await pop.locator("#email-input").pressSequentially(email, { delay: 5 }); + await pop.locator("#display-name-input").pressSequentially(name, { delay: 5 }); + await pop.locator("#display-name-input").blur().catch(() => {}); + await pop.waitForTimeout(400); + await settle(pop, email); await pg.waitForTimeout(2500); } -/** Press the emulator's submit until the popup actually goes away. +/** Get the emulator's sign-in form to actually submit, and fail loudly if it will not. + * + * THE EMULATOR'S POPUP IS THE LEAST RELIABLE THING THIS SUITE TOUCHES, and it has now + * cost three misdiagnosed runs, each at a different sign-in. What is actually going on: + * the form is Angular, and a click dispatched in the same tick as the field fill can land + * before the model has updated. The button is NOT disabled when this happens — that was + * checked — so the click is delivered to a live control and the form simply does not + * submit. No error, nothing in the console; the popup just sits there. * - * ONE CLICK IS NOT ENOUGH, and this is not paranoia. The emulator's form is Angular: - * Playwright's fill() updates the model, but a click dispatched in the same tick can - * land before the form is valid, and the button then does nothing at all — no error, no - * navigation, the form simply still sitting there. It failed about one run in three, and - * always on the third or later sign-in, which is what made it look like a problem with - * whoever was signing in rather than with the timing. + * So rather than one way of pressing it, this tries three, rotating per attempt: a DOM + * click (which survives the Material ripple overlay that swallows synthetic ones), a real + * Playwright click (genuine pointer events, which the DOM click does not produce), and + * Enter in the form (submits without needing the button at all). * - * Retrying is safe: once the popup has gone, `isClosed()` is true and the loop stops, and - * a click on a form that already submitted has nothing to hit. + * Retrying is safe: once the popup has gone `isClosed()` is true and the loop stops, and + * pressing a form that already submitted has nothing to hit. * * It throws with what the popup was SHOWING rather than "never closed", because the * latter sends you reading the wrong file — this suite's whole design principle. */ -async function settle(pop, email, press) { +async function settle(pop, email) { + const strategies = [ + () => + pop.evaluate(() => { + const b = [...document.querySelectorAll("button")].find((n) => + /sign in with google/i.test(n.innerText), + ); + b?.click(); + return Boolean(b); + }), + () => + pop + .getByRole("button", { name: /sign in with google/i }) + .click({ timeout: 3000, force: true }) + .then(() => true), + () => pop.locator("#email-input").press("Enter").then(() => true), + ]; + + // THE POPUP CLOSING IS A SIDE EFFECT, NOT THE THING BEING WAITED FOR. What matters is + // whether the sign-in landed, and the Auth emulator can be asked that directly. Under + // load the account was being created a second or two after the press while the window + // sat there, so a close-only wait failed a run that had in fact succeeded — the state + // dump proved it: field filled, button enabled, no error, form simply still open. + // + // So each attempt races three outcomes: the window closes, the account appears, or the + // wait expires and the next press strategy is tried. for (let i = 0; i < 6; i++) { if (pop.isClosed()) return; - const found = await press().catch(() => false); - if (i === 0 && !found) throw new Error(`emulator popup: no submit control for ${email}`); - const closed = await pop - .waitForEvent("close", { timeout: 5000 }) - .then(() => true) - .catch(() => false); - if (closed || pop.isClosed()) return; + const pressed = await strategies[i % strategies.length]().catch(() => false); + if (i === 0 && !pressed) throw new Error(`emulator popup: no submit control for ${email}`); + + for (let waited = 0; waited < 8000; waited += 500) { + if (pop.isClosed()) return; + if ((await authAccounts()).includes(email.toLowerCase())) { + // Landed. The window is cosmetic from here; close it so the next sign-in in this + // run does not inherit a stray popup. + await pop.close().catch(() => {}); + return; + } + await new Promise((r) => setTimeout(r, 500)); + } } - const where = pop.url(); - const what = await pop.evaluate(() => document.body.innerText.slice(0, 200)).catch(() => "?"); + + // WHAT THE FORM ACTUALLY THINKS, not just what it looks like. "Never closed" sent me + // reading the click strategies three times when the question was whether the field had + // a value in it at all. + const state = await pop + .evaluate(() => { + const b = [...document.querySelectorAll("button")].find((n) => + /sign in with google/i.test(n.innerText), + ); + const inputs = [...document.querySelectorAll("input")].map( + (i) => `${i.id || i.name || i.type}="${i.value}"`, + ); + return { + button: b ? `disabled=${b.disabled}` : "NOT FOUND", + inputs: inputs.join(" "), + error: document.body.innerText.match(/error|invalid|required/i)?.[0] ?? "none", + }; + }) + .catch(() => ({ button: "?", inputs: "?", error: "?" })); throw new Error( - `emulator popup never closed for ${email}\n at ${where}\n showing: ${what.replace(/\n+/g, " / ")}`, + `emulator popup never closed for ${email}\n` + + ` popup console: ${(pop.__errs ?? []).slice(0, 3).join(" | ") || "no errors"}\n` + + ` button: ${state.button}\n` + + ` inputs: ${state.inputs}\n` + + ` error text: ${state.error}\n` + + ` at ${pop.url()}`, ); } -// THERE IS NO "SIGN IN AGAIN AS AN EXISTING ACCOUNT" HELPER, and that is a decision worth -// recording because it looks like an omission. -// -// The member appears twice in this suite: once to join, and again later to enrol in -// mentorship after an organiser has published a mentor. The obvious way to write that is -// a second sign-in that picks the existing account out of the emulator's chooser. It does -// not work reliably: the chooser renders each account as a nest of divs with the click -// handler somewhere up the tree, and neither a Playwright click nor a dispatched one on -// the leaf reliably selects a row — six attempts, still sitting on the picker. -// -// So the member's page is simply KEPT OPEN between the two blocks instead, which is both -// more reliable and closer to what actually happens: a student does not sign in twice in -// an afternoon, they come back to a tab that is still signed in. - -// WARM THE ROUTES UP BEFORE MEASURING ANYTHING. `next dev` compiles a route the first -// time it is requested, and this suite is usually the first thing that has ever asked for -// /onboarding or /dashboard — so the very first client-side redirect into one of them -// took longer than the assertion waiting for it, and the run failed claiming sign-in had -// not redirected. It had; the destination was still being built. -// -// The tell was that the same run passed on a second attempt with no code change, which is -// the signature of a warm-up problem rather than a real one. Requesting each route once, -// up front, moves that cost outside the measurements. It costs a second and it is the -// difference between a suite you trust and one you re-run. -for (const r of ["/", "/join", "/onboarding", "/dashboard", "/admin", "/privacy"]) { - await fetch(BASE + r).catch(() => {}); -} - const browser = await chromium.launch(); const csp = []; const errs = []; @@ -263,6 +308,9 @@ const ADMIN_MAIL = "organiser@sst.scaler.com"; * helpers. The member joins, the organiser publishes a mentor, the member comes back to * enrol, and the organiser looks at who picked whom: four blocks, two sessions. */ let adminPg = null; +/** The organiser signs in on its own browser process — see the note where it is created. + * Hoisted so the teardown at the foot of the file can close it. */ +let adminBrowser = null; let memberPg = null; console.log("\nthe join flow, driven in a real browser\n"); @@ -293,41 +341,6 @@ console.log("-- a student signs up --"); ok("the sign-in button spans the card", bw / cw > 0.75, `${Math.round(bw)}px in ${cw}px`); } - // THE WORKING STATE, ASSERTED MID-FLIGHT. It exists for about a second and it is the - // difference between a reader waiting and a reader clicking again — and clicking again - // is how you get auth/cancelled-popup-request, which then looks like a broken button. - { - const btn = pg.locator("#apply button.btn-primary"); - const popping = pg.waitForEvent("popup", { timeout: 15000 }); - await btn.click(); - await pg.waitForTimeout(150); - ok("the button says what is happening while it happens", - /redirecting to google/i.test(await btn.innerText())); - // NOT "and cannot be pressed twice". It deliberately can: Firebase takes five to - // seven seconds to notice a closed popup, so a button disabled for the duration is - // a dead control at exactly the moment somebody wants to pick another account. - ok("and stays pressable, so a closed chooser is not a dead end", - !(await btn.isDisabled())); - ok("while still announcing itself as busy", - (await btn.getAttribute("aria-busy")) === "true"); - ok("with a spinner, not only a label", - (await btn.locator("svg.animate-spin").count()) === 1); - // Closing the chooser must hand the card back. A `busy` that is set on click and - // only cleared on success leaves the one control on the page disabled forever, and - // the reader's only way out is a reload. - const pop = await popping.catch(() => null); - await pop?.close(); - await pg.waitForTimeout(1200); - ok("and pressing it again reopens the chooser rather than doing nothing", - await (async () => { - const again = pg.waitForEvent("popup", { timeout: 12000 }); - await btn.click(); - const p2 = await again.catch(() => null); - await p2?.close(); - return Boolean(p2); - })()); - } - // The three links in the card's footer. Google will not publish an OAuth consent // screen without a reachable privacy policy, so these are a release requirement and // not decoration — and a sign-in card with dead links is worse than one with none. @@ -523,7 +536,13 @@ console.log("\n-- an organiser opens the dashboard --"); body: JSON.stringify({ fields: { added_by: { stringValue: "console" } } }), }); - const pg = await (await browser.newContext({ viewport: { width: 1440, height: 1800 } })).newPage(); + // A FRESH BROWSER, not just a fresh context. The emulator's popup handler becomes + // unresponsive after a couple of successful sign-ins in one browser — the form fills, + // the button is enabled, the clicks land, no console error appears, and the account is + // simply never created. Contexts are already isolated and did not help; a new browser + // process does. + adminBrowser = await chromium.launch(); + const pg = await (await adminBrowser.newContext({ viewport: { width: 1440, height: 1800 } })).newPage(); // Held open: the member enrols in the next block, and this same page is then refreshed // to check the interest list. Closed at the very end. adminPg = pg; @@ -544,11 +563,20 @@ console.log("\n-- an organiser opens the dashboard --"); await pg.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" }); await pg.waitForTimeout(4000); // innerText is the RENDERED text and .label uppercases via CSS, so compare lowercased. - const t = (await pg.locator("main").innerText()).toLowerCase(); + let t = (await pg.locator("main").innerText()).toLowerCase(); ok("the dashboard renders for an admin", !t.includes("not for you")); ok("it counts the membership", /registered members\s*1\b/.test(t), t.match(/registered members\s*\d+/)?.[0] ?? ""); + // THE BREAKDOWNS ARE BEHIND A PRESS NOW, and asserting that is the point: they are the + // only figures on the page that cannot be counted in the database, because batch and + // branch are read out of the address rather than stored. Reading every member to draw + // them on every load is what used to exhaust the daily quota. const heads = ["by batch", "by year", "by branch", "by hostel", "by route in"]; - ok("all five breakdowns render", heads.every((h) => t.includes(h))); + ok("the breakdowns are not drawn until asked for", !heads.every((h) => t.includes(h))); + await pg.getByRole("button", { name: /load all \d+ members/i }).first().click(); + await pg.waitForTimeout(3000); + t = (await pg.locator("main").innerText()).toLowerCase(); + ok("and all five render once loaded", heads.every((h) => t.includes(h)), + heads.filter((h) => !t.includes(h)).join(", ")); ok("the member is listed", t.includes(MEMBER_MAIL)); // THE REPLACEMENT FOR THE TWO REGEXES. Batch, branch and year used to be guessed from a // free-text box, with an "Unparsed" bucket for whatever did not match. They are read @@ -559,7 +587,10 @@ console.log("\n-- an organiser opens the dashboard --"); await pg.getByLabel("Search members").fill("nobody"); await pg.waitForTimeout(600); const filtered = (await pg.locator("main").innerText()).toLowerCase(); - ok("search narrows the table", filtered.includes("no member matches")); + // After the full scan above, the table covers the whole club, so the empty state is the + // plain one. Before a scan it says "no LOADED member matches — load the rest", which is + // the honest version when search can only see the pages fetched so far. + ok("search narrows the table", /no (loaded )?member matches/.test(filtered), filtered.slice(-90)); ok("but the counts do not move with the filter", /registered members\s*1\b/.test(filtered)); await pg.getByLabel("Search members").fill(""); @@ -626,7 +657,7 @@ console.log("\n-- an organiser opens the dashboard --"); await pg.getByLabel("Filter by hostel").selectOption("uniworld-1"); await pg.waitForTimeout(500); ok("two filters combine rather than replace", - /no member matches/i.test(await pg.locator("main").innerText())); + /no (loaded )?member matches/i.test(await pg.locator("main").innerText())); await pg.getByRole("button", { name: /^clear 2$/i }).click(); await pg.waitForTimeout(500); @@ -805,12 +836,34 @@ console.log("\n-- the organiser sees who picked whom --"); const pg = adminPg; await pg.goto(`${BASE}/admin`, { waitUntil: "domcontentloaded" }); await pg.waitForTimeout(4500); - const t = await pg.locator("main").innerText(); - const lower = t.toLowerCase(); + let t = await pg.locator("main").innerText(); + let lower = t.toLowerCase(); + + // WHAT THE PAGE COSTS TO OPEN, asserted before anything is loaded. The counts and the + // demand charts come from aggregate queries — one read each, flat as the club grows — + // while the breakdowns and the interest list need every document and wait behind a + // press. This block used to read the whole membership on load; the assertion that it no + // longer does is the point of the change. + ok("the dashboard opens without reading every member", + /load all \d+ members/i.test(t), t.match(/Load all \d+ members/i)?.[0] ?? "no scan button"); + ok("and without reading every enrolment", /load the interest list/i.test(t)); + ok("the counts are live anyway, from aggregates", + /students enrolled\s*1\b/.test(lower), lower.match(/students enrolled\s*\d+/)?.[0] ?? ""); + ok("and so is the demand per mentor", lower.includes("first preferences")); + + // NOW load it. Three assertions below used to pass against the MEMBERS table at the top + // of the page — "Asha V Verma" and both mentor names appear there too — so they were + // green while the interest list rendered nothing at all. Scoping them to the panel's own + // table is what stops that recurring. + await pg.getByRole("button", { name: /load the interest list/i }).click(); + await pg.waitForTimeout(4000); + t = await pg.locator("main").innerText(); + lower = t.toLowerCase(); - ok("the interest list names the student", t.includes("Asha V Verma")); - ok("with the batch read from their address", /2023–27/.test(t)); - ok("and both preferences", /priya nair/.test(lower) && /arjun rao/.test(lower)); + const panelText = await pg.locator("table").last().innerText(); + ok("the interest list names the student", panelText.includes("Asha V Verma"), panelText.slice(0, 80)); + ok("with the batch read from their address", /2023–27/.test(panelText)); + ok("and both preferences", /priya nair/i.test(panelText) && /arjun rao/i.test(panelText)); ok("the enrolled count is stated", /students enrolled\s*1\b/.test(lower), lower.match(/students enrolled\s*\d+/)?.[0] ?? ""); ok("the published mentor count is stated", /mentors published\s*3\b/.test(lower), lower.match(/mentors published\s*\d+/)?.[0] ?? ""); ok("first preferences are counted", lower.includes("first preferences")); @@ -842,10 +895,77 @@ console.log("\n-- the organiser sees who picked whom --"); await pg.close(); } +console.log("\n-- the sign-in button's working state --"); +// LAST, AND THAT PLACEMENT IS THE FIX. These assertions deliberately open a chooser and +// abandon it, twice. That leaves the AUTH EMULATOR holding a pending handler session, and +// the next signInWithPopup — in any context, in any page, even after a reload — is then +// swallowed: the form fills, the button is enabled, no error appears, and the account is +// never created. Reproduced in isolation, and a separate browser context does NOT fix it, +// which is what proves the state is server-side rather than in the profile. +// +// So nothing that needs to sign in may run after this block. It measures a real behaviour +// worth keeping — the button must stay pressable, because Firebase takes five to seven +// seconds to notice a closed window and a disabled control there is a dead end — so it +// moves rather than goes. +// +// WHETHER REAL GOOGLE BEHAVES THIS WAY IS UNVERIFIED from here. It maps onto a real path: +// close the chooser twice, then sign in properly. +{ + // THE WORKING STATE, ASSERTED MID-FLIGHT. It exists for about a second and it is the + // difference between a reader waiting and a reader clicking again — and clicking again + // is how you get auth/cancelled-popup-request, which then looks like a broken button. + // + // IN ITS OWN BROWSER CONTEXT, and that is the fix for the worst flakiness in this file. + // These assertions deliberately open a chooser and abandon it, twice. signInWithPopup + // does not reject promptly when a window closes — five to seven seconds, measured — so + // the page is left holding a pending attempt, and the next call on the same context is + // swallowed without opening anything. One racy assertion was failing six later ones that + // had nothing wrong with them, and no amount of waiting fixed it reliably. + // + // A throwaway context cannot leak into the real sign-in below, because it is discarded. + const churn = await browser.newContext({ viewport: { width: 1440, height: 1600 } }); + const cp = await churn.newPage(); + await cp.goto(`${BASE}/join?path=program-track`, { waitUntil: "networkidle" }); + await cp.waitForTimeout(1200); + + const btn = cp.locator("#apply button.btn-primary"); + const popping = cp.waitForEvent("popup", { timeout: 20000 }); + await btn.click(); + await cp.waitForTimeout(150); + ok("the button says what is happening while it happens", + /redirecting to google/i.test(await btn.innerText())); + // NOT "and cannot be pressed twice". It deliberately can: Firebase takes five to + // seven seconds to notice a closed popup, so a button disabled for the duration is + // a dead control at exactly the moment somebody wants to pick another account. + ok("and stays pressable, so a closed chooser is not a dead end", !(await btn.isDisabled())); + ok("while still announcing itself as busy", (await btn.getAttribute("aria-busy")) === "true"); + ok("with a spinner, not only a label", (await btn.locator("svg.animate-spin").count()) === 1); + + // Closing the chooser must hand the card back. A `busy` that is set on click and only + // cleared on success leaves the one control on the page disabled forever, and the + // reader's only way out is a reload. + const pop = await popping.catch(() => null); + await pop?.close(); + // Wait for Firebase to actually notice, or the next click is cancelled rather than + // reopening — which is the behaviour under test, not a flake to paper over. + await cp.waitForTimeout(8000); + ok("and pressing it again reopens the chooser rather than doing nothing", + await (async () => { + const again = cp.waitForEvent("popup", { timeout: 20000 }); + await btn.click(); + const p2 = await again.catch(() => null); + await p2?.close(); + return Boolean(p2); + })()); + + await churn.close(); +} + ok("no CSP violations anywhere in the flow", csp.length === 0, csp.slice(0, 2).join(" | ")); ok("no uncaught page errors", errs.length === 0, errs.slice(0, 2).join(" | ")); await browser.close(); +await adminBrowser?.close().catch(() => {}); console.log( fail === 0 ? `\n ${pass} passed. Sign-in, onboarding, the dashboard, mentor publishing and enrolment all work.\n` diff --git a/web/scripts/e2e-mentorship.mjs b/web/scripts/e2e-mentorship.mjs index a00d29d..85037cd 100644 --- a/web/scripts/e2e-mentorship.mjs +++ b/web/scripts/e2e-mentorship.mjs @@ -102,6 +102,37 @@ async function seedAdmin(email) { * ALWAYS FROM /dashboard. /admin renders a per-panel "not for you" state rather than a * sign-in card, so there is no button there to start from — an organiser signs in like * anybody else and then navigates. */ +/** Addresses the Auth emulator currently holds — the only reliable signal that a sign-in + * landed. The popup closing is a side effect and, under load, a late one. */ +async function authAccounts() { + try { + const r = await fetch( + `http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/projects/${PROJECT}/accounts:query`, + { method: "POST", headers: { Authorization: "Bearer owner", "Content-Type": "application/json" }, body: "{}" }, + ); + return ((await r.json())?.userInfo ?? []).map((u) => (u.email ?? "").toLowerCase()); + } catch { + return []; + } +} + +/** Sign in through the emulator's popup, and do not return until the account exists. + * + * THE PREVIOUS VERSION FILLED THE FORM, CLICKED ONCE, AND WAITED FOUR SECONDS. That works + * for the first identity in a run and reliably fails for the second: the emulator's form + * is Angular, and a click dispatched in the same tick as the fill lands before the model + * has updated. The button is NOT disabled when this happens, so the click is delivered to + * a live control and nothing submits — no error, nothing in the console, the popup just + * sits there. + * + * It failed silently in the worst possible way here: the member never signed in, so + * /dashboard rendered the sign-in card, and five assertions about the mentorship section + * failed as though the section were missing. Four consecutive runs created an organiser + * account and no member account at all. + * + * So: real keystrokes rather than fill(), the submit pressed repeatedly, and the loop + * exits on the ACCOUNT APPEARING rather than on a timer. Ported from e2e-auth.mjs, which + * hit the same thing. */ async function signIn(ctx, pg, email, name) { await pg.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded", timeout: 60000 }); await pg.waitForTimeout(1500); @@ -109,30 +140,48 @@ async function signIn(ctx, pg, email, name) { await pg.getByRole("button", { name: /continue with google/i }).click(); const pop = await popupP; await pop.waitForLoadState("domcontentloaded"); - await pop.waitForTimeout(600); + await pop.waitForTimeout(800); + // A DOM click, scrolled into view: the emulator's picker lists every account made - // earlier in the run, so "Add new account" drifts below the fold. - const added = await pop.evaluate(() => { + // earlier in the run, so "Add new account" drifts below the fold. Absent on a fresh + // emulator, where the add form is shown directly — so a miss is not an error. + await pop.evaluate(() => { const el = [...document.querySelectorAll("button, a, [role=button]")].find((n) => /add new account/i.test(n.innerText || ""), ); - if (!el) return false; - el.scrollIntoView({ block: "center" }); - el.click(); - return true; + el?.scrollIntoView({ block: "center" }); + el?.click(); }); - if (!added) throw new Error("emulator picker: could not find 'Add new account'"); + await pop.waitForTimeout(900); + + await pop.locator("#email-input").pressSequentially(email, { delay: 8 }); + await pop.locator("#display-name-input").pressSequentially(name, { delay: 8 }); await pop.waitForTimeout(700); - await pop.locator("#email-input").fill(email); - await pop.locator("#display-name-input").fill(name); - await pop.evaluate(() => { - [...document.querySelectorAll("button")] - .find((n) => /sign in with google/i.test(n.innerText)) - ?.click(); - }); - await pg.waitForTimeout(4000); + + const wanted = email.toLowerCase(); + for (let i = 0; i < 10 && !pop.isClosed(); i++) { + await pop + .evaluate(() => { + const b = [...document.querySelectorAll("button")].find((n) => + /sign in with google/i.test(n.innerText), + ); + b?.click(); + }) + .catch(() => {}); + for (let w = 0; w < 8; w++) { + await new Promise((r) => setTimeout(r, 500)); + if ((await authAccounts()).includes(wanted) || pop.isClosed()) break; + } + if ((await authAccounts()).includes(wanted)) break; + } + if (!pop.isClosed()) await pop.close().catch(() => {}); + if (!(await authAccounts()).includes(wanted)) { + throw new Error(`emulator never created an account for ${email} — the popup did not submit`); + } + await pg.waitForTimeout(3000); } + await up(`${DOCS}/mentors`, "Firestore"); await up(AUTH_CLEAR, "Auth"); await fetch(FS_CLEAR, { method: "DELETE" }).catch(() => {}); @@ -178,7 +227,18 @@ ok("it is active, so the picker will offer it", mentors[0]?.fields?.active?.bool ok("no uncaught errors on the admin page", orgErrs.length === 0, orgErrs.join(" | ")); console.log("\n-- a member enrols --"); -const ctxB = await browser.newContext({ viewport: { width: 1400, height: 1100 } }); +// A SECOND BROWSER PROCESS, not a second context, and that is not tidiness. The Auth +// emulator's popup handler stops responding after the first successful sign-in in a +// browser: the form fills, the button is enabled, the clicks land, no console error +// appears, and the account is simply never created. Contexts are already isolated and do +// NOT fix it; a new browser does. Diagnosed at length in scripts/e2e-auth.mjs, which +// gives its organiser the same treatment. +// +// The symptom here was especially misleading: the member never signed in, so /dashboard +// rendered the sign-in card, and five assertions about the mentorship section failed as +// though the section were missing. +const memberBrowser = await chromium.launch({ args: ["--disable-popup-blocking"] }); +const ctxB = await memberBrowser.newContext({ viewport: { width: 1400, height: 1100 } }); const mem = await ctxB.newPage(); const memErrs = []; mem.on("pageerror", (e) => memErrs.push(e.message.slice(0, 120))); @@ -187,7 +247,31 @@ mem.on("console", (m) => { }); await signIn(ctxB, mem, MEMBER, "Test Member"); + +// FINISHING THE PROFILE IS A GATE NOW, so this suite has to walk through it rather than +// landing on /dashboard cold. A member with an incomplete profile is sent to /onboarding +// and the dashboard renders nothing until they are done — which is the point of the gate, +// and would otherwise show up here as "the Mentorship section is missing". await mem.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded", timeout: 60000 }); +// WAIT FOR THE REDIRECT, NOT FOR "either URL". The page starts on /dashboard and moves +// once the profile read comes back, so a pattern matching /dashboard was satisfied by the +// starting state and the check ran before the gate had fired — green or red depending on +// how fast Firestore answered. +await mem.waitForURL(/\/onboarding/, { timeout: 25000 }).catch(() => {}); +if (/\/onboarding/.test(mem.url())) { + ok("a first-time member is sent to finish joining", true, mem.url()); + await mem.fill("#pf-name", "Test Member").catch(() => {}); + await mem.check('input[name="hostel"][value="uniworld-1"]').catch(() => {}); + await mem.getByRole("button", { name: /finish joining/i }).click().catch(() => {}); + await mem.waitForURL(/\/dashboard/, { timeout: 30000 }).catch(() => {}); +} else { + ok("a first-time member is sent to finish joining", false, `stayed on ${mem.url()}`); +} + +// MENTORSHIP IS ITS OWN ROUTE NOW. It was the last panel on the overview, below four +// weekly panels — buried, and mixed in with things that change every week when it is a +// decision made once a term. +await mem.goto(`${BASE}/dashboard/mentorship`, { waitUntil: "domcontentloaded", timeout: 60000 }); await mem.waitForTimeout(5000); // REGRESSION 1: the section has to be on the page at all. @@ -245,6 +329,7 @@ if (enrolments.length) { ok("no uncaught errors on the dashboard", memErrs.length === 0, memErrs.join(" | ")); await browser.close(); +await memberBrowser.close(); console.log( fail === 0 ? `\n ${pass} passed. An organiser can publish a mentor and a member can enrol.\n` diff --git a/web/scripts/htaccess.mjs b/web/scripts/htaccess.mjs index 4e22e6b..d7c5326 100644 --- a/web/scripts/htaccess.mjs +++ b/web/scripts/htaccess.mjs @@ -34,7 +34,22 @@ if (!existsSync(OUT)) { process.exit(1); } -const headers = securityHeaders({ dev: false }); +// READ .env.local, NOT process.env, and for the same reason scripts/hosting-config.mjs +// does: this runs as a plain node process, so nothing has inlined NEXT_PUBLIC_* anywhere it +// can see. Without the two values below the policy falls back to `*.firebaseapp.com` in +// frame-src and `*.cloudfunctions.net` in connect-src — both functional, both wider than a +// deployment that knows its own project needs, on a policy whose whole argument is that it +// is strict. This file was passing neither, so the .htaccess and the firebase.json carried +// different policies for the same site. +const ENV = join(here, "..", ".env.local"); +const envFile = existsSync(ENV) ? readFileSync(ENV, "utf8") : ""; +const fromEnvFile = (k) => (envFile.match(new RegExp(`^${k}=(.*)$`, "m"))?.[1] ?? "").trim(); + +const headers = securityHeaders({ + dev: false, + authDomain: fromEnvFile("NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN"), + projectId: fromEnvFile("NEXT_PUBLIC_FIREBASE_PROJECT_ID"), +}); // Apache needs the value quoted, and none of our values contain a double quote — assert // that rather than assume it, because a stray quote would silently truncate a policy and diff --git a/web/scripts/qa.mjs b/web/scripts/qa.mjs index 1c71b51..8e42fba 100644 --- a/web/scripts/qa.mjs +++ b/web/scripts/qa.mjs @@ -172,11 +172,20 @@ for (const vp of VIEWPORTS) { } // 2. Text too small to read comfortably on a phone. + // + // THE FLOOR IS 12, WHICH IS WHAT THIS FILE'S HEADER ALWAYS CLAIMED. It had been + // lowered to 11 at some point, and the site's smallest text was 11.0004px — + // passing by four ten-thousandths of a pixel. So this check reported clean on a + // site whose body copy was 13.5px and whose eyebrows were 11px, and "0 issues + // across 96 combinations" was taken as evidence the typography was fine. + // + // A floor moved to fit the thing it is measuring is not a floor. If this fails, + // the answer is to raise the type, not to lower this number again. for (const el of document.querySelectorAll("p,span,li,a,dd,dt,td,th")) { const t = (el.textContent || "").trim(); if (!t || el.children.length) continue; const size = parseFloat(getComputedStyle(el).fontSize); - if (size && size < 11) add("tiny-text", `${size}px "${t.slice(0, 34)}"`, el); + if (size && size < 12) add("tiny-text", `${size}px "${t.slice(0, 34)}"`, el); } // 3. Tap targets. 44px is the accessibility floor for a touch device. @@ -371,6 +380,26 @@ for (const vp of VIEWPORTS) { // Cheap: there are only ever a handful, and it converts a silent false pass // into a real number. The marker fills most of its own box behind short text, // so the modal colour in that box IS the painted background. + // + // FIXED OVERLAYS COME OUT FIRST, AND WITHOUT THIS THE PASS MEASURES THE WRONG + // THING. A locator screenshot scrolls to its own target, so an element can land + // underneath the nav plate or the outline panel — both `position: fixed`, both + // frosted — and what gets captured is the element seen THROUGH them. + // + // It cost a real diagnosis: an orange sticky note reported 1.63:1 on "painted + // rgb(60,47,39)" in exactly one of eight combinations, `desktop+outline` in dark. + // The note is black on #fdba74, 12.4:1, and every other combination said so. The + // one that differed was the one with a frosted panel open in the corner the note + // scrolls into. A checker that reports a number this confidently has to be + // measuring the element and nothing in front of it. + // Removed again below, because the reference screenshot at the foot of this + // loop is meant to show the page as a reader sees it, nav and all. + const overlayMask = result.deferred.length + ? await page.addStyleTag({ + content: + "header, #page-outline { visibility: hidden !important }", + }) + : null; for (const d of result.deferred) { let png; try { @@ -407,6 +436,7 @@ for (const vp of VIEWPORTS) { }); } } + await overlayMask?.evaluate((el) => el.remove()); const tag = `${route.name}-${vp.name}-${theme}`; // fullPage, unlike before. A viewport-sized shot of a 6,000px page is evidence diff --git a/web/scripts/rules-emulator.mjs b/web/scripts/rules-emulator.mjs index 6f04929..e922435 100644 --- a/web/scripts/rules-emulator.mjs +++ b/web/scripts/rules-emulator.mjs @@ -1130,18 +1130,27 @@ const applicationFor = (over = {}) => ({ }); const withSubmitted = (d) => ({ ...d, submitted_at: serverTimestamp() }); -await check("a stranger applying", true, () => +// THE DOOR IS SHUT, AND THESE FOUR USED TO ASSERT IT WAS OPEN. Joining is sign-in only +// now: membership is an @sst.scaler.com address, which is the one thing an anonymous form +// could never check. /join renders the sign-in gate and lib/applications.ts is deleted, so +// nothing writes here at all. +// +// THE HISTORY IS WHY THESE ARE KEPT AS DENIALS RATHER THAN DELETED. A merge once closed +// this exact door while the form was still on the page, and every application in between +// was silently refused. If a form ever comes back, these four fail loudly and force the +// rule to move with it — which is the coupling that incident was missing. +await check("a stranger applying", false, () => setDoc(doc(stranger(), "applications", "app-1"), withSubmitted(applicationFor())), ); -await check("a stranger applying with no github", true, () => { +await check("a stranger applying with no github", false, () => { const d = applicationFor(); delete d.github; return setDoc(doc(stranger(), "applications", "app-2"), withSubmitted(d)); }); -await check("a signed-in member applying", true, () => +await check("a signed-in member applying", false, () => setDoc(doc(member(UID_A, MAIL_A), "applications", "app-3"), withSubmitted(applicationFor())), ); -await check("applying with Other ticked and explained", true, () => +await check("applying with Other ticked and explained", false, () => setDoc( doc(stranger(), "applications", "app-4"), withSubmitted(applicationFor({ programs: ["gsoc", "other"], programs_other: "Zephyr" })), @@ -1182,10 +1191,10 @@ await check("an application with an extra field", false, badApplication({ admin: await check("an application with no name", false, badApplication({ name: "" })); await check("an application with a malformed email", false, badApplication({ email: "not-an-email" })); await check("an application with no year or branch", false, badApplication({ year_branch: "" })); -// THE SET THAT HAD ALREADY DRIFTED. The rule recovered from git accepted none/some-git/ -// merged; the form has offered beginner/intermediate since upstream changed it. Deployed -// unexamined, it would have refused every real application while looking correct. -await check("the level the form actually sends", true, () => +// WAS "the level the form actually sends", asserting the rule accepted what the form +// offered. There is no form and no level field left in the rules; a well-formed +// application is refused for the same reason a malformed one is. +await check("a well-formed application is refused like any other", false, () => setDoc( doc(stranger(), "applications", "app-level"), withSubmitted(applicationFor({ level: "intermediate" })), diff --git a/web/scripts/rules.mjs b/web/scripts/rules.mjs index f334e1e..81f0843 100644 --- a/web/scripts/rules.mjs +++ b/web/scripts/rules.mjs @@ -18,7 +18,7 @@ // It is a pure text check with no Firebase dependency and no network, so it runs in // CI whether or not a Firebase project exists. -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -152,17 +152,21 @@ function contentKeys(exportName) { ); } -// EVERY OCCURRENCE OF EACH SET, not the first. `hostel` and `path` are written TWICE now — -// once on the member's profile and once on the anonymous application — exactly as -// `programme` is written twice below. A checker that read only the first copy would let -// the second drift, and the drift presents as a real applicant getting a permission error -// on a form that renders perfectly. +// EVERY OCCURRENCE OF EACH SET, not the first — `programme` below is still written twice, +// and a checker that read only the first copy would let the second drift into a permission +// error on a form that renders perfectly. +// +// THE COUNTS DROPPED WHEN THE APPLICATION FORM WENT. `hostel` and `path` were written +// twice, once on the member's profile and once on the anonymous application; that second +// copy is gone with the collection's validator. `level` and `programs` were +// application-only, so they are now written nowhere — they are not on the profile, which +// asks three questions and derives the rest from the address. Asserting 0 keeps that a +// decision rather than a thing that quietly came back. for (const [field, fromContent, expected] of [ - ["path", contentIds("PATHS"), 2], - ["hostel", contentValues("HOSTELS"), 2], - // Application-only, so far. - ["level", contentKeys("LEVEL_LABEL"), 1], - ["programs", contentValues("PROGRAMS"), 1], + ["path", contentIds("PATHS"), 1], + ["hostel", contentValues("HOSTELS"), 1], + ["level", contentKeys("LEVEL_LABEL"), 0], + ["programs", contentValues("PROGRAMS"), 0], ]) { const sets = rulesSets(field); ok(`${field} is a closed set everywhere it appears`, sets.length === expected, @@ -240,7 +244,10 @@ for (const konst of [ // // It also catches the quieter direction: a field ADDED to the form and not to the rules // means every application is refused, on a page that renders perfectly. -const applicationsLib = readFileSync(join(here, "..", "lib", "applications.ts"), "utf8"); +// lib/applications.ts IS GONE, along with the form that wrote to it and the validator +// that guarded it. What used to be here was a field-by-field parity check between that +// module's Application type and isWellFormedApplication's hasOnly list. There is nothing +// left for it to compare, and the collection is asserted sealed further down instead. /** The `hasOnly([...])` list inside a named rules function. */ function hasOnlyIn(fnName) { @@ -260,19 +267,6 @@ function typeFields(src, typeName) { return new Set([...m[1].matchAll(/^\s{2}(\w+)\??:/gm)].map((x) => x[1])); } -const appType = typeFields(applicationsLib, "Application"); -// submitted_at is added by submitApplication() rather than typed on the form's payload, -// so it is the one field the rules expect that the type does not carry. -const appExpected = appType ? new Set([...appType, "submitted_at"]) : null; -const appRules = hasOnlyIn("isWellFormedApplication"); -ok( - "the application's fields match lib/applications.ts", - same(appRules, appExpected), - same(appRules, appExpected) - ? `(${appRules.size} fields)` - : `rules=${show(appRules)} client=${show(appExpected)}`, -); - // ------------------------------------------------ closed sets outside join.ts // // Three more lists the rules hardcode, and none of them live in content/join.ts — they @@ -408,14 +402,21 @@ ok("applications stay unreadable from every client", /match \/applications\/\{id\}[\s\S]*?allow read: if false/.test(rules)); ok("applications cannot be edited once sent", /match \/applications\/\{id\}[\s\S]*?allow update, delete: if false/.test(rules)); -// THE ONLY UNAUTHENTICATED WRITE IN THE FILE, and the club's front door. A merge once -// closed it with a comment calling the collection legacy while /join still rendered the -// form that writes to it, so every application was silently refused. These two say the -// door is open AND that the validator is the thing holding it. -ok("a stranger may still apply", - /match \/applications\/\{id\}[\s\S]*?allow create: if isWellFormedApplication/.test(rules)); -ok("an application is validated field by field", - /function isWellFormedApplication\(d\)[\s\S]{0,200}hasOnly/.test(rules)); +// THERE IS NO LONGER AN UNAUTHENTICATED WRITE IN THIS FILE, and that is the assertion. +// Joining is sign-in only: membership is an @sst.scaler.com address, which is the one +// thing an anonymous form could not check. +// +// THE HISTORY MATTERS HERE. A merge once closed this exact door with a comment calling the +// collection legacy WHILE /join still rendered the form that wrote to it, and every +// application in between was silently refused. Closing it is only safe while nothing +// writes here — so if a form ever comes back, this assertion must fail and force the rule +// to move with it. That coupling is the point. +ok("no client may create an application any more", + /match \/applications\/\{id\}[\s\S]*?allow create: if false/.test(rules)); +ok("and the validator that guarded it is gone with it", + !/function isWellFormedApplication\(/.test(rules)); +ok("nothing in the app still writes applications", + !existsSync(join(here, "..", "lib", "applications.ts"))); ok("no test-mode wildcard write", !/allow read, write:\s*if true/.test(rules)); // FIELDS THAT WERE DELETED STAY DELETED, in both files or neither. `hasOnly` is strict, // so a field reintroduced to the form but not the rules means every save fails; the diff --git a/web/scripts/smoke.mjs b/web/scripts/smoke.mjs index 6cb2f3a..7c3b4df 100644 --- a/web/scripts/smoke.mjs +++ b/web/scripts/smoke.mjs @@ -201,35 +201,37 @@ await pg.waitForTimeout(700); // fine, which is how a team learns to ignore its own checks. The signed-in half of this // flow is still covered by scripts/e2e-auth.mjs (`npm run e2e:auth`), against the // emulators. +// JOINING IS SIGN-IN ONLY AGAIN, and these assertions moved with it rather than being +// deleted. The page held an anonymous application form for a spell; membership is an +// @sst.scaler.com address, which is the one thing that form could not check, so the door +// and the test are the same act now. ok( - "join renders the application form to a signed-out reader", - (await pg.evaluate(() => document.querySelectorAll("#af-name, #af-path").length)) === 2, + "join offers sign-in, not a form", + (await pg.evaluate(() => + /continue with google/i.test(document.querySelector("main")?.innerText ?? ""), + )), ); +// THE ASSERTION THAT WOULD CATCH A REGRESSION HERE. A form reappearing on this page is +// the specific thing this change removed, so its absence is checked rather than assumed — +// zero inputs of any kind in the main column. ok( - "join preselects the path from ?path", - (await pg.evaluate(() => document.querySelector("#af-path")?.value)) === "program-track", + "and no application fields survive on the page", + (await pg.evaluate( + () => document.querySelectorAll("main input, main select, main textarea").length, + )) === 0, ); ok( "join keeps ?path in the URL", new URL(pg.url()).searchParams.get("path") === "program-track", ); -// A HAND-EDITED ?path IS NOT TRUSTED. It is checked against the real PATHS, so a bogus -// one has to fall back to the empty option rather than being selected — a value the -// rules would refuse on submit, which presents to an applicant as a form that silently -// will not send. -await pg.goto(`${BASE}/join?path=nonsense-not-a-path`, { waitUntil: "networkidle" }); -await pg.waitForTimeout(400); -ok( - "join ignores a bogus ?path rather than selecting it", - (await pg.evaluate(() => document.querySelector("#af-path")?.value)) === "", -); -// THE WAY BACK IN, the one thing on this page that is not addressed to an applicant. A -// returning member who presses the nav's "Join" out of habit lands here, and this link -// is what stops them filling in a second application. It is deliberately quiet, which -// makes it the kind of thing a copy pass deletes without noticing — so it is asserted. +// THE DOMAIN RULE IS ON THE PAGE, not just in the rules. It is the whole membership test +// and the one sentence a reader cannot afford to skim past, so a copy pass that removed it +// would leave people signing in with a personal Gmail and being refused with no warning. ok( - "join offers a member the way back in", - (await pg.evaluate(() => document.querySelectorAll('main a[href="/dashboard"]').length)) === 1, + "and states the one address that can register", + (await pg.evaluate(() => + /sst\.scaler\.com/i.test(document.querySelector("main")?.innerText ?? ""), + )), ); // The form must NOT be reachable without signing in. This is a UI assertion, not a // security one — the boundary is firestore.rules — but a form rendering to a signed-out diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index 2d16038..7901312 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -129,8 +129,22 @@ const config: Config = { // type would change at the two extremes and hold across the middle of the // range, which is most desktop widths. The offset has to ride the interpolated // term to be a real 2px everywhere rather than at the endpoints only. - "display-xl": ["clamp(2.75rem, 4.65vw, 5.25rem)", { lineHeight: "1.12", letterSpacing: "-0.015em" }], - "display-lg": ["clamp(1.9375rem, 2.7vw, 2.9375rem)", { lineHeight: "1.22", letterSpacing: "-0.003em" }], + // THE vw TERMS ARE BACK AT THEIR FULL VALUE. They were multiplied by 0.75 to + // ride the `html { font-size: 75% }` that used to sit in globals.css, so that + // headings shrank across the middle of the viewport range along with the rem + // ends. That root is gone — it was making the whole site render at three + // quarters of the sizes measured below — so a 0.75 vw term would now hold the + // OLD size across most desktop widths while the ends grew, which is the exact + // failure the note above describes, in reverse. + // ONE ELEMENT ON THE SITE WEARS THIS: the home page hero, which is two words. + // The four sub-page mastheads used to as well, and at a 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 and the standfirst below and nothing else visible. They take + // display-lg now. A step called xl that everything uses is not a step. + "display-xl": ["clamp(2.75rem, 6.2vw, 5.25rem)", { lineHeight: "1.12", letterSpacing: "-0.015em" }], + "display-lg": ["clamp(1.9375rem, 3.6vw, 2.9375rem)", { lineHeight: "1.22", letterSpacing: "-0.003em" }], // Apple's tracking is POSITIVE below roughly 40px. Measured off // apple.com/mac: 80px/-1.2px (-0.015em), 48px/-0.144px (-0.003em), then it // crosses zero — 32px/+0.128px (+0.004em), 28px/+0.196px (+0.007em), @@ -138,39 +152,75 @@ const config: Config = { // below the hero was being over-tightened. Optical sizing runs the other // way at text sizes: large type needs closing up, small type needs opening // out, and copying the display value downward is the usual mistake. - "display-md": ["clamp(1.375rem, 1.575vw, 1.8125rem)", { lineHeight: "1.32", letterSpacing: "0.006em" }], + "display-md": ["clamp(1.375rem, 2.1vw, 1.8125rem)", { lineHeight: "1.32", letterSpacing: "0.006em" }], // Body copy gets the same treatment for a different reason: 1.5 is the WCAG // 1.4.8 floor for a block of text, not a comfortable value, and this page's // paragraphs run to a 44em measure. Long lines need more leading than short // ones to stop the eye returning to the line it just left. - "body-lg": ["clamp(1.1875rem, 1.2vw, 1.5rem)", { lineHeight: "1.62", letterSpacing: "0.008em" }], + // + // THE TWO BODY STEPS CAME DOWN A NOTCH — 1.72 to 1.6, and 1.62 to 1.5 — when + // the root went back to 16px. Those ratios were set against 13.5px and 17.3px + // text, where generous leading is what keeps small type readable. At 18px and + // 24px the same ratio is 31px and 39px of line box, which reads as gappy + // rather than airy and put a third of the home page's height into the gaps + // between lines. Leading is relative to size; a ratio tuned at one size does + // not survive a third being added to it. + "body-lg": ["clamp(1.1875rem, 1.6vw, 1.5rem)", { lineHeight: "1.5", letterSpacing: "0.008em" }], // 17px — Apple's body size, and the reference the tracking values above were // measured from. It spent a while at 19px and is back. The tracking was // deliberately NOT re-derived when it went up and is not re-derived now that // it has come down: optical sizing moves in fractions of an em across a 2px // step, and re-measuring one step of a scale that was taken from a single // source is how the halves of it start disagreeing. - "body": ["1.0625rem", { lineHeight: "1.72", letterSpacing: "0.009em" }], - "label": ["0.6875rem", { lineHeight: "1.3", letterSpacing: "0.18em" }], - // Tailwind's own `sm`, overridden rather than left at its 0.875rem/1.25rem - // default. 17 of its 22 uses here are sans — card body copy, form help text, - // the FAQ answers — so it has the same short-lowercase problem as `body` and - // needs the same correction. The lineHeight has to be restated: Tailwind's - // default pairs a FIXED 1.25rem with this step, which at the new size would - // compute to 1.33 and come out tighter than the value it replaced. - "sm": ["0.9375rem", { lineHeight: "1.6" }], - // `xs` is back at Tailwind's own 0.75rem and stays STATED rather than deleted, - // which is not redundancy. The size is only half of what this step declares: - // the leading is a RATIO here, where Tailwind's default pairs a fixed 1rem - // with it. The ratio is what the default pair described at 12px, and stating - // it is what keeps the step from silently retightening if the size ever moves - // again — which is precisely what the +2px pass would have done to it, since - // 1rem on a 14px glyph is 1.14 and that is a 12px step's leading. - "xs": ["0.75rem", { lineHeight: "1.3333" }], + "body": ["1.125rem", { lineHeight: "1.6", letterSpacing: "0.009em" }], + // THE BOTTOM THREE STEPS ARE RE-CUT, and it is a spacing fix rather than a + // resize. They were 0.9167 / 0.9583 / 1.0625rem — 14.7, 15.3 and 17px — three + // steps inside 2.3px, which is not a hierarchy anybody can see. Worse, the gap + // they left at 1rem was filled by hand: `text-sm` is the single most common + // type utility in the codebase, 74 uses, a ninth size with no token. + // + // 13 / 14 / 16 / 18 gives four steps a reader can actually tell apart, and it + // puts a token exactly where those 74 hand-written uses already are. + // + // THE TRACKING IS ALSO A MERGE. `label` carried 0.18em here while `.label` in + // globals.css carried 0.07em — the same name, two values, both in use, because + // the CSS class and the Tailwind token were written separately. 0.12em is one + // value for one name: still clearly letterspaced small caps, without the gappiness + // 0.18em gave a 13px glyph. + "label": ["0.8125rem", { lineHeight: "1.3", letterSpacing: "0.12em" }], + // 16px. The workhorse: card body copy, form help text, FAQ answers, most UI + // labels. It overrides Tailwind's own `sm` (0.875rem paired with a FIXED + // 1.25rem), and the lineHeight has to be restated for that reason — a fixed + // 1.25rem against a 16px glyph is 1.25, tighter than the 1.6 a block of prose + // at this size wants. + "sm": ["1rem", { lineHeight: "1.6" }], + // 14px, for genuinely secondary text — captions, footnotes, table meta. The + // leading stays a RATIO rather than the fixed 1rem Tailwind pairs with its own + // `xs`, so the step cannot silently retighten if the size moves again: 1rem on + // a 14px glyph is 1.14, which is a caption set solid. + "xs": ["0.875rem", { lineHeight: "1.3333" }], }, // -0.015em is Apple's 80px value exactly, so it belongs on display-xl only. letterSpacing: { tightest: "-0.015em" }, borderRadius: { + // FOUR RADII, AND FOUR IS THE WHOLE SET. The home page rendered ten — 4, 5, 6, + // 8, 9, 10, 12, 18, 20, 24, 28 and the pill — several of which no eye can tell + // apart at the sizes they were used. That is not a system, it is what happens + // when every component picks its own corner. + // + // inline 10px badges, tags, tooltips, inputs, small controls + // tile 18px cards + // panel 28px large panels and feature surfaces + // full pills and avatars + // + // The one deliberate exception is the 2px on the contribution-wall cells and + // the focus ring, which are not surfaces — a 10px corner on a 10px square is a + // circle. + // + // A radius is proportional to the box it is on, so a badge and a feature panel + // genuinely do need different ones; three surface steps is the smallest set + // that can say that. Anything past four is drift. + inline: "10px", // Apple's tiles measured 18px on /store and 28px on /mac — small cards and // large feature panels respectively. Ours were 10-14px, which reads as a // different, tighter system.