From 8650668dd681bf112ebbe1097c10c5ed3f8a4412 Mon Sep 17 00:00:00 2001 From: bhumindeshpande8-spec Date: Fri, 9 Oct 2026 14:16:35 +0530 Subject: [PATCH] fix: team, structure and dashboard --- CONTRIBUTING.md | 44 +- FIREBASE.md | 6 + firebase.json | 5 + firestore.rules | 453 ++++- functions/github.js | 27 +- functions/index.js | 118 +- functions/test-github.mjs | 6 + web/.gitignore | 8 + web/app/(app)/admin/build-days/page.tsx | 31 + web/app/(app)/admin/cohort/page.tsx | 25 + web/app/(app)/admin/members/page.tsx | 5 +- web/app/(app)/admin/notices/page.tsx | 4 +- web/app/(app)/admin/sessions/page.tsx | 4 +- .../(app)/dashboard/build-days/floor/page.tsx | 22 + web/app/(app)/dashboard/build-days/page.tsx | 14 + .../dashboard/build-days/screen/page.tsx | 14 + web/app/(app)/dashboard/events/page.tsx | 19 + web/app/(app)/dashboard/leaderboard/page.tsx | 20 + web/app/(app)/dashboard/page.tsx | 2 +- web/app/(app)/onboarding/page.tsx | 6 +- web/app/(site)/events/page.tsx | 450 +++++ web/app/(site)/guide/page.tsx | 192 +++ web/app/(site)/hall-of-fame/page.tsx | 64 +- web/app/(site)/how-to-join/page.tsx | 751 -------- web/app/(site)/join/page.tsx | 242 ++- web/app/(site)/page.tsx | 699 ++------ web/app/(site)/privacy/page.tsx | 18 +- web/app/(site)/programmes/page.tsx | 232 +-- web/app/(site)/projects/page.tsx | 497 ++---- web/app/(site)/team/page.tsx | 113 +- web/app/globals.css | 518 +++++- web/app/icon.svg | 11 + web/app/layout.tsx | 14 +- web/components/AdminDashboard.tsx | 31 +- web/components/AdminMentors.tsx | 112 +- web/components/AdminMentorship.tsx | 18 +- web/components/AppHeader.tsx | 6 +- web/components/AudienceBackfill.tsx | 10 +- web/components/Banner.tsx | 2 +- web/components/CommitGraph.tsx | 9 +- web/components/CommunityBanner.tsx | 33 +- web/components/Composer.tsx | 17 +- web/components/Fold.tsx | 200 +++ web/components/Footer.tsx | 60 +- web/components/FormBuilder.tsx | 11 +- web/components/JoinGate.tsx | 18 +- web/components/LangDot.tsx | 43 + web/components/Logo.tsx | 63 + web/components/MediaSplit.tsx | 149 +- web/components/MemberDashboard.tsx | 5 +- web/components/MemberOnly.tsx | 14 +- web/components/MemberStory.tsx | 2 +- web/components/MentorPicker.tsx | 38 +- web/components/Mentors.tsx | 10 +- web/components/MergePlayground.tsx | 301 ++++ web/components/Nav.tsx | 98 +- web/components/NumbersStrip.tsx | 5 +- web/components/OnboardingGate.tsx | 8 +- web/components/OrgWall.tsx | 3 +- web/components/Outline.tsx | 10 +- web/components/PRTimeline.tsx | 14 +- web/components/PathCard.tsx | 140 ++ web/components/Portrait.tsx | 141 +- web/components/ProfileCard.tsx | 6 +- web/components/ProfileForm.tsx | 17 +- web/components/ProgramMentors.tsx | 216 +++ web/components/ProofPanel.tsx | 7 +- web/components/Roster.tsx | 44 +- web/components/Sessions.tsx | 72 +- web/components/SignInCard.tsx | 27 +- web/components/Team.tsx | 200 ++- web/components/Ticker.tsx | 8 +- web/components/admin/BuildDays.tsx | 721 ++++++++ web/components/admin/Cohort.tsx | 362 ++++ web/components/admin/FloorMentors.tsx | 161 ++ web/components/admin/Gate.tsx | 4 +- web/components/admin/MentorshipAdmin.tsx | 6 +- web/components/admin/Overview.tsx | 15 +- web/components/dashboard/Board.tsx | 12 +- web/components/dashboard/BuildDaysSection.tsx | 229 +++ web/components/dashboard/Contributions.tsx | 48 +- web/components/dashboard/DetailsSection.tsx | 8 +- web/components/dashboard/EventsSection.tsx | 213 +++ web/components/dashboard/FloorCard.tsx | 345 ++++ web/components/dashboard/Forms.tsx | 26 +- web/components/dashboard/Leaderboard.tsx | 266 +++ .../dashboard/MentorshipResults.tsx | 189 ++ .../dashboard/MentorshipSection.tsx | 37 +- web/components/dashboard/NextSessions.tsx | 30 +- web/components/dashboard/NextUp.tsx | 53 +- web/components/dashboard/RequireProfile.tsx | 29 +- web/components/dashboard/Shell.tsx | 168 +- web/components/dev/DevLogin.tsx | 233 --- web/components/dev/DevLoginSlot.tsx | 57 - web/components/floor/BigScreen.tsx | 205 +++ web/components/floor/FloorDesk.tsx | 460 +++++ web/components/fx/AchievementToast.tsx | 67 + web/components/fx/Note.tsx | 105 +- web/components/fx/PeelSticker.tsx | 65 + web/components/fx/achievements.ts | 41 + web/components/fx/celebrate.ts | 3 +- web/components/hall/Hall.tsx | 942 ++++++---- web/components/hall/Roster.tsx | 6 +- web/components/hero/Hero.tsx | 125 +- web/components/hero/HeroGraph.tsx | 116 ++ web/components/hero/PaintGrid.tsx | 52 + web/components/hero/Terminal.tsx | 13 +- web/content/club.ts | 1527 ----------------- web/content/essence.ts | 46 +- web/content/events.ts | 208 +++ web/content/how-to-join.ts | 122 ++ web/content/join.ts | 260 ++- web/content/lookup.ts | 84 + web/content/mentors.ts | 46 + web/content/people.ts | 44 +- web/content/positioning.ts | 244 +++ web/content/programmes.ts | 309 ++++ web/content/projects.ts | 155 +- web/content/selections.ts | 286 +++ web/content/showcase.ts | 70 + web/content/site.ts | 138 +- web/content/team-editorial.ts | 4 +- web/content/team.ts | 552 ++++++ web/content/tracks.ts | 110 ++ web/lib/auth.tsx | 31 +- web/lib/buildDays.ts | 416 +++++ web/lib/contributions.ts | 28 + web/lib/firebase.ts | 30 + web/lib/floor.ts | 421 +++++ web/lib/leaderboard.ts | 97 ++ web/lib/mentorship.ts | 87 +- web/lib/roster.ts | 2 +- web/lib/security-headers.js | 3 + web/lib/sessions.ts | 14 + web/next.config.js | 2 +- web/package.json | 6 +- web/public/people/README.md | 5 +- web/public/people/aarsheya-jasrotia.jpg | Bin 17340 -> 0 bytes web/public/people/arnav-singh.jpg | Bin 27784 -> 0 bytes web/public/people/bhumi-n-deshpande.jpg | Bin 49402 -> 0 bytes web/public/people/divyanshi-saini.jpg | Bin 26764 -> 0 bytes web/public/people/kunal-kumar.jpg | Bin 34185 -> 0 bytes web/public/people/prateek-singh.jpg | Bin 37255 -> 0 bytes web/public/people/rushab-mistry.jpg | Bin 10613 -> 0 bytes web/public/people/sarvika-sharma.jpg | Bin 54688 -> 0 bytes web/public/people/srishti-kumari.jpg | Bin 36601 -> 0 bytes web/public/people/yash.jpeg | Bin 45289 -> 0 bytes web/scripts/assert-no-dev-login.mjs | 88 - web/scripts/assert-site.mjs | 7 +- web/scripts/browsers.mjs | 20 +- web/scripts/dev.mjs | 130 ++ web/scripts/e2e-auth.mjs | 4 +- web/scripts/e2e-build-days.mjs | 360 ++++ web/scripts/e2e-mentorship.mjs | 8 +- web/scripts/palette.mjs | 8 +- web/scripts/rules-emulator.mjs | 435 ++++- web/scripts/rules.mjs | 32 + web/scripts/smoke.mjs | 5 +- web/scripts/team-roster.mjs | 2 +- web/tailwind.config.ts | 48 +- 160 files changed, 13346 insertions(+), 5362 deletions(-) create mode 100644 web/app/(app)/admin/build-days/page.tsx create mode 100644 web/app/(app)/admin/cohort/page.tsx create mode 100644 web/app/(app)/dashboard/build-days/floor/page.tsx create mode 100644 web/app/(app)/dashboard/build-days/page.tsx create mode 100644 web/app/(app)/dashboard/build-days/screen/page.tsx create mode 100644 web/app/(app)/dashboard/events/page.tsx create mode 100644 web/app/(app)/dashboard/leaderboard/page.tsx create mode 100644 web/app/(site)/events/page.tsx create mode 100644 web/app/(site)/guide/page.tsx delete mode 100644 web/app/(site)/how-to-join/page.tsx create mode 100644 web/app/icon.svg create mode 100644 web/components/Fold.tsx create mode 100644 web/components/LangDot.tsx create mode 100644 web/components/Logo.tsx create mode 100644 web/components/MergePlayground.tsx create mode 100644 web/components/PathCard.tsx create mode 100644 web/components/ProgramMentors.tsx create mode 100644 web/components/admin/BuildDays.tsx create mode 100644 web/components/admin/Cohort.tsx create mode 100644 web/components/admin/FloorMentors.tsx create mode 100644 web/components/dashboard/BuildDaysSection.tsx create mode 100644 web/components/dashboard/EventsSection.tsx create mode 100644 web/components/dashboard/FloorCard.tsx create mode 100644 web/components/dashboard/Leaderboard.tsx create mode 100644 web/components/dashboard/MentorshipResults.tsx delete mode 100644 web/components/dev/DevLogin.tsx delete mode 100644 web/components/dev/DevLoginSlot.tsx create mode 100644 web/components/floor/BigScreen.tsx create mode 100644 web/components/floor/FloorDesk.tsx create mode 100644 web/components/fx/AchievementToast.tsx create mode 100644 web/components/fx/PeelSticker.tsx create mode 100644 web/components/fx/achievements.ts create mode 100644 web/components/hero/HeroGraph.tsx create mode 100644 web/components/hero/PaintGrid.tsx delete mode 100644 web/content/club.ts create mode 100644 web/content/events.ts create mode 100644 web/content/how-to-join.ts create mode 100644 web/content/lookup.ts create mode 100644 web/content/mentors.ts create mode 100644 web/content/positioning.ts create mode 100644 web/content/programmes.ts create mode 100644 web/content/selections.ts create mode 100644 web/content/showcase.ts create mode 100644 web/content/team.ts create mode 100644 web/content/tracks.ts create mode 100644 web/lib/buildDays.ts create mode 100644 web/lib/floor.ts create mode 100644 web/lib/leaderboard.ts delete mode 100644 web/public/people/aarsheya-jasrotia.jpg delete mode 100644 web/public/people/arnav-singh.jpg delete mode 100644 web/public/people/bhumi-n-deshpande.jpg delete mode 100644 web/public/people/divyanshi-saini.jpg delete mode 100644 web/public/people/kunal-kumar.jpg delete mode 100644 web/public/people/prateek-singh.jpg delete mode 100644 web/public/people/rushab-mistry.jpg delete mode 100644 web/public/people/sarvika-sharma.jpg delete mode 100644 web/public/people/srishti-kumari.jpg delete mode 100644 web/public/people/yash.jpeg delete mode 100644 web/scripts/assert-no-dev-login.mjs create mode 100644 web/scripts/dev.mjs create mode 100644 web/scripts/e2e-build-days.mjs 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. */} +

    {e.when}

    + + {(e.where || e.audience) && ( +

    + {[e.where, e.audience].filter(Boolean).join(" · ")} +

    + )} + + {e.what &&

    {e.what}

    } + + {/* 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. */} +

    + {e.name} +

    + +

    {e.when}

    + + {e.what &&

    {e.what}

    } + + {e.eligibility && ( +

    + + {e.eligibility} +

    + )} + + {((e.tags && e.tags.length > 0) || e.platform) && ( +
      + {e.platform && ( +
    • + {e.platform} +
    • + )} + {(e.tags ?? []).map((t) => ( +
    • + {t} +
    • + ))} +
    + )} +
    + +
    + {/* 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 && ( +
    +

    {e.done ? "Closed" : "Register by"}

    +

    + {e.registerBy} +

    +
    + )} + {e.prize && ( +
    +

    What you get

    +

    {e.prize}

    +
    + )} + + {e.done ? "Read about it ↗" : "Open the listing ↗"} + +
    +
    +
  • + ); +} + +/* 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. +

    +
    + + {/* ---- Sessions ------------------------------------------------------ */} +
    +
    +

    + Guest Sessions and talks +

    + {sessions.length > 0 && ( +

    + {sessions.length} coming up +

    + )} +
    + + {sessions.length === 0 ? ( + + ) : ( +
      + {sessions.map((e) => ( + + ))} +
    + )} +
    + + {/* ---- 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. +

    +
    + + {/* ---- The loop ----------------------------------------------------- */} +
    +

    The loop

    + +

    + Five steps. One of them is somebody asking for changes, which is{" "} + the normal case, not a failure. +

    + + {/* Equal columns: the longest command is ~484px of `white-space: pre` that + must not wrap, so a narrower terminal column clips it. */} +
    + + +
    +

    And the commands, for real

    +

    + These work on this site's own repo. Copy away — the prompts stay behind. +

    + /scaleropensourcelabs.com.git" }, + { kind: "cmd", text: "cd scaleropensourcelabs.com/web" }, + { kind: "cmd", text: "npm install" }, + { kind: "cmd", text: "npm run dev" }, + { kind: "out", text: "ready on http://localhost:3000" }, + { kind: "note", text: "then, for the change itself:" }, + { kind: "cmd", text: "git checkout -b fix-the-thing" }, + { kind: "cmd", text: "npm run typecheck" }, + { kind: "cmd", text: "git commit -am 'Fix the thing'" }, + { kind: "cmd", text: "git push origin fix-the-thing" }, + ]} + /> + +
    +
    +
    + + {/* ---- 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. +

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

    {m.title}

    +

    {m.body}

    +
    + ))} +
    + + +
    + + {/* ---- 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) => ( -
    -
    -

    {LEVEL_LABEL[level]}

    - -
    - -
    - {PATHS.filter((p) => p.level === level).map((p, i) => ( -
    -
    -
    - - {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) => ( -
    1. - - {w} -
    2. - ))} -
    -
    - -
    -

    - 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. -

    - /scaleropensourcelabs.com.git" }, - { kind: "cmd", text: "cd scaleropensourcelabs.com/web" }, - { kind: "cmd", text: "npm install" }, - { kind: "cmd", text: "npm run dev" }, - { kind: "out", text: "ready on http://localhost:3000" }, - { kind: "note", text: "then, for the change itself:" }, - { kind: "cmd", text: "git checkout -b fix-the-thing" }, - { kind: "cmd", text: "npm run typecheck" }, - { kind: "cmd", text: "git commit -am 'Fix the thing'" }, - { kind: "cmd", text: "git push origin fix-the-thing" }, - ]} - /> - -
    -
    -
    - - {/* ---- 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) => ( -
    1. - {/* 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
    2. 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}

      -
      -
    3. - ))} -
    -
    - - {/* 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. */} -
    -

    - ${" "} - - {CODE_LINES[i % CODE_LINES.length][0]} - -

    -

    - {CODE_LINES[i % CODE_LINES.length][1]} -

    -
    -
    - ))} -
    -
    - - {/* ---- 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. */} +
    +

    Be honest with yourself

    + +
      + {NOT_FOR.map((n) => ( +
    • + + {n} +
    • + ))} +
    +

    + Still here? Then it's for you. +

    +
    + + {/* ---- FAQ ---------------------------------------------------------- */} +
    +

    Questions

    + + +
    + {/* ---- 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. */} +

    + {PODIUM.line}{" "} + + {PODIUM.source.label} ↗ + +

    +
    + + {/* ---- 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. */}
      {WHAT_IT_IS.map((w) => ( - // .rise is the hover for a block whose only furniture is its own - // hairline: the rule goes accent and the block lifts 2px. There is no - // card here to lift and no fill to tint, which is why this shape had - // no hover state anywhere on the site until now.

      {w.title}

      {w.body}

      @@ -222,396 +252,25 @@ export default function Home() { />
      - - - - {/* ---- 1b. Who actually maintains it ---------------------------------- - Placed before the career argument on purpose. A page that only ever says - "this is good for your resume" produces the contributor maintainers - complain about — four pull requests in October and never seen again. This - is the counterweight, and it is what makes the section after it readable - as a consequence rather than as the point. */} -
      -

      Who is on the other side

      - -

      - This is the part that changes how you behave, and almost nobody explains it - before somebody's first contribution. -

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

      {m.title}

      -

      {m.body}

      -
      - ))} -
      -
      -
      - - - {/* ---- 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. */} -
      - {GLOSSARY.map((g) => ( -
      -
      {g.term}
      -
      {g.meaning}
      -
      - ))} -
      -
      - - {/* ---- Thesis ------------------------------------------------------ */} -
      -

      What this is

      - -
      -

      - 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. */} -
      - - - - {/* 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. */} - - - {/* 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. */} - - - - - {COMPARISON.map((row) => ( - - - {[row.cp, row.aiml].map((c, i) => ( - - ))} - {/* 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. */} - - - ))} - -
      - Competitive programming, AI/ML and open source compared across six - questions -
      - - CP club - - AI/ML club - - OSC club -
      - {row.axis} - - {c.stat && ( -

      - -

      - )} -

      {c.line}

      - -
      - {row.osc.stat && ( -

      - -

      - )} -

      {row.osc.line}

      - -
      -
      - -

      - {COMPARISON_NOTE.line}{" "} - - {COMPARISON_NOTE.source.label} ↗ - -

      - - - - {/* ---- 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." />
    - {buildDay.length > 0 && ( + {projects.length > 0 && (

    - {buildDay.length} project{buildDay.length === 1 ? "" : "s"} + {projects.length} project{projects.length === 1 ? "" : "s"}

    )}

    - 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.

    - {buildDay.length === 0 ? ( + {projects.length === 0 ? (

    Nothing listed for this cycle yet.

    - 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. */} +

      + {p.repo ? ( + + {p.name} + + ↗ + + + ) : ( + p.name + )}

      {/* 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 && ( -
    -
    -
    Maintainer
    -
    - {p.maintainerGithub ? ( - - {p.maintainer} ↗ - - ) : ( - p.maintainer - )} -
    -
    -
    -
    Start here
    -
    - {p.goodFirstIssue ? ( - - Good first issue ↗ - - ) : ( - - Ask on the day - - )} -
    -
    -
    + {(p.maintainer || p.goodFirstIssue || p.contributing) && ( +
    +
    + {/* A club repo has no one name to print here, and an empty + "Maintainer" label is worse than none. */} + {p.maintainer && ( +
    +
    Maintainer
    +
    + {p.maintainerGithub ? ( + + {p.maintainer} ↗ + + ) : ( + p.maintainer + )} +
    +
    + )} +
    +
    Start here
    +
    + {p.goodFirstIssue ? ( + + Good first issue ↗ + + ) : ( + + Ask on the day + + )} +
    +
    +
    + + {p.contributing && ( + + )} +
    )} ))} @@ -193,273 +252,17 @@ export default function Projects() { )} - - {/* ---- 2. Club repositories ------------------------------------------- */} -
    -
    -

    Ours, all year

    - -
    - -

    - 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} - - ↗ - - -
      - -

      {r.what}

      - - {r.whyStartHere && ( -

      - - {r.whyStartHere} -

      - )} - -
        - {r.stack.map((s) => ( -
      • - {s} -
      • - ))} -
      -
      - -
      - {r.goodFirstIssue && ( - - Good first issues ↗ - - )} - {r.contributing && ( - - Read CONTRIBUTING.md ↗ - - )} -
      -
      -
    • - ))} -
    -
    - - - {/* ---- 3. Upstream ---------------------------------------------------- */} -
    -
    -
    -

    In the wild

    - -
    - {upstream.length > 0 && ( -

    - {pt.orgs} org{pt.orgs === 1 ? "" : "s"} · {pt.contributors} member - {pt.contributors === 1 ? "" : "s"} -

    - )} -
    - -

    - 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 - )} -
      - - - -

      {p.what}

      -

      {p.did}

      - - {p.proof && ( -
      - {p.proof.label} -

      - {p.proof.value} -

      -
      - )} - -
      - {p.memberUrl ? ( - - {p.member} - - ) : ( - {p.member} - )} - {p.language && {p.language}} - {p.prUrl && ( - - The PR ↗ - - )} -
      -
    • - ))} -
    - - )} - -

    - 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 && ( + )} + +
    { + if (e.target === e.currentTarget && isOpen) setSettled(true); + }} + > +
    {children}
    +
    + + ); +} 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. */}