diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9b86695..a66f369 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,21 +22,32 @@ beyond those commands, that is a bug — please open an issue.
## The files that matter
Almost every contribution is an edit to one file under **`web/content/`**. They hold
-all the site's content as typed arrays, one module per page, so you do not need to
+all the site's content as typed arrays, one module per topic, so you do not need to
touch a React component to add a person, a project, or an answer.
| File | What lives there | Page |
| --- | --- | --- |
| `site.ts` | Links, the page list, the footer's institutional copy | every page |
-| `essence.ts` | What open source is, career impact, the ICPC comparison, member stories | Essence |
+| `essence.ts` | What open source is, career impact, the glossary, member stories | Home |
+| `positioning.ts` | What a member walks away with, the ICPC comparison, the trade-offs | Home |
+| `showcase.ts` | Verified upstream contributions, and the headline counts derived from them | Home, Projects |
| `projects.ts` | Build-day projects, club repos, upstream contributions | Projects |
-| `programs.ts` | The seven programmes, tiers, the reverse clock | Programs |
-| `people.ts` | Core team, alumni, achievers, organisations | Hall of Fame |
-| `join.ts` | The four entry paths, what we look for, culture, FAQ, form options | How to Join |
-
-This used to be a single `club.ts`. It was split when the site became five pages,
-because one 800-line file holding five pages' content meant every content PR touched
-it and every one of them conflicted.
+| `tracks.ts` | The three tracks, and the four steps of a first contribution | Programmes, How to Join |
+| `programmes.ts` | The seven programmes, tiers, their names and colours, the deadline calendar | Programmes |
+| `selections.ts` | Who got selected into which programme, and by whom | Hall of Fame |
+| `people.ts` | Alumni, partner organisations, and the counts that span both rosters | Hall of Fame |
+| `team.ts` | Officers, leads, shadows — the org chart | Team |
+| `mentors.ts` | The published mentor bench | Team |
+| `events.ts` | Internal sessions and external events | Events |
+| `how-to-join.ts` | What we look for, how the club runs, who it is not for, the FAQ | How to Join |
+| `join.ts` | The named entry paths, the mentor bench, the join form's options | Join, Onboarding |
+| `lookup.ts` | Looks one person up by name across the lists above. No content of its own. | — |
+
+This used to be a single `club.ts`. It was split because one file holding every
+page's content meant every content PR touched it and every one of them conflicted.
+Each module above owns one topic and is the only place that topic is written down,
+so two of them can never disagree — see `lookup.ts` for how a section borrows a fact
+recorded somewhere else instead of restating it.
> **Editing the join form's options?** `join.ts` holds the four paths, the two hostels
> and the programme list — and `firestore.rules` at the repo root keeps a **second copy**
@@ -50,17 +61,18 @@ it and every one of them conflicted.
> FIREBASE.md before changing it.
```ts
-// content/people.ts
-export const ACHIEVERS: Achiever[] = [
+// content/selections.ts
+export const SELECTIONS: Selection[] = [
{
name: "Full Name",
- achievement: { kind: "programme", programme: "GSOC" }, // or { kind: "hackathon", event: "..." }
- year: "2026",
- org: "The organisation that selected them",
- work: "One specific sentence on what they actually built.",
+ programme: "GSOC", // a key of PROGRAMME_NAME in programmes.ts
+ year: "2026", // the programme edition, not their year of study
+ studyYear: "3rd year", // optional
+ org: "The organisation that selected them", // optional until somebody confirms it
+ work: "One specific sentence on what they actually built.", // optional
photo: "/people/full-name.jpg", // optional
github: "their-login", // optional
- url: "https://link-that-proves-it",
+ url: "https://link-that-proves-it", // optional
consented: true, // REQUIRED — see below
},
];
diff --git a/FIREBASE.md b/FIREBASE.md
index e8e1675..0bfb5d0 100644
--- a/FIREBASE.md
+++ b/FIREBASE.md
@@ -307,6 +307,12 @@ actual collection.
Delete `.env.local` when you are done, or the form will keep pointing at an emulator
that is no longer running.
+**Shortcut:** with `NEXT_PUBLIC_FIRESTORE_EMULATOR` in `.env.local`, plain `npm run dev`
+starts the Auth + Firestore emulator for you (or reuses one already running), and saves
+its data on Ctrl+C to `%LOCALAPPDATA%\osc-dev\emulator-data` (`~/.cache/osc-dev/...`
+elsewhere; override with `OSC_EMULATOR_DATA`), so accounts survive restarts. Delete
+that folder for a clean slate. `npm run dev:next` is the old, Next-only command.
+
### Testing the whole flow in a browser
```bash
diff --git a/firebase.json b/firebase.json
index aa60b34..1b8714a 100644
--- a/firebase.json
+++ b/firebase.json
@@ -79,6 +79,11 @@
"source": "/programs",
"destination": "/programmes",
"type": 301
+ },
+ {
+ "source": "/how-to-join",
+ "destination": "/join",
+ "type": 302
}
]
}
diff --git a/firestore.rules b/firestore.rules
index 2e4917c..7632696 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -7,7 +7,7 @@ rules_version = '2';
// static export with no server — so there is no back end doing checks. Everything that
// protects members' data is in this file.
//
-// The site now has sign-in, a dashboard and an organisers' page. Ten collections:
+// The site now has sign-in, a dashboard and an organisers' page. Twelve collections:
//
// users/{uid} one profile per member. Readable and writable ONLY by that
// member, plus readable by admins. Contains their name, college
@@ -37,6 +37,24 @@ rules_version = '2';
// bypasses this file. A count a client can write is a count a
// client can invent, and "merged pull requests" is the one number
// here somebody has a reason to inflate.
+// leaderboard/{id} the club's ranking, as ONE document the same function derives
+// from the collection above. Read by admitted members, written by
+// no client. It exists so that showing a ranking does not require
+// letting every member list everybody's counts — the disclosure is
+// the document's contents and nothing wider.
+//
+// And the two Build Days brought:
+//
+// attendance/{sessionId}
+// the roll taken at one Build Day: a count and who took it, no
+// names. Any signed-in student may read it. It exists so that "the
+// roll was never taken" is distinguishable from "nobody came" —
+// without it, every session an organiser forgot to mark reports 0%.
+// attendance/{sessionId}/present/{uid}
+// one row per student who was there, and what they were working on.
+// Read by its subject and by admins, WRITTEN ONLY BY ADMINS — the
+// student it is about cannot write it, for the contributions/ reason:
+// the club picks its mentor cohort partly on attendance.
//
// TWO THINGS CHANGED IN THE BLOCKS ABOVE WHEN THOSE ARRIVED, and both are easy to miss:
//
@@ -182,6 +200,28 @@ service cloud.firestore {
return isAdmin() && adminRow().get('role', 'admin') == 'owner';
}
+ /** A FLOOR MENTOR: somebody an organiser has asked to help in the room at Build Days.
+ * They work the live help queue and nothing else — they cannot read profiles, the
+ * roll, the cohort or anything an admin can.
+ *
+ * A SEPARATE ROLE RATHER THAN AN admins/ ROW, because an admin row is the whole
+ * organisers' area: every profile, every form answer, the roster. Somebody helping
+ * forty students with `git rebase` for an afternoon needs none of that.
+ *
+ * Keyed by lowercased email like admins/, so a mentor can be added before they first
+ * sign in. A missing `active` means active, the same default admins/ uses. */
+ function isFloorMentor() {
+ return isStudent()
+ && exists(/databases/$(database)/documents/floor_mentors/$(request.auth.token.email.lower()))
+ && get(/databases/$(database)/documents/floor_mentors/$(request.auth.token.email.lower()))
+ .data.get('active', true) == true;
+ }
+
+ /** Anybody who may work the floor: a floor mentor, or an organiser. */
+ function isFloorHelper() {
+ return isAdmin() || isFloorMentor();
+ }
+
/** May this reader see the document being read? ONE FUNCTION, applied identically
* to announcements, forms and sessions, so the three cannot drift.
*
@@ -365,7 +405,7 @@ service cloud.firestore {
// `path` is optional: it is carried in from a ?path= link rather than asked for,
// and most members arrive without one.
&& (!('path' in d)
- || d.path in ['build-day', 'first-contribution', 'fast-track', 'program-track'])
+ || d.path in ['build-day', 'hackathon', 'first-contribution', 'fast-track', 'program-track'])
// The server's clock, never the client's, so "member since" and "last edited"
// cannot be backdated.
@@ -549,7 +589,7 @@ service cloud.firestore {
function isWellFormedSession(d) {
return
d.keys().hasOnly([
- 'title', 'speaker', 'location', 'notes', 'starts_at',
+ 'title', 'speaker', 'location', 'notes', 'starts_at', 'kind',
'audience', 'created_by', 'created_at', 'updated_at'
])
&& isWellFormedAudience(d)
@@ -558,6 +598,20 @@ service cloud.firestore {
&& d.title is string && d.title.size() > 0 && d.title.size() <= 140
&& d.starts_at is timestamp
+ // `kind` MARKS THE SESSIONS THE CLUB TAKES A ROLL AT, and it is the key that
+ // attendance/{sessionId} hangs off — see that block below.
+ //
+ // A FIELD ON A SESSION RATHER THAN A COLLECTION OF ITS OWN. A Build Day IS a
+ // session: it has a time, a room, an audience and a place in the schedule, and
+ // every one of those is already built here. A parallel collection would have had
+ // to grow its own editor, its own audience gating and its own upcoming/past split,
+ // and would have drifted from this one the first time either moved.
+ //
+ // ABSENT MEANS AN ORDINARY SESSION, which is what every session scheduled before
+ // this field existed is. Contrast `audience`, where absent had to be read as a
+ // value — nothing here queries on `kind`, so an absent one costs nothing.
+ && (!('kind' in d) || d.kind == 'build-day')
+
&& (!('speaker' in d) || (d.speaker is string && d.speaker.size() > 0 && d.speaker.size() <= 120))
&& (!('location' in d) || (d.location is string && d.location.size() > 0 && d.location.size() <= 120))
&& (!('notes' in d) || (d.notes is string && d.notes.size() > 0 && d.notes.size() <= 2000))
@@ -637,6 +691,245 @@ service cloud.firestore {
&& d.updated_at == request.time;
}
+ /** THE HEADER ON ONE SESSION'S ROLL, and it exists to make one distinction the
+ * subcollection cannot: "nobody was marked" versus "the roll was never taken".
+ *
+ * Without it those two are the same absence, and every Build Day an organiser forgot
+ * to mark reports 0% attendance — which is not a cosmetic problem, because the club
+ * selects its mentor-supported cohort partly on attendance across three sessions. A
+ * number that silently means "we did not look" is worse than a missing one.
+ *
+ * `present_count` IS A CONVENIENCE AND THE SUBCOLLECTION IS THE TRUTH. Members cannot
+ * list the roll — see the match block — so this is the only way a count reaches
+ * anybody but an organiser. Two organisers marking at once can leave it one behind;
+ * it is recomputed from the rows on every load of the grid, so it corrects itself. */
+ function isWellFormedRoll(d, sessionId) {
+ return
+ d.keys().hasOnly(['session_id', 'taken_by', 'taken_at', 'present_count', 'updated_at'])
+ && d.keys().hasAll(['session_id', 'taken_by', 'present_count', 'updated_at'])
+
+ // The id in the body must agree with the id in the path, so a roll cannot claim to
+ // be a different session's — the same pin as uid on a profile.
+ && d.session_id == sessionId
+
+ && d.taken_by is string && d.taken_by.size() > 0
+ && d.present_count is int && d.present_count >= 0
+
+ && d.updated_at == request.time
+ && (!('taken_at' in d) || d.taken_at == request.time || d.taken_at == resource.data.taken_at);
+ }
+
+ /** ONE STUDENT, PRESENT AT ONE BUILD DAY, plus what they were working on.
+ *
+ * PRESENCE IS EXISTENCE. There is no `present: true` field, because a document that
+ * can say `false` is a document somebody has to remember to write for all sixty
+ * students — and the first absent row nobody wrote would be indistinguishable from a
+ * student who was never in the list. Unmarking deletes the row.
+ *
+ * THIS IS THE ONE SHAPE FUNCTION IN THIS FILE THAT DOES NOT PIN THE SUBJECT TO THE
+ * CALLER, and that is the whole difference from isWellFormedResponse above. A response
+ * is a member describing themselves, so `d.uid == request.auth.uid` holds. A roll is
+ * an ORGANISER describing somebody else, so the only identity check expressible here
+ * is that the body agrees with the path. Who may write it is decided by the match
+ * block — `isAdmin()`, and nobody else, including the student the row is about.
+ *
+ * WHY THE STUDENT CANNOT WRITE THEIR OWN. It is the same argument as contributions/:
+ * attendance is a number the club selects a cohort on, so it is a number somebody has
+ * a reason to inflate. A self-check-in is a reasonable feature and is NOT this — it
+ * would need a code an organiser holds and a Cloud Function to check it, because a
+ * code these rules can read is a code the student can read out of the session too.
+ *
+ * `track` IS HERE RATHER THAN ON THE PROFILE, deliberately. Putting it on users/{uid}
+ * would mean widening isWellFormedProfile, onlyMembershipChanged() and
+ * membershipUnchanged() — the three functions that stop a member granting themselves
+ * membership — for a field that is genuinely per-session anyway: the club allocates
+ * tracks during the first Build Day and students move between them. */
+ function isWellFormedAttendee(d, uid) {
+ return
+ d.keys().hasOnly([
+ 'uid', 'email', 'name', 'track',
+ 'repo', 'issue_url', 'pr_url', 'blocker', 'next_step',
+ 'marked_by', 'updated_at'
+ ])
+ && d.keys().hasAll(['uid', 'email', 'track', 'marked_by', 'updated_at'])
+
+ && d.uid == uid
+ && d.email is string && d.email.size() > 0 && d.email.size() <= 160
+ && (!('name' in d) || (d.name is string && d.name.size() > 0 && d.name.size() <= 120))
+
+ // A closed set, mirrored in web/lib/buildDays.ts. The three tracks the club
+ // announced; an unrecognised one would put a student in a bucket no screen draws.
+ && d.track in ['beginner', 'intermediate', 'advanced']
+
+ // "owner/name", the form the dashboard already stores contributions in. Bounded
+ // and shaped rather than free text, so the grid's repo column cannot become a
+ // place to type a sentence.
+ && (!('repo' in d)
+ || (d.repo is string
+ && d.repo.size() > 0
+ && d.repo.size() <= 140
+ && d.repo.matches('^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')))
+
+ // https:// ONLY, the same guard the notice board's `link` carries and for the same
+ // reason: these are rendered as anchors on the organisers' screen, and a
+ // `javascript:` href typed into a text box is the cheapest XSS there is.
+ && (!('issue_url' in d)
+ || (d.issue_url is string
+ && d.issue_url.size() > 0
+ && d.issue_url.size() <= 500
+ && d.issue_url.matches('^https://[^ ]+$')))
+ && (!('pr_url' in d)
+ || (d.pr_url is string
+ && d.pr_url.size() > 0
+ && d.pr_url.size() <= 500
+ && d.pr_url.matches('^https://[^ ]+$')))
+
+ && (!('blocker' in d) || (d.blocker is string && d.blocker.size() > 0 && d.blocker.size() <= 500))
+ && (!('next_step' in d) || (d.next_step is string && d.next_step.size() > 0 && d.next_step.size() <= 500))
+
+ && d.marked_by is string && d.marked_by.size() > 0
+ && d.updated_at == request.time;
+ }
+
+ /** One floor mentor, as an organiser writes them. */
+ function isWellFormedFloorMentor(d, email) {
+ return
+ d.keys().hasOnly(['email', 'name', 'active', 'added_by', 'added_at', 'updated_at'])
+ && d.keys().hasAll(['email', 'name', 'active', 'added_by', 'added_at', 'updated_at'])
+ && d.email == email
+ && email.matches('^[^@A-Z]+@sst[.]scaler[.]com$')
+ && d.name is string && d.name.size() > 0 && d.name.size() <= 120
+ && d.active is bool
+ && d.added_by is string && d.added_by.size() > 0
+ && d.updated_at == request.time;
+ }
+
+ /** Is the live floor open for this session? A Build Day, from an hour before it starts
+ * until twelve hours after. Mirrored by FLOOR_OPENS_BEFORE / FLOOR_CLOSES_AFTER in
+ * web/lib/floor.ts.
+ *
+ * A WINDOW RATHER THAN AN "ON" SWITCH, so nobody has to remember to open the floor
+ * with forty students already waiting — and a stale floor cannot take writes a week
+ * later from somebody who left the tab open. */
+ function floorOpen(sessionId) {
+ let s = get(/databases/$(database)/documents/sessions/$(sessionId)).data;
+ return s.get('kind', '') == 'build-day'
+ && request.time > s.starts_at - duration.value(1, 'h')
+ && request.time < s.starts_at + duration.value(12, 'h');
+ }
+
+ /** An optional https:// link. Rendered as an anchor on the mentors' screen, so the
+ * `javascript:` guard the roll's link fields carry applies here too. */
+ function isOptionalHttps(d, k) {
+ return !(k in d)
+ || (d[k] is string && d[k].size() > 0 && d[k].size() <= 500 && d[k].matches('^https://[^ ]+$'));
+ }
+
+ /** ONE STUDENT'S LIVE STATUS AT ONE BUILD DAY: where they are, and whether they want
+ * a hand.
+ *
+ * SELF-REPORTED, AND THAT IS WHY IT IS NOT ON THE ROLL. attendance/ is organiser-
+ * written because the cohort is selected on it. This is a student telling the room
+ * "I'm stuck on setup" — worthless if they cannot write it themselves, and nothing
+ * reads it as evidence of anything once the session is over.
+ *
+ * `help` IS A THREE-STATE, and each state pins what else may be present:
+ * none — no help_at, no note, no claim
+ * open — help_at, optional note, no claim
+ * claimed — help_at, optional note, and who is on their way
+ * so no screen ever has to decide what a claim on a closed request means. */
+ function isWellFormedFloorRow(d, uid) {
+ return
+ d.keys().hasOnly([
+ 'uid', 'name', 'seat', 'phase', 'phase_at',
+ 'help', 'help_note', 'help_at', 'claimed_by', 'claimed_name', 'claimed_at',
+ 'last_helped_at', 'repo', 'issue_url', 'pr_url', 'updated_at'
+ ])
+ && d.keys().hasAll(['uid', 'name', 'phase', 'phase_at', 'help', 'updated_at'])
+ && d.uid == uid
+ && d.name is string && d.name.size() > 0 && d.name.size() <= 120
+ && (!('seat' in d) || (d.seat is string && d.seat.size() > 0 && d.seat.size() <= 60))
+
+ // A closed set, mirrored in web/lib/floor.ts — `npm run rules` diffs the two.
+ && d.phase in [
+ 'joined', 'setup', 'finding-repo', 'found-repo', 'running-locally',
+ 'finding-issue', 'found-issue', 'created-issue', 'coding', 'committed',
+ 'pr-opened', 'existing-pr', 'review', 'merged'
+ ]
+ && d.phase_at is timestamp
+
+ && d.help in ['none', 'open', 'claimed']
+ && ('help_at' in d) == (d.help != 'none')
+ && (!('help_at' in d) || d.help_at is timestamp)
+ && (!('help_note' in d)
+ || (d.help != 'none' && d.help_note is string
+ && d.help_note.size() > 0 && d.help_note.size() <= 280))
+ && ('claimed_by' in d) == (d.help == 'claimed')
+ && ('claimed_name' in d) == (d.help == 'claimed')
+ && ('claimed_at' in d) == (d.help == 'claimed')
+ && (!('claimed_by' in d)
+ || (d.claimed_by is string && d.claimed_name is string
+ && d.claimed_name.size() > 0 && d.claimed_name.size() <= 120
+ && d.claimed_at is timestamp))
+ && (!('last_helped_at' in d) || d.last_helped_at is timestamp)
+
+ && (!('repo' in d)
+ || (d.repo is string && d.repo.size() > 0 && d.repo.size() <= 140
+ && d.repo.matches('^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')))
+ && isOptionalHttps(d, 'issue_url')
+ && isOptionalHttps(d, 'pr_url')
+
+ && d.updated_at == request.time;
+ }
+
+ /** What a student may change about their own row.
+ *
+ * EVERYTHING ABOUT THEMSELVES, NOTHING ABOUT THE MENTOR. They can raise a hand, edit
+ * the note, or put the hand down — but they cannot claim themselves, rewrite who is
+ * coming, or reset when they were last helped (which is what the quiet list reads). */
+ function isFloorStudentEdit(n, o) {
+ return n.get('last_helped_at', null) == o.get('last_helped_at', null)
+ && (n.phase == o.phase ? n.phase_at == o.phase_at : n.phase_at == request.time)
+ && (
+ // Same help state: only the note may move.
+ (n.help == o.help
+ && n.get('help_at', null) == o.get('help_at', null)
+ && n.get('claimed_by', null) == o.get('claimed_by', null)
+ && n.get('claimed_name', null) == o.get('claimed_name', null)
+ && n.get('claimed_at', null) == o.get('claimed_at', null))
+ // Raising a hand.
+ || (o.help == 'none' && n.help == 'open' && n.help_at == request.time)
+ // Putting it down — "sorted it myself". The shape function strips the rest.
+ || n.help == 'none'
+ );
+ }
+
+ /** What a mentor may change about somebody else's row: the help fields, and only
+ * through the three moves the queue has. */
+ function isFloorHelperEdit(n, o) {
+ return n.diff(o).affectedKeys().hasOnly([
+ 'help', 'help_note', 'help_at', 'claimed_by', 'claimed_name', 'claimed_at',
+ 'last_helped_at', 'updated_at'
+ ])
+ && (
+ // CLAIM: "I'm coming". Also how a mentor checks in on somebody who never asked,
+ // which is the point of the quiet list — help_at starts now in that case.
+ (n.help == 'claimed'
+ && n.claimed_by == request.auth.token.email
+ && n.claimed_at == request.time
+ && n.help_at == o.get('help_at', request.time)
+ && n.get('help_note', null) == o.get('help_note', null)
+ && n.get('last_helped_at', null) == o.get('last_helped_at', null))
+ // RELEASE: back in the queue, keeping their place.
+ || (o.help == 'claimed' && n.help == 'open'
+ && n.help_at == o.help_at
+ && n.get('help_note', null) == o.get('help_note', null)
+ && n.get('last_helped_at', null) == o.get('last_helped_at', null))
+ // DONE: hand down, and the quiet clock restarts.
+ || (o.help != 'none' && n.help == 'none' && n.last_helped_at == request.time)
+ );
+ }
+
/** AN APPLICATION FROM A STRANGER, and the only unauthenticated write in this file.
*
* WHY IT IS ANONYMOUS, restated because the obvious "improvement" is to require
@@ -922,6 +1215,123 @@ service cloud.firestore {
allow delete: if isAdmin();
}
+ // ------------------------------------------------------------- attendance
+
+ /** THE ROLL TAKEN AT ONE BUILD DAY. Keyed by the session's own id, so a roll cannot
+ * exist for a session that does not, and a session cannot have two.
+ *
+ * READABLE BY ANY SIGNED-IN STUDENT, and that is safe because of what is NOT in this
+ * document: it holds a count and the organiser who took it, and no names. Who was
+ * there lives one level down, behind an owner-or-admin rule. A student learning that
+ * eighteen people came to a Build Day learns nothing the room did not already tell
+ * them. */
+ match /attendance/{sessionId} {
+ allow get: if isStudent();
+ allow list: if isAdmin();
+
+ allow create: if isAdmin()
+ && request.resource.data.taken_by == request.auth.token.email
+ && isWellFormedRoll(request.resource.data, sessionId);
+
+ // taken_by gets the pin-then-freeze treatment a notice's byline does: the club has
+ // several organisers, and the second one to mark somebody present must not end up
+ // recorded as the person who took the roll.
+ allow update: if isAdmin()
+ && request.resource.data.taken_by == resource.data.taken_by
+ && isWellFormedRoll(request.resource.data, sessionId)
+ && (!('taken_at' in resource.data)
+ || request.resource.data.taken_at == resource.data.taken_at);
+
+ // NO DELETE, AND THIS ONE IS NOT SYMMETRY WITH THE SUBCOLLECTION. Deleting this
+ // document does NOT delete the rows under it — Firestore subcollections outlive
+ // their parent — so a delete here would leave every student's row in place while the
+ // club's record said the roll was never taken. Unmarking everybody is the honest way
+ // to undo a roll, and it leaves present_count at 0, which is a true statement.
+ allow delete: if false;
+ }
+
+ /** ONE STUDENT AT ONE BUILD DAY. Keyed by uid, so a second row for the same student is
+ * not refused by a check somebody has to remember — it cannot be expressed.
+ *
+ * THE LIST RULE IS ADMIN-ONLY, for the reason the form responses carry: this is an
+ * attributed record the club selects a cohort from, and it is only safe to keep
+ * because no student can enumerate it. A member reads exactly their own row.
+ *
+ * WRITTEN ONLY BY AN ORGANISER, INCLUDING THE ROW ABOUT YOURSELF. See
+ * isWellFormedAttendee() for the argument; briefly, attendance is one of the numbers
+ * the mentor cohort is chosen on, and a number its subject can write is a number its
+ * subject can inflate. */
+ match /attendance/{sessionId}/present/{uid} {
+ allow get: if isStudent() && (request.auth.uid == uid || isAdmin());
+ allow list: if isAdmin();
+
+ // marked_by is pinned to the caller on EVERY write rather than frozen after the
+ // first, which is the opposite of the byline treatment above and is deliberate. On a
+ // session this field answers "who scheduled this"; here it answers "who last said
+ // this student was working on that", which is a question about the most recent edit.
+ allow create, update: if isAdmin()
+ && request.resource.data.marked_by == request.auth.token.email
+ && isWellFormedAttendee(request.resource.data, uid);
+
+ // UNMARKING IS A DELETE, because presence is existence — see the shape function. An
+ // organiser who ticks the wrong row has to be able to untick it, and there is no
+ // `present: false` for them to write instead.
+ allow delete: if isAdmin();
+ }
+
+ // ------------------------------------------------------------ the floor
+
+ /** Who may work the floor. Organisers appoint; a mentor reads only their own row. */
+ match /floor_mentors/{email} {
+ allow get: if isStudent() && (request.auth.token.email.lower() == email || isAdmin());
+ allow list: if isAdmin();
+
+ allow create: if isAdmin()
+ && request.resource.data.added_by == request.auth.token.email
+ && request.resource.data.added_at == request.time
+ && isWellFormedFloorMentor(request.resource.data, email);
+ allow update: if isAdmin()
+ && request.resource.data.added_by == resource.data.added_by
+ && request.resource.data.added_at == resource.data.added_at
+ && isWellFormedFloorMentor(request.resource.data, email);
+ // Not a handover record like admins/ — a mentor for one term is just removed.
+ allow delete: if isAdmin();
+ }
+
+ /** One student's live status. Theirs to write while the floor is open; any floor
+ * helper may move the help fields.
+ *
+ * THE WHOLE FLOOR IS VISIBLE TO EVERY STUDENT, names, phases and raised hands
+ * included — the club's call, so the room can see who is where and students can help
+ * each other. Nothing here is more personal than what the room already sees: a name,
+ * a phase, a seat, and a one-line note the student chose to write. The roll
+ * (attendance/) stays admin-only; this is not that. */
+ match /floor/{sessionId}/students/{uid} {
+ allow get, list: if isStudent();
+
+ allow create: if isStudent()
+ && request.auth.uid == uid
+ && floorOpen(sessionId)
+ && isWellFormedFloorRow(request.resource.data, uid)
+ && request.resource.data.phase_at == request.time
+ && request.resource.data.help in ['none', 'open']
+ && request.resource.data.get('help_at', request.time) == request.time
+ && !('last_helped_at' in request.resource.data);
+
+ allow update: if isStudent()
+ && isWellFormedFloorRow(request.resource.data, uid)
+ && (
+ (request.auth.uid == uid
+ && floorOpen(sessionId)
+ && isFloorStudentEdit(request.resource.data, resource.data))
+ || (request.auth.uid != uid
+ && isFloorHelper()
+ && isFloorHelperEdit(request.resource.data, resource.data))
+ );
+
+ allow delete: if false;
+ }
+
// ------------------------------------------------------------------ forms
match /forms/{formId} {
@@ -1031,6 +1441,43 @@ service cloud.firestore {
allow write: if false;
}
+ // ------------------------------------------------------------ leaderboard
+
+ match /leaderboard/{id} {
+ // THE ONE PLACE ONE MEMBER'S COUNTS ARE VISIBLE TO ANOTHER, and it is deliberately
+ // a DERIVED document rather than a widening of the block above.
+ //
+ // The obvious way to build a leaderboard is `allow list: if isClubMember()` on
+ // contributions/ — and that is not a leaderboard rule, it is "every member may read
+ // every member's GitHub record", with the ranking being the only part that happens
+ // to render. This document holds a name, a handle and three counts per member and
+ // nothing else, so what a reader can obtain is exactly what the page shows them.
+ // The private collection stays get-only to its owner, which is what it was for.
+ //
+ // isClubMember(), NOT isStudent(). It names people, and it names them to the club
+ // rather than to the domain — the same line the private notice board draws. The
+ // function that writes it applies the same filter to the rows, so the people on the
+ // board and the people who can see it are one list. Both halves have to move
+ // together: relaxing this to isStudent() without changing functions/index.js would
+ // publish members' names to every address on the domain.
+ //
+ // APPEARING IS VOLUNTARY BECAUSE A HANDLE IS. Only members who typed a GitHub
+ // username into their own profile are ranked, and clearing that field drops them
+ // from the next nightly build. There is no opt-out flag here because there is
+ // nothing to opt out of until a member publishes the handle themselves.
+ allow get: if isClubMember() || isAdmin();
+
+ // Nothing lists this. There is one document and its id is known, so a list rule
+ // would only ever serve a caller enumerating what else somebody put in here.
+ allow list: if false;
+
+ // WRITTEN BY NO CLIENT, for the reason the collection it is built from is: a rank
+ // somebody can edit is a rank somebody will edit. The Cloud Function uses the Admin
+ // SDK and bypasses this file entirely — see rebuildLeaderboard() in
+ // functions/index.js, which is also the only place the shape is defined.
+ allow write: if false;
+ }
+
// ---------------------------------------------------- legacy applications
match /applications/{id} {
diff --git a/functions/github.js b/functions/github.js
index 4fde2b3..3e72a50 100644
--- a/functions/github.js
+++ b/functions/github.js
@@ -18,9 +18,12 @@
// WHAT IT COSTS, AND WHICH LIMIT IT COSTS AGAINST. GitHub meters search separately from
// everything else, and this file touches both meters:
//
-// /search/issues TWO requests per member (merged, then open), against the SEARCH
-// limit — 10 per minute unauthenticated, 30 with a token. This is the
-// binding constraint, and the throttle in index.js is sized against it.
+// /search/issues THREE requests per member (merged, then open, then issues opened),
+// against the SEARCH limit — 10 per minute unauthenticated, 30 with a
+// token. This is the binding constraint, and GAP_MS in index.js is
+// sized against it: change the number of searches here and that
+// constant has to move with it, or the sweep starts rate-limiting
+// itself halfway through the club.
// /users/{handle} ONE request per member, against the CORE limit — 60 per hour
// unauthenticated, 5,000 with a token. Effectively free with a token
// and the first thing to break without one.
@@ -166,12 +169,12 @@ async function fetchContributions(handle, token) {
}
if (!(await exists(h, token))) {
- return { github: h, merged: 0, open: 0, repos: 0, recent: [], not_found: true };
+ return { github: h, merged: 0, open: 0, issues: 0, repos: 0, recent: [], not_found: true };
}
- // Two requests, merged first. If the second is rate-limited the first is still thrown
- // away — a row with real merged counts and a zeroed `open` would be a lie that looks
- // like data, and the caller's retry is cheap.
+ // Three requests, merged first. If a later one is rate-limited the earlier ones are
+ // still thrown away — a row with real merged counts and a zeroed `open` would be a lie
+ // that looks like data, and the caller's retry is cheap.
//
// PAGE_MAX ON THE MERGED SEARCH, NOT RECENT, AND THAT IS NOT A TYPO. The list only
// shows RECENT rows, so asking for eight would seem to be enough — but `repos` is
@@ -181,6 +184,15 @@ async function fetchContributions(handle, token) {
// fact rather than a truncation. One page of 100 costs exactly the same one request.
const merged = await search(`type:pr is:merged author:${h}`, token, PAGE_MAX);
const open = await search(`type:pr is:open author:${h}`, token, 1);
+ // ISSUES OPENED, IN ANY STATE, and the absence of a state filter is the decision.
+ // `is:open` would count only the ones nobody has dealt with yet, so a member whose
+ // reports were all triaged and closed would read zero — which is the opposite of what
+ // happened. Reporting a bug well is a contribution whatever the maintainer then does
+ // with it, and unlike a pull request there is no "rejected" state that makes the raw
+ // count flattering: an issue is either raised or it is not.
+ //
+ // `per_page=1` because nothing lists these. Only the total is stored.
+ const issues = await search(`type:issue author:${h}`, token, 1);
const all = merged.items.map((i) => toPull(i, "merged"));
@@ -188,6 +200,7 @@ async function fetchContributions(handle, token) {
github: h,
merged: merged.total,
open: open.total,
+ issues: issues.total,
// Distinct repositories among the merged PRs this request returned. GitHub's search
// API has no distinct-repository count, so this is derived — and it is therefore
// EXACT up to PAGE_MAX merged pull requests and an undercount past it. A second page
diff --git a/functions/index.js b/functions/index.js
index 2377abd..63ff383 100644
--- a/functions/index.js
+++ b/functions/index.js
@@ -163,10 +163,18 @@ const USERS = "users";
const CONTRIBUTIONS = "contributions";
const FORMS = "forms";
const RESPONSES = "responses";
+// The published ranking. One document, rebuilt nightly — see rebuildLeaderboard().
+const LEADERBOARD = "leaderboard";
+const BOARD_ID = "current";
-/** Two search requests per member against a 30-per-minute authenticated limit means one
- * member every 4.5 seconds is the ceiling. 5s leaves a little headroom. */
-const GAP_MS = 5000;
+/** THREE search requests per member — merged, open, and issues opened — against a
+ * 30-per-minute authenticated limit means one member every 6 seconds is the ceiling. 7s
+ * leaves a little headroom.
+ *
+ * It was 5s when there were two searches. Whoever adds a fourth moves this again:
+ * functions/github.js is where the count lives, and the two have to agree or the sweep
+ * rate-limits itself partway through the club and stops. */
+const GAP_MS = 7000;
/** How many members one scheduled run will refresh.
*
@@ -218,6 +226,93 @@ async function readOne(uid) {
};
}
+/** THE CLUB'S LEADERBOARD, AS ONE DOCUMENT.
+ *
+ * WHY IT IS DERIVED HERE RATHER THAN QUERIED BY THE CLIENT. `contributions` is
+ * get-only to its owner and list-only to an admin, and that rule is load-bearing: a
+ * member who could list the collection could read every other member's counts
+ * whether or not anything rendered them. A leaderboard needs the ranking, not the
+ * collection, so the Admin SDK computes the ranking and publishes the one document
+ * members are allowed to see. The private collection stays private.
+ *
+ * WHAT IS IN IT IS THE WHOLE DISCLOSURE. Name, handle and three counts — nothing
+ * from the profile that a member did not already publish about themselves. Email,
+ * hostel, batch and path are on the same user document and none of them appear here;
+ * whoever adds a field adds it to a page every member of the club can read.
+ *
+ * ADMITTED MEMBERS ONLY, not every verified address. Signing in proves somebody
+ * studies here, which was never membership in anything (see `membership` in
+ * web/lib/profile.ts) — and this is the club's board, published to the club. The
+ * read rule in firestore.rules says isClubMember() for the same reason, so the two
+ * halves agree: the people on it are the people who can see it.
+ *
+ * AND ONLY PEOPLE WHO GAVE A HANDLE, which is what makes appearing voluntary. There
+ * is no opt-out switch because there is no row to opt out of until a member types a
+ * GitHub username into their own profile; clearing that field removes them from the
+ * next build. If the club ever wants to be on the board without publishing a handle,
+ * that is a real opt-in field and a rules change, not a tweak here.
+ *
+ * BUILT BY THE NIGHTLY SWEEP AND NOWHERE ELSE. The obvious addition is to rebuild it
+ * at the end of refreshContributions() so a member who just merged something sees
+ * themselves move — and that is two full collection reads per press of a button with
+ * a ten-minute cooldown, on the one path a member can trigger at will. The board
+ * carries `built_at` and the page says "as of ..." instead, which is both cheaper and
+ * more honest than a number that is fresh for one row and a day old for the rest. */
+const BOARD_SIZE = 100;
+
+async function rebuildLeaderboard() {
+ const db = admin.firestore();
+ const [members, counts] = await Promise.all([
+ db.collection(USERS).get(),
+ db.collection(CONTRIBUTIONS).get(),
+ ]);
+ const profiles = new Map(members.docs.map((d) => [d.id, d.data()]));
+
+ const rows = counts.docs
+ .map((d) => ({ uid: d.id, ...d.data() }))
+ .filter((c) => {
+ const p = profiles.get(c.uid);
+ // A handle GitHub could not find is dropped rather than ranked at zero. "Nobody
+ // by that name" and "nothing merged yet" are different facts, and only one of
+ // them belongs on a board about what people have done.
+ return p && p.membership === "member" && !c.not_found && isValidHandle((c.github ?? "").trim());
+ })
+ .map((c) => ({
+ uid: c.uid,
+ // The handle is the fallback name, not "Anonymous": a member with a blank name
+ // is a profile mid-edit, and @handle still identifies them to their own club.
+ name: String(profiles.get(c.uid).name ?? "").trim() || c.github.trim(),
+ github: c.github.trim(),
+ merged: c.merged ?? 0,
+ repos: c.repos ?? 0,
+ // NULL, NOT ZERO, for a row synced before the function counted issues — the same
+ // distinction web/lib/contributions.ts makes for the same reason. Telling somebody
+ // who has filed a dozen bug reports that they have filed none is worse than saying
+ // we have not looked.
+ issues: typeof c.issues === "number" ? c.issues : null,
+ }))
+ .sort(
+ (a, b) =>
+ b.merged - a.merged ||
+ b.repos - a.repos ||
+ (b.issues ?? 0) - (a.issues ?? 0) ||
+ a.name.localeCompare(b.name),
+ );
+
+ await db
+ .collection(LEADERBOARD)
+ .doc(BOARD_ID)
+ .set({
+ rows: rows.slice(0, BOARD_SIZE),
+ // How many were ranked in total, so a member who is not in the top hundred can be
+ // told that plainly rather than being left to wonder whether the sync missed them.
+ counted: rows.length,
+ built_at: admin.firestore.FieldValue.serverTimestamp(),
+ });
+
+ logger.info(`Leaderboard rebuilt: ${rows.length} ranked, top ${Math.min(rows.length, BOARD_SIZE)} published.`);
+}
+
exports.syncContributions = onSchedule(
{
// 04:00 IST — the middle of the night for the only people who use this. The timezone
@@ -288,6 +383,22 @@ exports.syncContributions = onSchedule(
}
logger.info(`Sync finished. ${done} updated, ${failed} failed.`);
+
+ // THE BOARD IS REBUILT EVEN WHEN THE SWEEP GOT NOWHERE, including after a rate limit
+ // broke out of the loop above. It is derived from what is stored, not from what this
+ // run fetched, so skipping it on a bad night would leave yesterday's document in
+ // place while `built_at` said yesterday — which is correct but useless. Rebuilding
+ // costs two collection reads and makes the timestamp mean "we looked", which is the
+ // thing the page promises its reader.
+ try {
+ await rebuildLeaderboard();
+ } catch (err) {
+ // A failed board does not fail the sync. The counts are the record; the ranking is
+ // a view of it, and a stale view is worth more than a run marked failed.
+ logger.error("Could not rebuild the leaderboard.", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
},
);
@@ -432,3 +543,4 @@ exports.tallyResponses = onDocumentWritten(
logger.info("Tally updated.", { formId, responses: responses.size });
},
);
+
diff --git a/functions/test-github.mjs b/functions/test-github.mjs
index d11475a..1edba93 100644
--- a/functions/test-github.mjs
+++ b/functions/test-github.mjs
@@ -112,11 +112,15 @@ stubFetch([
}),
],
["is%3Aopen", json({ total_count: 2, items: [] })],
+ // The issues search carries no is: filter at all, which is what distinguishes it here
+ // as well as at GitHub — see the note in github.js on why state is not filtered.
+ ["type%3Aissue", json({ total_count: 5, items: [] })],
]);
{
const c = await fetchContributions("asha", "");
eq("merged is the search total, not the page length", c.merged, 12);
eq("open is counted separately", c.open, 2);
+ eq("issues opened are counted separately again", c.issues, 5);
eq("repos counts DISTINCT repositories across the whole page", c.repos, 4);
eq("the stored list is capped at RECENT", c.recent.length, 8);
eq("not_found is absent for a real account", c.not_found, undefined);
@@ -128,6 +132,7 @@ stubFetch([
["/users/", json({ login: "newbie" })],
["is%3Amerged", json({ total_count: 0, items: [] })],
["is%3Aopen", json({ total_count: 0, items: [] })],
+ ["type%3Aissue", json({ total_count: 0, items: [] })],
]);
{
const c = await fetchContributions("newbie", "");
@@ -168,6 +173,7 @@ stubFetch([["/users/", json({}, 500)]]);
["/users/", json({}, 500)],
["is%3Amerged", json({ total_count: 1, items: [item(1)] })],
["is%3Aopen", json({ total_count: 0, items: [] })],
+ ["type%3Aissue", json({ total_count: 0, items: [] })],
]);
const c = await fetchContributions("asha", "");
eq("a 500 on the user lookup does not flag a typo", c.not_found, undefined);
diff --git a/web/.gitignore b/web/.gitignore
index 03b7526..1d49a56 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -21,3 +21,11 @@ tsconfig.tsbuildinfo
# Playwright capture output. The script is committed; its screenshots are not —
# they are large, regenerable in one command, and would churn every run.
study/
+
+# Member photos. A face is personal data and this repository is readable by anyone
+# with the clone URL, so the images live on the machines that deploy and go no
+# further. README.md is the instructions for adding one, not a photo, so it stays.
+# An absent file is a designed state, not a break: Portrait.tsx renders a monogram,
+# which is why a fresh clone and CI show initials rather than empty frames.
+public/people/*
+!public/people/README.md
diff --git a/web/app/(app)/admin/build-days/page.tsx b/web/app/(app)/admin/build-days/page.tsx
new file mode 100644
index 0000000..b21be9c
--- /dev/null
+++ b/web/app/(app)/admin/build-days/page.tsx
@@ -0,0 +1,31 @@
+import type { Metadata } from "next";
+import AdminGate from "@/components/admin/Gate";
+import SectionHead from "@/components/dashboard/SectionHead";
+import BuildDays from "@/components/admin/BuildDays";
+import FloorMentors from "@/components/admin/FloorMentors";
+
+// One organiser concern, one route. The roll call is the screen an organiser has open
+// DURING a session rather than between them, which is the argument for it not being a
+// panel on /admin/sessions: scheduling is done in advance and calmly, and marking forty
+// people is done in a room with the clock running.
+// NOT a privilege gate; see components/admin/Gate.tsx.
+export const metadata: Metadata = {
+ title: "Build Days",
+ description: "Who came, their track, and what they worked on.",
+ robots: { index: false, follow: false },
+};
+
+export default function Page() {
+ return (
+
+
+ Who came, their track, and what they worked on. PRs live on each member’s
+ dashboard, straight from GitHub.
+
+
+
+
+
+
+ );
+}
diff --git a/web/app/(app)/admin/cohort/page.tsx b/web/app/(app)/admin/cohort/page.tsx
new file mode 100644
index 0000000..4246649
--- /dev/null
+++ b/web/app/(app)/admin/cohort/page.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from "next";
+import AdminGate from "@/components/admin/Gate";
+import SectionHead from "@/components/dashboard/SectionHead";
+import Cohort from "@/components/admin/Cohort";
+
+// One organiser concern, one route. Taking the roll is weekly; this is once a term and
+// across every Build Day at once, which is why it is not a tab on that screen.
+// NOT a privilege gate; see components/admin/Gate.tsx.
+export const metadata: Metadata = {
+ title: "Cohort",
+ description: "Every student's Build Days side by side, for choosing the mentor cohort.",
+ robots: { index: false, follow: false },
+};
+
+export default function Page() {
+ return (
+
+
+ Who keeps showing up and what they’ve landed, side by side — for picking the
+ mentor cohort.
+
+
+
+ );
+}
diff --git a/web/app/(app)/admin/members/page.tsx b/web/app/(app)/admin/members/page.tsx
index 846e5be..8040029 100644
--- a/web/app/(app)/admin/members/page.tsx
+++ b/web/app/(app)/admin/members/page.tsx
@@ -13,9 +13,8 @@ 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.
+ Everyone who's joined. Batch, branch and year come from their college email, so
+ breakdowns load on request.
diff --git a/web/app/(app)/admin/notices/page.tsx b/web/app/(app)/admin/notices/page.tsx
index 36c4e65..09aef2a 100644
--- a/web/app/(app)/admin/notices/page.tsx
+++ b/web/app/(app)/admin/notices/page.tsx
@@ -8,7 +8,7 @@ import Composer from "@/components/Composer";
// 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.",
+ description: "Post to the board on every member's dashboard.",
robots: { index: false, follow: false },
};
@@ -16,7 +16,7 @@ export default function Page() {
return (
- The board every member reads on their dashboard. Post something and it lands there first.
+ Post it here and it tops every member's dashboard.
diff --git a/web/app/(app)/admin/sessions/page.tsx b/web/app/(app)/admin/sessions/page.tsx
index c8ab622..c8b44d8 100644
--- a/web/app/(app)/admin/sessions/page.tsx
+++ b/web/app/(app)/admin/sessions/page.tsx
@@ -8,7 +8,7 @@ import Sessions from "@/components/Sessions";
// 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.",
+ description: "When the club meets, and what's on.",
robots: { index: false, follow: false },
};
@@ -16,7 +16,7 @@ export default function Page() {
return (
- When the club meets, and what is on. A session is the most time-bound thing a member sees.
+ When the club meets, and what's on.
diff --git a/web/app/(app)/dashboard/build-days/floor/page.tsx b/web/app/(app)/dashboard/build-days/floor/page.tsx
new file mode 100644
index 0000000..693d581
--- /dev/null
+++ b/web/app/(app)/dashboard/build-days/floor/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from "next";
+import SectionHead from "@/components/dashboard/SectionHead";
+import FloorDesk from "@/components/floor/FloorDesk";
+
+// The mentors' live view of a Build Day. Under /dashboard rather than /admin because floor
+// mentors are not organisers. NOT a privilege gate — see components/floor/FloorDesk.tsx.
+export const metadata: Metadata = {
+ title: "Help queue",
+ description: "Who wants a hand at today's Build Day.",
+ robots: { index: false, follow: false },
+};
+
+export default function Page() {
+ return (
+ <>
+
+ Hands up first, then the quiet ones.
+
+
+ >
+ );
+}
diff --git a/web/app/(app)/dashboard/build-days/page.tsx b/web/app/(app)/dashboard/build-days/page.tsx
new file mode 100644
index 0000000..061fd96
--- /dev/null
+++ b/web/app/(app)/dashboard/build-days/page.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from "next";
+import BuildDaysSection from "@/components/dashboard/BuildDaysSection";
+
+// `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: "Build Days",
+ description: "Your Build Days, your track, and what you worked on.",
+ robots: { index: false, follow: false },
+};
+
+export default function BuildDaysPage() {
+ return ;
+}
diff --git a/web/app/(app)/dashboard/build-days/screen/page.tsx b/web/app/(app)/dashboard/build-days/screen/page.tsx
new file mode 100644
index 0000000..2003efb
--- /dev/null
+++ b/web/app/(app)/dashboard/build-days/screen/page.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from "next";
+import BigScreen from "@/components/floor/BigScreen";
+
+// The projector view of a live Build Day. NOT a privilege gate — see
+// components/floor/BigScreen.tsx.
+export const metadata: Metadata = {
+ title: "Big screen",
+ description: "Today's Build Day, live.",
+ robots: { index: false, follow: false },
+};
+
+export default function Page() {
+ return ;
+}
diff --git a/web/app/(app)/dashboard/events/page.tsx b/web/app/(app)/dashboard/events/page.tsx
new file mode 100644
index 0000000..4948d94
--- /dev/null
+++ b/web/app/(app)/dashboard/events/page.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from "next";
+import EventsSection from "@/components/dashboard/EventsSection";
+
+// THE MEMBER'S DIARY. Distinct from /events, which is the public, hand-kept page aimed at
+// somebody deciding whether to join — this is the live `sessions` collection, including
+// the sessions an organiser marked members-only, which is why it can only live in here.
+//
+// `noindex`, like every signed-in route: the markup ships with the static export, and the
+// rules are what refuse a stranger the data behind it.
+
+export const metadata: Metadata = {
+ title: "What's on",
+ description: "Sessions and Build Days the club has scheduled.",
+ robots: { index: false, follow: false },
+};
+
+export default function EventsPage() {
+ return ;
+}
diff --git a/web/app/(app)/dashboard/leaderboard/page.tsx b/web/app/(app)/dashboard/leaderboard/page.tsx
new file mode 100644
index 0000000..4d5cd48
--- /dev/null
+++ b/web/app/(app)/dashboard/leaderboard/page.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+import Leaderboard from "@/components/dashboard/Leaderboard";
+
+// THE CLUB'S RANKING. Like every other route under (app), this markup is part of the
+// static export and ships to anybody who asks for it — what refuses a stranger is
+// firestore.rules, where `leaderboard/current` is readable only by an admitted member.
+//
+// `noindex`, because it names members. The rules already stop a crawler reading the data,
+// but "Leaderboard" on a club site is exactly the URL somebody links to from outside, and
+// the people on it agreed to be in a club rather than in a public ranking.
+
+export const metadata: Metadata = {
+ title: "Leaderboard",
+ description: "Merged pull requests across the club.",
+ robots: { index: false, follow: false },
+};
+
+export default function LeaderboardPage() {
+ return ;
+}
diff --git a/web/app/(app)/dashboard/page.tsx b/web/app/(app)/dashboard/page.tsx
index f8b835e..33d22a0 100644
--- a/web/app/(app)/dashboard/page.tsx
+++ b/web/app/(app)/dashboard/page.tsx
@@ -14,7 +14,7 @@ import MemberDashboard from "@/components/MemberDashboard";
export const metadata: Metadata = {
title: "Your dashboard",
- description: "Your details, what you have merged, and what the club has pinned up.",
+ description: "Your details, your merges, and the club board.",
robots: { index: false, follow: false },
};
diff --git a/web/app/(app)/onboarding/page.tsx b/web/app/(app)/onboarding/page.tsx
index f20d1aa..05ca93e 100644
--- a/web/app/(app)/onboarding/page.tsx
+++ b/web/app/(app)/onboarding/page.tsx
@@ -18,7 +18,7 @@ import OnboardingGate from "@/components/OnboardingGate";
export const metadata: Metadata = {
title: "Finish joining",
- description: "Three questions, and you are a member of the Scaler Open Source Club.",
+ description: "Three questions, and you're in.",
robots: { index: false, follow: false },
};
@@ -42,9 +42,7 @@ export default function Onboarding() {
Three questions.
- You only do this once, and you can change any of it later. It is what the
- organisers see when they are putting build-day pairs and programme cohorts
- together.
+ Just once, and you can change it later. It helps us pair you up on build days.
{/* The gate reads the query string, which needs a Suspense boundary or
diff --git a/web/app/(site)/events/page.tsx b/web/app/(site)/events/page.tsx
new file mode 100644
index 0000000..b2ee440
--- /dev/null
+++ b/web/app/(site)/events/page.tsx
@@ -0,0 +1,450 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import Doodle from "@/components/Doodle";
+import Duo from "@/components/Duo";
+import Eyebrow from "@/components/Eyebrow";
+import NextAction from "@/components/NextAction";
+import Glow from "@/components/fx/Glow";
+import { DASHBOARD_HREF, JOIN_HREF } from "@/content/site";
+import {
+ closedExternal,
+ internalOfKind,
+ openExternal,
+ pastInternal,
+ type ExternalEvent,
+ type InternalEvent,
+} from "@/content/events";
+
+// THE EVENTS PAGE. Two halves: what we run, and what is worth entering elsewhere.
+//
+// WHY BOTH HALVES LIVE ON ONE ROUTE rather than /events and /hackathons. A student
+// opening this is asking one question — "is there something I should turn up to or
+// sign up for this month" — and the answer crosses the boundary. Splitting it would
+// make them ask it twice and give them half an answer each time. The division that
+// matters to them is not ours-versus-theirs, it is *can I still enter*, which is why
+// closed things sink to the bottom of their section rather than getting a page.
+//
+// THE HEADINGS NAME WHAT THE BLOCK COSTS, not who runs it. "Walk in" and "Apply" are
+// the real difference between the halves, and they are the two words a student is
+// scanning for — where the earlier pass ("Ours" / "Somebody else's", under headings
+// ending "so we can promise the room" and "we are not the ones enforcing them") spent
+// both second clauses on the club's own liability. A heading that is a disclaimer is a
+// wasted heading: the caveat is true, it belongs in the standfirst, and it is there.
+//
+// The club's accuracy about which claims are its own survives in the second section's
+// trail — "not ours to move" is the disclaimer earning its place by also being the
+// most useful thing a reader can know about an external deadline.
+//
+// ORDER: internal first. It is the thing a reader can act on this week without an
+// application, and it is the only half where turning up is enough.
+//
+// EVERY LIST RENDERS FROM content/events.ts AND EVERY ONE HAS A HOLDING STATE. The
+// file ships empty — the data arrives after this page does — so an empty group has to
+// read as "nothing this cycle" rather than as a section that failed to load. Same
+// dashed-box pattern as the projects page, for the same reason.
+
+export const metadata: Metadata = {
+ title: "Events",
+ description:
+ "Build days, sessions and hackathons we run — plus outside ones worth entering.",
+};
+
+/* ---- One internal event --------------------------------------------------
+ A card, not a table row. The fields are mostly optional and a table with four
+ empty cells reads as missing data, where a card with three lines reads as a
+ short entry. `done` greys it and drops the action: a past event is context, and
+ a button on something that already happened is a dead end with a hover state. */
+function InternalCard({ e }: { e: InternalEvent }) {
+ return (
+
+
+
+ {e.name}
+
+ {/* NOT shrink-0, and the projects page learned this the expensive way: a
+ long value in a flex row with no min-width forces the row past the
+ viewport, and `overflow-x: hidden` on the body hides the evidence. */}
+ {e.done && (
+
+ Done
+
+ )}
+
+
+ {/* The date is the loudest thing after the name, because it is the only field
+ that decides whether the reader can act. Mono and tabular so a column of
+ them lines up. */}
+
}
+
+ {/* Each optional row gated on its own data — an empty tag strip under a rule
+ reads as a rendering fault. */}
+ {e.tags && e.tags.length > 0 && (
+
+ {e.tags.map((t) => (
+
+ {t}
+
+ ))}
+
+ )}
+
+ {e.href && !e.done && (
+
+
+ {e.cta ?? "Details"}
+
+
+ )}
+
+ );
+}
+
+/* ---- One external event --------------------------------------------------
+ Wider than the internal card and laid out as a field with a sidebar, because it
+ carries something the internal ones do not: an outbound link that IS the point of
+ the entry. It gets a real button in its own column rather than a text link at the
+ end of a paragraph — a reader scanning six of these is scanning for the way in.
+
+ THE HOST IS NAMED ABOVE THE EVENT, not under it. "Ministry of Education" tells a
+ student more about whether to enter than the hackathon's name does, and a page of
+ external listings with no visible source is indistinguishable from spam. */
+function ExternalCard({ e }: { e: ExternalEvent }) {
+ return (
+
+
+
+
+ {e.done ? "Closed" : e.host}
+
+
+ {/* break-words, min-w-0: an event name from a data file has no bounded
+ length, and a long unbroken one at display size overflows a phone. */}
+
+ {/* THE DEADLINE IS ABOVE THE BUTTON, not beside the dates. It is the one
+ fact that expires, and a reader who has already decided to enter is
+ looking at this column and nothing else. */}
+ {e.registerBy && (
+
+ );
+}
+
+/* The holding state, shared by every group on the page. One sentence saying the
+ section is empty and one saying why that is a choice rather than a failure. */
+function Nothing({ title, body }: { title: string; body?: string }) {
+ return (
+
+
{title}
+ {/* The second line is optional. An empty state that only has to say the
+ list is empty says it in the title; a paragraph under it explaining why
+ is a paragraph the reader did not ask for. */}
+ {body &&
{body}
}
+
+ );
+}
+
+export default function EventsPage() {
+ const buildDays = internalOfKind("build-day");
+ const sessions = internalOfKind("session");
+ const ourHackathons = internalOfKind("hackathon");
+ const past = pastInternal();
+ const external = openExternal();
+ const closed = closedExternal();
+
+ return (
+
+ {/* THE CHIP IS THE h1. The headline and the standfirst under it are gone at
+ the client's instruction, and a page with no h1 at all is a regression a
+ screen reader hits before anybody else does — there is no other heading
+ here above the section h2s. Preflight resets heading size and weight to
+ inherit, so `.chip` renders it exactly as it rendered as a span; the div
+ wrapper is because a heading inside a
is invalid. */}
+
+
+
What’s on
+
+
+
+
+ {/* ---- 1. Ours ------------------------------------------------------
+ First, because it is the half a reader can act on this week. The three
+ groups are separate headings rather than one merged list: a build day and
+ a hackathon ask for completely different amounts of a student's weekend,
+ and merging them would hide the cheap one behind the expensive one. */}
+
+
+
+
+
Walk in
+
+
+
+ {/* The standfirst no longer restates "no fee, no selection, no form" — the
+ heading above now says that, and this paragraph said it again three lines
+ later. What it carries instead is the thing the heading cannot: that the
+ room is split by level, which is the actual answer to "am I
+ too early for this". */}
+
+ Tables are split by level, from first-time git to real issues, so{" "}
+ turning up early is not turning up unprepared.
+
+
+ {/* ---- Build days --------------------------------------------------- */}
+
+
+
+ Build days
+
+ {buildDays.length > 0 && (
+
+ {buildDays.length} coming up
+
+ )}
+
+
+ {buildDays.length === 0 ? (
+
+ ) : (
+
+ {buildDays.map((e) => (
+
+ ))}
+
+ )}
+
+ {/* The members' copy of this list, which is the live one. Named here
+ rather than left implicit: somebody who has already joined and wants
+ the next four dates should not have to find out that the dashboard
+ has them. */}
+
+ Already a member?{" "}
+
+ Build days
+ {" "}
+ has the full schedule and your attendance.
+
+
+ {/* ---- Our own hackathons -------------------------------------------- */}
+
+
+
+ Hackathons we run
+
+ {ourHackathons.length > 0 && (
+
+ {ourHackathons.length} coming up
+
+ )}
+
+
+ {ourHackathons.length === 0 ? (
+
+ ) : (
+
+ {ourHackathons.map((e) => (
+
+ ))}
+
+ )}
+
+
+ {/* ---- What already happened ------------------------------------------
+ Only when there is one. A club with a visible past is more convincing
+ than one that only ever advertises — but an empty "Already happened"
+ heading says the opposite of what it is there for. */}
+ {past.length > 0 && (
+
+
+
+ Already happened
+
+
+
+ {past.map((e) => (
+
+ ))}
+
+
+ )}
+
+
+ {/* ---- 2. Everyone else's --------------------------------------------
+ A band, so the change of who is responsible is visible before it is read.
+ The alternation also matters structurally: the section above is plain and
+ NextAction below is a band, so this sits between them correctly. */}
+
+
+
Apply
+
+
+
+ {external.length === 0 ? (
+
+ ) : (
+
+ {external.map((e) => (
+
+ ))}
+
+ )}
+
+ {closed.length > 0 && (
+
+
+
+ Closed for this year
+
+
+
+ Most run yearly — use these to plan for next year’s.
+
+
+ {closed.map((e) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/app/(site)/guide/page.tsx b/web/app/(site)/guide/page.tsx
new file mode 100644
index 0000000..9b2851d
--- /dev/null
+++ b/web/app/(site)/guide/page.tsx
@@ -0,0 +1,192 @@
+import type { Metadata } from "next";
+import Doodle from "@/components/Doodle";
+import Duo from "@/components/Duo";
+import NextAction from "@/components/NextAction";
+import PRTimeline from "@/components/PRTimeline";
+import Terminal from "@/components/Terminal";
+import { GLOSSARY, MAINTAINERS, MAINTAINERS_SOURCE } from "@/content/essence";
+import { JOIN_HREF, LINKS } from "@/content/site";
+
+// THE GUIDE. Everything a first-timer needs explained, for the reader who has
+// already decided to care.
+//
+// It collects the explainers that used to sit in the first screens of other pages:
+// the pull-request loop and the real commands (from /how-to-join), and who is on
+// the other side of a PR plus the vocabulary (from the home page). All of it is
+// good, none of it answers "should I care", which is the only question a stranger
+// on the home page is asking. Here it answers "so how does this actually work".
+//
+// Linked from the home page's open-source explainer and from the footer. Not in the
+// nav: it is somewhere a reader is sent, not a choice the bar offers.
+
+export const metadata: Metadata = {
+ title: "Guide",
+ description:
+ "How a pull request gets merged, the commands for your first one, and the jargon, decoded.",
+};
+
+export default function Guide() {
+ return (
+
+
+
+ The guide
+
+
+
+
+ What happens after you hit submit, the commands, who reads your patch,
+ and the words everyone assumes you know.
+
+
+
+ {/* ---- Who is on the other side -------------------------------------
+ The counterweight to the career argument on the home page: a reader who
+ only ever hears "this is good for your resume" becomes the contributor
+ maintainers complain about. */}
+
+
Who is on the other side
+
+
+ Knowing this changes how you write a pull request.
+
+
+
+ {/* ---- The vocabulary ------------------------------------------------
+ The barrier to a first contribution is very often linguistic: a
+ second-year who writes fine Python still stalls at "rebase onto
+ upstream/main and squash before we triage". */}
+
+
The words
+
+ {/* The count is derived, so a thirteenth term cannot make the prose lie. */}
+
+ {GLOSSARY.length} words used constantly, explained never. We all learned
+ them{" "}
+ by being confused in public.
+
+
+
+ {GLOSSARY.map((g) => (
+
+
{g.term}
+
{g.meaning}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/web/app/(site)/hall-of-fame/page.tsx b/web/app/(site)/hall-of-fame/page.tsx
index 6a6b589..9b44313 100644
--- a/web/app/(site)/hall-of-fame/page.tsx
+++ b/web/app/(site)/hall-of-fame/page.tsx
@@ -2,12 +2,11 @@ import type { Metadata } from "next";
import Doodle from "@/components/Doodle";
import Duo from "@/components/Duo";
import Hall from "@/components/hall/Hall";
-import Ticker from "@/components/Ticker";
import NextAction from "@/components/NextAction";
import Glow from "@/components/fx/Glow";
-import Sticker from "@/components/fx/Sticker";
import Note from "@/components/fx/Note";
-import { CALENDAR } from "@/content/club";
+import { CALENDAR } from "@/content/programmes";
+import { JOIN_HREF } from "@/content/site";
// THE HALL OF FAME. Students selected into international programmes.
//
@@ -23,7 +22,7 @@ import { CALENDAR } from "@/content/club";
//
// `consented` gates every card. A real person's name and photograph do not render
// without it, and the gate is in the data rather than in a reviewer's memory —
-// see the note at the head of club.ts.
+// see the note at the head of content/selections.ts.
export const metadata: Metadata = {
title: "Hall of Fame",
@@ -37,8 +36,8 @@ export default function HallOfFame() {
{/* No separate title block on this route. The section below already opens
with the eyebrow, the Duo and the standfirst that a page header would
otherwise repeat verbatim two inches further up — so it carries
- `.page-top` itself and is the header. The 25-card grid is the content and
- it should start as high as the nav allows. */}
+ `.page-top` itself and is the header. The cohort grids are the content and
+ they should start as high as the nav allows. */}
{/* ---- The hall: selections into international programmes ----------
Placed first because it is the strongest thing the club can say. A
named student next to "GSoC 2026" is proof somebody else ran a
@@ -91,30 +90,6 @@ export default function HallOfFame() {
first thing under the nav. See the note over `.page-top` in globals.css
for why the two cannot both be present. */}
- {/* Sticker 2 of 3. Right, level with the "Selected" eyebrow.
- `top-28` put it across the headline from 1024 to about 1140: the
- Duo below is capped at max-w-4xl, so its right edge is pinned at
- 920px whatever the window does, and a 182px sticker flush right
- needs the window past ~1140 before the two stop meeting. Nothing
- catches that — it is not overflow, and the QA sweep samples 1440,
- which is clear.
-
- The eyebrow's row is the right band to use and the padding strip
- above it is not, which is worth writing down because the padding
- looks like the safer choice: it is empty at every width, whereas
- this row is only empty to the RIGHT of a 130px chip. But a
- section's top padding is shared space — it reads as the gap after
- the section above, and parked there this sticker sat against the
- bottom corner of the apply form and looked like a comment on
- somebody's half-filled sign-up form.
- Level with the eyebrow it is unambiguously part of the hall, and
- the chip beside it is short at every width there is. */}
-
{/* Left gutter — the right one is already carrying the sticker and
the glow. Pinned rather than taped, so the two decorations either
side of this heading are not one object mirrored.
@@ -171,8 +146,8 @@ export default function HallOfFame() {
trail="GSoC, LFX Mentorship, C4GT, Summer of Bitcoin."
/>
- These are competitive, international selection processes run by other
- organisations. Getting in is not something a club can award itself.
+ Competitive, international, and run by other organisations. No club can
+ hand these out.
{/* Hall used to sit outside the container because the WebGL stage was
@@ -192,19 +167,10 @@ export default function HallOfFame() {
- {/* ---- The ticker ---------------------------------------------------
- Between the hall and the section that answers it, and that position is
- the reason it earns a place rather than being movement for its own
- sake: the reader has just met a grid of names next to programme
- credentials, and the strip names the ecosystems those credentials
- belong to before "we are not checking whether you can already code"
- takes the pressure back off.
-
- It is also the only continuously moving element on the page. One is a
- pulse; two would be a fairground, which is the same argument the hero
- comment makes about a second spectacle cancelling the first. */}
-
-
+ {/* The ticker of programme and ecosystem names used to sit here. It mixed
+ programmes members have been selected into with projects nobody has
+ (Kubernetes, PyTorch, Mozilla…), on the one page whose claim is that
+ every name is a real selection, so it went. Ticker.tsx is unmounted. */}
{/* The alumni table used to sit here — "Alumni, and where they went",
with a row per past core member and a "Now at" column. Removed rather
@@ -226,16 +192,14 @@ export default function HallOfFame() {
self-contained removal.
Incidentally better for the band rhythm: this section was a `.band` and so
- is NextAction below it, which put two tinted blocks against each other.
- The Ticker above is a plain `.section`, so the alternation now runs
- plain → tinted the way it does everywhere else. */}
+ is NextAction below it, which put two tinted blocks against each other. */}
diff --git a/web/app/(site)/how-to-join/page.tsx b/web/app/(site)/how-to-join/page.tsx
deleted file mode 100644
index c95b0ba..0000000
--- a/web/app/(site)/how-to-join/page.tsx
+++ /dev/null
@@ -1,751 +0,0 @@
-import type { Metadata } from "next";
-import Link from "next/link";
-import Banner from "@/components/Banner";
-import PRTimeline from "@/components/PRTimeline";
-import Terminal from "@/components/Terminal";
-import Doodle from "@/components/Doodle";
-import Duo from "@/components/Duo";
-import Faq from "@/components/Faq";
-import NextAction from "@/components/NextAction";
-import Note from "@/components/fx/Note";
-import Sticker from "@/components/fx/Sticker";
-import { JOIN_HREF } from "@/content/site";
-import {
- CULTURE,
- FAQ,
- LINKS,
- LOOKING_FOR,
- NOT_FOR,
- PATH,
- totals,
-} from "@/content/club";
-// PATH and PATHS are both here and are not the same thing, which is worth stating
-// once because the names are one character apart. PATH (club.ts) is the four steps
-// every first contribution goes through, in order — a sequence. PATHS (join.ts) is
-// the set of named entry routes a person can pick from, split by level, and it is
-// what the join form's preselect is derived from. The page renders the choice
-// first and the sequence second, because "which of these am I" comes before "what
-// happens then".
-import { LEVEL_LABEL, PATHS, type Level } from "@/content/join";
-
-// The two level groups the paths are rendered in, in order.
-//
-// NOT `LEVELS` from join.ts, and the near-miss is worth naming: that export is the
-// join form's experience dropdown — "Never contributed", "Some Git experience",
-// "I have merged pull requests" — which is a different question with three answers
-// rather than this one with two. Importing it here typechecks as an array and
-// renders two sections titled after form options. Hence the explicit local name.
-const PATH_LEVELS: Level[] = ["beginner", "intermediate"];
-
-// HOW TO JOIN. Everything a student needs to have read before the form.
-//
-// THE ORDER IS A SEQUENCE OF OBJECTIONS, answered in the order they occur:
-//
-// looking-for "they must already be brilliant" — no, here is the bar
-// path "I would not know where to start" — four steps, named
-// Banner (the stop where that argument lands)
-// culture "what is it actually like" — Maggi and arguing
-// who-not-for "is this a waste of my time" — four honest reasons to leave
-// faq everything left over
-//
-// The deterrent section leading is not an accident of layout. A grid of students
-// selected into international programmes — which is what a reader arrives here
-// from — is simultaneously this club's strongest claim and its biggest deterrent,
-// and the private thought it produces is "I could never". That gets answered
-// first, at the moment it occurs, rather than in an FAQ nobody scrolls to.
-//
-// WHO-NOT-FOR STAYS. Four reasons to walk away, on the page whose entire job is
-// recruitment, is the section every instinct says to cut — and it is the one that
-// makes the rest credible. A page that claims a club is for everybody is a page
-// making a claim nobody believes.
-
-/* The mini code frames on the bento cards. Ordinary git — commands any reader can
- run themselves — rather than simulated output. A fake `remote: 3 commits pushed`
- would be indistinguishable from the real figures the hero terminal reads out of
- the content file, and the two must not be confusable. */
-const CODE_LINES: [string, string][] = [
- ["git clone ", "# read it first"],
- ["git switch -c fix/typo", "# small first"],
- ["git commit -m '...'", "# say why, not what"],
- ["gh pr create", "# then read the review"],
- ["git pull --rebase", "# keep history clean"],
-];
-
-export const metadata: Metadata = {
- title: "How to Join",
- description:
- "What the club looks for, the four steps to a first contribution, what the sessions are actually like, and who this is not for.",
-};
-
-export default function HowToJoin() {
- const t = totals();
-
- return (
-
-
-
The way in
-
-
- No interview, no test, no selection at the door. What follows is what the
- club actually looks for, the four steps everybody goes through, and an
- honest account of who tends to leave.
-
-
-
- {/* ---- The four paths --------------------------------------------------- */}
- {PATH_LEVELS.map((level, levelIndex) => (
-
-
-
- {String(
- // Numbered 01–04 across BOTH sections rather than restarting
- // at each level, because the brief presents them as four
- // paths and two independent "01"s would read as two lists.
- levelIndex * 2 + i + 1,
- ).padStart(2, "0")}
-
-
- {p.name}
-
-
{p.tagline}
-
- {p.bring && (
-
- Bring: {p.bring}
-
- )}
-
- {/* Per-path entry into the same form. Not a competing action —
- it is the page's one action, addressed to whichever path the
- reader has just finished reading. */}
-
-
- Apply on this path →
-
-
-
-
-
-
-
Who it's for
-
{p.forWho}
-
-
-
-
What week one looks like
-
- {p.weekOne.map((w, wi) => (
-
-
- {w}
-
- ))}
-
-
-
-
-
- What you walk away with
-
-
-
{p.walkAway}
-
-
- {/* Only the hackathon path carries one today. Rendered as a quiet
- aside rather than a highlighted callout — it is a
- clarification about how the thing runs, not a selling point. */}
- {p.note && (
-
-
- {p.note}
-
- )}
-
-
-
- ))}
-
-
- ))}
-
-
- {/* ---- What actually happens to a pull request -------------------------
- Placed here rather than on the home page on purpose: it answers the
- question the four paths above have just raised, which is "yes but what
- literally happens when I press the button". */}
-
-
The loop
-
-
- The reason first contributions feel frightening is that nobody describes this
- part, so it feels like posting an exam to a stranger. It is five steps and one
- of them is somebody asking you to change something, which is{" "}
- the normal case, not a failure.
-
-
- {/* Equal columns, not a 24rem sidebar. The longest command in the terminal is
- about 484px of `white-space: pre` that must not wrap — a shell command broken
- 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. */}
-
-
-
-
-
And the commands, for real
-
- These work today against this website's own repository. The prompts
- are excluded from what you copy.
-
-
-
- {/* ---- What we look for ---------------------------------------------
- Placed directly after the hall on purpose. A grid of named students
- selected into international programmes is the page's strongest claim
- AND its biggest deterrent — the immediate private thought is "they must
- already be brilliant, I could never". This answers that at the exact
- moment it occurs, rather than in an FAQ nobody scrolls to. */}
-
- {/* Sticker 3 of 3. No hover effect on this one: three stickers that all
- react is a page that fidgets. */}
-
-
- What we look for
-
-
-
-
- Syntax is a few weeks of work. Reading somebody else's codebase and
- reasoning about it is the part that actually decides whether your first
- patch gets merged — and it is{" "}
- not what any exam measures. There is no
- test to pass here and no interview to prepare for. This is simply what
- the work turns out to reward.
-
-
- {/* A contrast rather than a list. "We value reasoning" alone is the kind
- of thing every club says; setting each value against the credential it
- replaces is what makes it specific enough to be believed.
-
- TWO CARDS, where this used to be one stack of paired rows. The pairing
- is still real — index i on the left is the credential index i on the
- right replaces — but it is now carried by ORDER rather than by
- adjacency, which is the one thing this layout gives up. What it buys
- is that each side reads as a single position you can take in at a
- glance, and that the right-hand card can be visibly the answer: it is
- the one with the border, the tint and the weight.
-
- Both lists are rendered from the same array in the same order, so they
- cannot drift; if a future entry breaks the correspondence, it breaks
- in the content file rather than here.
-
- ORDER is only half of it, though. The right-hand lines set two deep
- and the left-hand ones set one, so left row 3 was drifting a whole
- line above its own replacement — the correspondence was true but
- unreadable. So the rows are pinned instead of flowed: both cards are
- grid items in one row and therefore already equal height, each card
- is a column that hands its leftover height to the list, and each list
- divides that height into N equal tracks. Pair i then begins at the
- same y on both sides no matter how either line wraps.
-
- The track count comes off the array rather than being written out, so
- a fifth pair cannot silently land in a four-row grid. */}
- {/* Its own group rather than one more child of the section's: the pair
- sits a screen below the heading that introduces it, and on the
- section's trigger both cards would have finished arriving before
- anybody scrolled far enough to see either. */}
-
- {/* Left: the discarded measure, now tinted red. Deliberately the
- quieter card — no lift, everything struck through. It is here to be
- dismissed and it should look dismissed.
-
- The tints are 4%/6% washes rather than the soft pastels the brief
- sketches, because these are large fills behind long paragraphs. At
- the strength a badge wants, a full card of red reads as an error
- state — and the whole point of the copy inside is that having a
- contest rating is not a failure, it is simply the wrong measure.
- The border carries the colour instead; the fill only has to keep
- the two sides from being the same object. */}
-
- {/* self-start, or the pill spans the whole card. `.chip` is
- `display: inline-block`, but these cards are `flex flex-col` and
- flexbox BLOCKIFIES an inline-level child — so the chip becomes a
- block-level flex item and the default `align-items: stretch`
- runs it edge to edge. It renders as a full-width bar with the
- label at one end, which reads as a section header rather than a
- sticker. Same fix on the emerald card below. */}
-
Old way 🚩
-
- {LOOKING_FOR.map((r) => (
-
- {/* A struck-through circle rather than a red cross. These are
- not failures — they are simply the wrong measure, and an
- error icon against "a contest rating" insults every reader
- who has one. The rule through it says "crossed off"; a
- cross would say "wrong". */}
-
- ✕
-
-
- {r.not}
-
-
- ))}
-
-
-
- {/* Right: what actually counts. Louder on every axis — emerald tint
- and border, mint sticker, heavier type, and a star per line that
- carries a real glow (see .star-glow) rather than just a colour. */}
-
-
OSC way 🟢
-
- {LOOKING_FOR.map((r) => (
-
-
-
{r.yes}
-
- ))}
-
-
-
-
-
- {/* ---- The path. Numbered because it genuinely is a sequence. ------- */}
-
- {/* A FLOW note rather than a gutter one, and this is the section that
- makes the case for that mode existing. The list below is capped at
- max-w-3xl inside an 88rem container, so there are roughly 450px of
- empty container to its right at 1280 and never fewer than 190 at
- lg — the note sits INSIDE that, and therefore appears for everyone
- on a laptop instead of waiting for a 1600px monitor.
-
- It also has something to say here: the headline points at "the
- third" and then the steps make you count to find it.
-
- anchor 49: step 03's own body text ends at 733px at 1024, 1280 and
- 1440 alike — a capped column's ink does not move — so 784 puts the
- note 51px off the step it is talking about at every width. It used
- to hang against the container edge, which at 1800 was 429px away
- from that step and pointing at nothing. */}
-
- {/* The void here runs the full 864px of the section, so the note takes
- the middle of it and a sticker takes the foot. Two decorations in
- one column only works because that column is measured empty end to
- end — see scripts and the placement note in Note.tsx. */}
-
-
How a first contribution actually goes
-
-
- {/* Numbered markers are usually decoration, and were nearly cut for that
- reason. They stay because here the order is the content: the headline
- points at "the third", so a reader has to be able to find which step
- that is.
-
- Which is exactly what the previous 2x2 grid prevented. Four numbered
- items in two columns can be read across (01, 02 / 03, 04) or down
- (01, 03 / 02, 04), and nothing on screen said which — so the one
- sentence above it that depends on position was unresolvable. A single
- column has one reading order. It also costs nothing: these are four
- short steps, not a dense grid needing the horizontal room. */}
-
- {PATH.map((s, i) => (
-
- {/* The numeral was 12px mono in the margin. It is a filled blue
- block now — the sequence has to survive being scanned, and the
- headline above it points at "the third", so finding step three
- at a glance is the whole job. */}
-
- {String(i + 1).padStart(2, "0")}
-
- {/* The gap has to live INSIDE the bordered element. With the padding
- on the
the rule only spanned each text block and broke in
- every gap between steps — four detached ticks instead of one
- line through the sequence. */}
-
-
- {s.step}
-
-
{s.body}
-
-
- ))}
-
-
-
- {/* A deliberate stop, placed where the argument has just finished: the
- reader has learned how a first contribution goes, so "start one" lands
- here. See Banner for why this is not redundant with the sticky bar. */}
-
-
-
-
- {/* ---- How the club actually runs ----------------------------------- */}
-
- {/* The sticky note, in the left gutter beside this section's heading.
- The first one on the page, and the reason the rest exist.
-
- An earlier version of it overlapped the heading by 124px at EVERY
- width from 1280 up, including 1920 — it claimed to be "taped to the
- left gutter" while sitting almost entirely inside the content
- column, because its offset was a 24px nudge rather than the note's
- own width and its gate was `xl:`, which is exactly the width at
- which the gutter is ZERO.
-
- None of the automated checks can catch that. Overlap is not
- overflow, not contrast, not a tap target; the QA sweep passed clean
- through every version of the bug. It took looking at the page —
- which is why the arithmetic now lives in Note.tsx, where the next
- note added cannot get it wrong by being written from memory. */}
- {/* The copy uses `children` rather than `body` for one word of mono,
- which is the only place a note does that. It earns it: the joke is
- that the register this club keeps is a real command the reader can
- run, and setting it in the same face as the sentence around it
- would leave that as a claim instead of a thing you can type.
-
- 13px mono, matching the body line it replaces — the sweep flags
- anything under 11px, and JetBrains Mono's large x-height means it
- sets visibly bigger than the sans at the same size rather than
- smaller. */}
- {/* top-32, level with the headline it is a remark about. It sat at
- top-56 while a flow note ("Laptop open.") held the opposite margin
- — two notes at one height read as one symmetrical ornament either
- side of a headline rather than as two remarks, so this was the one
- that moved. That note now lives beside #what-we-run, which is the
- section that actually lists the sessions it describes, so this is
- the only decoration in the band again and it can sit where it
- belongs. */}
-
-
- The only register here is{" "}
- git log, and it never forgets.
-
-
-
-
What it's like
-
-
- {/* THE BENTO. Two columns at 1.5rem gap, per the brief.
- CULTURE holds five entries and a 2-column grid takes four, so the
- last one spans both columns rather than sitting in a half-empty row.
- That is what a bento grid IS — cells of different spans on one
- rhythm — so the odd count is an opportunity here rather than the
- ragged tail it would be in a uniform grid. */}
- {/* The bento cards come up one after another rather than as a block.
- This is the grid the effect was worth adding for: five cells of
- unequal span on one rhythm, so a sequence reads as the grid being
- dealt out, where a single fade reads as a slab. */}
-
- {CULTURE.map((c, i) => (
-
-
-
- {String(i + 1).padStart(2, "0")}
-
-
- {c.title}
-
-
{c.body}
-
-
- {/* The dark code frame on the right of each card.
- Generic git, not invented output: every line here is a command
- anybody can run, so there is nothing in it that could be read
- as a claim about what this club has done. The one place this
- page shows real numbers is the hero terminal, which reads them
- from the content file.
- sm-and-up only — at 320px of card width the frame would take
- half the cell and leave the sentence in a gutter. */}
-
-
-
- {/* ---- Who this is not for ------------------------------------------
- An explicit filter immediately before the ask. Stating who should not
- join makes the invitation read as selective rather than desperate. */}
-
- {/* Placed low, level with the end of the list rather than its start —
- the joke only works once you have read all four reasons to leave.
- Flow placement: the list is max-w-3xl, so the room is already
- there.
-
- anchor 52: the thing to clear here is the list's own right edge
- rather than any line of text — max-w-3xl is 768 inside 24px of
- padding, so the boundary is a hard 792 at every width, and 832 keeps
- 40px off it. */}
-
- {/* Head of the same column, where the note is at the foot of it. */}
-
-
Be honest with yourself
-
-
- {NOT_FOR.map((n) => (
-
-
- {n}
-
- ))}
-
-
-
- {/* ---- FAQ ---------------------------------------------------------- */}
-
- {/* Beside the answers, where somebody who has run out of them is
- looking. It is the only note on the page that asks for anything,
- and it asks for the smallest possible thing.
-
- anchor 52, off the accordion's right edge — the same fixed 792 the
- #who-not-for list gives, for the same reason. */}
-
-
-
Questions
-
-
-
-
-
-
- );
-}
diff --git a/web/app/(site)/join/page.tsx b/web/app/(site)/join/page.tsx
index eab918d..10b2b3e 100644
--- a/web/app/(site)/join/page.tsx
+++ b/web/app/(site)/join/page.tsx
@@ -2,18 +2,62 @@ import type { Metadata } from "next";
import Link from "next/link";
import JoinGate from "@/components/JoinGate";
import Duo from "@/components/Duo";
+import Doodle from "@/components/Doodle";
+import PathCard from "@/components/PathCard";
+import ProgramMentors from "@/components/ProgramMentors";
+import Faq from "@/components/Faq";
import Note from "@/components/fx/Note";
+import { ENTRY_PATHS, MENTORED_PATH_ID, PATHS } from "@/content/join";
+import { FAQ, NOT_FOR } from "@/content/how-to-join";
-// THE APPLICATION FORM. One route, one job.
+// JOINING. The door, the two ways through it, and the case for walking in.
+//
+// THIS ROUTE ABSORBED THE FRONT HALF OF /how-to-join, and the reason is the nav:
+// the bar used to carry "How to Join" as a route AND "Join" as a filled button
+// three inches to its right, two controls with the same word on them dividing one
+// question between two pages, with nothing on screen saying which half was where.
+// The button is the one every reader presses, so the button's destination is where
+// the answer had to be. See the note over PAGES in content/site.ts.
+//
+// WHAT IT NOW HOLDS, in the order a reader meets it:
+//
+// #apply what joining is, and the door itself
+// #ways-in the two ways in — build days and hackathons
+// #why-join why bother at all: the programme track, and the mentors on it
+// #who-not-for four honest reasons to walk away
+// #faq what people actually ask
+// (closing) the two futures
+//
+// THE LAST TWO CAME FROM /how-to-join, WHICH IS GONE. That page split one question
+// ("how do I join") across two routes with the same word in their names, and
+// half of what it held was a second telling of the pull-request loop the home
+// page and /programmes also told. The loop and the commands went to /guide; the
+// part a person deciding whether to sign in actually needs — who this is not for,
+// and the FAQ — came here. /how-to-join redirects to this page.
+//
+// TWO WAYS IN AND NOT FIVE. content/join.ts still holds five paths and they are
+// all real; ENTRY_PATHS names the two this page offers, for the reason written
+// there. A stranger choosing between five doors is a stranger who closes the tab,
+// and the other three are routes people move ONTO rather than arrive by — the
+// onboarding form still offers every one of them.
+//
+// WHY #why-join IS LAST RATHER THAN FIRST. It is the strongest material on the
+// page — six named seniors, six organisations, every one selected into a paid
+// programme — and the instinct is to lead with it. It is also the most
+// intimidating material on the page, and the reader this route is written for is
+// the one who is not sure they are good enough to be in the room. Leading with a
+// wall of GSoC selections answers a question they have not asked yet and confirms
+// the fear they arrived with. So: the door first, the low-stakes Saturday second,
+// and the ambitious thing only once "you do not need to be good yet" has been said
+// and demonstrated.
//
// It carries no NextAction, and it is the only route that does not: the form IS
-// the next action, and a closing "here is what to do next" band underneath a
-// half-filled form is an invitation to abandon it.
+// the next action, and a closing "here is what to do next" band pointing somewhere
+// else is an invitation to abandon it.
//
-// It does now close on a band, but the distinction above is what that band is
-// built to respect — it contains no link, so it argues for the form rather than
-// offering somewhere else to be. Anything added down there has to clear the same
-// bar: no exits under an unfinished form.
+// It does close on a band, and that band is built to respect the same rule — the
+// only link in it goes back UP to the form. Anything added down there has to clear
+// the same bar: no exits under an unfinished form.
//
// It is also absent from PAGES, so it appears in neither the nav strip nor beside
// the other routes in the footer. The nav carries it as a filled button at the
@@ -29,9 +73,17 @@ import Note from "@/components/fx/Note";
export const metadata: Metadata = {
title: "Join",
description:
- "Join the Scaler Open Source Club. Sign in with your college account — no fee, no interview, no prior experience needed.",
+ "Join the Scaler Open Source Club: build days, hackathons and a mentored programme track. Free, no interview, no experience needed.",
};
+/* The programme track, pulled out by id rather than by position. It is the one
+ path this page renders outside the two front-door cards, and MENTORED_PATH_ID is
+ the same constant ProgramMentors is attached by — so the bench and the path it
+ sits under cannot come apart. Missing means the section does not render, which
+ is the honest failure: a "why join" band with the argument removed is worse than
+ no band. */
+const TRACK = PATHS.find((p) => p.id === MENTORED_PATH_ID);
+
export default function Join() {
return (
@@ -149,8 +201,8 @@ export default function Join() {
because it still reads fine and is simply no longer about anything.
Named and linked instead. */}
- Most people arrive having never opened a pull request. That is the
- normal starting point, not a disqualification — every name in the{" "}
+ Most people arrive having never opened a pull request. So did everyone
+ in the{" "}
hall of fame
{" "}
@@ -194,13 +246,162 @@ export default function Join() {
+ {/* ---- The two ways in -----------------------------------------------
+ Directly under the door, because "which of these am I" is the question
+ the sign-in card raises and does not answer. Both are beginner paths and
+ both are a day rather than a commitment — the point of putting them here
+ is that neither one asks the reader to already be anything.
+
+ NUMBERED, which a two-item list usually should not be. It is not a
+ ranking and nothing is sequential about them; the numerals are here
+ because the section above promises "two ways", and a numbered pair is
+ the cheapest way for a reader scrolling past to confirm they have seen
+ both rather than wondering whether a third scrolled by. */}
+
+
+ Where people actually start
+
+
+
+
+ No track to pick. Choose a day and show up with nothing ready —{" "}
+ the setup is the part that defeats people, so we do it with you.
+
+
+
+ {ENTRY_PATHS.map((p, i) => (
+
+ ))}
+
+
+ {/* The three paths this page does not offer. One line, not a fourth and
+ fifth card: they are real routes and somebody arriving with merged pull
+ requests deserves to know the club has somewhere to put them, but a
+ stranger deciding whether to sign in should not have to rule them out
+ first. The onboarding form lists all five. */}
+
+ Already have merged PRs? Say so after you sign in and skip straight to a
+ project team.
+
+
+ {/* Points DOWN the page, not off it: the honest part and the FAQ live
+ here now (see the head of this file), so this is not an exit from
+ the form. */}
+
+
+ The honest bit: who this isn't for, plus FAQs ↓
+
+
+
+
+ {/* ---- Why bother ----------------------------------------------------
+ The case, made last and made with people rather than with process. See
+ the note at the head of this file for why it is down here and not at the
+ top, and ProgramMentors.tsx for why the bench is six faces rather than a
+ sentence.
+
+ IT RENDERS THE PROGRAMME TRACK IN FULL — the same card as the two above,
+ same fields, same layout — rather than a summary of it. Two reasons: a
+ reader who has just read two cards knows how to read a third, and a
+ summary is a second copy of content/join.ts's prose that would drift from
+ it the first time anybody edited either. */}
+ {TRACK && (
+
+
Why bother
+
+
+ A build day is a good evening. This is what it's an evening{" "}
+ towards: GSoC, LFX and Outreachy — paid, competitive, and won
+ by people maintainers already know. Our track is run by people who got in.
+
+
+
+
+
+
+
+
+ )}
+
+ {/* ---- Who this is not for -----------------------------------------
+ Four reasons to walk away, on the page whose job is recruitment. It is
+ the section that makes the rest credible: a club that claims to be for
+ everybody is making a claim nobody believes. */}
+
+
+
+
+
+
{/* ---- The two futures ---------------------------------------------
The closing band, and the exception the comment at the top of this
file now carries: it is not a NextAction. Every other route ends by
handing you somewhere else to go, which under a half-filled form
- would be an exit. This band has no link in it at all. It restates
- the decision already on the screen and then stops, so the only
- thing to do with it is scroll back up and finish.
+ would be an exit. This one restates the decision and then stops.
+
+ IT CARRIES EXACTLY ONE LINK NOW, AND IT POINTS BACKWARDS. The band
+ had none at all while the form was one scroll above it. It is three
+ sections above it since this route absorbed the ways in and the
+ programme track, and "scroll back up and finish" stopped being a
+ thing a reader does — it is a 4,000px drag past two arguments they
+ have already read. So the band ends with the way back to the door.
+ That is not an exit: it is the same action the whole page is for,
+ offered at the one place somebody has finished reading and has
+ nothing in front of them. A link to anywhere ELSE still does not
+ belong here.
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
@@ -229,9 +430,7 @@ export default function Join() {
If you close this tab
- 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.
+ You'll be back in February. Same tap, one semester behind.
@@ -244,6 +443,17 @@ export default function Join() {
Somebody messages you this week. You show up regularly. By November
you're the one answering the questions.
+ {/* Quiet, mono, and pointing up — the loud version of this control is
+ already in the bar at every scroll position, and a second filled
+ button here would be the third thing on the page saying "join". */}
+
+
+ ↑ Back to the sign-in
+
+
diff --git a/web/app/(site)/page.tsx b/web/app/(site)/page.tsx
index 366c3fb..3c9ef0a 100644
--- a/web/app/(site)/page.tsx
+++ b/web/app/(site)/page.tsx
@@ -1,31 +1,18 @@
+import Link from "next/link";
import Hero from "@/components/hero/Hero";
import Doodle from "@/components/Doodle";
+import LangDot from "@/components/LangDot";
import Duo from "@/components/Duo";
-import CountUp from "@/components/CountUp";
import CommitGraph from "@/components/CommitGraph";
-import NumbersStrip from "@/components/NumbersStrip";
import MemberStory from "@/components/MemberStory";
import MediaSplit from "@/components/MediaSplit";
+import MergePlayground from "@/components/MergePlayground";
import CommunityBanner from "@/components/CommunityBanner";
-import Note from "@/components/fx/Note";
-import {
- COMPARISON,
- COMPARISON_NOTE,
- OUTCOMES,
- totals,
-} from "@/content/club";
-import type { Cell } from "@/content/club";
-import {
- EVERYDAY,
- GLOSSARY,
- IMPACT,
- MAINTAINERS,
- MAINTAINERS_SOURCE,
- WHAT_IT_IS,
-} from "@/content/essence";
+import { Fold, FoldGroup } from "@/components/Fold";
+import { PODIUM, WHY_IT_MATTERS } from "@/content/positioning";
+import { EVERYDAY, WHAT_IT_IS } from "@/content/essence";
-// THE ESSENCE PAGE. What the club is, why it and not something else, and what it
-// actually runs — then one way in.
+// THE ESSENCE PAGE. What the club is, whether you should care, and one way in.
//
// Fully static. No database, no auth, no API routes on this route — it is HTML and
// a handful of client components, so it renders identically anywhere and there is
@@ -34,149 +21,196 @@ import {
// Everything after the hero is deliberately quiet. The hero only reads as premium
// if what follows it is disciplined; a second spectacle cancels the first.
//
-// WHAT THIS PAGE IS *NOT* CARRYING, and where each of those went, because the
-// single-page version had all of it and a reader of this file will wonder:
+// THE ORDER IS THE FIRST THIRTY SECONDS. A reader who has just arrived is asking
+// "what is this and should I care", so the page answers that first — what the club
+// is, why it matters, people who did it, what it runs — and only then explains open
+// source itself, for the reader who is now interested enough to want it. The
+// explainer used to open the page, ahead of any of that, which spent the reader's
+// first half-minute on Linux and VS Code before saying what the club was.
//
-// the hall of selections → /hall-of-fame
-// projects and upstream → /projects
-// programmes, tracks, dates→ /programmes
-// team and mentors → /team
-// what we look for, the
-// path, culture, FAQ → /how-to-join
-// the application form → /join
-// the institutional band → the footer, on every route
+// WHAT LEFT, and where it went:
//
-// The four sections left here are the ones that answer "what is this and should I
-// care", which is the only question a reader who has just arrived is asking.
-
-/**
- * The citation links under a comparison cell.
- *
- * Local to this file because it is one shape used in one table, and pulling it
- * into components/ would mean a reader of that directory meets a component whose
- * only caller is here. Every claim in that section terminates in a third-party
- * link: with no testimonials and no placement data, external verifiability is
- * this page's substitute for social proof — and it is the only thing that makes
- * a comparison against two rival clubs fair rather than merely confident.
- */
-function Sources({ cell }: { cell: Cell }) {
- if (!cell.sources?.length) return null;
- return (
-
- {cell.sources.map((s) => (
- // NOT .tap, and that is a fix rather than an omission. .tap grows a target
- // with 14px of block padding and a matching -14px margin, which is correct
- // for a link that STANDS ALONE — its own comment in globals.css says so. In
- // this list they stack, so each link's padded box reached 14px up into its
- // neighbour's and the pair overlapped by 24px. The centre of "ICPC Regional
- // Rules" resolved to "ICPC 2025 standings": clicking the middle of the first
- // citation opened the second one. Measured with elementFromPoint, not
- // guessed, and invisible until the hover underline gave the row a visible
- // hover state to disagree with.
- //
- // 14px of SYMMETRIC padding instead. No negative margin, so no overlap, and
- // 16px of line box plus 28px lands at 44px — which is the HIGHER of the two
- // numbers in play rather than a squeak past the lower one (WCAG 2.5.8 asks
- // 24px at AA; 2.5.5 and Apple's HIG want 44, and scripts/qa.mjs enforces 40).
- // A first pass used 3px and cleared 24 but not 40, trading the overlap bug for
- // a small-target one.
- //
- // IT WAS 12px UNTIL THE TYPE SCALE CAME BACK DOWN, and the failure is worth
- // recording because of how narrow it was. At the old `text-xs` of 14px the box
- // measured 42.67px and passed; at 12px it measures 39.9996px, and the sweep
- // tests `height < 40`. Four ten-thousandths of a pixel, reported as "40x153" —
- // a number that looks like it should pass, in a check it fails. The 1.3333
- // leading ratio is where the fraction comes from. Padding to 44 rather than
- // back to 40 is what stops the same 2px anywhere in this scale from doing it
- // again.
- // The cost is about 20px of extra height per citation, in the two cells that
- // carry more than one.
-
- {s.label} ↗
-
- ))}
-
- );
-}
+// who maintains open source,
+// the vocabulary → /guide, with the PR loop and the commands
+// the CP / AI-ML / OSC table → one line in #thesis (the table stays in
+// content/positioning.ts, unmounted)
+// "Beyond the stipend" and
+// "Why it matters" → merged into one list in #thesis; they were the
+// same argument made twice
+// the hall, projects, programmes, team, events → their own routes
export default function Home() {
- const t = totals();
-
return (
<>
- {/* ---- 1. What open source actually is ---------------------------------
- Opening with the reader's own laptop rather than a definition. "Software
- built in public" is abstract; "the editor you have open on the other
- monitor is one of these, here is its source" is not, and it does the
- convincing before the definition arrives. */}
- {/* STAGGERED RATHER THAN SETTLED, and every section on this page now is.
- The settle — the whole section moving 12px as one object — was the
- default because revealing every card and paragraph separately makes a
- page that twitches continuously as you scroll. That is still true of
- revealing every ELEMENT, and it is not what a group does: the container
- holds still and its direct children arrive in order, which on a section
- is eyebrow, then headline, then rule, then standfirst, then the grid.
- Four steps, once per section.
- It also switches on the two effects that only exist inside a group —
- the heading rule plotting itself and the highlighter wiping across a
- marked phrase — which were reaching four sections out of fourteen. */}
+ {/* Every section folds, one open at a time — see components/Fold.tsx. The
+ thesis is open on arrival so the page opens on what the club is. The
+ community banner stays out: it is the way in, not a section to read. */}
+
+ {/* ---- 1. What this is, and why it matters ------------------------- */}
+
+
+ What this is
+
+
+
+ >
+ }
+ >
+
+
+ Most student clubs count attendance. We count pull requests a stranger
+ chose to merge.
+
+
+ Nobody reads your college projects. They read your commits — the one
+ part of your CV a stranger has{" "}
+ already checked for you.
+
+
+
+
+ {WHY_IT_MATTERS.map((w) => (
+
+
{w.title}
+
{w.body}
+
+ ))}
+
+
+ {/* What the six-row comparison table came down to. One sentence, still
+ sourced, and no longer a column-by-column case against two other
+ clubs on the same campus. */}
+
+
+
+ {/* ---- 1b. Press the button -------------------------------------------
+ Bands alternate down the page (try-it, what-we-run) so each section
+ has its own ground; two adjacent bands merge into one slab.
+ A pull request the reader is allowed to merge. Not folded: it is the
+ one thing on the page you do rather than read. See MergePlayground. */}
-
- What it is
-
-
-
+
+
try it
+
+
+
+
+
+
+
+ {/* ---- 2. Members, first person ----------------------------------------
+ The heading deliberately does not count them. The rail renders whatever
+ has consent, so "six people" would be a claim that goes stale the first
+ time somebody adds or pulls a story. */}
+
+
In their words
+
+ >
+ }
+ >
+
+
+
+ {/* ---- 3. What the club actually runs ---------------------------------
+ The same four names /join uses, so a reader meets one vocabulary. */}
+
+
+ {/* ---- 4. What open source actually is ---------------------------------
+ Last of the sections, not first: it is for the reader who is already
+ interested and wants the ground under it. Opening with the reader's own
+ laptop rather than a definition — "the editor you have open on the
+ other monitor is one of these, here is its source" does the convincing
+ before the definition arrives. */}
+
+
+ New to open source?
+
+
+
+ >
+ }
+ >
- 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.
+ Software written in public, for everyone. These four run half the planet,
+ 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. */}
+ arriving as one slab. */}
{EVERYDAY.map((e) => (
{e.name}
- {/* 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}
+ {/* min-w-0, not shrink-0: text from a data file is a viewport
+ overflow waiting for a longer value. */}
+ {/* GitHub's language dots, as on a repo card. */}
+
+ {e.language.split(",").map((l) => (
+
+ ))}
{e.what}
{e.fact}
- {/* The spacing lives on the wrapper, not on the link. `.tap` sets
- margin-block and padding-block to build a 44px target, so a
- margin or padding utility on the same element either loses to it
- or breaks its compensation. See the note on .tap in globals.css. */}
+ {/* Spacing on the wrapper: `.tap` owns the link's margin and
+ padding. See the note on .tap in globals.css. */}
-
-
-
- {/* ---- 1c. The vocabulary --------------------------------------------
- The most open-source-specific thing on the site. The barrier to a first
- contribution is very often linguistic rather than technical: a second-year
- who writes fine Python still will not open a PR when the contributing guide
- says "rebase onto upstream/main and squash before we triage". */}
-
-
- The words
-
-
-
- {/* The count is DERIVED. Written as "Twelve words" it was a number in prose
- 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 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.
+ Who reviews your PR, the jargon, and the commands to make one tonight →
+
-
- {/* A definition list, because that is what it is. Two columns at lg so
- twelve terms do not become a twelve-screen scroll on a laptop. */}
- {/* 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. */}
-
- Most student open-source groups measure attendance. We measure the
- quality and number of pull requests a maintainer accepted, because
- that is what somebody outside the room had to agree to.
-
-
- Everything on this page links to the upstream repository. If a claim
- here cannot be checked in one click, it should not be here.
-
-
-
- {/* Outcomes merged in here rather than living as its own section. Two
- philosophy blocks separated by Programmes broke the momentum twice,
- and this argument is the evidence for the claim above — it belongs
- in the same breath as it. */}
-
Beyond the stipend
-
-
- {OUTCOMES.map((o) => (
-
-
{o.title}
-
{o.body}
-
- ))}
-
-
-
-
-
- {/* ---- 2. How it changes your life ------------------------------------ */}
-
-
Why it matters
-
-
- 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) => (
-
-
- {i.title}
-
-
{i.body}
- {i.aside && (
-
-
- {i.aside}
-
- )}
-
- ))}
-
-
-
- {/* ---- Why this and not the CP club ---------------------------------
- The one place on the page that argues against a rival activity, and
- now the only one. It used to be preceded by a column of sourced
- claims making the same case in prose; the two read as the same
- argument made twice, so the prose went and the table stayed. It is
- 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
- reader would keep. Void measured at 240px tall, 238px clear.
-
- anchor 59, THE ONE ON THIS PAGE THAT IS NOT SET BY ITS OWN INK, and
- the reason is a width nobody samples. This heading is the only one
- here that wraps to FILL its max-w-4xl cap: at 1180 the trail line
- "Open source does not." ends at 896px, i.e. flush with the column's
- own right edge, where at 1440 it ends at 724 and at 1024 at 786. An
- anchor derived from the wide measurement (52 was the first attempt)
- therefore reads clear at 1024, 1280, 1440 and 1800 and sits ON the
- headline at 1180 — which is exactly what scripts/tmp-notes-check.mjs
- caught, and nothing else would have.
- So it is anchored off the CAP (896 + 24 of padding + 24 of air)
- rather than off any measured line. The cost is that on a wide monitor
- it stands further out than its neighbours; the alternative is a note
- through a headline on a 1180px laptop. */}
-
-
Choosing a club
-
-
-
- Three clubs on this campus want the same four years of your evenings.
- Here are six questions, asked of all three.
-
-
- {/* A THREE-WAY TABLE, where this used to be a single column of claims
- stacked down a hairline.
-
- The old layout could only ever assert. Every row was a sentence about
- open source with a competitive-programming fact quoted inside it, so
- the comparison lived in the prose and the reader had to hold both
- halves in their head to see it. A grid does that work structurally:
- the row says which question is being asked, and the three cells are
- the three answers side by side. Nothing has to be claimed, because
- "unbounded" sitting in the same row as "three, per college, per year"
- is the argument.
-
- It is a real
rather than a grid of divs. The content IS
- tabular — six questions crossed with three clubs — and that means a
- screen reader can announce "OSC club, what it pays" when it lands in
- a cell, which is precisely the orientation a sighted reader gets for
- free from the column being under a heading.
-
- The horizontal scroll on narrow screens is the same trade the
- calendar table above makes: six rows of three paragraphs cannot be
- honestly reflowed into one phone column without either dropping the
- side-by-side reading or duplicating the whole table in a second
- markup path that then drifts from this one. */}
-
- {/* table-fixed, and it is load-bearing. Under auto layout the three
- w-1/3 data columns claim the entire width between them and the
- question column is squeezed to whatever is left — "How many can
- win" came out four lines tall in a 5rem gutter. Fixed layout
- honours the one declared width and splits the remainder equally,
- which is the intent: one narrow label column, three equal answers. */}
-
-
- Competitive programming, AI/ML and open source compared across six
- questions
-
-
-
- {/* Empty corner cell. It heads the column of row questions, and
- those are already marked up as row headers, so it has nothing
- to say — but it must still exist or every data cell in the
- table shifts one column left of its heading. */}
-
-
- CP club
-
-
- AI/ML club
-
- {/* The one column wearing colour. Same device as the "what we
- look for" pair below the hall: the answer is the one with the
- tint, the border and the weight, and it is the same mint so a
- reader who has scrolled past that section already knows what
- the green means. */}
-
- OSC club
-
-
-
-
- {COMPARISON.map((row) => (
-
-
- {row.axis}
-
- {[row.cp, row.aiml].map((c, i) => (
-
- {c.stat && (
-
-
-
- )}
-
{c.line}
-
-
- ))}
- {/* flag-good rather than a `bg-emerald-500/5` utility, for the
- reason spelled out over that class: the QA sweep reads a
- translucent fill as its own un-composited colour and scores
- the text on it against a surface that does not exist. */}
-
-
-
-
- {/* ---- 3. The club in numbers ----------------------------------------- */}
- {/* The strip itself is one item and stays one item — the tiles are 1px apart
- over a grey ground, so staggering them would show a row of grey slots
- filling in. The figures inside count instead. See NumbersStrip. */}
-
-
Where we are
-
-
- Derived from the other four pages, not typed in by hand — so it cannot say
- more than the evidence does.
-
-
-
-
-
- {/* ---- 4. Members, first person ----------------------------------------
- The heading deliberately does not count them. The rail renders whatever
- has consent, so "six people" would be a claim that goes stale the first
- time somebody adds or pulls a story. */}
-
-
In their words
-
-
-
-
- {/* ---- What the club actually runs ----------------------------------
- Placed after the culture bento and before the tracks, which is where
- it answers the question the bento raises. "It is mostly people
- arguing about code with Maggi" says what the room feels like; this
- says what is actually ON, in four named formats, next to the faces of
- the people running them. */}
-
+
+
{/* ---- The community banner -----------------------------------------
- Sits directly under the media split so the section ends on an action.
- It is the second of only two places the join CTA appears outside the
- form itself — the hero and here — because a page that repeats its own
- call to action every screen reads as nagging rather than confident. */}
+ The way in, under everything. It is the second of only two places the
+ join CTA appears outside the form itself — the hero and here — because
+ a page that repeats its own call to action every screen reads as
+ nagging rather than confident. */}
>
diff --git a/web/app/(site)/privacy/page.tsx b/web/app/(site)/privacy/page.tsx
index 115119b..63130c7 100644
--- a/web/app/(site)/privacy/page.tsx
+++ b/web/app/(site)/privacy/page.tsx
@@ -90,9 +90,8 @@ export default function Privacy() {
you signed up and when you last changed something.
- That is the entire list — it is the Profile type in{" "}
- web/lib/profile.ts, and this site is open source, so you can
- check rather than take our word for it. There is no analytics account, no
+ That's the entire list — the Profile type in{" "}
+ web/lib/profile.ts. The site is open source, so you can check. There is no analytics account, no
advertising identifier, no third-party tracker, and no data sold or shared
with anybody outside the club.
@@ -134,7 +133,7 @@ export default function Privacy() {
any programme
{" "}
— Google Summer of Code, LFX Mentorship and the rest select their own people
- and the club has no say in it. Anyone who tells you otherwise is wrong.
+ and the club has no say in it.
Sessions are open to any student at the university, and there is no
@@ -162,9 +161,8 @@ export default function Privacy() {
whole document, not a flag on it.
- An organiser does this by hand, so give it a few days. We are a handful of
- students with a database, not a service desk, and we would rather say that
- than print a deletion window we cannot keep.
+ An organiser does this by hand, so give it a few days. We're students,
+ not a service desk, so we won't promise a window we can't keep.
Your Google account is not ours to touch. To take away this site's
@@ -178,15 +176,13 @@ export default function Privacy() {
your Google account's connections
. Deleting your record here does not sign you out of Google, and signing out
- of Google does not delete your record — they are two separate things and it
- is worth doing both.
+ of Google does not delete your record — do both.
- Something here wrong, or out of date against the code? This site is one of the
- club's own repositories —{" "}
+ Something wrong or out of date? This site is open source —{" "}
open an issue
{" "}
diff --git a/web/app/(site)/programmes/page.tsx b/web/app/(site)/programmes/page.tsx
index 9cc18ee..34c3f90 100644
--- a/web/app/(site)/programmes/page.tsx
+++ b/web/app/(site)/programmes/page.tsx
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Doodle from "@/components/Doodle";
import Duo from "@/components/Duo";
-import Icon from "@/components/Icon";
import NextAction from "@/components/NextAction";
import Note from "@/components/fx/Note";
import { JOIN_HREF } from "@/content/site";
@@ -12,9 +11,8 @@ import {
PAID,
PROGRAMME_NAME,
PROGRAMME_SHORT,
- TRACKS,
type ProgrammeInfo,
-} from "@/content/club";
+} from "@/content/programmes";
import { achieversFor } from "@/content/people";
// THE PROGRAMMES PAGE. The paid, competitive things a student can be selected
@@ -35,11 +33,15 @@ import { achieversFor } from "@/content/people";
// tiers, repeating this page's own
as its heading. It is gone; see the note
// where it stood.
//
+// The "Pick your path" track cards that closed this page are gone too. They were
+// a fourth list of ways in, and the reader could not tell it from the three on
+// /join, /events and the home page. /join is the one place that names them now.
+//
// The route is /programmes, with /programs redirecting to it. See the note over
// `redirects` in next.config.js.
export const metadata: Metadata = {
- title: "Programmes",
+ title: "OS Programmes",
description:
"GSoC, LFX Mentorship, C4GT and Summer of Bitcoin — what they are, where to start, and when the work that earns a place actually happens.",
};
@@ -97,7 +99,7 @@ function ProgrammeField({ p }: { p: ProgrammeInfo }) {
: "border-seam text-haze"
}`}
>
- {paid ? "Paid · selective" : "Open entry"}
+ {paid ? "Paid · selective" : "Unpaid · open entry"}
@@ -181,8 +183,8 @@ function ProgrammeField({ p }: { p: ProgrammeInfo }) {
// the field beside it is worse than no copy.
{paid
- ? "Nobody yet. Which means the first person from this college to get in has not been picked — and there is no queue."
- : "Nobody has logged one yet. Nothing is stopping you: there is no selection here, so it comes down to turning up when it opens."}
+ ? "Nobody yet. The first spot is up for grabs."
+ : "Nobody yet. No selection — just show up when it opens."}
)}
@@ -209,7 +211,7 @@ export default function Programmes() {
return (
-
Paid open source
+
OS Programmes
- {PAID.length} paid programmes you have to be selected into, and{" "}
- {OPEN_ENTRY.length} you can start today — all run by organisations that have
- nothing to do with this club or this college. We cannot get anybody in. What
- we can do is make sure you are the kind of contributor they pick.
+ {OPEN_ENTRY.length} unpaid ones you can start today, {PAID.length} paid ones you get
+ picked for. We can't get you in — but we can make you the contributor
+ they pick.
@@ -239,11 +244,11 @@ export default function Programmes() {
-
Start here
+
Unpaid
- Neither of these pays and neither carries much weight on a resume. They are
- still where almost everyone should start, because they teach the mechanics —
- fork, branch, review, merge — somewhere the stakes are zero.
+ No pay, little resume weight — but the best place to learn fork, branch,
+ review, merge with zero stakes.
@@ -277,7 +281,7 @@ export default function Programmes() {
/* `relative` for the gutter note below — it anchors to this section, so
it cannot drift when a section above it changes height. */
className="band section relative pb-16 pt-16 sm:pb-24 sm:pt-24"
- aria-label="Paid, selective programmes"
+ aria-label="Paid programmes"
data-reveal-group
>
{/* Right gutter. A reader who has just been told these programmes are
@@ -308,7 +312,7 @@ export default function Programmes() {
className="-right-40 top-40"
/>
-
Paid and competitive
+
Paid
- Five programmes that pay a stipend to people with no professional experience.
- You do not need a degree, a CGPA or a referral for any of them — you need a
- few months of visible contribution before the window opens.
+ {PAID.length} programmes that pay students a stipend. No degree, CGPA or referral
+ needed — just a few months of public contributions before the window opens.
@@ -346,7 +349,7 @@ export default function Programmes() {
their place.
Its rows carried the same five fields ProgrammeField already renders
- for #open-entry and #paid, from the same content/club.ts entries — so
+ for #open-entry and #paid, from the same content/programmes.ts entries — so
every paid programme appeared twice on one page, the second time with
less detail: no "who from the club has done it", no "start preparing".
The shorter copy of a thing always looks like the authoritative one
@@ -423,10 +426,8 @@ export default function Programmes() {
trail="Which is why starting now is the whole trick."
/>
- Organisations pick contributors they already recognise. By the time a
- proposal window opens, the people who get in have been committing to
- that repository since autumn. Waiting a year does not delay you by a
- year — it costs you the cycle.
+ Orgs pick people they already recognise — usually ones committing since
+ autumn. Wait a year and you lose the whole cycle.
@@ -455,184 +456,11 @@ export default function Programmes() {
- {/* ---- Tracks ------------------------------------------------------
- A CARD GRID, not the stacked full-width rows this used to be.
-
- The rows were three near-identical bands of text: a heading in a 20rem
- left column, a paragraph in the right, repeated down the page. Nothing
- about that shape said "these are three parallel choices, pick one" —
- read top to bottom it looked like a sequence, which is the opposite of
- what the section means. Three cards side by side ARE the argument:
- equal weight, equal size, one decision.
-
- Three columns rather than the reference's four, because there are three
- tracks. A four-column grid with three cards leaves a hole in the row,
- and inventing a fourth to fill it would put a programme on the page
- that the club does not run. */}
-
-
Pick your path
-
-
-
- {TRACKS.map((track, i) => (
- // `group` so the arrow in the footer link moves with a hover anywhere
- // on the card; flex-col so the dark frame, the tags and the link line
- // up across all three cards whatever the paragraph above them
- // measures — see the flex-1 on the detail.
-
-
- {/* A label, NOT a ranking. The tracks are parallel choices a
- member picks from by their own level, so the order carries no
- information and the headline no longer claims it does — each
- card states its own difficulty in `summary` and `tags`
- instead. The numeral stays because the card header is a
- two-item row (mark left, doodle right) and it reads as an
- index, but if it ever starts reading as 1st/2nd/3rd it should
- go rather than the headline bending back to an order. */}
-
- {String(i + 1).padStart(2, "0")}
-
-
-
-
- {/* Two-tone and two-line, which is the device the section headings
- already use (see Duo) at card scale. The break is authored in
- the content file rather than left to the wrap, so all three
- headings are two lines deep at every width and the cards keep a
- common baseline. */}
-
- {track.name.lead}
-
-
- {track.name.trail}
-
-
-
-
- {track.summary}
-
-
- {/* flex-1: the three paragraphs are 2, 4 and 2 sentences, so without
- it the code frames sit at three different heights and the row
- stops reading as a set. */}
-
{track.detail}
-
- {/* The dark frame, in place of the reference's screenshot panel —
- but a terminal rather than a mocked dashboard, because a fake UI
- on a page whose whole argument is verifiable evidence is the one
- thing this design cannot afford. Every line is a command a
- reader can run; see the note on Track.preview in club.ts.
-
- Fixed dark fill on both themes, like the other code frames on
- this page: a terminal is a terminal. */}
-
-
-
-
-
-
- {track.preview.title}
-
-
-
- {track.preview.lines.map((l) => (
-
- {l.kind === "cmd" && (
- $
- )}
- {l.text}
-
- ))}
-
-
-
-
- {track.tags.map((tag) => (
-
- {tag}
-
- ))}
-
-
- {track.cta && (
- // mt-5 and self-start BOTH move to the wrapper. The margin because
- // `.tap` would eat it (see app/projects/page.tsx), and `self-start`
- // because it is a flex-item property — left on the inner link it
- // would be addressing a flex container that is no longer its
- // parent, and the card's CTA would stretch the full width.
-
-
-
diff --git a/web/app/(site)/projects/page.tsx b/web/app/(site)/projects/page.tsx
index 4fef0d4..e04cbaa 100644
--- a/web/app/(site)/projects/page.tsx
+++ b/web/app/(site)/projects/page.tsx
@@ -1,16 +1,12 @@
import type { Metadata } from "next";
+import Link from "next/link";
import Doodle from "@/components/Doodle";
import Duo from "@/components/Duo";
import Eyebrow from "@/components/Eyebrow";
-import ProofPanel from "@/components/ProofPanel";
+import LangDot from "@/components/LangDot";
import NextAction from "@/components/NextAction";
import { JOIN_HREF } from "@/content/site";
-import {
- projectTotals,
- publishedBuildDay,
- publishedClubRepos,
- publishedUpstream,
-} from "@/content/projects";
+import { publishedProjects } from "@/content/projects";
// THE PROJECTS PAGE. Where the club's code actually went.
//
@@ -18,29 +14,21 @@ import {
// with the least room for adjectives: every card here terminates in a link to a
// merged pull request or a public repository. `published` gates each entry, so an
// unverifiable claim cannot reach the grid even by accident — see the note at the
-// head of club.ts.
+// head of content/projects.ts.
export const metadata: Metadata = {
title: "Projects",
description:
- "Merged pull requests, club repositories, and the upstream projects our members contribute to.",
+ "The club's own repositories, each with issues sized for a first pull request.",
};
export default function Projects() {
- // ONE SET OF TOTALS, from content/projects.ts — the wider inventory: build-day
- // projects, club repositories, and every upstream contribution including the
- // ones with no verified rank attached.
- //
- // There used to be two. club.ts's curated PROJECTS carried their own totals()
- // and fed a second upstream section at the foot of this page, so `t` and `pt`
- // sat in one scope counting genuinely different sets — precisely how a headline
- // ends up quoting the wrong figure. That section is gone and so is the hazard;
- // club.ts still backs the hero and the hall, which is where its curation earns
- // its keep.
- const pt = projectTotals();
- const buildDay = publishedBuildDay();
- const clubRepos = publishedClubRepos();
- const upstream = publishedUpstream();
+ // ONE list, from content/projects.ts, where `published` gates each entry. Build
+ // days and club repositories used to be two sections; the note at the head of
+ // projects.ts says why they are not any more. No totals are read here: the page
+ // counts what it shows. projectTotals() still exists for the NumbersStrip, which
+ // is now the one place the upstream figures are quoted.
+ const projects = publishedProjects();
return (
@@ -50,26 +38,34 @@ export default function Projects() {
arriving, and `.page-top` is what clears the floating nav. Do not add a
pt-* utility beside it; see the note over `.page-top` in globals.css. */}
-
Upstream work
+ {/* The header used to read "Where our code went. Every line links
+ upstream" over a page that lists the club's OWN repos — the upstream
+ sections it described were removed (see the note further down). It
+ now says what the page actually is. Selections and their merged PRs
+ are on /hall-of-fame. */}
+
Club projects
- Not a portfolio of things we built for ourselves. These are contributions
- into projects other people maintain, which is the only kind that has to
- survive somebody else's review.
+ Club repos with issues sized for your very first PR. Work in other
+ people's projects lives in the{" "}
+
+ hall of fame
+
+ .
- {/* ---- 1. Build days -------------------------------------------------- */}
+ {/* ---- Everything of ours, in one list ------------------------------- */}
@@ -81,44 +77,78 @@ export default function Projects() {
trail="Turn up and pick one."
/>
- These are the projects people are actually working on in build days. Every
- card names the person to talk to and links an issue sized for somebody who
- has never done this before.
+ What people are building on build days, plus the repos we keep running
+ between them — this site included.
- A card only goes up once its good-first-issue link resolves to genuinely
- open, genuinely beginner-sized issues. A promise that leads to an empty
- issue list is worse than an empty section.
+ Cards go up once there are real beginner issues waiting.
) : (
- {buildDay.map((p) => (
+ {projects.map((p) => (
+ {/* One word, not a section. A reader deciding where to spend a
+ Saturday still wants to know which of these will exist in March
+ — it just never needed its own heading and its own paragraph to
+ say so. */}
+ {p.clubMaintained && (
+
+ Club maintained
+
+ )}
+
-
- {p.name}
+ {/* `min-w-0 break-all` IS LORE, NOT TIDINESS. A repo name like
+ "scaleropensourcelabs.com" is one unbreakable token and it
+ overflowed the viewport at 390px — measured at 375px inside a
+ 390px viewport, taking the document to 420px. `break-words`
+ does not help: it only breaks BETWEEN words, and a domain name
+ has no space to break at. `min-w-0` is what lets the flex item
+ shrink below its content width, which it will not do by
+ default. It stayed invisible for a while because
+ `body { overflow-x: hidden }` clips the strip rather than
+ showing a scrollbar, and the smoke test measures at desktop
+ width; the QA sweep at 390px is what caught it. */}
+
{/* NOT shrink-0. It was, and a long value forced this flex row
- wider than the phone viewport — measured at 318px, taking the
- document to 486px against 390px. shrink-0 is only safe on text
+ wider than the phone viewport. shrink-0 is only safe on text
whose length is bounded, and content from a data file never is. */}
{p.size && (
@@ -131,6 +161,16 @@ export default function Projects() {
{p.problem}
)}
+ {p.whyStartHere && (
+
+
+ {p.whyStartHere}
+
+ )}
+
{/* Each optional row is gated on its own data. A holding card with
no maintainer and no stack should be a title and nothing else —
an empty label under a rule reads as a rendering bug. */}
@@ -141,51 +181,70 @@ export default function Projects() {
key={s}
className="rounded-inline border border-seam bg-sunk px-2.5 py-1 font-mono text-sm text-haze"
>
- {s}
+
))}
)}
- {p.maintainer && (
-
- Longer-lived than a build-day project and maintained by the club rather than
- by one person. If you want a first merged pull request with the shortest
- possible feedback loop, start here — the maintainer reviewing it is{" "}
- somebody you can find in the lab.
-
-
-
- {clubRepos.map((r) => (
-
-
-
- Club maintained
-
- {/* `inline-flex` BECAME `flex` AND THE NAME CAN NOW BREAK, because
- this link overflowed the viewport at 390px once it was rendered
- in this design's type scale rather than the one it was written
- for. `text-display-md` clamps to 1.9375rem here against 1.625rem
- there — about 5px larger at the top of the ramp — and a repo name
- like "scaleropensourcelabs.com" is one unbreakable token, so it
- measured 375px inside a 390px viewport with padding either side
- and took the document to 420px.
-
- It was invisible in every obvious way: `body { overflow-x: hidden }`
- clips the strip rather than showing a scrollbar, and the smoke
- test's overflow check runs at desktop width. The QA sweep at 390px
- is what caught it.
-
- `break-all` rather than `break-words`, and that is the part that
- matters — `break-words` only breaks BETWEEN words, and there is no
- space in a domain name to break at. `min-w-0` lets the flex item
- shrink below its content width, which it will not do by default. */}
-
- {r.name}
-
- ↗
-
-
-
- Nobody here can award these to themselves. A maintainer with no reason to be
- kind to us read the diff and agreed to it. Every card links the repository —
- open it and check the commit history.
-
-
- {upstream.length === 0 ? (
-
-
Nothing published yet.
-
- This fills in as members land work upstream. Each entry carries a link to
- the merged pull request and numbers read from GitHub rather than
- estimated.
-
-
- ) : (
- <>
- {/* The section's lead visual is the evidence itself. There is no image to
- put here, and a stock photo of somebody at a laptop on a page whose
- whole argument is "every claim links to a source" would undercut the
- argument it decorated. */}
-
-
-
- {upstream.map((p) => (
-
-
- {/* The org, set as type in a bordered plate rather than as a
- logo. Their trademark, and the site's CSP blocks remote
- images anyway — see content/projects.ts. */}
-
- {p.org}
-
- {p.tag ? (
- {p.tag.label}
- ) : (
- Contribution
- )}
-
- Contributor counts and merge ratios were read from the GitHub API on
- 2026-07-29. They move — open the repository if you want today's number.
-
-
-
- {/* There was a second upstream section here — "Upstream work / Where our
- code went" — carrying its own ProofPanel and a carousel of the curated
- club.ts entries. It said the same thing as "In the wild" directly above,
- with the same panel and the same repository in it, so a reader scrolling
- past hit the OWASP/OpenCRE numbers twice in one screen and had to work
- out whether the second pass was new evidence. It was not. One upstream
- section, and the curated club.ts list feeds the hero and the hall. */}
+ {/* Two upstream sections used to sit here — "In the wild" and a second
+ "Upstream work / Where our code went" — each with its own ProofPanel
+ over the same OWASP/OpenCRE numbers. Both are gone. The per-contribution
+ evidence now lives on the hall, and publishedUpstream() still feeds the
+ NumbersStrip, so nothing on the site stops linking to the merged PRs. */}
diff --git a/web/app/(site)/team/page.tsx b/web/app/(site)/team/page.tsx
index 4868d13..071c2d2 100644
--- a/web/app/(site)/team/page.tsx
+++ b/web/app/(site)/team/page.tsx
@@ -5,7 +5,8 @@ import Mentors from "@/components/Mentors";
import NextAction from "@/components/NextAction";
import Note from "@/components/fx/Note";
import { JOIN_HREF } from "@/content/site";
-import { TEAM_SHADOWS, publishedMentors, teamSize } from "@/content/club";
+import { publishedMentors } from "@/content/mentors";
+import { shadowCount, teamSize } from "@/content/team";
// THE TEAM PAGE. Who runs the club, and who teaches in it.
//
@@ -25,7 +26,7 @@ import { TEAM_SHADOWS, publishedMentors, teamSize } from "@/content/club";
export const metadata: Metadata = {
title: "Team",
description:
- "The students who run the Scaler Open Source Club, the shadows training to take over, and the mentors who have been through these programmes.",
+ "The students who run the club, the shadows learning the ropes, and the mentors who've been there.",
};
export default function TeamPage() {
@@ -40,9 +41,7 @@ export default function TeamPage() {
trail="Which is why the handover is built in."
/>
- Everyone on this page graduates. A club that depends on the people
- currently running it lasts exactly as long as they do, so every role has
- somebody shadowing it from the year below.
+ Everyone here graduates, so every role has a shadow from the year below.
@@ -86,7 +85,7 @@ export default function TeamPage() {
standfirst keeps who-to-ask; it says it once and says it better.
So the margin now answers the question the chart CANNOT: not who
- holds which office, but why eleven students bother. It is the one
+ holds which office, but why that many students bother. It is the one
warm sentence beside a deliberately formal org chart, which is the
contrast the note format is for — and it is second-person, where
everything else in this section is third.
@@ -96,27 +95,27 @@ export default function TeamPage() {
hand-typed number here goes stale the first time somebody joins
it.
- anchor 49: the headline's second clause ends at 737px, so 784 sits
- 47px off it. Unchanged, and it stays valid only because the Duo
- above was left exactly as it was — this number is derived from that
- headline's line break, so re-word the heading and this needs
- re-measuring, not adjusting by eye. */}
-
-
Who runs it
-
+ In flow beside the heading, not at a measured anchor — see the
+ placement note in Note.tsx. */}
+
+
+
Who runs it
+
+
+
+
This page exists to answer one
question: who to ask.
@@ -134,7 +133,7 @@ export default function TeamPage() {
over nothing, which reads worse than the box did.
So the gate lives here rather than in the component, because the copy
- it has to suppress lives here too. Add a mentor to content/club.ts and
+ it has to suppress lives here too. Add a mentor to content/mentors.ts and
the section returns in full, unchanged — nothing about it was deleted.
aria-label because this section has no nav link — "Team" took that
@@ -150,38 +149,32 @@ export default function TeamPage() {
actually matters to somebody deciding whether the advice will be
current. Void: 240px tall, 320px clear.
- anchor 59, the largest on the page, because this headline's trail
- is the longest — its ink reaches 900px from the container's left
- edge. 44px of gap, same as the rest; the number differs because
- the sentence does. */}
-
-
Who reads your code
-
+ In flow beside the heading, not at a measured anchor — see the
+ placement note in Note.tsx. */}
+
+
+
Who reads your code
+
+
+
+
- Every mentor here has been through one of these programmes
- themselves. What they offer is narrow and recent: they wrote the
- proposal, sat through the review comments and landed the patch,
- from this campus, within the last couple of years. Each entry says
- what they shipped, links the public record, and names the few
- things they are genuinely useful for.
+ Each one wrote the proposal, survived the review and landed the patch —
+ from this campus, recently. Here's what they shipped and what to
+ ask them about.
@@ -191,7 +184,7 @@ export default function TeamPage() {
eyebrow="Come and meet them"
lead="These are the people in the room."
trail="The room is open to anyone."
- body="No interview, no selection at the door, no prior experience. Turn up to a working session and somebody on this page will find you something to start on."
+ body="No interview, no experience needed. Turn up and someone here will find you something to start on."
href={JOIN_HREF}
cta="Join the club"
/>
diff --git a/web/app/globals.css b/web/app/globals.css
index b7605cc..d74fab7 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -38,6 +38,47 @@
:root {
color-scheme: light;
+ /* THE PAGE SCALE. One number, and it is the browser's own zoom control moved
+ into the stylesheet: at 0.8 the site renders exactly as it does at 80% Chrome
+ zoom, with no per-component resizing anywhere.
+
+ WHY `zoom` AND NOT A ROOT font-size. The note in the `html` rule below is the
+ record of that attempt: `font-size: 75%` shrank the type and the rem spacing
+ and left everything written in px at full size — the 1px keylines, the 10/18/
+ 28px radii, the hard shadows, the 24px dot pitch, the 56px nav plate. Those
+ are the page's FURNITURE, and furniture at 100% around type at 75% is not a
+ zoomed page, it is a differently proportioned one. That is what "muddy rather
+ than merely small" in that note is describing. `zoom` scales the used value of
+ every length in the subtree — px, rem, em, borders, shadows, images — so the
+ proportions the design was drawn at survive intact and only the ruler changes.
+
+ THE ONE THING IT DOES NOT SCALE IS VIEWPORT UNITS, and that is why this is a
+ token rather than a lone declaration. Measured in Chrome 151: under
+ `zoom: 0.8` a `100vw` box renders 1024px wide on a 1280px viewport, so every
+ full-bleed band in this file would have pulled 128px short on each side and sat
+ in the page as an inset stripe. So each vw/vh term is divided by this token at
+ its call site — `50vw / var(--zoom)` — which resolves the unit against the
+ viewport the page WOULD have had at this zoom and lands the bleed back on the
+ real edge. Grep for `var(--zoom)` to see the full set: fifteen call sites, five
+ here, eight in tailwind.config.ts (the four fluid type steps and the four
+ `screen` size utilities) and two in components.
+
+ This is also what real browser zoom does to a clamp, and the fluid type steps
+ depend on it: at 80% a 1280px window reports a 1600px viewport, so
+ `clamp(2.75rem, 6.2vw, 5.25rem)` stops tracking the vw term and pins to its
+ rem ceiling. Divide the vw term and the same crossover happens here, at the
+ same width. Leave it undivided and the headline shrinks twice — once by the
+ zoom and once by the smaller viewport it thinks it is in.
+
+ TO CHANGE THE SITE'S SCALE, CHANGE THIS AND NOTHING ELSE. To return the site
+ to 1:1, set it to 1: every division above becomes a no-op and `zoom: 1` is
+ inert, so there is no second value to hunt down.
+
+ NOT A REPLACEMENT FOR THE READER'S OWN ZOOM. It composes with it — a reader at
+ 125% sees this at 100% — and because it is `zoom` rather than a font-size floor,
+ their zoom still scales everything on the page. */
+ --zoom: 0.8;
+
/* THE ANGLE. One number for every object on this site that is PLACED rather than
laid out — chips, notes, stickers, the tape across a note's top edge.
@@ -456,8 +497,31 @@ html {
scroll-padding-top: 80px;
-webkit-text-size-adjust: 100%;
- /* NO ROOT SCALING. There is deliberately no `font-size` here, and the absence is
- the fix rather than an omission.
+ /* THE PAGE SCALE, applied. See --zoom in :root for what the number is and why it
+ is `zoom` rather than a root font-size; the note below is the history that
+ argument rests on.
+
+ ON THE ROOT rather than on body, because `position: fixed` descendants are what
+ decide it. A fixed element's containing block is the viewport, so fixed children
+ of a zoomed BODY would escape the zoom and render at full size — the nav plate
+ and the sticky CTA would be the only two objects on the page that did not come
+ down with everything else. Zoomed from the root they scale AND still fill the
+ viewport: `left: 0; right: 0` on a fixed element measures the full 1280px of a
+ 1280px viewport at zoom 0.8, because percentage and inset sizing resolve inside
+ the zoomed coordinate space. Explicit vw/vh units are the only lengths that do
+ not, which is the division the token describes.
+
+ WHAT THIS DOES NOT MOVE IS THE BREAKPOINTS. Media queries answer to the real
+ viewport, so a 1280px window still gets the 1280px layout rather than the
+ 1600px one true 80% browser zoom would hand it. Deliberate: it means this token
+ changes the SIZE of everything without ever changing WHICH layout a window
+ gets, so a column count cannot shift underneath the site because somebody
+ nudged the scale. Scaling theme.screens by the same number is the change if the
+ extra columns are wanted, and it is a different and much louder one. */
+ zoom: var(--zoom);
+
+ /* NO ROOT FONT-SIZE SCALING — the mechanism above is deliberately not this one.
+ There is no `font-size` here, and the absence is a fix rather than an omission.
This carried `font-size: 75%` for a long time, on the reasoning that "a 1080p
laptop had to be zoomed to 75% before a section fit on screen — which is the
@@ -477,7 +541,28 @@ html {
10,566px — about twelve screens. The height was in the padding all along.
So the type is back at its designed size and the space it needed came out of
- the spacing, not the letters. If a section does not fit, cut the section. */
+ the spacing, not the letters. If a section does not fit, cut the section.
+
+ None of that is contradicted by the `zoom` above: the tokens in
+ tailwind.config.ts still state the real size the type was drawn at, the rem
+ scale still resolves against a 16px root into whole pixels, and the page scale
+ is then one multiplication applied to the finished layout. The failure recorded
+ here was a root that made the type lie about its own size; this changes the
+ ruler and leaves the numbers honest. */
+}
+
+/* THE FALLBACK, for a browser with no `zoom` at all. It puts the token back to 1,
+ which turns every `/ var(--zoom)` division — here, in the components, and in
+ tailwind.config.ts — into a no-op and returns the site to 1:1. Without it those
+ divisions would survive alone: vw terms 25% too large, so full-bleed bands
+ overhanging the viewport by 12.5% a side and headlines a quarter oversize, which
+ is a good deal worse than simply not zooming. Chrome has had `zoom` throughout,
+ Firefox since 126 and Safari since 18 — this is the long tail, not the common
+ case. */
+@supports not (zoom: 0.8) {
+ :root {
+ --zoom: 1;
+ }
}
body {
@@ -1682,37 +1767,6 @@ body::before {
box-shadow: 9px 9px 0 0 #000000;
}
-/* ---- Where a flow note sits horizontally ----------------------------------
- ONE `left`, AND IT IS A CLAMP RATHER THAN A BREAKPOINT. The note is anchored
- beside the column it annotates — --note-anchor, measured per note, see
- Note.tsx — and pulled back to a flush right inset on any container too narrow
- to honour that.
-
- The clamp is what replaces a media query per note. A capped column's ink stops
- at a fixed x, so the anchor is a constant; the container is not, and below
- about 1180px the anchor would put a 224px note through the container's right
- edge. `min()` resolves that per width with no number written twice: at 1024 it
- yields 840px, which is the flush-right position these notes already held, and
- from ~1180 up it yields the anchor.
-
- 1.5rem of reserve, not 1rem, and the extra 8px is the tilt: a rotated box's
- border box is wider than the element, so a 224x190 note at 4deg measures 237
- across and a 16px inset leaves 10px. Same arithmetic the `right-4` these
- replaced was written against — see the placement note in Note.tsx.
-
- --note-w TRACKS `w-40`/`xl:w-56` ON THE COMPONENT and is the one thing here
- that can silently rot: change either width without changing this and the clamp
- protects the wrong edge. */
-.note-flow {
- --note-w: 10rem;
- left: min(var(--note-anchor, 52rem), 100% - var(--note-w) - 1.5rem);
-}
-@media (min-width: 1280px) {
- .note-flow {
- --note-w: 14rem;
- }
-}
-
/* ---- Paper stocks: THREE, FROM ONE PAD ------------------------------------
There were six — mint #86efac, pink #f9a8d4, sky #7dd3fc, lime #bef264,
orange #fdba74, lilac #c4b5fd — plus the yellow default. Each was picked for
@@ -2045,8 +2099,11 @@ body::before {
text is the sr-only sibling in Term.tsx — see the note there for why it takes
two elements.
Capped against the viewport as well as in rem, so the bubble itself stays
- inside the screen on the narrow widths where it is shown. */
- max-width: min(15rem, 60vw);
+ inside the screen on the narrow widths where it is shown. The vw term carries
+ the page-scale division every vw term in this file does — see --zoom in :root;
+ without it the cap would come in 20% tighter than the screen it is capping
+ against, which on a 390px phone is a bubble narrower than its own words. */
+ max-width: min(15rem, 60vw / var(--zoom));
pointer-events: none;
display: none;
}
@@ -2101,8 +2158,11 @@ body::before {
this and every "role @ place — what it involved" wraps to a third line, which
on a four-highlight card is four extra lines of height for nothing.
The vw term is a CONTAINMENT RULE — see the alignment block below for the half
- of that rule this cannot express on its own. */
- width: min(24rem, 34vw);
+ of that rule this cannot express on its own. It is divided by the page scale
+ like every other vw term here (--zoom in :root): the rule is "stay inside the
+ viewport", and an undivided vw resolves against the viewport the page is drawn
+ in rather than the one it is shown at. */
+ width: min(24rem, 34vw / var(--zoom));
padding: 0.7rem 0.85rem;
border-radius: 10px; /* rounded-inline */
background: #0f172a;
@@ -2765,7 +2825,15 @@ body::before {
The bleed uses inset:auto with symmetric negative offsets rather than 100vw:
100vw includes the scrollbar gutter and would push the page sideways. body
already sets overflow-x:hidden, so any sub-pixel overshoot is clipped rather
- than becoming a scrollbar. */
+ than becoming a scrollbar.
+
+ THE vw HALF IS DIVIDED BY THE PAGE SCALE and the 50% half is not, which looks
+ asymmetric and is not: `zoom` scales percentages along with the box they measure,
+ so 50% needs nothing, while vw is resolved against the viewport before the zoom
+ is applied and arrives 20% short. Measured at zoom 0.8 with the division in
+ place: 1280px of bleed on a 1280px viewport, 1536 on 1536, 390 on 390. Without
+ it, 1024px of bleed with a 128px stripe of page showing down each side — a band
+ that reads as a wide card instead of the edge-to-edge sheet it is. */
.band {
position: relative;
}
@@ -2774,8 +2842,8 @@ body::before {
position: absolute;
top: 0;
bottom: 0;
- left: calc(50% - 50vw);
- right: calc(50% - 50vw);
+ left: calc(50% - 50vw / var(--zoom));
+ right: calc(50% - 50vw / var(--zoom));
background: rgb(var(--band));
/* Behind this section's own content, in front of the page background. */
z-index: -1;
@@ -3220,6 +3288,61 @@ button:focus-visible .icon-slide {
grid-template-rows: 1fr;
}
+/* ---- Home-page folds -------------------------------------------------------
+ The FAQ panel above, reused for whole sections (components/Fold.tsx). The head
+ is the click target; the button is its keyboard and screen-reader handle and
+ reuses .faq-mark, so a section opens with the same plus-to-cross as an answer. */
+.fold-head {
+ display: flex;
+ align-items: flex-start;
+ gap: 1.5rem;
+ cursor: pointer;
+}
+.fold-btn {
+ flex: none;
+ display: grid;
+ place-items: center;
+ width: 2.75rem;
+ height: 2.75rem;
+ margin-top: 0.25rem;
+ border-radius: 9999px;
+ /* Highlighted at rest: a soft accent disc with an accent rim, so a folded
+ section reads as "tap to open" rather than as a stray ring. Hover fills it. */
+ border: 1px solid rgb(var(--accent) / 0.45);
+ background: rgb(var(--accent-soft));
+ color: inherit;
+ cursor: pointer;
+ transition:
+ border-color 180ms ease-in-out,
+ background-color 180ms ease-in-out,
+ box-shadow 180ms ease-in-out;
+}
+.fold-btn .faq-mark {
+ margin-top: 0;
+}
+.fold-head:hover .fold-btn,
+.fold-btn:focus-visible {
+ border-color: rgb(var(--accent));
+ background: rgb(var(--accent));
+ box-shadow: 0 0 0 4px rgb(var(--accent) / 0.15);
+}
+.fold-btn .faq-mark {
+ color: rgb(var(--accent));
+}
+/* White on the filled disc. --accent-soft is the page-coloured end of the
+ accent pair, so it is the readable ink on --accent in both themes. */
+.fold-head:hover .fold-btn .faq-mark,
+.fold-btn:focus-visible .faq-mark {
+ color: rgb(var(--accent-soft));
+}
+.fold-btn[aria-expanded="true"] .faq-mark {
+ transform: rotate(135deg);
+}
+/* Unclipped once fully open — see `settled` in Fold.tsx. */
+.fold-panel[data-settled="true"] > div {
+ overflow: visible;
+}
+
/* ---- THE AMBIENT LAYER ----------------------------------------------------
The page's resting state. Everything below runs with nobody touching anything,
which is the part that separates a page that has been animated from one that is
@@ -3348,9 +3471,10 @@ button:focus-visible .icon-slide {
The horizontally scrolling testimonials under "In their words". The drift, the
wrap and the arrows are in MemberStory.tsx; what is here is the geometry.
- FULL-BLEED THE SAME WAY .band IS, and for the same reason the note there gives:
+ FULL-BLEED THE SAME WAY .band IS, and for the same reasons the note there gives:
symmetric negative inline margins rather than 100vw, because 100vw includes the
- scrollbar gutter and would push the page sideways. Applied as margins on the
+ scrollbar gutter and would push the page sideways, and the vw term divided by the
+ page scale while the 50% is left alone. Applied as margins on the
rail's own wrapper, since this escapes a `.section` that has already capped and
centred itself — 50% therefore resolves against that capped width, which is
exactly the inset being cancelled.
@@ -3372,7 +3496,7 @@ button:focus-visible .icon-slide {
contrast failure that nothing in the QA sweep would call a colour bug. */
--story-blue: #0038ff;
position: relative;
- margin-inline: calc(50% - 50vw);
+ margin-inline: calc(50% - 50vw / var(--zoom));
}
/* No padding and no gap on the rail: a slide is exactly one rail-width, so the
inset that lines its content up with the rest of the page has to live INSIDE
@@ -3826,3 +3950,309 @@ button:focus-visible .icon-slide {
scroll-behavior: auto;
}
}
+
+/* No dot grid. The rule that draws it (body::before, above) is kept so the
+ grid can come back by deleting this. */
+body::before {
+ content: none;
+}
+
+/* ===========================================================================
+ THE TOYS. Contribution graph, terminal caret, merge badge, the merge
+ playground, peel-off stickers, achievement toasts. They keep GitHub's own
+ state colours, since each one is a picture of GitHub.
+ =========================================================================== */
+
+/* ---- Contribution graph levels ------------------------------------------------
+ GitHub's exact five steps, per theme. */
+:root {
+ --gh-0: #ebedf0;
+ --gh-1: #9be9a8;
+ --gh-2: #40c463;
+ --gh-3: #30a14e;
+ --gh-4: #216e39;
+}
+:root[data-theme="dark"] {
+ --gh-0: #161b22;
+ --gh-1: #0e4429;
+ --gh-2: #006d32;
+ --gh-3: #26a641;
+ --gh-4: #39d353;
+}
+:root[data-theme="light"] {
+ --gh-0: #ebedf0;
+ --gh-1: #9be9a8;
+ --gh-2: #40c463;
+ --gh-3: #30a14e;
+ --gh-4: #216e39;
+}
+.gh-cell {
+ display: inline-block;
+ background: var(--gh-0);
+ outline: 1px solid rgb(27 31 35 / 0.06);
+ outline-offset: -1px;
+ transition: transform 120ms ease-out;
+}
+.gh-cell[data-l="1"] { background: var(--gh-1); }
+.gh-cell[data-l="2"] { background: var(--gh-2); }
+.gh-cell[data-l="3"] { background: var(--gh-3); }
+.gh-cell[data-l="4"] { background: var(--gh-4); }
+.gh-graph .gh-cell:hover {
+ transform: scale(1.6);
+ outline: 1px solid rgb(var(--ink) / 0.6);
+ position: relative;
+ z-index: 1;
+}
+
+/* ---- The hero's toys ----------------------------------------------------------- */
+.caret {
+ display: inline-block;
+ width: 0.42em;
+ height: 0.8em;
+ margin-left: 0.12em;
+ vertical-align: baseline;
+ background: rgb(var(--accent));
+ animation: caret-blink 1.1s steps(1) infinite;
+}
+@keyframes caret-blink {
+ 50% {
+ opacity: 0;
+ }
+}
+
+/* GitHub's "Merged" state badge: merged purple, white text, merge icon. Tilted
+ like the sticky notes, because somebody stuck it there. */
+.pr-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4em;
+ padding: 0.4rem 0.85rem;
+ border-radius: 2em;
+ background: #8957e5;
+ color: #ffffff;
+ font-family: var(--font-sans), system-ui, sans-serif;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ transform: rotate(6deg);
+ box-shadow: 3px 3px 0 0 rgb(1 4 9 / 0.85);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .caret {
+ animation: none;
+ }
+}
+
+:root,
+:root[data-theme="light"] {
+ --link: 9 105 218;
+ --added: 26 127 55;
+}
+:root[data-theme="dark"] {
+ --link: 68 147 248;
+ --added: 63 185 80;
+}
+
+/* The OS-dark reader with no saved choice gets the dark graph and link colours too. */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --gh-0: #161b22;
+ --gh-1: #0e4429;
+ --gh-2: #006d32;
+ --gh-3: #26a641;
+ --gh-4: #39d353;
+ --link: 68 147 248;
+ --added: 63 185 80;
+ }
+}
+
+/* ---- The pull request card ------------------------------------------------- */
+.pr-card {
+ overflow: hidden;
+ border: 1px solid rgb(var(--seam));
+ border-radius: 6px;
+ background: rgb(var(--bg));
+ box-shadow: 6px 6px 0 0 rgb(var(--accent) / 0.4);
+}
+/* Contributor / maintainer switch: GitHub-style tabs sitting on the card. */
+.pr-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin-bottom: -1px;
+}
+.pr-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4em;
+ padding: 0.55rem 1rem;
+ border: 1px solid transparent;
+ border-bottom: 0;
+ border-radius: 6px 6px 0 0;
+ color: rgb(var(--haze));
+ font-size: 0.875rem;
+ font-weight: 600;
+ transition: color 150ms ease-out;
+}
+.pr-tab:hover { color: rgb(var(--ink)); }
+.pr-tab[aria-selected="true"] {
+ position: relative;
+ z-index: 1;
+ border-color: rgb(var(--seam));
+ background: rgb(var(--bg));
+ color: rgb(var(--ink));
+ box-shadow: inset 0 2px 0 0 rgb(var(--accent));
+}
+.pr-input {
+ display: block;
+ width: 100%;
+ padding: 0.45rem 0.7rem;
+ border: 1px solid rgb(var(--seam));
+ border-radius: 6px;
+ background: rgb(var(--sunk));
+ color: rgb(var(--ink));
+ font-size: 0.875rem;
+}
+.pr-input:focus {
+ outline: 2px solid rgb(var(--link));
+ outline-offset: -1px;
+}
+.pr-input:disabled { opacity: 0.7; }
+.pr-state {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35em;
+ padding: 0.3rem 0.75rem;
+ border-radius: 2em;
+ color: #ffffff;
+ font-weight: 600;
+ font-size: 0.875rem;
+ transition: background-color 300ms ease-out;
+}
+/* GitHub's own state colours: open is green, merged is purple. Green belongs
+ here — this is literally the open state of a PR. */
+.pr-state-open { background: #238636; }
+.pr-state-merged { background: #8957e5; }
+.pr-ref {
+ padding: 0.1em 0.45em;
+ border-radius: 6px;
+ background: rgb(var(--link) / 0.15);
+ color: rgb(var(--link));
+ font-family: var(--font-mono), ui-monospace, monospace;
+ font-size: 0.8125rem;
+}
+.diff-del {
+ background: rgb(248 81 73 / 0.12);
+ color: rgb(var(--ink));
+}
+.diff-add {
+ background: rgb(var(--added) / 0.15);
+ color: rgb(var(--ink));
+}
+/* The merge button is GitHub's green, the one green control on the site. */
+.btn-merge {
+ background: #238636;
+ color: #ffffff;
+ border: 1px solid rgb(240 246 252 / 0.1);
+ box-shadow: 3px 3px 0 0 rgb(35 134 54 / 0.45);
+}
+.btn-merge:hover {
+ background: #2ea043;
+ transform: translate(-1px, -1px);
+ box-shadow: 4px 4px 0 0 rgb(35 134 54 / 0.6);
+}
+.btn-merge:active {
+ transform: translate(3px, 3px);
+ box-shadow: none;
+}
+.btn-merge:disabled {
+ opacity: 0.7;
+ cursor: progress;
+}
+
+/* ---- Peel-off stickers ------------------------------------------------------ */
+.peel {
+ position: absolute;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3em;
+ padding: 0.45rem 0.9rem;
+ border-radius: 9999px;
+ border: 2px solid #ffffff;
+ font-family: var(--font-mono), ui-monospace, monospace;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ white-space: nowrap;
+ cursor: grab;
+ user-select: none;
+ touch-action: none;
+ box-shadow: 0 2px 0 rgb(0 0 0 / 0.25), 3px 4px 0 0 rgb(1 4 9 / 0.7);
+ transition:
+ transform 160ms cubic-bezier(0.34, 1.56, 0.64, 1),
+ box-shadow 160ms ease-out;
+}
+.peel:hover {
+ box-shadow: 0 2px 0 rgb(0 0 0 / 0.25), 5px 7px 0 0 rgb(1 4 9 / 0.7);
+}
+.peel.is-held {
+ cursor: grabbing;
+ z-index: 50;
+ transition: box-shadow 160ms ease-out;
+ box-shadow: 0 2px 0 rgb(0 0 0 / 0.25), 10px 16px 0 0 rgb(1 4 9 / 0.55);
+}
+.peel-paper { background: #ffffff; color: #1f2328; border-color: #d0d7de; }
+.peel-purple { background: #8957e5; color: #ffffff; }
+.peel-blue { background: #0969da; color: #ffffff; }
+.peel-green { background: #1f883d; color: #ffffff; }
+.peel-yellow { background: #e3b341; color: #1f2328; }
+
+/* ---- Paintable graph --------------------------------------------------------- */
+.gh-paint .gh-cell {
+ cursor: crosshair;
+}
+.gh-paint {
+ touch-action: none;
+}
+
+/* ---- Achievement toast ------------------------------------------------------- */
+.ach-toast {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ width: min(22rem, calc(100vw / var(--zoom) - 2.5rem));
+ padding: 0.8rem 1rem;
+ border: 1px solid rgb(var(--seam));
+ border-radius: 6px;
+ background: rgb(var(--band));
+ box-shadow: 5px 5px 0 0 rgb(var(--accent) / 0.55);
+ animation: ach-in 420ms cubic-bezier(0.34, 1.56, 0.64, 1) both,
+ ach-out 300ms ease-in 4.9s both;
+}
+.ach-medal {
+ display: grid;
+ place-items: center;
+ width: 3rem;
+ height: 3rem;
+ flex: none;
+ border-radius: 9999px;
+ font-size: 1.6rem;
+ background: radial-gradient(circle at 35% 30%, #f7e08a, #d4a72c 70%);
+ box-shadow: inset 0 -3px 0 rgb(0 0 0 / 0.2), 0 0 0 3px rgb(var(--accent) / 0.35);
+}
+@keyframes ach-in {
+ from {
+ transform: translateY(1.5rem) scale(0.9);
+ opacity: 0;
+ }
+}
+@keyframes ach-out {
+ to {
+ transform: translateX(-1rem);
+ opacity: 0;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .ach-toast {
+ animation: none;
+ }
+}
diff --git a/web/app/icon.svg b/web/app/icon.svg
new file mode 100644
index 0000000..7be2ffb
--- /dev/null
+++ b/web/app/icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index 35205c8..cf5bdd4 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
-import { JetBrains_Mono, Plus_Jakarta_Sans, Space_Grotesk } from "next/font/google";
+import { Fraunces, JetBrains_Mono, Plus_Jakarta_Sans, Space_Grotesk } from "next/font/google";
import "./globals.css";
// TWO faces, where there were four. Both self-hosted at build time by next/font,
@@ -79,6 +79,15 @@ const mono = JetBrains_Mono({
display: "swap",
});
+// The CherryPick wordmark, and nothing else. One heavy cut of a soft serif, to match
+// the logo artwork; see components/Logo.tsx.
+const brand = Fraunces({
+ subsets: ["latin"],
+ weight: ["800"],
+ variable: "--font-brand",
+ display: "swap",
+});
+
import { AuthProvider } from "@/lib/auth";
const SITE_URL =
@@ -113,7 +122,8 @@ export default function RootLayout({
return (
{/* Anti-flash for the theme toggle.
diff --git a/web/components/AdminDashboard.tsx b/web/components/AdminDashboard.tsx
index 1ec1ee1..1082f27 100644
--- a/web/components/AdminDashboard.tsx
+++ b/web/components/AdminDashboard.tsx
@@ -179,7 +179,7 @@ export default function AdminDashboard() {
} catch (e) {
console.error("[osc] could not change membership", e);
setMemberError(
- `Could not change membership for ${r.name || r.email}. The rules refused it, or the connection dropped.`,
+ `Couldn't change membership for ${r.name || r.email} — the rules refused it, or the connection dropped.`,
);
} finally {
setSaving(null);
@@ -237,7 +237,7 @@ export default function AdminDashboard() {
} catch (e) {
console.error("[osc] could not load the dashboard", e);
setError(
- "Firestore refused the query. Either your address is not in the admins collection, or the rules are not deployed.",
+ "Firestore refused the query — you're not in admins, or the rules aren't deployed.",
);
} finally {
setReloading(false);
@@ -461,8 +461,8 @@ export default function AdminDashboard() {
{user
- ? "You are signed in, but your address is not on the organisers list. If it should be, ask somebody who already has access to add you."
- : "Sign in with your college account first. If you are an organiser, this page will fill in."}
+ ? "You're signed in, but not on the organisers list. Ask an organiser to add you."
+ : "Organiser? Sign in with your college account first."}
);
@@ -553,7 +553,7 @@ export default function AdminDashboard() {
rows={breakdowns.path}
total={breakdowns.withPath}
empty="Nobody arrived through a path link yet."
- footnote={`Of the ${breakdowns.withPath} member${breakdowns.withPath === 1 ? "" : "s"} who arrived through a link that named a path. It is not asked for, so most members have none.`}
+ footnote={`Of the ${breakdowns.withPath} member${breakdowns.withPath === 1 ? "" : "s"} who came in via a path link — it's optional, so most have none.`}
/>
@@ -564,10 +564,8 @@ export default function AdminDashboard() {
Breakdowns
- By batch, year, branch, hostel and route in. These are the only figures on this
- page that cannot be counted in the database — batch and branch are read out of
- each member's address rather than stored, so grouping by them means
- loading every member.
+ By batch, year, branch, hostel and route in. These need every member loaded —
+ batch and branch aren't stored.
)}
- Batch, branch and year are read from each member's college address rather than
- asked for — 23bcs10045 is the 2023–27
- batch, branch BCS. An address that does not follow that pattern is counted as{" "}
- Unknown rather than guessed at.
+ Batch, branch and year come from the college address —{" "}
+ 23bcs10045 is 2023–27, BCS. Anything
+ else counts as Unknown.
{/* The list. Filtering is local — the whole membership is already in memory, so a
@@ -887,9 +883,8 @@ export default function AdminDashboard() {
- This is every member's own words about themselves, including their college
- address. Treat the export the way you would a class list: it does not go in a
- group chat, and it is not published on the site.
+ The export holds members' personal details, college addresses included. Treat
+ it like a class list — never in a group chat, never on the site.
diff --git a/web/components/AdminMentors.tsx b/web/components/AdminMentors.tsx
index 02c6d75..1c998f6 100644
--- a/web/components/AdminMentors.tsx
+++ b/web/components/AdminMentors.tsx
@@ -30,7 +30,14 @@
import { useState } from "react";
import { field, labelOf } from "@/components/admin/ui";
import { PROGRAMS } from "@/content/join";
-import { deleteMentor, saveMentor, type Mentor, type MentorInput } from "@/lib/mentorship";
+import {
+ deleteMentor,
+ programTrackMentorInputs,
+ programTrackMentorsWithoutBlurb,
+ saveMentor,
+ type Mentor,
+ type MentorInput,
+} from "@/lib/mentorship";
/** A blank mentor, for the add form. `gsoc` because that is the cohort the club runs;
* the select is there so a second programme needs no code change. */
@@ -111,7 +118,7 @@ function Editor({
maxLength={600}
rows={4}
className={`${field} resize-y`}
- placeholder="What they work on, and what they are useful for. 'Kubernetes and Go; good on proposal structure; not the person to ask about frontend.'"
+ placeholder="What they work on and help with. 'Kubernetes and Go; good on proposals; not frontend.'"
value={v.description}
onChange={(e) => set("description", e.target.value)}
/>
@@ -220,6 +227,67 @@ export default function AdminMentors({
const [editing, setEditing] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
+ /** What the last import did. Cleared on the next one. */
+ const [imported, setImported] = useState("");
+
+ /* THE IMPORT, and the two things it deliberately is not.
+ It is not a sync: a mentor already in this list is left exactly as they are,
+ matched on name, however far their description here has drifted from the
+ public page's. An organiser who edited a description in the dashboard did
+ that on purpose, and a button that silently reverted it would be the last
+ time anybody pressed it.
+ It is not a bulk create either — it writes one document per missing mentor
+ through the same saveMentor every other row goes through, so the rules
+ validate each one and a refusal stops the run with the reason on screen
+ rather than leaving half a list written by a path nothing else uses. */
+ async function importBench() {
+ if (mentors === null) return;
+ setSaving(true);
+ setError("");
+ setImported("");
+ const here = new Set(mentors.map((m) => m.name.trim().toLowerCase()));
+ const missing = programTrackMentorInputs().filter(
+ (m) => !here.has(m.name.trim().toLowerCase()),
+ );
+ try {
+ for (const input of missing) await saveMentor(input);
+ /* Named only for the rows this run actually created. Everybody on the bench
+ is imported, description or not — one without a written line arrives with
+ a stand-in that says so on their card, which is a thing to fix rather
+ than a reason to leave somebody out of the picker. But an organiser who
+ has already rewritten that stand-in here does not need telling about it
+ every time they press the button, and a mentor who was typed in by hand
+ is not missing anything either. */
+ const created = new Set(missing.map((m) => m.name));
+ const thin = programTrackMentorsWithoutBlurb().filter((n) =>
+ created.has(n),
+ );
+ setImported(
+ [
+ missing.length === 0
+ ? "Everybody on the bench was already here."
+ : `Added ${missing.length}: ${missing.map((m) => m.name).join(", ")}.`,
+ thin.length > 0 &&
+ `${thin.join(", ")} got a placeholder description — edit the row below, or fill it in PROGRAM_TRACK_MENTORS in content/join.ts.`,
+ ]
+ .filter(Boolean)
+ .join(" "),
+ );
+ if (missing.length > 0) onChanged();
+ } catch (e) {
+ console.error("[osc] could not import the bench", e);
+ // The parent re-reads the list even on failure: some rows may already be
+ // written, and leaving a stale list on screen is what would make a second
+ // press create them a second time - the opposite of what the sentence below
+ // promises.
+ onChanged();
+ setError(
+ "Firestore refused a write — you're not in admins, or the rules aren't deployed. Anything added before it is saved; retrying won't duplicate them.",
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
async function save(input: MentorInput, id?: string) {
setSaving(true);
@@ -231,7 +299,7 @@ export default function AdminMentors({
} catch (e) {
console.error("[osc] could not save mentor", e);
setError(
- "Firestore refused that write. Either your address is not in the admins collection, or the rules are not deployed.",
+ "Firestore refused that write — you're not in admins, or the rules aren't deployed.",
);
} finally {
setSaving(false);
@@ -262,16 +330,37 @@ export default function AdminMentors({
{editing !== "new" && (
-
+
+ {/* Named after where the list comes from rather than "import mentors",
+ because the organiser pressing it needs to know WHICH six arrive and
+ where to go to change them. It sits beside the manual add rather
+ than replacing it — the bench is one source of mentors, not the
+ only one. */}
+
+
+
)}
+ {imported && (
+
+ {imported}
+
+ )}
+
{error && (
{error}
@@ -295,8 +384,7 @@ export default function AdminMentors({
// Until there is one mentor, every member's dashboard shows "enrolment opens
// when the organisers add them" — which is a sentence somebody has to act on.
- No mentors yet. Until you add one, the mentorship card on every member's
- dashboard says enrolment has not opened.
+ No mentors yet — every member sees enrolment as closed until you add one.
)}
diff --git a/web/components/AdminMentorship.tsx b/web/components/AdminMentorship.tsx
index a66c456..1713bff 100644
--- a/web/components/AdminMentorship.tsx
+++ b/web/components/AdminMentorship.tsx
@@ -449,7 +449,7 @@ export default function AdminMentorship({
{stats.total === 0
? "Nobody has enrolled yet."
- : `${stats.total} student${stats.total === 1 ? "" : "s"} enrolled. The counts and charts on this page did not need any of their records; the list does, so it loads a page at a time.`}
+ : `${stats.total} student${stats.total === 1 ? "" : "s"} enrolled. The list loads a page at a time.`}
{stats.total > 0 && (
Mentors nobody has picked
@@ -512,18 +512,14 @@ export default function AdminMentorship({
)}
- Named rather than drawn as an empty bar, because a bar at zero is a row that
- disappears. A hidden mentor with nobody is expected; a visible one is worth a
- look at their description.
+ A hidden mentor here is expected; a visible one may need a better description.
- 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
- somebody makes.
+ Each student picks two, so demand adds up past 100%. These are requests, not
+ pairings — an organiser still decides.
);
diff --git a/web/components/AppHeader.tsx b/web/components/AppHeader.tsx
index f9bb433..2b8f233 100644
--- a/web/components/AppHeader.tsx
+++ b/web/components/AppHeader.tsx
@@ -20,6 +20,7 @@
// forbids. Same object, different contents.
import Link from "next/link";
+import Logo from "@/components/Logo";
import ThemeToggle from "@/components/ThemeToggle";
import { useAuth } from "@/lib/auth";
import { batchFromEmail } from "@/lib/batch";
@@ -44,9 +45,10 @@ export default function AppHeader() {
screen. The QA sweep measured this at 33x28 on mobile before it was added. */}
- OSC
+
{/* The rule is drawn rather than typed, for the reason the site nav's separators
diff --git a/web/components/AudienceBackfill.tsx b/web/components/AudienceBackfill.tsx
index e45d7f3..39b2b6e 100644
--- a/web/components/AudienceBackfill.tsx
+++ b/web/components/AudienceBackfill.tsx
@@ -97,7 +97,7 @@ export default function AudienceBackfill() {
} catch (e) {
console.error("[osc] backfill failed", e);
setError(
- "Firestore refused part of that. Nothing is half-written — each document is its own write, so run it again and it will pick up whatever is left.",
+ "Some didn't save. Run it again — it picks up where it left off.",
);
} finally {
setBusy(false);
@@ -110,8 +110,7 @@ export default function AudienceBackfill() {
// Confirm the run that just happened, then go quiet for good.
return done > 0 ? (
- Stamped {done} older {done === 1 ? "item" : "items"} as visible to everyone. Nothing
- left to migrate.
+ Marked {done} older {done === 1 ? "item" : "items"} as everyone. All done.
) : null;
}
@@ -126,9 +125,8 @@ export default function AudienceBackfill() {
{Object.entries(stale)
.map(([name, ids]) => `${ids.length} ${COLLECTIONS.find((c) => c[0] === name)?.[1] ?? name}`)
.join(", ")}{" "}
- were posted before notices had an audience. Members cannot see them until they are
- marked. This sets them to everyone, which is
- who they reached when they were written.
+ predate audiences, so nobody sees them. This sets them to{" "}
+ everyone, like before.
{error && (
diff --git a/web/components/Banner.tsx b/web/components/Banner.tsx
index 8a0e6f1..f896c5b 100644
--- a/web/components/Banner.tsx
+++ b/web/components/Banner.tsx
@@ -18,7 +18,7 @@
import Link from "next/link";
import Doodle from "@/components/Doodle";
-import { LINKS } from "@/content/club";
+import { LINKS } from "@/content/site";
export default function Banner() {
return (
diff --git a/web/components/CommitGraph.tsx b/web/components/CommitGraph.tsx
index e3f2579..7ca57fb 100644
--- a/web/components/CommitGraph.tsx
+++ b/web/components/CommitGraph.tsx
@@ -149,8 +149,7 @@ export default function CommitGraph({ className = "" }: { className?: string })
The grey line
- The project's history — every change anyone has ever made to it,
- public, in order, with names attached.
+ The project's history — every change, public, with names attached.
@@ -158,8 +157,7 @@ export default function CommitGraph({ className = "" }: { className?: string })
The blue line
- Your branch. You copy the project, change something on your own copy, and
- nothing you do here can break anybody else's work.
+ Your branch. Your own copy — you can't break anyone else's work.
@@ -167,8 +165,7 @@ export default function CommitGraph({ className = "" }: { className?: string })
The filled dot
- The merge. A maintainer agreed, and your change is now part of the grey
- line — permanently, and for everyone who downloads it after.
+ The merge. A maintainer said yes — your change is in the grey line for good.
diff --git a/web/components/CommunityBanner.tsx b/web/components/CommunityBanner.tsx
index f7cdaed..8a8a1de 100644
--- a/web/components/CommunityBanner.tsx
+++ b/web/components/CommunityBanner.tsx
@@ -10,7 +10,7 @@
//
// 1. "Join 300+ Contributors". There is no 300 anywhere in this repo, and no
// field that could become one: the club records its published
-// selections (SELECTIONS in club.ts, and the count moves as names come in —
+// selections (SELECTIONS in content/selections.ts, and the count moves as
// which is why it is not written out here), eight people running it, and
// one project with an API-verified
// merge count. A recruitment page for sixteen-year-olds that inflates its
@@ -30,7 +30,7 @@
import Portrait from "@/components/Portrait";
import Doodle from "@/components/Doodle";
import CelebrateLink from "@/components/fx/CelebrateLink";
-import { publishedSelections, selectionStats } from "@/content/club";
+import { publishedSelections, selectionStats } from "@/content/selections";
/** How many faces before the stack stops reading as a group and starts as a list. */
const FACES = 5;
@@ -45,24 +45,17 @@ export default function CommunityBanner() {
return (
{/* Left: the claim. Pitch black regardless of theme, because this panel
keeps its pale ground on both — 18.4:1 on #F0F6FF. */}
The students winning are{" "}
@@ -73,8 +66,7 @@ export default function CommunityBanner() {
up the leading. */}
@@ -95,18 +87,17 @@ export default function CommunityBanner() {
// is what separates overlapping circles into distinct faces
// rather than one smeared row.
className="relative inline-block h-9 w-9 overflow-hidden rounded-full ring-2"
- style={{ zIndex: FACES - i, ["--tw-ring-color" as string]: "#F0F6FF" }}
+ style={{ zIndex: FACES - i, ["--tw-ring-color" as string]: "rgb(var(--band))" }}
>
))}
{stats.total} selected
-
+
this cohort
diff --git a/web/components/Composer.tsx b/web/components/Composer.tsx
index 8a3ca5a..0e923bf 100644
--- a/web/components/Composer.tsx
+++ b/web/components/Composer.tsx
@@ -61,7 +61,7 @@ export default function Composer() {
setPosts(await readAnnouncements());
} catch (e) {
console.error("[osc] could not read the board", e);
- setError("Could not load what is already posted. The rules may not be deployed.");
+ setError("Couldn't load the board. Are the rules deployed?");
setPosts([]);
}
}, []);
@@ -80,7 +80,7 @@ export default function Composer() {
setError("");
setNote("");
if (!isWellFormedPost({ title, body })) {
- setError("A notice needs both a title and something to say.");
+ setError("Add a title and a message.");
return;
}
// Checked here as well as in the rules so the reader gets a sentence instead of a
@@ -103,16 +103,16 @@ export default function Composer() {
// stays on screen showing what the next one will be.
setNote(
audience === "members"
- ? "Posted. Club members see it on their dashboard now."
+ ? "Posted. Members see it now."
: audience === "students"
- ? "Posted. Students who are not members see it; members will not."
- : "Posted. Everyone who signs in sees it on their dashboard now.",
+ ? "Posted. Non-members only."
+ : "Posted. Everyone sees it now.",
);
await load();
} catch (e) {
console.error("[osc] could not post", e);
setError(
- "Firestore refused that. Either the rules are not deployed, or your address is not in the admins collection.",
+ "Firestore said no. Check the rules are deployed and you're an admin.",
);
} finally {
setBusy(false);
@@ -159,8 +159,7 @@ export default function Composer() {
Tell everyone something.
- This lands on every member's dashboard the moment you post it. Pin the one
- that has to survive a fortnight.
+ Lands on dashboards instantly. Pin the ones that should stick around.
@@ -188,7 +187,7 @@ export default function Composer() {
value={body}
maxLength={2000}
onChange={(e) => setBody(e.target.value)}
- placeholder="Blank lines become paragraph breaks. Nothing else is formatted."
+ placeholder="Plain text. Blank lines make paragraphs."
/>
{/* Only past halfway. A counter that is visible from the first keystroke reads
as a limit somebody is about to hit; one that appears at 1000 characters is
diff --git a/web/components/Fold.tsx b/web/components/Fold.tsx
new file mode 100644
index 0000000..bd55af2
--- /dev/null
+++ b/web/components/Fold.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+// Collapsible sections, one open at a time — the FAQ's accordion, a page up.
+//
+// FoldGroup holds which section is open; every Fold inside it reads that. Opening
+// one closes whichever was open, for the same reason the FAQ is single-open: the
+// outgoing section shuts as the incoming one opens, so a reader is never back at
+// the full-length page having clicked their way there.
+//
+// The head (chip and headline) is always on show and is the whole click target;
+// the body is what folds. The explicit button exists for keyboards and screen
+// readers — clicks on it bubble to the head's handler, so there is one toggle,
+// not two that cancel each other out.
+//
+// THE CLICKED HEAD STAYS PUT. Closing a long section above the one you just
+// opened would otherwise drag the page up by that section's height, and the
+// header you clicked would slide off the top of the screen. So for the length of
+// the collapse, every frame scrolls back by however far the head has moved.
+// Browsers with native scroll anchoring have usually done it already, in which
+// case the measured drift is zero and this does nothing.
+//
+// A Fold with no FoldGroup around it is just a section, always open, no button.
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+type Ctx = { isOpen: (id: string) => boolean; toggle: (id: string) => void };
+const FoldCtx = createContext(null);
+
+// A touch longer than the .faq-panel transition, so the last frame of the
+// collapse is still being corrected for.
+const SETTLE_MS = 380;
+
+export function FoldGroup({
+ initial = null,
+ allOpen = false,
+ children,
+}: {
+ initial?: string | null;
+ /** Every section starts open and each folds on its own, instead of the
+ * one-at-a-time accordion. The home page uses this: a column of collapsed
+ * headlines read as an empty page. */
+ allOpen?: boolean;
+ children: ReactNode;
+}) {
+ const [open, setOpen] = useState(initial);
+ // allOpen mode tracks the sections somebody has CLOSED.
+ const [closed, setClosed] = useState>(() => new Set());
+
+ const toggle = useCallback(
+ (id: string) => {
+ if (allOpen) {
+ setClosed((cur) => {
+ const next = new Set(cur);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ } else {
+ setOpen((cur) => (cur === id ? null : id));
+ }
+ },
+ [allOpen],
+ );
+ const isOpen = useCallback(
+ (id: string) => (allOpen ? !closed.has(id) : open === id),
+ [allOpen, closed, open],
+ );
+
+ // A link to /#impact should land on an open section, not a closed one.
+ useEffect(() => {
+ const fromHash = () => {
+ const id = decodeURIComponent(window.location.hash.slice(1));
+ if (id && document.querySelector(`[data-fold="${CSS.escape(id)}"]`)) {
+ setOpen(id);
+ setClosed((cur) => {
+ if (!cur.has(id)) return cur;
+ const next = new Set(cur);
+ next.delete(id);
+ return next;
+ });
+ }
+ };
+ fromHash();
+ window.addEventListener("hashchange", fromHash);
+ return () => window.removeEventListener("hashchange", fromHash);
+ }, []);
+
+ return {children};
+}
+
+export function Fold({
+ id,
+ head,
+ children,
+ className,
+ label,
+}: {
+ id: string;
+ /** Always visible: the chip and the headline. */
+ head: ReactNode;
+ /** What collapses. */
+ children: ReactNode;
+ className?: string;
+ /** Names the section, for aria-label and the toggle's accessible name. */
+ label: string;
+}) {
+ const ctx = useContext(FoldCtx);
+ const isOpen = ctx ? ctx.isOpen(id) : true;
+ const panelId = useId();
+ const headRef = useRef(null);
+ const panelRef = useRef(null);
+ // Open AND finished opening. Only then does the body stop clipping — the
+ // collapse needs overflow hidden, but left on it would shave the hover lift
+ // off every card along the body's edges.
+ const [settled, setSettled] = useState(isOpen);
+ useEffect(() => {
+ if (!isOpen) setSettled(false);
+ }, [isOpen]);
+
+ // React 18 has no `inert` prop. A closed body is still in the DOM (there is no
+ // height to animate from otherwise), and unlike an FAQ answer it is full of
+ // links — so it has to be taken out of the tab order, not just hidden.
+ useEffect(() => {
+ const el = panelRef.current;
+ if (!el) return;
+ if (isOpen) el.removeAttribute("inert");
+ else el.setAttribute("inert", "");
+ }, [isOpen]);
+
+ const onHeadClick = () => {
+ if (!ctx) return;
+ const head = headRef.current;
+ const before = head?.getBoundingClientRect().top ?? 0;
+ ctx.toggle(id);
+ if (!head) return;
+ const start = performance.now();
+ const hold = () => {
+ const drift = head.getBoundingClientRect().top - before;
+ if (Math.abs(drift) > 0.5) {
+ window.scrollTo({
+ top: window.scrollY + drift,
+ behavior: "instant" as ScrollBehavior,
+ });
+ }
+ if (performance.now() - start < SETTLE_MS) requestAnimationFrame(hold);
+ };
+ requestAnimationFrame(hold);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/web/components/Footer.tsx b/web/components/Footer.tsx
index 9fcaf4f..a649741 100644
--- a/web/components/Footer.tsx
+++ b/web/components/Footer.tsx
@@ -1,7 +1,7 @@
// The footer, on every page.
//
// It carries the two audiences that have no page of their own — faculty and
-// sponsors, and maintainers — for the reason given over INSTITUTIONAL in club.ts:
+// sponsors, and maintainers — for the reason given over INSTITUTIONAL in site.ts:
// every route is addressed to a student deciding whether to join, and a
// "for sponsors" band inserted into one of them would compete with that page's
// single next action. Chrome is the right place for an audience that is not the
@@ -29,10 +29,17 @@
// and chrome is the only thing that reaches everyone. No content was dropped in
// the merge — the three statements, the email CTA, the page list and the
// trademark note are all still here, laid out as four columns instead of a card.
+//
+// THE THREE STATEMENTS HAVE SINCE GONE. "What we produce / How we run / What we
+// need" restated the home page on every route, under every page's own closing
+// action, so a reader who reached the bottom of anything met the club's pitch a
+// second time. INSTITUTIONAL stays in site.ts, unmounted; the email link is what a
+// faculty member or sponsor actually needs from here, and it stays.
import Link from "next/link";
import Console from "@/components/fx/Console";
-import { DASHBOARD_HREF, INSTITUTIONAL, JOIN_HREF, LINKS, PAGES } from "@/content/site";
+import Logo from "@/components/Logo";
+import { DASHBOARD_HREF, JOIN_HREF, LINKS, PAGES } from "@/content/site";
export default function Footer() {
return (
@@ -50,13 +57,11 @@ export default function Footer() {
className="grid gap-x-10 gap-y-12 md:grid-cols-2 lg:grid-cols-[1.35fr_1fr_1fr_1fr]"
data-reveal-group
>
- {/* The identity column. `font-display` has to state its own weight —
- Space Grotesk defaults to 400 where the poster face it replaced shipped
- one heavy cut, so a bare `font-display` looks like body copy at
- headline size. See the type note at the head of globals.css. */}
+ {/* The identity column: the full lockup, tagline and all — the one place
+ with room for it. */}
-
- OSC
+
+
A student-run open source club at Scaler School of Technology.
@@ -104,15 +109,6 @@ export default function Footer() {
- {/* The three institutional statements, each under a hairline and a mono
- label. */}
- {INSTITUTIONAL.map((i) => (
-
-
-
{i.title}
-
{i.body}
-
- ))}
{/* Every route, as one mono row, so it reads as wayfinding rather than as a
@@ -127,6 +123,18 @@ export default function Footer() {
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. */}
-
-
{/* NO "NO COLLEGE ACCOUNT?" FALLBACK, and this reverses a judgement I made a
turn earlier. I added an organisers' email here on the reasoning that a closed
door should have a bell on it. The club's answer is that the door is the point:
diff --git a/web/components/LangDot.tsx b/web/components/LangDot.tsx
new file mode 100644
index 0000000..7ed9559
--- /dev/null
+++ b/web/components/LangDot.tsx
@@ -0,0 +1,43 @@
+// A language with its dot, the way GitHub marks a repository's language.
+//
+// The colours are GitHub's own (linguist's languages.yml), so a reader who lives on
+// GitHub recognises TypeScript blue or Python's two-tone before reading the word.
+// Anything not in the table gets the neutral grey linguist uses for unknowns.
+
+const COLOURS: Record = {
+ typescript: "#3178c6",
+ javascript: "#f1e05a",
+ python: "#3572A5",
+ c: "#555555",
+ "c++": "#f34b7d",
+ go: "#00ADD8",
+ rust: "#dea584",
+ java: "#b07219",
+ ruby: "#701516",
+ html: "#e34c26",
+ css: "#663399",
+ shell: "#89e051",
+ "next.js": "#ededed",
+ tailwind: "#38bdf8",
+ node: "#5fa04e",
+ playwright: "#2EAD33",
+ vitest: "#6E9F18",
+ docker: "#2496ED",
+};
+
+export function langColour(name: string): string {
+ return COLOURS[name.trim().toLowerCase()] ?? "#8b949e";
+}
+
+export default function LangDot({ name, className = "" }: { name: string; className?: string }) {
+ return (
+
+
+ {name}
+
+ );
+}
diff --git a/web/components/Logo.tsx b/web/components/Logo.tsx
new file mode 100644
index 0000000..38e8a05
--- /dev/null
+++ b/web/components/Logo.tsx
@@ -0,0 +1,63 @@
+// The CherryPick mark: two branches, and a commit lifted from one onto the other.
+//
+// Drawn rather than shipped as the supplied JPG, because the JPG has an off-white
+// ground baked in and would sit on the dark theme as a pale rectangle. As SVG the
+// branches take `currentColor`, so they follow --ink in both themes and inherit a
+// link's hover colour. The cherries stay cherry in every theme: they ARE the mark.
+//
+// The wordmark is type, not outlines, set in --font-brand (layout.tsx), so it stays
+// crisp at every size and a screen reader gets the name for free.
+
+const CHERRY = "#D1454E";
+
+/** The mark alone. Size it with a height class; the width follows. */
+export function LogoMark({ className = "h-7 w-auto" }: { className?: string }) {
+ return (
+
+ );
+}
+
+/** Mark plus wordmark. `tagline` adds the "Open source club" line beneath. */
+export default function Logo({
+ size = "sm",
+ tagline = false,
+}: {
+ size?: "sm" | "lg";
+ tagline?: boolean;
+}) {
+ const lg = size === "lg";
+ return (
+
+
+
+
+ CherryPick
+
+ {tagline && (
+
+ Open source club
+
+ )}
+
+
+ );
+}
diff --git a/web/components/MediaSplit.tsx b/web/components/MediaSplit.tsx
index f3302ac..4dd763b 100644
--- a/web/components/MediaSplit.tsx
+++ b/web/components/MediaSplit.tsx
@@ -5,7 +5,7 @@
//
// The brief asks for "a photo of students collaborating at laptops". This repo
// contains no images at all — `public/` holds a single README explaining how to
-// add them, and not one entry in content/club.ts has a `photo` set. So there are
+// add them, and not one entry in content/selections.ts has a `photo` set. So there
// three ways to fill this frame and only one of them is defensible:
//
// * A stock photograph of unrelated people, captioned as this club. That is a
@@ -26,9 +26,8 @@
import Doodle from "@/components/Doodle";
import Duo from "@/components/Duo";
+import { Fold } from "@/components/Fold";
import Note from "@/components/fx/Note";
-import Portrait from "@/components/Portrait";
-import { publishedSelections } from "@/content/club";
/** Set to a path under /public once a real photograph exists. Empty = fallback. */
const PHOTO = "";
@@ -36,7 +35,7 @@ const PHOTO = "";
/* The four things the club actually runs, which is why these are hardcoded here
rather than pulled from content: they describe the club's format, not its
record, so there is no figure in them that could go stale or be wrong. Anything
- with a number in it belongs in content/club.ts instead. */
+ with a number in it belongs in a content/ module instead. */
const FEATURES: {
title: string;
body: string;
@@ -51,52 +50,56 @@ const FEATURES: {
texture and this one would stop being read. */
caveat?: string;
}[] = [
+ // THE SAME NAMES /join USES. This grid used to list "commit sessions, hackathons
+ // & bounties, networking, mentorship" while /join offered build days, hackathons
+ // and a programme track, /events had three tracks and /programmes had three more —
+ // four vocabularies for one club. These are /join's, plus the club's own repos.
{
- title: "Regular commit sessions",
- body: "Open laptops, one issue each, somebody senior in the room.",
+ title: "Build days",
+ body: "One evening, one senior beside you, one real change by the end.",
glyph: "< />",
fill: "#FEF9C3",
ink: "#713F12", // 8.0:1 on the pastel yellow
},
{
- title: "Hackathons & bounties",
- body: "Short cycles where the output is a merged patch, not a slide deck.",
+ title: "Hackathons",
+ body: "Thirty people, one room, the same setup bugs. Nothing's ranked.",
glyph: "⚡",
fill: "#EDE9FE",
ink: "#4C1D95",
},
{
- title: "Student networking",
- body: "The people who got in last cycle are the people reviewing you now.",
- glyph: "◎",
- fill: "#DBEAFE",
- ink: "#1E3A8A",
- },
- {
- title: "1-on-1 mentorship",
- body: "A mentor who has landed work upstream reads your patch first.",
+ title: "Programme track",
+ body: "GSoC and LFX prep, with a 1-on-1 mentor who got in last cycle reading your patch first.",
glyph: "★",
fill: "#D1FAE5",
ink: "#065F46",
- caveat: "*accessible to serious students only",
+ // It used to read "accessible to serious students only", under a standfirst
+ // promising no selection at the door. What is actually rationed is mentor time,
+ // and it goes by who keeps turning up, not by who passes a bar.
+ caveat: "*1-on-1 mentor pairs go to the people who keep showing up",
+ },
+ {
+ title: "Club projects",
+ body: "This site and the club's other repos — your reviewer sits in the lab.",
+ glyph: "◎",
+ fill: "#DBEAFE",
+ ink: "#1E3A8A",
},
];
export default function MediaSplit() {
- const people = publishedSelections().slice(0, 4);
-
return (
-
+ className="band section relative py-20 sm:py-28"
+ label="What the club runs"
+ /* Folds with the rest of the home page — see Fold.tsx. */
+ head={
+ <>
{/* THE TITLE BLOCK. Every other section on the page opens with a chip and a
Duo; this one used to open with the frame itself, so the 2x2 arrived with
nothing telling a reader what it was a list OF.
@@ -127,31 +130,11 @@ export default function MediaSplit() {
shares a horizontal level with it; the two decorations either side are
hundreds of pixels clear. See the vertical spacing note in Note.tsx.
- anchor 51, measured rather than guessed, per Note.tsx —
- scripts/tmp-measure.mjs prints exactly this. The furthest-right ink in
- the note's y band is the Duo's trail at 737px from the container's left
- edge (1440 and 1800; 709 at 1280, 658 at 1180, 578 at 1024), so 816
- leaves 79px of air at the widest wrap and more at every narrower one.
- The clamp in `.note-flow` takes over below ~1180.
-
- THE VERTICAL IS THE TIGHT ONE HERE, not the horizontal. The band runs
- from the top of the section to the `.measure` standfirst, which is
- full-width and would sit straight under the note: 242px at 1024, where
- the note is at its TALLEST because w-40 wraps the body to five lines.
- That leaves 25px, and it is why the body lost "Sessions are" — at six
- lines the foot came within 5px of the paragraph. From xl the note is
- 160px and there are 110+. A longer body has to lose a line rather than
- move down. */}
-
+ In flow beside the heading rather than at a measured inset — see the
+ placement note in Note.tsx. The old anchor went stale when the display
+ face changed and the note sat on "syllabus". */}
+
+
Why students join
@@ -161,47 +144,43 @@ export default function MediaSplit() {
lead="College gives you a syllabus."
trail="This gives you a review thread."
/>
+
+
+
+ >
+ }
+ >
- Four things that actually run every week, and every one of them is{" "}
- open to anyone — there is no selection at the
- door.
+ Four things we run, all{" "}
+ open to anyone — no selection at the door.
-
- {/* ---- Left: the frame ------------------------------------------- */}
-
+ {/* ---- Left: the photograph, once there is one -------------------
+ The fallback was four monogram tiles captioned "photographs to
+ follow" — a placeholder announcing itself on the home page. With no
+ photo the frame is simply absent and the four cards take the row. */}
+ {PHOTO && (
+
- {/* Four member tiles at 2x2. Real people, real fallback treatment
- — this is exactly what their cards look like in the hall, so the
- frame is consistent with the rest of the page rather than being
- a bespoke placeholder nobody maintains. */}
-
- {people.map((p) => (
-
- ))}
-
-
- Members of the current cohort · photographs to follow
-
+ {/* ---- The four cards -------------------------------------------- */}
+
{FEATURES.map((f) => (
-
+
);
}
diff --git a/web/components/MemberDashboard.tsx b/web/components/MemberDashboard.tsx
index c498cb3..09b0b14 100644
--- a/web/components/MemberDashboard.tsx
+++ b/web/components/MemberDashboard.tsx
@@ -86,10 +86,7 @@ function Overview({ user, profile }: { user: User; profile: Profile }) {
return (
<>
-
- Anything the club needs from you turns up here. When this page is quiet there is
- genuinely nothing to do — which is most weeks, and is not a sign you are behind.
-
+
{/* "WAITING ON YOU" LEADS, and it is the one figure the design did not have. Opening
on a contribution total makes the page a leaderboard, which is the wrong
diff --git a/web/components/MemberOnly.tsx b/web/components/MemberOnly.tsx
index 0f524fc..06b7d79 100644
--- a/web/components/MemberOnly.tsx
+++ b/web/components/MemberOnly.tsx
@@ -58,9 +58,8 @@ export default function MemberOnly({
Sign-in is not set up here.
- This deployment has no Firebase configuration, so there is nothing to sign in
- to. If you are running the site locally, see web/.env.example. If
- you are seeing this on the live site, that is a bug — please tell us.
+ Running locally? See web/.env.example. Seeing this live? That's
+ a bug — tell us.
- Build days and evening sessions get planned around which building people have to
- walk back to. That is the only thing this is used for.
+ Only used to plan sessions around your walk home.
@@ -291,7 +288,7 @@ export default function ProfileForm({
{PATHS.find((p) => p.id === effectivePath)?.name ?? effectivePath}
- , so that is recorded as how you found us. You can change it later.
+ . Change it any time.
)}
@@ -324,9 +321,7 @@ export default function ProfileForm({
{/* What happens to the data, next to the button rather than in a policy page
nobody opens. It is the member's information, not ours. */}
- Your details are visible to you and to the club organisers, and to nobody else.
- Nothing here is published on this site — the names on it are only there because those
- people were asked and said yes. You can edit or correct any of this at any time.
+ Only you and the organisers see this. Nothing gets published. Edit it any time.
);
diff --git a/web/components/ProgramMentors.tsx b/web/components/ProgramMentors.tsx
new file mode 100644
index 0000000..9aab39c
--- /dev/null
+++ b/web/components/ProgramMentors.tsx
@@ -0,0 +1,216 @@
+// The mentor bench, under the Program track.
+//
+// WHY THIS IS PEOPLE AND NOT A SENTENCE
+// Everything else around it is an argument about process — what week one looks
+// like, what you walk away with. This is the one part that is an argument about
+// PEOPLE, and it is the club's strongest: a student choosing between this and
+// doing GSoC alone is choosing between six named seniors who got in and a Discord
+// server. That claim used to live in a subordinate clause at the end of the path's
+// walkAway line, where it was worth roughly nothing.
+//
+// WHY THE CARDS LIE DOWN RATHER THAN STAND UP
+// The first version of this block was six portraits in a row, which looked like
+// the hall of fame and said the same thing the hall already says — here are six
+// faces, count them. Then each mentor got a paragraph on what they actually
+// built, and a paragraph is the reason the layout changed: a 180px column cannot
+// hold thirty words without setting them eight to a line, and the words are now
+// the point. So the portrait turns into a fixed 8.5rem plate on the left and the
+// text runs beside it, two cards to a row. The count is still legible — three
+// rows of two reads as six — and the sentences are readable, which they were not.
+//
+// WHY IT SITS INSIDE THE PATH CARD RATHER THAN IN A SECTION OF ITS OWN
+// It is an answer to "why this path", and a reader who has scrolled past the
+// Program track card has already decided. Below the card is too late. So it goes
+// under the path's own two-column grid, full width, with a rule above it and its
+// own heading — far enough from the week-one list to read as a separate claim,
+// close enough that it is still part of the card's pitch. It is handed to PathCard
+// as `children` rather than switched on inside it; see the note there for why a
+// `mentors?: true` field on the Path type would be the wrong shape.
+//
+// IT LIVES ON /join NOW, in the #why-join band, and used to live on /how-to-join.
+// The route moved, the placement did not: it is still full width under the one
+// path it belongs to.
+//
+// WHY IT INVENTS NOTHING
+// The photograph, the programme, the year, the organisation and the office are
+// all read out of the club's own lists by name (see content/lookup.ts, which
+// file); the one line of prose per person comes from join.ts, compressed from
+// what each mentor supplied about themselves. This component holds no data at
+// all. Two consequences worth stating: a mentor's organisation here can never
+// disagree with the same person's row on the hall of fame, and a mentor whose
+// name does not match those lists renders as a face with a name and no credential
+// rather than as a face with a plausible one.
+//
+// Zero JavaScript of its own. Portrait is a client component for its image
+// fallback; this is a server component and stays one.
+
+import Link from "next/link";
+import Portrait from "@/components/Portrait";
+import { designationFor, newestSelectionFor, photoFor } from "@/content/lookup";
+import { PROGRAMME_SHORT } from "@/content/programmes";
+import { PROGRAM_TRACK_MENTORS } from "@/content/join";
+
+/* A mentor, assembled from what the rest of the site already knows about them.
+ Four lookups, no data: see content/lookup.ts for what each one
+ does and what it returns when it finds nothing. */
+const BENCH = PROGRAM_TRACK_MENTORS.map((m) => ({
+ ...m,
+ photo: photoFor(m.name),
+ office: designationFor(m.name),
+ credential: newestSelectionFor(m.name),
+}));
+
+/* The number in the standfirst, counted rather than typed. It is the one that
+ carries the argument — six mentors inside one organisation is a study group,
+ six inside six is a map of the landscape — and it is exactly the sort of number
+ that goes stale the moment a seventh name is added, so it is never written down
+ as a word. Empty orgs are dropped for the same reason selectionStats drops
+ them: an unrecorded organisation is not one. */
+const ORG_COUNT = new Set(
+ BENCH.map((m) => m.credential?.org).filter(Boolean),
+).size;
+
+export default function ProgramMentors() {
+ if (BENCH.length === 0) return null;
+
+ return (
+
+
+
+
The people you get
+ {/* h4 because the path's own name is the h3 on this card. The heading
+ level is the card's structure, not a size choice — the size comes
+ from the display class beside it. */}
+
+ {BENCH.length} mentors, and every one of them got in.
+
+
+ {ORG_COUNT > 0 && (
+
+ {ORG_COUNT} organisations, no two the same
+
+ )}
+
+
+
+ Your proposal gets torn apart by someone who wrote a winning one last March.
+ And when the build breaks in week three, help is two floors away.
+
+
+ {/* Two to a row at lg and above, one below it. No three-column step: at the
+ 88rem container three columns leave about 300px a card, of which the
+ portrait plate takes 136, and thirty words in the 160px left over is the
+ layout this block just moved away from.
+
+ gap-y is much the larger of the two because the cards have no border —
+ the only thing separating one row from the next is the air between them,
+ whereas the columns already have a portrait plate holding them apart. */}
+
+ {BENCH.map((m) => (
+
+ {/* STACKED ON A PHONE, side by side from sm up. The plate is 96px
+ and a phone is 430px, so keeping them side by side left the blurb
+ setting at about five words a line in the 180px remainder — a
+ paragraph read through a letterbox. Dropping the portrait onto its
+ own line above the text gives the sentence the full column and
+ costs one 96px band of height per card. */}
+
+ {/* The portrait plate. Fixed width rather than a fraction, so every
+ card in the row starts its text on the same vertical line
+ whatever the grid is doing — a percentage would leave the six
+ paragraphs starting at two different x positions per breakpoint.
+
+ It IS the tile: no border, no panel, as on the hall. This block
+ already sits inside a card, and a card inside a card inside a
+ card is where a page starts looking like a settings screen.
+ `tilt` on the plate only, never on the text beside it — type on a
+ rotated surface loses subpixel antialiasing and goes fuzzy.
+
+ h-fit matters once the card stacks: without it the plate is a
+ flex item in a column and stretches to the height of the blurb
+ beside it, which turns a 4:5 portrait into a 4:20 one. */}
+
+
+
+
+ {/* min-w-0 so a long unbroken token in the blurb cannot push the
+ flex item past its track and shove the portrait off the card. */}
+
+
+ {m.name}
+
+
+ {/* ONE META ROW, and everything factual about the person is on it:
+ the programme chip, the organisation that picked them, and the
+ club office where they hold one. Three separate lines was the
+ alternative and it pushed the blurb — the thing the reader is
+ actually here for — below the portrait plate on every card.
+
+ The chip is straightened (chip-true) because it labels the row
+ rather than being stickered onto it; a tilt next to two lines
+ of mono reads as a misalignment.
+
+ The office is in the accent and in caps rather than as a third
+ quiet mono item, because it answers a different question from
+ the two beside it: those say what this person pulled off, this
+ says which of them is the one who actually assigns you a
+ mentor. */}
+
+
+ {/* The sentence this whole layout was rearranged for. Rendered
+ only when there is one — a mentor whose line has not been
+ collected yet gets a card with a name, a credential and no
+ filler, which is a visible prompt to go and ask them. */}
+ {m.blurb && (
+
{m.blurb}
+ )}
+
+
+
+ ))}
+
+
+ {/* The proof link, out to the roster where the same names sit beside their
+ programme and year. Not a second call to action — the path's own "Apply
+ on this path" is the action, and this is the thing a sceptical reader
+ presses first. */}
+
+
+ Every selection the club has, in one list →
+
+
+
+ );
+}
diff --git a/web/components/ProofPanel.tsx b/web/components/ProofPanel.tsx
index be79daa..49e9eb8 100644
--- a/web/components/ProofPanel.tsx
+++ b/web/components/ProofPanel.tsx
@@ -29,7 +29,7 @@
// Text wears text tokens throughout. The accent appears on the marks and on the
// figure that names the rank, never on a label.
-import { PROJECTS } from "@/content/club";
+import { PROJECTS } from "@/content/showcase";
export default function ProofPanel() {
const lead = PROJECTS.find((p) => p.published && p.proof);
@@ -98,8 +98,7 @@ export default function ProofPanel() {
{pct}% merged.{" "}
- The rest were closed or superseded, which is a normal ratio and the
- reason we publish it rather than rounding it up.
+ The rest were closed — totally normal, so we don't round up.
@@ -119,7 +118,7 @@ export default function ProofPanel() {
- {lead.what} Counted from the public repository — open the link and check.
+ {lead.what} From the public repo — go check.
);
diff --git a/web/components/Roster.tsx b/web/components/Roster.tsx
index e3cdf8a..fe1ae57 100644
--- a/web/components/Roster.tsx
+++ b/web/components/Roster.tsx
@@ -61,7 +61,7 @@ export default function Roster() {
setRows(await readRoster());
} catch (e) {
console.error("[osc] could not read the roster", e);
- setError("Could not load the roster. The rules may not be deployed.");
+ setError("Couldn't load the roster. Are the rules deployed?");
setRows([]);
}
}, []);
@@ -106,19 +106,18 @@ export default function Roster() {
setNote("");
const key = email.trim().toLowerCase();
if (!isCollegeAddress(key, ALLOWED_EMAIL_DOMAIN)) {
- setError(`That has to be an @${ALLOWED_EMAIL_DOMAIN} address — no other address can sign in.`);
+ setError(`Use an @${ALLOWED_EMAIL_DOMAIN} address.`);
return;
}
if (!form.name.trim()) {
- setError("A roster row needs a name, because this is also what the team page shows.");
+ setError("Add a name — the team page shows it.");
return;
}
// THE LOCKOUT GUARD, stated before the write rather than after the refusal. The rules
// refuse it too; this is so the reason is a sentence instead of a permission error.
if (self) {
setError(
- "You cannot change your own row. That is what stops an owner demoting themselves " +
- "into a state only another owner can undo — ask one of the others.",
+ "You can't edit your own row — ask another owner.",
);
return;
}
@@ -136,7 +135,7 @@ export default function Roster() {
} catch (e) {
console.error("[osc] could not save the roster row", e);
setError(
- "Firestore refused that. Either the rules are not deployed, or your row is not an owner.",
+ "Firestore said no. Check the rules are deployed and you're an owner.",
);
} finally {
setBusy(false);
@@ -166,7 +165,7 @@ export default function Roster() {
await load();
} catch (e) {
console.error("[osc] could not retire/restore", e);
- setError("Could not change that. Only owners can, and never their own row.");
+ setError("Couldn't change that. Owners only, and not their own row.");
}
}
@@ -180,14 +179,12 @@ export default function Roster() {
Who runs this, and who can let others in.
- One list. It decides who reaches this page and who appears on the public
- team page, so there is nothing to keep in step by hand.
+ One list: who gets in here and who's on the team page.
{isOwner !== true && (
- You can see the roster but not change it — that is kept to the owners, so that one
- compromised account cannot appoint more. Ask an owner below.
+ View only — owners make changes. Ask one below.
- That is you. Nobody can change their own row.
+ That's you — you can't edit your own row.
)}
@@ -379,9 +376,8 @@ export default function Roster() {
this list again. Said here, where it can be acted on, rather than in a doc. */}
{rows !== null && owners < 2 && (
- Only {owners === 0 ? "nobody is" : "one person is"} an owner. Make a second one
- before they graduate — otherwise this list can never be changed again without
- the Firebase console.
+ Only {owners === 0 ? "nobody is" : "one person is"} an owner. Add another before
+ they graduate, or this list gets locked.
+ {/* Loads the row into the form above — the same path as typing the
+ address, which nobody would guess was how you edit. */}
+ {isOwner === true && !isSelf && (
+ {
+ setError("");
+ setNote("");
+ setEmail(r.email);
+ const field = document.getElementById("ro-email");
+ field?.scrollIntoView({ behavior: "smooth", block: "center" });
+ field?.focus({ preventScroll: true });
+ }}
+ className="tap shrink-0 font-mono text-label uppercase text-haze underline transition-colors hover:text-ink"
+ >
+ Edit
+
+ )}
{isOwner === true && !isSelf && (
{when.time}
- {s.title}
+
+ {s.title}
+ {/* MARKED IN THE LIST, because "is this one a Build Day" is otherwise only
+ answerable by opening the editor — and it is the field that decides whether a
+ roll can be taken at it at all. */}
+ {s.kind === "build-day" && (
+
+ build day
+
+ )}
+
{s.location && (
{s.location}
)}
@@ -65,6 +76,18 @@ function Row({
{s.speaker || "TBA"}
+ {/* ONLY ON THE ONES THAT HAVE ALREADY RUN. The roll-call screen offers no session
+ that has not started — nobody can be present at a thing that has not happened —
+ so a link here on a future Build Day would land on a picker that quietly chose
+ a different session. */}
+ {s.kind === "build-day" && past && (
+
+ Take the roll
+
+ )}
(DEFAULT_AUDIENCE);
+ const [buildDay, setBuildDay] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [note, setNote] = useState("");
@@ -104,7 +128,7 @@ export default function Sessions() {
setRows(await readSessions());
} catch (e) {
console.error("[osc] could not read sessions", e);
- setError("Could not load the schedule. The rules may not be deployed.");
+ setError("Couldn't load the schedule. Are the rules deployed?");
setRows([]);
}
}, []);
@@ -124,6 +148,7 @@ export default function Sessions() {
setLocation("");
setWhen("");
setAudience(DEFAULT_AUDIENCE);
+ setBuildDay(false);
setError("");
}
@@ -138,6 +163,7 @@ export default function Sessions() {
// this field existed opens in the editor showing the audience it actually has
// rather than an empty select that would save as something else.
setAudience(audienceOf(s.audience));
+ setBuildDay(s.kind === "build-day");
setNote("");
setError("");
}
@@ -153,7 +179,7 @@ export default function Sessions() {
// Checked before the write so the reason is a sentence rather than a permission
// error. The rules refuse a non-timestamp too, but they cannot explain themselves.
if (!when || Number.isNaN(at.getTime())) {
- setError("A session needs a date and a time — that is the whole point of one.");
+ setError("Add a date and a time.");
return;
}
setBusy(true);
@@ -161,24 +187,24 @@ export default function Sessions() {
await saveSession(
editing?.id ?? null,
user!.email!,
- { title, speaker, location, starts_at: at, audience },
+ { title, speaker, location, starts_at: at, audience, buildDay },
editing,
);
setNote(
editing
? "Updated."
: audience === "members"
- ? "Scheduled. Club members see it on their dashboard now."
+ ? "Scheduled. Members see it now."
: audience === "students"
- ? "Scheduled. Students who are not members see it; members will not."
- : "Scheduled. Everyone who signs in sees it on their dashboard now.",
+ ? "Scheduled. Non-members only."
+ : "Scheduled. Everyone sees it now.",
);
reset();
await load();
} catch (e) {
console.error("[osc] could not save the session", e);
setError(
- "Firestore refused that. Either the rules are not deployed, or your address is not in the admins collection.",
+ "Firestore said no. Check the rules are deployed and you're an admin.",
);
} finally {
setBusy(false);
@@ -284,6 +310,33 @@ export default function Sessions() {
controlClassName={ctl}
className="sm:col-span-2"
/>
+
+ {/* A CHECKBOX RATHER THAN A "TYPE" SELECT, because there are two kinds of
+ session and one of them is the ordinary one. A select would make an
+ organiser choose between "Session" and "Build Day" every time they schedule
+ a talk, to say the thing the empty state already says.
+
+ WHAT IT ACTUALLY TURNS ON is the roll — see /admin/build-days. It is worth
+ saying here, because "Build Day" is a word the club uses for a kind of
+ event, and a reader could reasonably think this only changes a label. */}
+
- This deployment has no Firebase configuration, so there is nothing for this page
- to show. If you are running the site locally, see web/.env.example.
- If you are seeing this on the live site, that is a bug — please tell us.
+ Running locally? See web/.env.example. Seeing this live? That's
+ a bug — tell us.
{/* NO "YOU CAN STILL APPLY" LINE, AND THAT IS A CORRECTION RATHER THAN AN OMISSION.
This card used to say the application form needed no account, which was true
@@ -146,14 +145,12 @@ export default function SignInCard() {
return (
-
Members only
-
{/* h1, NOT h2. This card IS the whole page for a signed-out reader on /dashboard —
the route's app shell carries no banner heading of its own, so without this the
document has no h1 at all and a screen reader gets a page with no title. The
signed-in half of the same route puts its h1 on the greeting, for the same
reason: exactly one, on whatever the reader actually came for. */}
-
You signed in as{" "}
- {wrongAccount}. Press the button
- again and pick your college account from the list — Google will ask which one
- to use.
+ {wrongAccount}. Try again and
+ pick your college account.
)}
@@ -259,14 +255,11 @@ export default function SignInCard() {
classes, not the tag, so nothing on screen changes. */}
Who can sign in
- Students with an @{DOMAIN} address. No
- other address can register, and that is the whole check — no fee, no
- interview, no prior experience.
+ Students with an @{DOMAIN} address.
+ That's the only check — no fee, no interview, no experience needed.
- We use Google rather than a password so nobody can register an address they
- do not own, and so you have no password to invent or lose. We never see your
- password.
+ Google, not a password — nothing to forget, and we never see it.
{/* THE "YOU CAN APPLY WITHOUT AN ACCOUNT" LINE IS GONE, and its absence is the
honest state rather than a loss. It was added when this card stood in front
@@ -278,10 +271,6 @@ export default function SignInCard() {
- {/* NO DEV LOGIN ON THIS CARD. It is rendered by the app shell instead, which wraps
- every route this card appears on — including this signed-out state — so putting
- one here too would show two of them. See components/dev/DevLoginSlot.tsx. */}
-
{/* NO "NO COLLEGE ACCOUNT?" FALLBACK. A closed door invites a bell, but the door is
the point here: an @sst.scaler.com address IS the membership test, so somebody
without one is not a student here. The footer carries the organisers' address on
diff --git a/web/components/Team.tsx b/web/components/Team.tsx
index a1ba4a7..bde5847 100644
--- a/web/components/Team.tsx
+++ b/web/components/Team.tsx
@@ -45,10 +45,19 @@
// line crosses the leads' horizontal rail). A dashed line crossing a solid one
// reads as two relationship types; two solid lines crossing reads as a mistake.
//
-// The relationship is never carried by the line ALONE, though — each shadow card
-// names its role in text ("Shadow — Vice President"). A reader on a phone gets the
-// stacked list instead of the chart, and a screen reader gets only that list, so
-// the geometry has to be a redundant encoding of something already written down.
+// The dashed line says "attached to that role", which is deliberately the broader
+// claim, because the tier holds two kinds of attachment: a shadow is next in line
+// for the office, an aide works to it and is not. A third stroke for the second
+// kind was the alternative and it is worse — the chart's whole vocabulary is one
+// solid line against one dashed one, and a third would have to be told apart from
+// both. The difference is a word on the card instead, and the key at the foot of
+// the section defines every word it draws.
+//
+// The relationship is never carried by the line ALONE, though — each card in that
+// tier names its role in text ("Shadow — Vice President", "Aide — Repo
+// Maintainer"). A reader on a phone gets the stacked list instead of the chart, and
+// a screen reader gets only that list, so the geometry has to be a redundant
+// encoding of something already written down.
//
// WHY A DESK IS NOT A FOURTH ROW OF THE SAME KIND
// Under the leads there is one more shape: a desk — several people doing one job
@@ -64,13 +73,13 @@
import Portrait from "@/components/Portrait";
import {
+ type Highlight,
TEAM_CONTENT,
TEAM_LEADS,
TEAM_OFFICERS,
TEAM_SHADOWS,
- type Highlight,
type TeamMember,
-} from "@/content/club";
+} from "@/content/team";
const LEAD_COUNT = TEAM_LEADS.length;
const OFFICER_COUNT = TEAM_OFFICERS.length;
@@ -125,7 +134,16 @@ const LEAD_D = "8rem";
const SHADOW_D = "6.5rem";
/** How wide a card is allowed to get, cell permitting. Caption wrapping tolerates it. */
-const CARD_MAX_W = "13.5rem";
+const CARD_MAX_W_REM = 13.5;
+const CARD_MAX_W = `${CARD_MAX_W_REM}rem`;
+
+/* THE NARROW END OF lg, where the grid is 960px — already the width every diameter
+ comment above reasons against, named here because the desk's dodge test needs it as
+ arithmetic rather than as prose. A rem-measured width is at its LARGEST share of the
+ grid here, so a clearance that holds at 960 holds at every width the chart renders
+ at. Widen the container and these cards only get relatively narrower. */
+const NARROW_GRID_PX = 960;
+const CARD_MAX_W_PCT = ((CARD_MAX_W_REM * 16) / NARROW_GRID_PX) * 100;
type Placed = {
member: TeamMember;
@@ -180,19 +198,32 @@ const SHADOWS: Placed[] = TEAM_SHADOWS.map((m): Placed | null => {
grid, because that is what a percentage max-width resolves against. A lone
shadow keeps the full width — with nothing to collide with, there is nothing to
pay for. */
-function shadowMaxWidth(): string {
+/** Closest pair of tier-3 connectors, in grid percent. Infinity below two cards. */
+const SHADOW_GAP = (() => {
const xs = SHADOWS.map((s) => s.x).sort((a, b) => a - b);
let closest = Infinity;
for (let i = 1; i < xs.length; i++) {
closest = Math.min(closest, xs[i] - xs[i - 1]);
}
- if (!Number.isFinite(closest)) return CARD_MAX_W;
+ return closest;
+})();
+
+function shadowMaxWidth(): string {
+ if (!Number.isFinite(SHADOW_GAP)) return CARD_MAX_W;
const cell = (LEAD_SPAN / COLS) * 100;
- return `min(${CARD_MAX_W}, ${((closest / cell) * 100).toFixed(3)}%)`;
+ return `min(${CARD_MAX_W}, ${((SHADOW_GAP / cell) * 100).toFixed(3)}%)`;
}
const SHADOW_MAX_W = shadowMaxWidth();
+/* HOW FAR A TIER-3 CARD REACHES EITHER SIDE OF ITS OWN CONNECTOR, which is the one
+ number that says whether a line may be drawn at a given x without running through
+ somebody's face. It is half the width resolved above, and the `min` has to be
+ evaluated rather than assumed: with the cards far apart the rem cap binds, and with
+ them close together the gap cap does. Taking only the gap would declare a collision
+ whenever two cards merely sit in adjacent columns. */
+const TIER3_REACH = Math.min(SHADOW_GAP, CARD_MAX_W_PCT) / 2;
+
/* ---------------------------------------------------------------------------
THE DESK TIER. A desk hangs off one lead and is drawn as a band under the
chart: a drop from that lead's column down to the desk's label, then a rail
@@ -216,6 +247,38 @@ const SHADOW_MAX_W = shadowMaxWidth();
const DESK_LEAD = TEAM_LEADS.findIndex((l) => l.designation === TEAM_CONTENT.of);
const DESK = DESK_LEAD >= 0 ? TEAM_CONTENT : null;
const DESK_X = DESK_LEAD >= 0 ? LEAD_X[DESK_LEAD] : 0;
+
+/* WHERE THE DESK'S DROP RUNS WHILE IT PASSES THE TIER-3 ROW, and the one piece of
+ routing on this chart.
+ The drop used to go straight down the lead's centre, and that was safe only for as
+ long as the lead with a desk had nobody hanging off it — a tier-3 card sits on its
+ principal's centre, so the moment the Repo Maintainer had BOTH an aide and the
+ content desk, the solid drop and the aide's portrait wanted the same 50%. The line
+ would have run straight through the portrait.
+ So while it passes that row the drop steps aside to the nearest lead-column
+ BOUNDARY that is clear — the same invariant the VP's shadow line rides, and the
+ only kind of x on this chart guaranteed to fall between two lead columns rather
+ than inside one — then steps back under the lead before the desk's label, so the
+ label stays where it is true: under the office the desk reports to, rather than
+ floating between two.
+ Clear means further from every tier-3 connector than that card can reach. When
+ nothing is in the way this resolves to DESK_X and not a single line moves, which is
+ what keeps a chart with no aide on it drawn exactly as it was. If no boundary is
+ clear it gives up and returns DESK_X: a visibly crossed line, rather than a line
+ quietly routed somewhere it does not belong. */
+const DESK_DODGE_X = (() => {
+ if (!DESK) return DESK_X;
+ const blocked = (x: number) =>
+ SHADOWS.some((s) => Math.abs(s.x - x) < TIER3_REACH);
+ if (!blocked(DESK_X)) return DESK_X;
+ const boundaries = Array.from(
+ { length: LEAD_COUNT - 1 },
+ (_, i) => ((i + 1) / LEAD_COUNT) * 100,
+ ).sort((a, b) => Math.abs(a - DESK_X) - Math.abs(b - DESK_X));
+ return boundaries.find((b) => !blocked(b)) ?? DESK_X;
+})();
+
+const DESK_DODGES = DESK_DODGE_X !== DESK_X;
const DESK_MEMBER_X = TEAM_CONTENT.members.map(
(_, i) => ((i + 0.5) / TEAM_CONTENT.members.length) * 100,
);
@@ -248,6 +311,26 @@ const DASHED = "border-dashed border-dust/50";
const CAPTION =
"font-label text-sm font-semibold uppercase leading-[1.2] tracking-[0.07em]";
+/** A horizontal run between two percentages, in either order. Vertical position is
+ the caller's, via `className`, exactly as with VLine. */
+function HLine({
+ from,
+ to,
+ className = "",
+}: {
+ from: number;
+ to: number;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
function VLine({
x,
className = "",
@@ -473,11 +556,33 @@ function Node({
);
}
-/** How a shadow's role is written out, so the line is never the only statement of it. */
+/** How a tier-3 role is written out, so the line is never the only statement of it. */
function shadowLabel(s: Placed): string {
return `${s.member.designation} — ${s.principal}`;
}
+/* WHAT THE DASHED LINE MEANS, ONE SENTENCE PER DESIGNATION THAT IS ON THE CHART.
+ The stroke says only "attached to the role above". It cannot say WHICH kind of
+ attachment, because the tier holds two: a shadow is in line for the office, an
+ aide is not. Drawing them differently was the alternative and it is the worse
+ one — a third stroke would have to be told apart from both the solid reporting
+ line and the dashed one, on a chart whose only other distinction is that pair.
+ So the difference lives in the caption, where it is a word rather than a
+ texture, and this key defines the words.
+
+ Built from SHADOWS rather than written out, so it explains exactly the
+ designations present: fill the tier with shadows only and it collapses back to
+ the single line it has always been. A designation with no gloss still gets a row
+ — an unexplained word is better than a card the key silently ignores. */
+const TIER3_GLOSS: Record = {
+ Shadow: "being trained to take that role over at handover.",
+ Aide: "working to that role, alongside the core.",
+};
+
+const TIER3_KEY = Array.from(
+ new Set(SHADOWS.map((s) => s.member.designation)),
+).map((d) => `${d} — ${TIER3_GLOSS[d] ?? "attached to that role."}`);
+
export default function Team() {
return (
<>
@@ -633,8 +738,20 @@ export default function Team() {
))}
{/* The desk's drop leaves its lead here, directly beneath the card, and
- keeps going past the shadow row to the band below. */}
- {DESK && }
+ keeps going past the shadow row to the band below.
+ THE RAIL SITS AT THE VERY TOP OF THIS ROW when the drop has to step
+ aside, rather than halfway down it like every other jog on this chart,
+ and that is the whole difference between two readings. At the top it
+ leaves the LEAD'S CARD, and a reader sees one point under the office
+ from which a dashed line continues down to the aide and a solid one
+ goes sideways to the desk. Half a row lower it would appear to branch
+ off the dashed line instead, which would say the desk reports to the
+ aide. It also avoids laying a solid segment over the dashed one, which
+ renders as the dashed line simply disappearing. */}
+ {DESK && DESK_DODGES && (
+
+ )}
+ {DESK && }
{/* ---- Row 5: shadows, each in its principal's column ---- */}
@@ -658,25 +775,37 @@ export default function Team() {
{DESK && (
<>
- {/* The drop crosses the shadow row on a lead-column BOUNDARY — the
- same invariant row 3 relies on — so it threads between the shadow
- cards rather than through one. When there are no shadows this row
- has no height and the line is simply zero tall. */}
+ {/* The drop crosses the tier-3 row on whatever x cleared the cards in
+ it — the lead's own centre when nothing is in the way, a lead-column
+ boundary when something is. Either way it threads between the cards
+ rather than through one. When there are no shadows this row has no
+ height and the line is simply zero tall. */}
-
+
- {/* ---- Row 6: -> the desk's label ---- */}
+ {/* ---- Row 6: -> the desk's label ----
+ The step back, mid-row rather than at an edge: nothing else is drawn
+ in this row, so the jog has clear air either side of it and lands
+ vertically into the label below rather than arriving at it sideways. */}
{/* ---- Row 7: the label the drop reaches ----
@@ -705,12 +834,10 @@ export default function Team() {
style={{ gridRow: 8, gridColumn: `1 / span ${COLS}` }}
>
-
{DESK_MEMBER_X.map((x) => (
@@ -889,15 +1016,24 @@ export default function Team() {
{/* The key. A dashed line means nothing on its own, and a reader should not
- have to infer it from the two cards it happens to connect. */}
- {SHADOWS.length > 0 && (
-
+ have to infer it from the two cards it happens to connect.
+ ONE swatch for however many lines, because there is only one stroke on the
+ chart to explain — repeating it beside each sentence would draw a
+ distinction the chart does not make. `items-start` with the swatch nudged
+ down to the first line's optical centre, so it reads as a label for the
+ block rather than as a bullet for its first row. */}
+ {TIER3_KEY.length > 0 && (
+
- Shadow — being trained to take that role over at handover.
-
+
+ {TIER3_KEY.map((line) => (
+
{line}
+ ))}
+
+
)}
>
);
diff --git a/web/components/Ticker.tsx b/web/components/Ticker.tsx
index dac68c9..0a162a8 100644
--- a/web/components/Ticker.tsx
+++ b/web/components/Ticker.tsx
@@ -36,7 +36,7 @@
//
// THE STRIP IS HALF DERIVED AND HALF LOCAL, and the split is the point.
//
-// The PROGRAMMES half is read out of club.ts, so this strip lists exactly the
+// The PROGRAMMES half is read out of content/programmes.ts, so this strip lists
// programmes /programmes documents — no more, no fewer. It used to name two of
// them by hand while that page described seven, which is the kind of drift that
// is invisible until somebody notices the ticker is advertising a shorter list
@@ -44,7 +44,7 @@
// appears here; remove one and it leaves.
//
// The PROJECTS half stays a local array, and deliberately does NOT move into
-// club.ts. Everything in that file is data some other section derives a claim
+// content/. Everything in there is data some other section derives a claim
// from, and a reader meeting this list there would reasonably assume the club
// has a relationship with each entry. It does not: this is the ecosystem the
// work lands in, which is a weaker and truer statement.
@@ -58,9 +58,9 @@
// and sourced. Keep this list aspirational-but-honest: ecosystems, not receipts.
import { useState } from "react";
-import { PROGRAMME_SHORT } from "@/content/club";
+import { PROGRAMME_SHORT } from "@/content/programmes";
-/** Every programme the site documents, in the order club.ts declares them. */
+/** Every programme the site documents, in the order programmes.ts declares them. */
const PROGRAMMES_IN_STRIP = Object.values(PROGRAMME_SHORT);
/** Projects and foundations the work lands in. Names only — see the note above. */
diff --git a/web/components/admin/BuildDays.tsx b/web/components/admin/BuildDays.tsx
new file mode 100644
index 0000000..18cf82d
--- /dev/null
+++ b/web/components/admin/BuildDays.tsx
@@ -0,0 +1,721 @@
+"use client";
+
+// The roll call, and the one screen an organiser has open while a Build Day is running.
+//
+// EVERY STUDENT WITH A PROFILE IS LISTED, not only club members, and that follows from the
+// SOP rather than from laziness: the first three Build Days are open to the whole college,
+// and the mentor-supported cohort is selected FROM whoever turns up. A grid that listed
+// only members would be empty on exactly the three sessions it exists to record. The
+// membership filter is there for later in the term, when the answer changes.
+//
+// WRITES GO ONE ROW AT A TIME, NOT ON A SAVE BUTTON. A roll call is sixty interruptions —
+// somebody arrives late, a laptop shuts, a tab is closed by accident — and a screen that
+// holds forty ticks in memory until a button is pressed is a screen that loses forty ticks.
+// Each tick is its own write, and the row says so while it is in flight.
+//
+// THE NOTES ARE BEHIND AN EXPANDER AND THE TICK IS NOT. Repo, issue, blocker and next step
+// are what the club actually wants out of a Build Day (SOP §9), but four text fields times
+// sixty students inside a two-hour session is a form nobody finishes twice — so the fast
+// path is one tick and one select, and the fields are filled in for the students an
+// organiser genuinely spoke to.
+//
+// TRACKS CARRY FORWARD FROM THE PREVIOUS BUILD DAY. Re-selecting a track for forty students
+// every week is how a weekly screen stops being used by week three, so the previous Build
+// Day's roll is read as well and seeds the default. It is a DEFAULT, not a copy: nothing is
+// written for a student until somebody ticks them, and moving tracks is one select.
+
+import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import { Bars, ctl, field, tally } from "@/components/admin/ui";
+import { useAuth } from "@/lib/auth";
+import {
+ TRACKS,
+ TRACK_LABEL,
+ buildDays,
+ held,
+ isHttps,
+ isRepo,
+ markPresent,
+ readAttendees,
+ readRoll,
+ trackOf,
+ unmarkPresent,
+ type Attendee,
+ type AttendeeInput,
+ type Roll,
+ type Track,
+} from "@/lib/buildDays";
+import { isClubMember, readAllProfiles, type Profile } from "@/lib/profile";
+import { watchFloor } from "@/lib/floor";
+import { readSessions, sessionWhen, type SessionDoc } from "@/lib/sessions";
+
+/** The editable half of one row, held locally while somebody types. Kept out of the
+ * Attendee map so a half-typed repo never looks like stored data. */
+type Draft = {
+ repo: string;
+ issue_url: string;
+ pr_url: string;
+ blocker: string;
+ next_step: string;
+};
+
+const EMPTY_DRAFT: Draft = { repo: "", issue_url: "", pr_url: "", blocker: "", next_step: "" };
+
+function draftOf(a: Attendee | undefined): Draft {
+ return {
+ repo: a?.repo ?? "",
+ issue_url: a?.issue_url ?? "",
+ pr_url: a?.pr_url ?? "",
+ blocker: a?.blocker ?? "",
+ next_step: a?.next_step ?? "",
+ };
+}
+
+function BuildDaysGrid() {
+ const { user, isAdmin } = useAuth();
+ const params = useSearchParams();
+
+ const [sessions, setSessions] = useState(null);
+ const [profiles, setProfiles] = useState(null);
+ const [sessionId, setSessionId] = useState("");
+ /** Who is on the roll, keyed by uid. A Map rather than an array because every write here
+ * is about one student and the screen re-renders on each one. */
+ const [present, setPresent] = useState