diff --git a/.claude/skills/figma-impl/SKILL.md b/.claude/skills/figma-impl/SKILL.md new file mode 100644 index 000000000..5c3884a75 --- /dev/null +++ b/.claude/skills/figma-impl/SKILL.md @@ -0,0 +1,40 @@ +--- +name: figma-impl +description: Implement a Figma frame with spec-table-first pixel verification. Use for any "implement/match this Figma design" task in devcon or event-app — it prevents the recurring miss of exact sizes, paddings, and colors. +--- + +# Figma implementation workflow + +The historical failure mode: implementing from the Figma *screenshot* and estimating values, then needing 2–3 user-flagged correction rounds for things that were exact in the design data (16px vs 20px icons, missing 16px padding, wrong icon color). This workflow makes the exact values an explicit deliverable **before** any code is written. + +## 1. Pull the design + +Invoke the `figma:figma-design-to-code` skill first (mandatory prerequisite), then call `get_design_context` AND `get_screenshot` for the node. + +## 2. Spec table BEFORE editing any file + +From the **design context values — never estimated from the screenshot** — write out a markdown table: + +| Element | W×H | Padding | Gap | Font (size/weight/family) | Color (exact hex) | Radius | Notes | + +Cover every element in the frame, including icon dimensions and stroke widths. If a needed value is missing or ambiguous in the design context, ask the user rather than guessing; otherwise proceed without waiting. + +## 3. Implement + +Follow the owning project's conventions: + +- **devcon**: SCSS modules (no inline styles for anything non-trivial), brand tokens — `#221144` text, no left-border callouts, single quotes/no semicolons. +- **event-app**: double quotes + semicolons, `dc-*` tokens / `trackTheme.ts`, reuse `@/components/Buttons`, AppHeader owns mobile title+back. + +**Assets** (icons, illustrations, gems): ask the user to export them from Figma. Never attempt sprite-sheet slicing or background-stripped extraction — it has failed repeatedly. + +## 4. Verify + +1. `pnpm exec tsc --noEmit` in the project (event-app: `pnpm typecheck`). Both are clean at HEAD, so gate on a zero exit code. In a fresh clone/worktree, `TS2307` errors on `.png`/`.svg` imports just mean `next-env.d.ts` hasn't been generated yet — run `pnpm dev` once. +2. Screenshot affected routes with the checked-in harness (see the project's `verify` skill): + `node scripts/shot.mjs --port ` at 390 + 1440 (add 768 when the design has a tablet frame). Confirm which app owns the port first. +3. **Diff every spec-table row against the screenshots** and report the checklist with a pass/fail per row. Zoom (`--selector`) on anything uncertain. Do not report done with unchecked rows. + +## 5. Stop point + +Commit locally only when asked. Never push. List any adjacent issues spotted along the way instead of fixing them. diff --git a/.gitignore b/.gitignore index d6cbd46b7..03736afe4 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,9 @@ devcon/analyze/** # Claude .claude/settings.local.json +.claude/.typecheck-marker +.claude/hooks/ +.screenshots/ # Claude planning docs (superpowers plans/specs) - local only, never push diff --git a/devcon/.claude/skills/verify/SKILL.md b/devcon/.claude/skills/verify/SKILL.md index 4937d0eed..ac4848265 100644 --- a/devcon/.claude/skills/verify/SKILL.md +++ b/devcon/.claude/skills/verify/SKILL.md @@ -1,6 +1,6 @@ --- name: verify -description: How to launch and drive the devcon website to verify UI changes at runtime (dev server + headless Chromium via playwright-core). +description: How to launch and drive the devcon website to verify UI changes at runtime (dev server + headless screenshots via scripts/shot.mjs). --- # Verifying devcon website changes @@ -10,26 +10,26 @@ description: How to launch and drive the devcon website to verify UI changes at `pnpm dev` from `monorepo/devcon` starts TinaCMS + Next.js on `http://localhost:3000`. - If it fails with "Datalayer server is busy on port 9000", a dev server is **already running** (often the user's own) — just use `http://localhost:3000` directly. -- Routes redirect (308) through the i18n middleware; follow redirects or use the trailing-slash URL (e.g. `/speaker-applications/`). +- **Confirm which app owns the port** before screenshotting — event-app also defaults to 3000 (second server started lands on 3001): `curl -s http://localhost:3000/ | grep -o "[^<]*"` (event-app → "Devcon App v2"). +- Routes redirect (308) through the i18n middleware; the harness follows redirects, but prefer trailing-slash URLs (e.g. `/speaker-applications/`). -## Drive (headless browser) +## Screenshots -No Playwright/Puppeteer in the repo, but Playwright browsers are cached on this machine. Recipe: +Use the checked-in harness — do NOT write ad-hoc Playwright scripts: -1. In a scratch dir: `npm i playwright-core` -2. Launch with the cached headless shell (adjust revision to whatever is in the cache dir): - ```js - const { chromium } = require('playwright-core') - const exe = `${os.homedir()}/Library/Caches/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-mac-arm64/chrome-headless-shell` - const browser = await chromium.launch({ executablePath: exe }) - ``` -3. CSS module class names in dev render as `-module-scss-module____` — the local name is a **suffix**, so select with `[class$="__track-card"]` / `[class*="track-card-inner"]`, not `[class*="track-card__"]`. +```bash +node ../scripts/shot.mjs / --port 3000 # 390/768/1440 into .screenshots/ +node ../scripts/shot.mjs /speaker-applications/ --port 3000 --full-page +node ../scripts/shot.mjs / --port 3000 --selector 'section#supporters' +``` -## Emulation notes +`--port` is required by design. Widths < 768 get mobile emulation (isMobile + hasTouch), so `matchMedia('(hover: none)')`/`(pointer: coarse)` match — which is what `src/hooks/useIsTouchDevice.ts` keys off. -- Touch/tap mode: `newContext({ isMobile: true, hasTouch: true, viewport: {width: 390, height: 844} })` correctly makes `matchMedia('(hover: none)')` and `(pointer: coarse)` match, which is what `src/hooks/useIsTouchDevice.ts` keys off. -- Reduced motion: `page.emulateMedia({ reducedMotion: 'reduce' })`. -- Screenshot a section with `page.locator('section#id').screenshot(...)` after `scrollIntoViewIfNeeded()`. +For anything the harness can't do (clicking through flows, reduced-motion emulation via `page.emulateMedia({ reducedMotion: 'reduce' })`), write a one-off script importing `playwright-core` from the repo root and reuse the executable-lookup pattern from `scripts/shot.mjs`. + +## Selector notes + +CSS module class names in dev render as `-module-scss-module____` — the local name is a **suffix**, so select with `[class$="__track-card"]` / `[class*="track-card-inner"]`, not `[class*="track-card__"]`. ## Gotchas diff --git a/event-app/.claude/skills/verify/SKILL.md b/event-app/.claude/skills/verify/SKILL.md new file mode 100644 index 000000000..6204d6083 --- /dev/null +++ b/event-app/.claude/skills/verify/SKILL.md @@ -0,0 +1,55 @@ +--- +name: verify +description: How to launch and drive the event-app PWA to verify UI changes at runtime (dev server + headless screenshots via scripts/shot.mjs). +--- + +# Verifying event-app changes + +## Launch + +`pnpm dev` from `monorepo/event-app` starts Next.js (turbopack) on `http://localhost:3000` — **unless something else already owns 3000**, in which case Next silently takes 3001. + +Always confirm which app owns the port before screenshotting (the devcon site also defaults to 3000): + +```bash +curl -s http://localhost:3000/ | grep -o "[^<]*" +# event-app → "Devcon App v2"; the devcon site has a Devcon.org title +``` + +A dev server is often already running (the user's own) — check before starting a second one. + +## Screenshots + +Use the checked-in harness — do NOT write ad-hoc Playwright scripts: + +```bash +node ../scripts/shot.mjs /schedule --port 3000 # 390/768/1440 into .screenshots/ +node ../scripts/shot.mjs /speakers --port 3000 --widths 390 --full-page +node ../scripts/shot.mjs /schedule --port 3000 --selector '[data-testid="foo"]' +``` + +`--port` is required by design. Widths < 768 get mobile emulation (isMobile + hasTouch), so `(hover: none)`/`(pointer: coarse)` match like a real phone. Output lands in `.screenshots/` relative to cwd (gitignored). + +## Time-dependent UI + +The app auto-mocks "now" to the selected dataset's event start. To pin a specific moment use `--mock-now`: + +```bash +node ../scripts/shot.mjs /schedule --port 3000 --mock-now "2024-11-13T14:00:00Z" +``` + +(equivalent to `?mockNow=` in the URL; `?mockSpeed=` also exists — see `src/hooks/useNow.ts`). + +## Gotchas + +- **Service worker is disabled in dev** — anything SW-dependent (push, offline, update toast) can only be verified on a production build or deploy. +- Type-check with `pnpm typecheck`. **Clean at HEAD — gate on a zero exit code.** + If you get a wall of `TS2307: Cannot find module './foo.png'`, that is not a + real failure: `next-env.d.ts` declares the ambient types for image/SVG imports, + it is gitignored, and it is generated by `next dev` / `next build`. A fresh + clone or git worktree has never run either, so it is missing. Run `pnpm dev` + once (or `pnpm build`) and re-check. Don't "diff against a baseline" — there + isn't one, and treating these as expected hides your own type errors. +- `pnpm lint` for lint. +- Session times: verify against venue-timezone rendering (`src/data/eventTime.ts`), not your local clock. +- Code style here: double quotes + semicolons (unlike the devcon package). diff --git a/event-app/CLAUDE.md b/event-app/CLAUDE.md index cb04c2077..d21338c29 100644 --- a/event-app/CLAUDE.md +++ b/event-app/CLAUDE.md @@ -26,6 +26,66 @@ Authored in one Notion DB ("Devcon 8 App · Announcements & Highlights", Type co Announcements with the Notion `Push` checkbox go out as web push at their Send At time; the inbox stays the source of truth (push is best-effort). Pipeline: `src/app/api/push/service.ts` (claim/fan-out/prune design notes in its header) + routes under `src/app/api/push/`; SW handlers at the bottom of `src/sw.ts` (Declarative Web Push JSON for Safari 18.4+, classic handler elsewhere); opt-in UI on `/announcements` (`PushOptIn` + `src/data/push/usePushSubscription.ts` — never auto-prompt). The dispatcher is `netlify/functions/push-dispatch.mts` (every minute → secret-gated `/api/push/dispatch`; idempotent, crash-reclaim after 10 min). Team test-sends: `POST /api/push/test {id}` (@ethereum.org only, doesn't consume the row's status). Env: `NEXT_PUBLIC_VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` (one keypair forever — rotating orphans every subscription), `PUSH_DISPATCH_SECRET`. Subscriptions live in `devcon8_push_subscriptions`. Note: the SW is disabled in dev, so subscribe/receive can only be tested on a production build or deploy. +## Images (offline) + +Every image in the app must survive going offline, so adding one has three +requirements. All remote images are served from our own Supabase Storage +(`speaker-avatars`, `event-app-announcements`), which sends +`Access-Control-Allow-Origin: *`. + +1. **Put remote images on our Supabase Storage**, mirrored like avatars and + highlight images already are — never hotlink a third-party CDN. A host that + doesn't send CORS headers breaks rule 2, and an expiring URL (Notion + attachments, ~1h) breaks caching entirely. +2. **Add `crossOrigin="anonymous"` to every cross-origin ``.** Without it + the request is `no-cors` and the response is opaque, which is quota-padded far + beyond its real size; a cache full of opaque entries trips the SW's + `purgeOnQuotaError` and wipes *every* cached image. Never mix modes for the + same URL: `Cache.match` keys on URL alone, so an opaque entry cached by a + `no-cors` request will be found and then refused by a later CORS-mode request. + That's also why `use-warm-images.ts` fetches with `mode: "cors"`. +3. **Static assets in `public/` need listing too.** They're same-origin, so they + never show up in the warm list built from API data — that's exactly why they + were the one category that stayed broken after reconnecting. Small chrome + (logos, empty-state art, the manifest) goes in `additionalPrecacheEntries` + (next.config.ts) so it survives the first offline paint; anything large goes in + `APP_IMAGES` (`src/data/appImages.ts`) and is warmed at runtime instead. + **Never precache large art** — `login/backdrop.jpg` alone is 1.4MB and the SW + install is paid by every device. And every precache entry MUST exist: one 404 + fails the install everywhere, which has bitten this repo before. +4. **Warm it if it can render unfetched.** The SW caches images with CacheFirst, + so it only ever holds what the browser actually requested. Anything behind + `loading="lazy"`, a carousel, or a route the user may not visit is *not* + cached just because its data is. Add its URLs to `useWarmImages` + (`src/data/hooks/use-warm-images.ts`, wired up in `CacheWarmer`). + +5. **Never render a broken image.** Wire `onError` to `useRetryOnReconnect` + (`src/hooks/useRetryOnReconnect.ts`) and fall back to a placeholder — initials + for avatars, the lavender/Sparkle panel for cards. An `` that fails while + offline stays broken for the life of the page otherwise, so the hook also + remounts it (via `key={attempt}`) when `online` fires. Retry by remounting the + *same* URL, never a cache-busting query param: that would miss the SW's cached + copy and pile up duplicate cache entries. + +Do **not** drop `loading="lazy"` to force caching. It works, but rasterizing +hundreds of images down a tall page is the mechanism behind the iOS +content-process crash the speakers page already hit once. Warm via `fetch`, which +keeps the images out of the render tree. + +Warming is incremental on purpose: it reads the `static-images` cache and fetches +only the difference, so reopening the app with nothing changed costs nothing. +Keep it that way — don't add a separate "already warmed" ledger, which would +drift as soon as an entry expired or was LRU-evicted and then silently stop +re-warming. Avatar and mirrored-image filenames are content hashes, so a changed +image is a new URL and shows up as missing on its own. + +The SW image rule needs `CacheableResponsePlugin({ statuses: [0, 200] })`. +Serwist only skips its status-200-only filter when a plugin implements +`cacheWillUpdate`, and `ExpirationPlugin` doesn't — without it, every opaque +response is dropped and no cross-origin image caches at all. The service worker +is disabled in dev, so image caching can only be verified on a production build +or a deploy. + ## Why these rules exist Background and history (Serwist setup, precache sizing lessons from Bogota/SEA, Dexie rationale, Capacitor notes, update flow): `docs/architecture.md`. diff --git a/event-app/next.config.ts b/event-app/next.config.ts index f15f28799..a661a0419 100644 --- a/event-app/next.config.ts +++ b/event-app/next.config.ts @@ -34,6 +34,20 @@ const withSerwist = withSerwistInit({ // Offline fallback served by the SW when a document navigation can't be // fulfilled offline (see `fallbacks` in src/sw.ts). { url: "/offline", revision }, + // App-shell images + the PWA manifest. Small on purpose (~37KB total): these + // are the assets visible on the very first offline paint, before any runtime + // caching has happened. The manifest was previously handled by no SW rule at + // all (its request destination is "manifest", not "image"), so it failed on + // every offline load. Large art (login/backdrop.jpg at 1.4MB, tickets-hero, + // tickets-banner) is deliberately excluded and warmed at runtime instead — + // see APP_IMAGES in src/data/appImages.ts. + // Every file here MUST exist, per the note above. + { url: "/manifest.webmanifest", revision }, + { url: "/schedule/devcon8-logomark.svg", revision }, + { url: "/schedule/devcon8-logo.svg", revision }, + { url: "/login/devcon-8-logo.svg", revision }, + { url: "/schedule/empty-search.webp", revision }, + { url: "/partners/ens.png", revision }, ], reloadOnOnline: false, exclude: [ diff --git a/event-app/public/home/tickets-banner.webp b/event-app/public/home/tickets-banner.webp new file mode 100644 index 000000000..26583bf34 Binary files /dev/null and b/event-app/public/home/tickets-banner.webp differ diff --git a/event-app/src/app/(page-layout)/announcements/page.tsx b/event-app/src/app/(page-layout)/announcements/page.tsx index 2a48fab76..e766f1444 100644 --- a/event-app/src/app/(page-layout)/announcements/page.tsx +++ b/event-app/src/app/(page-layout)/announcements/page.tsx @@ -62,51 +62,55 @@ export default function AnnouncementsPage() { }, [announcements, nowMs]); return ( -
- {/* Mobile title comes from AppHeader (routeChrome); page h1 is desktop-only. */} -

- Announcements -

+ // Escape the 680px `.section` column to the 1312px desktop content box + // (same pattern as Ticket.tsx / Schedule). +
+
+ {/* Mobile title comes from AppHeader (routeChrome); page h1 is desktop-only. */} +

+ Announcements +

- + - {isLoading && ( -

Loading announcements…

- )} + {isLoading && ( +

Loading announcements…

+ )} - {!isLoading && error && announcements.length === 0 && ( -

- Couldn't load announcements. Check your connection and try - again. -

- )} - - {!isLoading && !error && announcements.length === 0 && ( -
- -

- Nothing yet — announcements from the team will show up here. + {!isLoading && error && announcements.length === 0 && ( +

+ Couldn't load announcements. Check your connection and try + again.

-
- )} + )} -
- {groups.map(([label, items]) => ( -
-

- {label} -

-
- {items.map((a) => ( - - ))} -
-
- ))} + {!isLoading && !error && announcements.length === 0 && ( +
+ +

+ Nothing yet — announcements from the team will show up here. +

+
+ )} + +
+ {groups.map(([label, items]) => ( +
+

+ {label} +

+
+ {items.map((a) => ( + + ))} +
+
+ ))} +
); diff --git a/event-app/src/app/(page-layout)/page.tsx b/event-app/src/app/(page-layout)/page.tsx index cad27c02d..2f9596068 100644 --- a/event-app/src/app/(page-layout)/page.tsx +++ b/event-app/src/app/(page-layout)/page.tsx @@ -1,5 +1,5 @@ -import { Menu } from "@/components/Menu"; +import { Home } from "@/components/home/Home"; -export default function Home() { - return ; +export default function HomePage() { + return ; } diff --git a/event-app/src/app/api/announcements/service.ts b/event-app/src/app/api/announcements/service.ts index 6438646b6..015514080 100644 --- a/event-app/src/app/api/announcements/service.ts +++ b/event-app/src/app/api/announcements/service.ts @@ -55,6 +55,7 @@ interface NotionRow { sortOrder: number; push: boolean; visible: boolean; + featured: boolean; } let supabase: SupabaseClient | null = null; @@ -237,6 +238,7 @@ async function fetchNotionRows(): Promise { sortOrder: p.Order?.number ?? 0, push: p.Push?.checkbox ?? false, visible: p.Visible?.checkbox ?? false, + featured: p.Featured?.checkbox ?? false, }); } cursor = data.has_more ? data.next_cursor : undefined; @@ -269,6 +271,11 @@ export async function syncAnnouncements(): Promise { const locked = status === "sending" || status === "sent"; // Highlights are never pushed, regardless of the Push checkbox. const armed = row.type === "announcement" && row.push && row.visible; + // ...and only a highlight can be the home-screen hero, regardless of the + // Featured checkbox. Mirrors the push rule above: the two flags are + // meaningful for exactly one type each, and forcing them here means a + // stray tick on the wrong row can never change what renders. + const featured = row.type === "highlight" && row.featured; // No image cell in Notion = image removed on purpose. A cell that fails // to mirror (transient storage/network error) must NOT clobber a // previously mirrored URL, so fall back to the stored one. @@ -286,6 +293,7 @@ export async function syncAnnouncements(): Promise { sort_order: row.sortOrder, push: row.push, visible: row.visible, + featured, status: locked ? status : armed ? "scheduled" : "draft", updated_at: now, }; @@ -326,7 +334,7 @@ export async function getAnnouncements( ): Promise { let query = getSupabase() .from("devcon8_announcements") - .select("id, type, title, message, url, image, send_at, sort_order") + .select("id, type, title, message, url, image, send_at, sort_order, featured") .eq("visible", true) .order("send_at", { ascending: false }) .limit(200); @@ -346,5 +354,6 @@ export async function getAnnouncements( image: r.image, sendAt: r.send_at, sortOrder: r.sort_order, + featured: r.featured ?? false, })); } diff --git a/event-app/src/app/globals.css b/event-app/src/app/globals.css index 2b09372a7..aa719d9a8 100644 --- a/event-app/src/app/globals.css +++ b/event-app/src/app/globals.css @@ -28,9 +28,10 @@ --color-dc-green: #009b27; /* harit-700: success toast border */ --color-dc-green-soft: #d5f4dd; /* harit-100: success toast fill */ - /* Match the /devcon project: Inter for body, Poppins for headings. - The CSS variables are provided by next/font in src/app/layout.tsx. */ - --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + /* Poppins-only app (no Inter anywhere, per the home redesign). font-sans + and font-heading resolve to the same face; font-heading remains for + places that set it explicitly. Variable via next/font in layout.tsx. */ + --font-sans: var(--font-poppins), ui-sans-serif, system-ui, sans-serif; --font-heading: var(--font-poppins), ui-sans-serif, system-ui, sans-serif; } diff --git a/event-app/src/app/layout.tsx b/event-app/src/app/layout.tsx index c6920757d..a9d9d9e12 100644 --- a/event-app/src/app/layout.tsx +++ b/event-app/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { Inter, Poppins } from "next/font/google"; +import { Poppins } from "next/font/google"; import "./globals.css"; import { SWRConfigProvider } from "@/data/cache"; import { CacheWarmer } from "@/components/CacheWarmer"; @@ -11,13 +11,8 @@ import { ServiceWorkerUpdater } from "@/components/ServiceWorkerUpdater"; import { PersonalizedManifestLink } from "@/components/PersonalizedManifestLink"; import APP_CONFIG from "@/CONFIG"; -// Match the /devcon project: Inter (body) + Poppins (headings). -// next/font self-hosts these at build time, so they work offline. -const inter = Inter({ - subsets: ["latin"], - variable: "--font-inter", - display: "swap", -}); +// Poppins everywhere (the home/announcements redesign dropped Inter — the +// app is Poppins-only). next/font self-hosts at build time for offline. const poppins = Poppins({ subsets: ["latin"], // 800 = ExtraBold, used by the desktop "Schedule" page title. @@ -25,6 +20,15 @@ const poppins = Poppins({ variable: "--font-poppins", display: "swap", }); +// Devanagari for the home-screen greeting (नमस्कार …) only — a single weight +// so the SW precache carries one ~39KB file instead of the subset across all +// five weights. The greeting span stacks it after the latin Poppins. +const poppinsDevanagari = Poppins({ + subsets: ["devanagari"], + weight: "800", + variable: "--font-poppins-dev", + display: "swap", +}); export const metadata: Metadata = { title: APP_CONFIG.APP_NAME, @@ -61,7 +65,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {/* iOS native launch-screen images, shown while the installed PWA diff --git a/event-app/src/app/page.native-app.tsx b/event-app/src/app/page.native-app.tsx index 256abbedb..1c3d16842 100644 --- a/event-app/src/app/page.native-app.tsx +++ b/event-app/src/app/page.native-app.tsx @@ -3,7 +3,7 @@ import { NativeRouter } from "@/native/NativeRouter"; // Import all client components -import { Menu } from "@/components/Menu"; +import { Home } from "@/components/home/Home"; import Speakers from "@/app/(page-layout)/speakers/speakers"; import Speaker from "@/app/(page-layout)/speakers/[id]/speaker"; import Schedule from "@/app/(page-layout)/schedule/schedule"; @@ -14,7 +14,7 @@ import RoomScreen from "@/app/(page-layout)/room-screens/[id]/room-screen"; function renderRoute(href: string) { // Home if (href === "/") { - return ; + return ; } // Speakers @@ -45,7 +45,7 @@ function renderRoute(href: string) { } // Fallback - return ; + return ; } export default function NativeApp() { diff --git a/event-app/src/components/AppHeader.tsx b/event-app/src/components/AppHeader.tsx index f5919aac5..72ab4e18f 100644 --- a/event-app/src/components/AppHeader.tsx +++ b/event-app/src/components/AppHeader.tsx @@ -8,6 +8,7 @@ import { Link, BackButton } from "@/routing"; import { useUser } from "@/data/auth/useUser"; import { useAnnouncements } from "@/data/announcements/useAnnouncements"; import { NAV_ITEMS, isNavActive } from "@/components/Nav"; +import { useRetryOnReconnect } from "@/hooks/useRetryOnReconnect"; /** * Pages render their own header buttons (filter, jump-to-now, …) into this @@ -47,6 +48,10 @@ function routeChrome(pathname: string): RouteChrome { * nav (Tickets, Room Screens, Announcements and the AI entry included). */ export function AppHeader({ onOpenAI }: { onOpenAI?: () => void } = {}) { + // Shared by the mobile logomark and the desktop logo — one reconnect retries + // whichever of them failed. + const { attempt: markAttempt, markFailed: markLogoFailed } = + useRetryOnReconnect(); const pathname = usePathname(); const { user } = useUser(); const { unreadCount } = useAnnouncements({ @@ -78,7 +83,9 @@ export function AppHeader({ onOpenAI }: { onOpenAI?: () => void } = {}) { {/* eslint-disable-next-line @next/next/no-img-element */} Devcon 8 India @@ -100,7 +107,9 @@ export function AppHeader({ onOpenAI }: { onOpenAI?: () => void } = {}) { {/* eslint-disable-next-line @next/next/no-img-element */} Devcon 8 India diff --git a/event-app/src/components/Avatar.tsx b/event-app/src/components/Avatar.tsx index ebb4aa88e..f0a75a833 100644 --- a/event-app/src/components/Avatar.tsx +++ b/event-app/src/components/Avatar.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import cn from "classnames"; +import { useRetryOnReconnect } from "@/hooks/useRetryOnReconnect"; import { DC8_TRACKS } from "@/components/schedule/trackTheme"; const initials = (name: string) => @@ -85,6 +86,10 @@ export function Avatar({ () => (src?.startsWith("data:") ? parseIdenticon(src) : null), [src] ); + // A photo that fails to load (offline, and not yet cached) degrades to the + // initials placeholder below rather than a broken image, and retries itself + // when the connection returns. + const { failed, attempt, markFailed } = useRetryOnReconnect(); if (identicon) { return ( @@ -107,11 +112,23 @@ export function Avatar({ // A non-identicon data URI is never rendered as an (that's the crash // path); it degrades to initials below. - if (src && !src.startsWith("data:")) { + if (src && !src.startsWith("data:") && !failed) { return ( + // crossOrigin: request with CORS so the response is a real 200 rather + // than an opaque one. Opaque cache entries are quota-padded far beyond + // their real size, which can trip the service worker's + // purgeOnQuotaError and wipe every cached image. Safe because all our + // avatars are mirrored to our own Supabase Storage, which sends + // Access-Control-Allow-Origin: *. // eslint-disable-next-line @next/next/no-img-element {name} [ + supporter.logo, + (supporter as { largeLogo?: string }).largeLogo, +]); /** * Subscribes to the core datasets (sessions, speakers, rooms) once, app-wide, so @@ -9,12 +20,38 @@ import { useSessions, useSpeakers, useRooms } from "@/data/hooks"; * them makes every list and detail page available offline after a single online * visit — no matter which page the user happened to open. * + * Then warms the images those datasets render — speaker avatars, session images, + * supporter logos, highlight and featured images — because caching the data + * without its images was the half of "works offline" that didn't hold: the + * speakers page came up complete but as a grid of blank circles. + * + * Scoped to the active dataset (the hooks are keyed on it), incremental (only + * what the cache is missing), skipped on metered/slow connections, and chunked + * at idle so it never blocks browsing. See `useWarmImages`. + * * Renders nothing. SWR dedupes, so this is effectively free alongside the pages * that already read the same keys. */ export function CacheWarmer() { - useSessions(); - useSpeakers(); + const { sessions } = useSessions(); + const { speakers } = useSpeakers(); useRooms(); + + // Also warms the announcements payload itself, which was previously only + // fetched by visiting the home or announcements page. + const { highlights, featured } = useAnnouncements({ + enabled: APP_CONFIG.ANNOUNCEMENTS_ENABLED, + }); + + // Priority order, most important first. Avatars are last on purpose: 645 of + // them dwarf everything else, and any single one matters least. + useWarmImages([ + [featured?.image, ...highlights.map((highlight) => highlight.image)], + APP_IMAGES, + SUPPORTER_LOGOS, + sessions.map((session) => session.image), + speakers.map((speaker) => speaker.avatar), + ]); + return null; } diff --git a/event-app/src/components/Menu.tsx b/event-app/src/components/Menu.tsx deleted file mode 100644 index b87c4dadd..000000000 --- a/event-app/src/components/Menu.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import APP_CONFIG from "@/CONFIG"; -import { useUser } from "@/data/auth/useUser"; -import { AnnouncementsSection } from "./announcements/AnnouncementsSection"; -import { HighlightsCarousel } from "./announcements/HighlightsCarousel"; -import { InstallAppButton } from "./InstallAppButton"; -import { Tickets } from "./Tickets"; - -export function Menu() { - const { user } = useUser(); - const name = user?.email?.split("@")[0]; - - return ( -
- {/* Welcome hero */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - -
-
-

- {name ? `Welcome back, ${name} 👋` : "Hello, welcome 👋"} -

-

{APP_CONFIG.APP_NAME}

-

- {APP_CONFIG.APP_DESCRIPTION} -

- -
-
- - {APP_CONFIG.ANNOUNCEMENTS_ENABLED && ( - <> - - - - )} - - {/* Tickets — also shown logged-out to prompt getting a ticket. */} - -
- ); -} diff --git a/event-app/src/components/Tickets.tsx b/event-app/src/components/Tickets.tsx index 699c705bd..41f43455d 100644 --- a/event-app/src/components/Tickets.tsx +++ b/event-app/src/components/Tickets.tsx @@ -7,10 +7,15 @@ import { useTickets } from "@/data/tickets/useTickets"; import { useUser } from "@/data/auth/useUser"; import { Link } from "@/routing"; import { QrLightbox, TicketCard, type QrTarget } from "./TicketCards"; +import { useRetryOnReconnect } from "@/hooks/useRetryOnReconnect"; -/** Renders the user's tickets as cards with QR codes (prompts to get a ticket - * when there are none, including when logged out). */ +/** Renders the user's tickets as cards with QR codes. Signed out, it becomes + * the key-art sign-in banner from the Figma home redesign. */ export function Tickets() { + const { + attempt: bannerAttempt, + markFailed: markBannerFailed, + } = useRetryOnReconnect(); const { user } = useUser(); const { tickets, qrCodes, isLoading, isRefreshing, error, refresh } = useTickets(); @@ -22,81 +27,131 @@ export function Tickets() { return (
-
-

Your tickets

+
+

+ Your tickets +

+ {/* Text CTA, unified with the "View all" link style */} {user && ( )}
+ {/* Order matters: isLoading folds in !hasInitialized (useTickets), so a + signed-in cold load — or an OFFLINE user whose auth can't resolve + yet — never flashes the signed-out banner over their cached + tickets/QR codes. */} {isLoading ? ( -

Loading tickets…

- ) : error ? ( -

- Couldn't load tickets: {error.message} -

- ) : allTickets.length === 0 ? ( +

Loading tickets…

+ ) : !user ? ( + /* Signed out: full-width key-art banner prompting sign-in. + bg fallback keeps the white text legible if the art fails/evicts. */ <> -
- {/* Real banner art from devcon.org/tickets + gradient for legibility */} + {/* eslint-disable-next-line @next/next/no-img-element */} -
-
-

- {user ? "Welcome!" : "Join Devcon"} + {/* Mobile-only legibility gradient under the text (Figma 5017:5545) */} +
+
+ Sign in +
+
+

+ Add your tickets to the Devcon app

-

- {user - ? "We couldn't find any tickets for your email yet." - : "Grab your ticket to unlock the full experience."} +

+ Sign in using your ticket purchase email to unlock the full + experience.

- - Get tickets -
+ + {/* Keep a purchase path reachable while signed out */} +

+ Don't have a ticket yet?{" "} + + Get tickets ↗ + +

+ + ) : error && allTickets.length === 0 ? ( +

+ Couldn't load tickets: {error.message} +

+ ) : allTickets.length === 0 ? ( +
+ {/* Real banner art from devcon.org/tickets + gradient for legibility */} + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+
+

Welcome!

+

+ We couldn't find any tickets for your email yet. +

+ + Get tickets +
- - {!user && ( -

- Already have a ticket?{" "} - - Sign in - {" "} - with the email that has a ticket associated with it. +

+ ) : ( + <> + {/* A failed revalidation must never hide cached tickets/QR codes — + keep the grid and add a quiet notice instead. */} + {error && ( +

+ Couldn't refresh tickets — showing your saved ones.

)} +
+ {allTickets.map(({ ticket, eventName }) => ( + + ))} +
- ) : ( -
- {allTickets.map(({ ticket, eventName }) => ( - - ))} -
)} setLightbox(null)} /> diff --git a/event-app/src/components/announcements/AnnouncementCard.tsx b/event-app/src/components/announcements/AnnouncementCard.tsx index 716ae0e00..277bb0fd4 100644 --- a/event-app/src/components/announcements/AnnouncementCard.tsx +++ b/event-app/src/components/announcements/AnnouncementCard.tsx @@ -1,29 +1,32 @@ "use client"; import cn from "classnames"; -import { ArrowUpRight } from "lucide-react"; +import { ArrowRight, ArrowUpRight } from "lucide-react"; import { Link } from "@/routing"; import { useRealWorldNowMs } from "@/hooks/useNow"; +import { resolveAnnouncementLink } from "@/data/announcements/linkUtils"; import type { Announcement } from "@/data/announcements/types"; /** - * Compact relative timestamp ("Just now", "5m ago", "3h ago", then a date). - * Future times ("In 10m") appear only in ?preview mode, where editors check - * scheduled announcements. + * Relative timestamp in the redesign's long-form units ("3 mins ago", + * "2 hrs ago", then a date). Future times ("In 10 mins") appear only in + * ?preview mode, where editors check scheduled announcements. */ function relativeTime(sendAt: string, nowMs: number): string { const diffMs = nowMs - new Date(sendAt).getTime(); const abs = Math.abs(diffMs); - const minutes = Math.round(abs / 60_000); - const hours = Math.round(abs / 3_600_000); + const minutes = Math.floor(abs / 60_000); + const hours = Math.floor(abs / 3_600_000); + // Pluralize from the value actually displayed. + const unit = (n: number, u: string) => `${n} ${u}${n === 1 ? "" : "s"}`; if (diffMs < 0) { - if (minutes < 60) return `In ${Math.max(minutes, 1)}m`; - if (hours < 24) return `In ${hours}h`; + if (minutes < 60) return `In ${unit(Math.max(minutes, 1), "min")}`; + if (hours < 24) return `In ${unit(hours, "hr")}`; } else { if (minutes < 1) return "Just now"; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; + if (minutes < 60) return `${unit(minutes, "min")} ago`; + if (hours < 24) return `${unit(hours, "hr")} ago`; } return new Date(sendAt).toLocaleDateString(undefined, { month: "short", @@ -31,58 +34,145 @@ function relativeTime(sendAt: string, nowMs: number): string { }); } +/** Generic CTA per the redesign: internal links → "Open →", external → "Open ↗". + * (Custom labels would need a CTA column in the Notion pipeline — not yet.) */ +function Cta({ external, mini }: { external: boolean; mini?: boolean }) { + const Icon = external ? ArrowUpRight : ArrowRight; + return ( + + Open + + ); +} + +function UnreadDot() { + return ( + + ); +} + +/** + * One announcement, in either of the redesign's two shapes: + * - "inbox" (default, /announcements): date/unread dot top-right in the title + * row, CTA bottom-left. + * - "home" (home preview grid): meta row at the card's bottom — dot + time on + * the left, CTA on the right; equal-height across the 3-up grid. + */ export function AnnouncementCard({ announcement, seen, + variant = "inbox", }: { announcement: Announcement; seen: boolean; + variant?: "inbox" | "home"; }) { const nowMs = useRealWorldNowMs(60_000); const { title, message, url, sendAt } = announcement; + const link = url ? resolveAnnouncementLink(url) : null; + const external = !!link?.external; + const time = relativeTime(sendAt, nowMs); - const body = ( -
-
-

- {!seen && ( - + // Linked cards get a purple border + CTA underline (`group`) on hover; + // cards without a url stay fully inert. Only the home-preview variant also + // scales/shadows like the other home cards — the inbox stays still. + const interactive = cn( + "group transition-[scale,box-shadow,border-color] duration-150 ease-out hover:border-dc-purple/40", + variant === "home" && + "hover:shadow-sm motion-safe:hover:scale-[1.03] motion-safe:active:scale-[0.97]" + ); + + const body = + variant === "home" ? ( +

+
+

+ {title} +

+ {message && ( +

+ {message} +

)} - {title} -

- - {relativeTime(sendAt, nowMs)} - +
+
+ + {!seen && } + + {time} + + + {link && } +
- {message && ( -

- {message} -

- )} - {url && ( - - Open - - )} -
- ); + ) : ( +
+
+

+ {title} +

+ + {!seen && } + + {time} + + +
+ {message && ( +

+ {message} +

+ )} + {link && ( +
+ +
+ )} +
+ ); - if (!url) return body; + if (!link) return body; - // Internal paths navigate in-app; anything absolute opens a new tab. - if (url.startsWith("/")) { - return {body}; + const linkClass = cn( + "block rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dc-purple", + variant === "home" && "h-full" + ); + + // Internal paths navigate in-app; anything else opens a new tab. + if (!link.external) { + return ( + + {body} + + ); } return ( - + {body} ); diff --git a/event-app/src/components/announcements/AnnouncementsSection.tsx b/event-app/src/components/announcements/AnnouncementsSection.tsx index 427380053..c4a29998c 100644 --- a/event-app/src/components/announcements/AnnouncementsSection.tsx +++ b/event-app/src/components/announcements/AnnouncementsSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronRight, Megaphone } from "lucide-react"; +import { ArrowRight } from "lucide-react"; import { Link } from "@/routing"; import { useAnnouncements } from "@/data/announcements/useAnnouncements"; import { AnnouncementCard } from "./AnnouncementCard"; @@ -8,37 +8,36 @@ import { AnnouncementCard } from "./AnnouncementCard"; const HOME_PREVIEW_COUNT = 3; /** - * Home-screen preview: the latest few announcements with a "View all" link to - * the inbox. Renders nothing while empty so the home page stays clean before - * the first announcement ships. + * Home-screen preview: the latest few announcements (3-up on desktop) with a + * "View all" link to the inbox. Renders nothing while empty so the home page + * stays clean before the first announcement ships. */ export function AnnouncementsSection() { - const { announcements, unreadCount } = useAnnouncements(); + const { announcements } = useAnnouncements(); if (announcements.length === 0) return null; return ( -
-
-

- +
+
+

Announcements - {unreadCount > 0 && ( - - {unreadCount} - - )}

- View all + View all
-
+
{announcements.slice(0, HOME_PREVIEW_COUNT).map((a) => ( - + ))}
diff --git a/event-app/src/components/announcements/HighlightsCarousel.tsx b/event-app/src/components/announcements/HighlightsCarousel.tsx index bd35c0195..978229e73 100644 --- a/event-app/src/components/announcements/HighlightsCarousel.tsx +++ b/event-app/src/components/announcements/HighlightsCarousel.tsx @@ -1,9 +1,13 @@ "use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import cn from "classnames"; import { Sparkle } from "lucide-react"; import SwipeToScroll from "lib/components/event-schedule/swipe-to-scroll-native"; import { Link } from "@/routing"; import { useAnnouncements } from "@/data/announcements/useAnnouncements"; +import { resolveAnnouncementLink } from "@/data/announcements/linkUtils"; +import { useRetryOnReconnect } from "@/hooks/useRetryOnReconnect"; import type { Announcement } from "@/data/announcements/types"; /** @@ -14,37 +18,69 @@ import type { Announcement } from "@/data/announcements/types"; * Notion-managed instead of hardcoded. */ function HighlightCard({ highlight }: { highlight: Announcement }) { - const { title, message, url, image } = highlight; + const { title, message, url } = highlight; + const { failed, attempt, markFailed } = useRetryOnReconnect(); + // A failed load falls back to the Sparkle placeholder below rather than a + // broken image, and retries when the connection returns. + const image = failed ? null : highlight.image; + const link = url ? resolveAnnouncementLink(url) : null; const card = ( -
+
{image ? ( + // crossOrigin: see Avatar.tsx — avoids opaque-response quota padding. // eslint-disable-next-line @next/next/no-img-element ) : ( -
- +
+
)}
-

{title}

+

+ {title} +

{message && ( -

{message}

+

+ {message} +

)}
); - if (!url) return card; - if (url.startsWith("/")) { - return {card}; + if (!link) return card; + + const linkClass = + "block rounded-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dc-purple"; + + if (!link.external) { + return ( + + {card} + + ); } return ( - + {card} ); @@ -52,22 +88,83 @@ function HighlightCard({ highlight }: { highlight: Announcement }) { export function HighlightsCarousel() { const { highlights } = useAnnouncements(); + const wrapRef = useRef(null); + const [fades, setFades] = useState({ left: false, right: false }); + + const sync = useCallback((el: HTMLElement) => { + setFades({ + left: el.scrollLeft > 5, + right: el.scrollLeft < el.scrollWidth - el.clientWidth - 5, + }); + }, []); + + // Edge-fade state. SwipeToScroll's own scroll indicators only sync on + // mount/resize (never on scroll), so we track the inner scroller ourselves: + // scroll events don't bubble, but they DO fire on ancestors in the capture + // phase, and the desktop drag path sets scrollLeft (which also fires them). + useEffect(() => { + const root = wrapRef.current; + if (!root) return; + const findScroller = (): HTMLElement | null => { + for (const n of root.querySelectorAll("div")) { + if (n.scrollWidth > n.clientWidth + 1) return n; + } + return null; + }; + const measure = () => { + const el = findScroller(); + if (el) sync(el); + else setFades({ left: false, right: false }); + }; + const onScroll = (e: Event) => { + const t = e.target; + if (t instanceof HTMLElement) sync(t); + }; + measure(); + root.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", measure); + return () => { + root.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", measure); + }; + }, [highlights.length, sync]); if (highlights.length === 0) return null; return ( -
-

- +
+

Highlights

- -
- {highlights.map((h) => ( - - ))} -
-
+ {/* Edge fades (right while more content, left once scrolled), applied as + a mask so they work over the page's gradient background — same + treatment as the speakers-page topic filters. The p-2 INSIDE the + scroll container gives the hover scale/shadow room to render without + being clipped by the scroller; the outer -m-2 re-aligns the cards + with the section edge. */} +
+ +
+ {highlights.map((h) => ( + + ))} +
+
+
); } diff --git a/event-app/src/components/announcements/PushOptIn.tsx b/event-app/src/components/announcements/PushOptIn.tsx index 4727ede48..35010a4ea 100644 --- a/event-app/src/components/announcements/PushOptIn.tsx +++ b/event-app/src/components/announcements/PushOptIn.tsx @@ -18,18 +18,18 @@ export function PushOptIn() { if (state === "loading" || state === "unsupported" || !signedIn) return null; return ( -
+
{state === "requires-install" && ( -

- +

+ To get notified about announcements on iOS, add the app to your Home Screen first (Share → Add to Home Screen).

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

- +

+ Notifications are blocked for this site — allow them in your browser settings to get announcement alerts.

@@ -37,16 +37,16 @@ export function PushOptIn() { {state === "off" && ( <> -

- +

+ Get notified when the team posts an announcement.

)} - {error &&

{error}

} + {error &&

{error}

}
); } diff --git a/event-app/src/components/home/FeaturedCard.tsx b/event-app/src/components/home/FeaturedCard.tsx new file mode 100644 index 000000000..846740026 --- /dev/null +++ b/event-app/src/components/home/FeaturedCard.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { ArrowRight, ArrowUpRight, Sparkle } from "lucide-react"; +import cn from "classnames"; +import APP_CONFIG from "@/CONFIG"; +import { Link } from "@/routing"; +import { useAnnouncements } from "@/data/announcements/useAnnouncements"; +import { useRetryOnReconnect } from "@/hooks/useRetryOnReconnect"; +import { resolveAnnouncementLink } from "@/data/announcements/linkUtils"; + +/** + * "Featured" section: the home-screen hero, driven by whichever highlight has + * Featured ticked in Notion (see `useAnnouncements` for how one is chosen). + * + * This used to be a hardcoded card, which made the most prominent slot on the + * home screen the one thing editors couldn't change without a deploy — exactly + * the slot you want to retarget mid-event. + * + * Renders nothing when there's no highlight to show (announcements switched + * off, an empty Notion DB, or a fresh install that hasn't synced yet), the same + * way the announcements and highlights sections below it drop out when empty. + */ +export function FeaturedCard() { + const { featured } = useAnnouncements({ + enabled: APP_CONFIG.ANNOUNCEMENTS_ENABLED, + }); + const { failed, attempt, markFailed } = useRetryOnReconnect(); + + if (!featured) return null; + + const { title, message, url } = featured; + // Treating a failed load as "no image" reuses the placeholder and the + // light-on-photo / dark-on-panel text switch below, instead of showing a + // broken image over unreadable text. Retries on reconnect. + const image = failed ? null : featured.image; + const link = url ? resolveAnnouncementLink(url) : null; + // Near-white on the glass circle over a photo (as the design has it); purple + // on the solid white circle, where near-white would be invisible. + const arrowColor = cn("size-4", image ? "text-dc-purple-fg" : "text-dc-purple"); + + const body = ( + <> + {image ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + ) : ( + // A highlight can have no image; light text would vanish on a photo-less + // card, so it falls back to the lavender panel the carousel uses. +
+ +
+ )} + + {link && ( +
+ {link.external ? ( + + ) : ( + + )} +
+ )} + +
+

+ {title} +

+ {message && ( +

+ {message} +

+ )} +
+ + ); + + const shell = cn( + "group relative flex h-[208px] w-full flex-col justify-end overflow-hidden rounded-xl border border-dc-hairline p-4 lg:h-60 lg:max-w-[400px]", + link && + "transition-[scale,box-shadow] duration-150 ease-out hover:shadow-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-dc-purple motion-safe:hover:scale-[1.03] motion-safe:active:scale-[0.97]" + ); + + return ( +
+

+ Featured +

+ {/* A highlight without a URL is still worth showing, just inert — the + same rule the announcement cards follow. */} + {!link ? ( +
{body}
+ ) : link.external ? ( + + {body} + + ) : ( + + {body} + + )} +
+ ); +} diff --git a/event-app/src/components/home/Greeting.tsx b/event-app/src/components/home/Greeting.tsx new file mode 100644 index 000000000..781fa5ba6 --- /dev/null +++ b/event-app/src/components/home/Greeting.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { LogIn, LogOut, UserCog } from "lucide-react"; +import { HEADER_ACTIONS_ID } from "@/components/AppHeader"; +import { Link } from "@/routing"; +import { useUser } from "@/data/auth/useUser"; + +// Greeting variants from the Figma spec strip (5017:5368), rotated in order. +// The strip's नमस्त entry was a truncated duplicate of नमस्ते (same +// pronunciation, missing matra) — dropped after review. +const GREETINGS = [ + { text: "नमस्कार", pron: "(na-muh-skaa)" }, + { text: "नमस्ते", pron: "(na-ma-stay)" }, + { text: "Hello", pron: "(heh-low)" }, +]; +const ROTATE_MS = 5_000; + +type AuthProps = { + user: ReturnType["user"]; + signOut: () => Promise; +}; + +/** + * Mobile auth controls, portaled into the AppHeader's #header-actions target + * (same pattern as the speakers page): a sign-in circle when signed out, the + * account + sign-out circles when signed in. Desktop renders its own inline + * controls next to the greeting instead. + */ +function HeaderAuthActions({ user, signOut }: AuthProps) { + const [target, setTarget] = useState(null); + useEffect(() => { + setTarget(document.getElementById(HEADER_ACTIONS_ID)); + }, []); + if (!target) return null; + + return ( + <> + {createPortal( + user ? ( + <> + {/* after:-inset-1.5 pads the 32px circles to a 44px hit area */} + + + + + + ) : ( + + + + ), + target + )} + + ); +} + +/** + * Home-page greeting row: rotating Devanagari/English greeting with + * pronunciation. Auth controls sit inline on desktop (Sign in pill, or email + + * account/sign-out round buttons); on mobile they live in the app header. + */ +export function Greeting() { + const { user, signOut } = useUser(); + const reducedMotion = useReducedMotion(); + const [index, setIndex] = useState(0); + + useEffect(() => { + // Reduced motion: no auto-rotation at all (WCAG 2.2.2 — auto-updating + // content), not just instant swaps. + if (reducedMotion) return; + const id = setInterval(() => { + // Skip ticks in a hidden tab so the greeting doesn't churn unseen. + if (document.hidden) return; + setIndex((i) => (i + 1) % GREETINGS.length); + }, ROTATE_MS); + return () => clearInterval(id); + }, [reducedMotion]); + + const greeting = GREETINGS[index]; + + return ( +
+
+ {/* Fixed-height relative box: entering/exiting variants are absolutely + positioned so the rotation never shifts the layout below. */} +
+ + + + {greeting.text} + + + {greeting.pron} + + + +
+ {/* Mobile: email sits under the greeting (desktop shows it inline right) */} + {user?.email && ( +

+ {user.email} +

+ )} +
+ + + + {/* Desktop-only inline controls */} +
+ {user ? ( + <> + + {user.email} + + {/* after:-inset-0.5 pads the 40px circles to a 44px hit area */} + + + + + + ) : ( + + + Sign in + + )} +
+
+ ); +} diff --git a/event-app/src/components/home/Home.tsx b/event-app/src/components/home/Home.tsx new file mode 100644 index 000000000..ecf72672b --- /dev/null +++ b/event-app/src/components/home/Home.tsx @@ -0,0 +1,46 @@ +"use client"; + +import APP_CONFIG from "@/CONFIG"; +import { AnnouncementsSection } from "../announcements/AnnouncementsSection"; +import { HighlightsCarousel } from "../announcements/HighlightsCarousel"; +import { InstallAppButton } from "../InstallAppButton"; +import { Tickets } from "../Tickets"; +import { FeaturedCard } from "./FeaturedCard"; +import { Greeting } from "./Greeting"; + +/** + * The home page (Figma home redesign): rotating greeting, the featured + * highlight hero, announcements preview, highlights carousel, tickets, and the + * "Devcon 8 India" sign-off art. + * + * FeaturedCard sits outside the ANNOUNCEMENTS_ENABLED gate on purpose: it owns + * that check itself and renders nothing when there's no highlight to show. Escapes the 680px `.section` column to the + * 1312px desktop content box (same pattern as Ticket.tsx / Schedule). + */ +export function Home() { + return ( +
+
+ {/* Visual title lives in AppHeader; keep a semantic h1 for AT */} +

Home

+ +
+ + {APP_CONFIG.ANNOUNCEMENTS_ENABLED && ( + <> + + + + )} + {/* HomeFooterArt ("Devcon 8 India") is parked for a design revisit — + the component is kept, just not rendered. */} +
+ + {/* Styled to match SecondaryButton (Buttons.tsx), centered */} + +
+
+
+
+ ); +} diff --git a/event-app/src/components/home/HomeFooterArt.tsx b/event-app/src/components/home/HomeFooterArt.tsx new file mode 100644 index 000000000..e5a2df2d7 --- /dev/null +++ b/event-app/src/components/home/HomeFooterArt.tsx @@ -0,0 +1,145 @@ +/** + * PARKED — not rendered anywhere (removed from Home pending a design revisit; + * neither width-fill approach — stretched glyphs or stretched spacing — passed + * review). To restore: re-add the Chloe localFont loader in layout.tsx, and + * restore src/fonts/Chloe-Regular.otf + public/home/footer-art-fill.webp from + * git history (removed from the build to stop shipping dead weight). + * + * Home-page sign-off: "Devcon 8 India" set in Chloe, filled with the India art + * image and finished with the Figma inner shadow (inset 0 1px 4px + 0 2px 8px + * rgba(22,11,43,.15)). Built as real SVG — not a pre-rendered image — + * so it scales fluidly with the viewport and the copy stays selectable by + * assistive tech. SVG because CSS background-clip:text cannot carry a + * glyph-following inner shadow, and CSS filter:url() on HTML text is + * unreliable in Safari; precedent: SideArt in TicketSignIn.tsx. + * + * textLength pins the lockup to the full container width (like the Figma + * full-bleed text) without depending on exact Chloe advance metrics. The + * default lengthAdjust="spacing" stretches only the gaps BETWEEN glyphs — + * never the glyph outlines ("spacingAndGlyphs" distorted the letterforms). + */ + +const FILL_SRC = "/home/footer-art-fill.webp"; + +function InnerShadowFilter({ id }: { id: string }) { + return ( + + {/* Invert the glyph alpha, blur+offset, tint, keep what falls back + inside the glyphs. Two passes = Figma's two stacked inset shadows. */} + + + + + + + + + + + + + + + + + + + + ); +} + +const chloeTextStyle: React.CSSProperties = { + fontFamily: "var(--font-chloe), serif", + fontWeight: 400, + letterSpacing: "-0.03em", +}; + +export function HomeFooterArt() { + return ( +
+

+ Gather with the curious, the builders, and the explorers +

+ + {/* Desktop: one full-width line */} + + + + + + Devcon 8 India + + + + + + {/* Vertical placement mirrors the Figma crop (img y −194 of 737) */} + + + + + + {/* Mobile: two centered lines ("Devcon 8" sets the width) */} + + + + + + Devcon 8 + + + India + + + + + + + + + +
+ ); +} diff --git a/event-app/src/data/announcements/linkUtils.ts b/event-app/src/data/announcements/linkUtils.ts new file mode 100644 index 000000000..d332096f6 --- /dev/null +++ b/event-app/src/data/announcements/linkUtils.ts @@ -0,0 +1,41 @@ +/** + * Announcement/highlight URLs come verbatim from a Notion column (no + * server-side normalization), so editors may paste anything: "/schedule", + * "devcon.org/x", "//host/x", "https://…". Classify + normalize once here: + * - internal = a single-slash path (in-app ) + * - external = an http(s) or mailto URL; scheme-less values (including protocol-relative + * "//host") get https:// so the anchor can't resolve relative to the current + * route (a bare "devcon.org/x" would 404 under /announcements/devcon.org/x). + * - anything else is refused, and callers render the card without a link. + * + * The refusal matters because the resolved value goes straight into an + * ``: allowing any scheme would let a pasted "javascript:…" become + * click-to-execute script in our own origin, on a page where the reader is + * signed in. Editors are trusted, but this function exists precisely to + * normalize input nobody has validated, so it allowlists rather than + * blocklists — "starts with a scheme" is not the same question as "is a + * scheme we're willing to put in an href". + */ +export function resolveAnnouncementLink( + url: string +): { href: string; external: boolean } | null { + const trimmed = url.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith("/") && !trimmed.startsWith("//")) { + return { href: trimmed, external: false }; + } + + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed); + if (scheme) { + const name = scheme[1].toLowerCase(); + // Allowlist, not a blocklist. mailto is here because an editor could + // reasonably paste one and it is inert in an href; javascript:, data:, + // vbscript: and blob: are the reason the check exists. + if (name !== "http" && name !== "https" && name !== "mailto") return null; + return { href: trimmed, external: true }; + } + + // Scheme-less ("devcon.org/x", "//devcon.org/x") — assume https. + return { href: `https://${trimmed.replace(/^\/+/, "")}`, external: true }; +} diff --git a/event-app/src/data/announcements/types.ts b/event-app/src/data/announcements/types.ts index ed7005df1..5714fc0d9 100644 --- a/event-app/src/data/announcements/types.ts +++ b/event-app/src/data/announcements/types.ts @@ -14,6 +14,11 @@ export interface Announcement { sendAt: string; /** Manual ordering for highlights (ascending). */ sortOrder: number; + /** + * Marks the one highlight rendered as the home-screen hero. Always false for + * announcements (the sync forces it), so only a highlight can win. + */ + featured: boolean; } export interface AnnouncementsResponse { diff --git a/event-app/src/data/announcements/useAnnouncements.ts b/event-app/src/data/announcements/useAnnouncements.ts index 889f5d0bc..26443ca3f 100644 --- a/event-app/src/data/announcements/useAnnouncements.ts +++ b/event-app/src/data/announcements/useAnnouncements.ts @@ -110,7 +110,7 @@ export function useAnnouncements(options: { enabled?: boolean } = {}) { // Highlights: evergreen home-screen cards. Curated order, no read state, // no time gate (Visible in Notion is their on/off switch). - const highlights = useMemo( + const allHighlights = useMemo( () => (data ?? []) .filter((a) => a.type === "highlight") @@ -118,6 +118,33 @@ export function useAnnouncements(options: { enabled?: boolean } = {}) { [data] ); + /** + * The home-screen hero: the highlight with Featured ticked in Notion, or + * null. Purely opt-in — no highlight ticked means no Featured section at all. + * + * Deliberately not falling back to a positional pick (e.g. the last + * highlight). A fallback would mean unticking the box silently promotes some + * other card rather than hiding the section, and reordering the carousel + * could change the hero as a side effect. Opting in is the predictable + * version: what an editor ticks is what shows. + * + * If two rows are ticked the first by Order wins — arbitrary but stable, since + * a per-row checkbox can't express "only one". + */ + const featured = useMemo( + () => allHighlights.find((h) => h.featured) ?? null, + [allHighlights] + ); + + /** + * The carousel, minus whatever is already the hero — otherwise the featured + * card renders twice on the same screen. + */ + const highlights = useMemo( + () => allHighlights.filter((h) => h.id !== featured?.id), + [allHighlights, featured] + ); + const unreadCount = useMemo( () => announcements.filter( @@ -155,6 +182,7 @@ export function useAnnouncements(options: { enabled?: boolean } = {}) { return { announcements, highlights, + featured, unreadCount, isLoading: enabled && data === undefined && error === undefined, /** True once the Dexie read state has hydrated (unread info is real). */ diff --git a/event-app/src/data/appImages.ts b/event-app/src/data/appImages.ts new file mode 100644 index 000000000..e4c1efa4d --- /dev/null +++ b/event-app/src/data/appImages.ts @@ -0,0 +1,26 @@ +/** + * Static images shipped in `public/` and rendered by the app shell. + * + * These are same-origin, so they never appeared in the warm list built from API + * data — which is why they were the one category that stayed broken after a + * connection came back. Listed here so `CacheWarmer` treats them like any other + * image. + * + * The small chrome (logos, logomark, empty-state art) is *also* in + * `additionalPrecacheEntries` in next.config.ts, so it's available from the very + * first offline paint. The large ones below are deliberately warm-only: the + * login backdrop alone is 1.4MB and precaching it would bloat the SW install for + * every device (see the precache sizing history in docs/architecture.md). + */ +export const APP_IMAGES: string[] = [ + // Large — warmed, never precached. + "/login/backdrop.jpg", + "/tickets-hero.jpg", + "/home/tickets-banner.webp", + // Small chrome — precached too, listed here so a cache miss still self-heals. + "/login/devcon-8-logo.svg", + "/schedule/devcon8-logo.svg", + "/schedule/devcon8-logomark.svg", + "/schedule/empty-search.webp", + "/partners/ens.png", +]; diff --git a/event-app/src/data/hooks/use-warm-images.ts b/event-app/src/data/hooks/use-warm-images.ts new file mode 100644 index 000000000..f0b8cf0d8 --- /dev/null +++ b/event-app/src/data/hooks/use-warm-images.ts @@ -0,0 +1,290 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Pre-fetch remote images so they're in the service worker's image cache before + * the network goes away. + * + * Why this is needed at all: the SW caches images with CacheFirst, but only ones + * the browser actually requested. `Avatar` renders with `loading="lazy"`, so an + * avatar never scrolled into view is never fetched and never cached — which left + * the speakers page fully available offline (its data is warmed app-wide by + * `CacheWarmer`) but showing a grid of blank circles. + * + * Deliberately a `fetch` rather than eager-loading the `` tags: warming via + * fetch never puts the images in the render tree. Dropping `loading="lazy"` + * would also populate the cache, but it would rasterize hundreds of images + * across a very tall page, which is the mechanism behind the iOS + * content-process crash the speakers page already had once. + * + * Incremental by construction: it asks the cache what it already holds and only + * fetches the difference. A restart with nothing changed does no work at all, + * and a handful of new avatars costs a handful of requests — so this never turns + * into a full re-download because the app was reopened. + */ + +/** + * Must match the `cacheName` on the image route in `src/sw.ts`. Kept as a + * literal on both sides rather than a shared import, because the service worker + * is bundled separately; if you rename one, rename the other. + */ +const IMAGE_CACHE = "static-images"; + +const CONCURRENCY = 6; +/** Images per batch; we yield to an idle callback between batches. */ +const CHUNK_SIZE = 60; + +/** + * One app-wide queue with a single runner, rather than per-effect work. + * + * The lifecycle version of this was subtly broken: the caller's URL list grows + * as each SWR dataset resolves (sessions, then speakers, then announcements), + * which re-ran the effect, and the cleanup aborted the warm already in flight. + * Worse, URLs were marked handled up front, so an aborted batch was never + * retried — a cold load warmed ~30 of 805 images and gave up. A queue absorbs + * late arrivals instead of restarting, and nothing is marked handled until it + * has actually been fetched. + */ +/** + * One queue per priority tier, drained lowest tier first. + * + * A single FIFO was wrong in practice: the ~645 avatars are the biggest group + * and the least individually important, and because the speakers dataset often + * resolves first they were being fetched ahead of the home screen's own images. + * Tiers mean a late-arriving highlight still jumps the avatar backlog. + */ +const queues: string[][] = []; +const queued = new Set(); +let running = false; +/** + * URLs whose fetch failed for connectivity reasons. Held here rather than + * dropped, and flushed back into the queue on the next `online` event — losing + * the network mid-warm otherwise meant those images stayed uncached until the + * next app launch, which is exactly when you can least afford it. + */ +const deferred: string[] = []; +let onlineHookAttached = false; +let totalQueued = 0; +let totalFetched = 0; +let totalSkipped = 0; +let totalFailed = 0; +let startedAt = 0; + +/** + * Metered or slow connections don't get a background download. `saveData` is an + * explicit user request to conserve; 2g means the warm would compete with + * whatever the user is actually trying to do. + */ +function connectionAllowsWarming(): boolean { + const connection = ( + navigator as Navigator & { + connection?: { saveData?: boolean; effectiveType?: string }; + } + ).connection; + if (!connection) return true; // Unsupported (Safari) — assume it's fine. + if (connection.saveData) return false; + return !/(^|-)2g$/.test(connection.effectiveType ?? ""); +} + +/** Run `task` when the browser is idle. Safari has no rIC, hence the fallback. */ +function onIdle(task: () => void): void { + const scope = window as Window & { + requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number; + }; + if (scope.requestIdleCallback) scope.requestIdleCallback(task, { timeout: 10_000 }); + else window.setTimeout(task, 2_000); +} + +/** Resolve on the next idle slot, so chunks yield the network between batches. */ +function nextIdle(): Promise { + return new Promise((resolve) => onIdle(() => resolve())); +} + +/** + * Which of `urls` the image cache doesn't already hold. + * + * One `keys()` call rather than a `match()` per URL: it turns ~1000 async cache + * lookups into a single call plus an in-memory set test. The cache is also the + * only source of truth worth consulting — a separate "already warmed" ledger + * would drift the moment an entry expired or was evicted, and would then + * silently stop re-warming it. + * + * Avatar and mirrored-image filenames are content hashes, so a changed image is + * a different URL: new ones show up as missing, unchanged ones never do. + */ +async function missingFromCache(urls: string[]): Promise { + try { + const cache = await caches.open(IMAGE_CACHE); + const cached = new Set((await cache.keys()).map((request) => request.url)); + return urls.filter((url) => !cached.has(url)); + } catch { + // No Cache Storage access — skip rather than fetch blindly. + return []; + } +} + +async function fetchChunk(chunk: string[]): Promise { + let cursor = 0; + const worker = async () => { + while (cursor < chunk.length) { + const url = chunk[cursor++]; + try { + // `mode: "cors"` on purpose: it must match how the tags request + // these (they carry crossOrigin="anonymous"). Cache.match keys on URL + // alone, so warming with no-cors would store an opaque response that a + // later CORS-mode request would find and then refuse to render. + const response = await fetch(url, { mode: "cors", credentials: "omit" }); + if (response.ok) { + totalFetched++; + } else { + // A real HTTP error (404 on a stale URL). Not worth retrying, and the + // SW won't cache it anyway — only [0, 200] are cacheable. + totalFailed++; + } + } catch { + // A thrown fetch means network, not HTTP: keep it for a retry once the + // connection is back. + deferred.push(url); + } + } + }; + await Promise.all(Array.from({ length: CONCURRENCY }, worker)); +} + +async function run(): Promise { + running = true; + startedAt = startedAt || Date.now(); + + while (pendingCount() > 0) { + // Always drain the most important non-empty tier, so anything queued later + // at a higher priority is picked up on the next chunk. + const tier = queues.find((t) => t && t.length > 0); + if (!tier) break; + // Re-check the cache per chunk rather than once up front, so images cached + // by the page itself while we work aren't fetched again. + const batch = tier.splice(0, CHUNK_SIZE); + const missing = await missingFromCache(batch); + totalSkipped += batch.length - missing.length; + if (missing.length > 0) await fetchChunk(missing); + + const remaining = pendingCount(); + if (remaining > 0) { + console.info( + `[warm-images] ${totalQueued - remaining}/${totalQueued}` + ); + await nextIdle(); + } + } + + const seconds = ((Date.now() - startedAt) / 1000).toFixed(1); + const parts = [`${totalFetched} fetched`]; + if (totalSkipped) parts.push(`${totalSkipped} already cached`); + if (totalFailed) parts.push(`${totalFailed} failed`); + if (deferred.length) parts.push(`${deferred.length} waiting for connection`); + console.info( + `[warm-images] ${deferred.length ? "paused" : "complete"}: ${totalQueued} image(s) — ${parts.join(", ")} in ${seconds}s` + ); + running = false; + startedAt = 0; + totalQueued = 0; + totalFetched = 0; + totalSkipped = 0; + totalFailed = 0; +} + +/** + * Flush deferred URLs back into the queue when the network returns. Attached + * once, lazily, and never removed: the warmer is app-wide and lives as long as + * the page. + */ +function armOnlineResume(): void { + if (onlineHookAttached) return; + onlineHookAttached = true; + window.addEventListener("online", () => { + if (deferred.length === 0) return; + const resumed = deferred.splice(0); + console.info(`[warm-images] back online, resuming ${resumed.length} image(s)`); + // Resumed work goes to the front: it was already deemed worth fetching. + (queues[0] ??= []).unshift(...resumed); + totalQueued += resumed.length; + if (!running) void run(); + }); +} + +function pendingCount(): number { + return queues.reduce((n, tier) => n + (tier?.length ?? 0), 0); +} + +function enqueue(tiers: string[][]): void { + let added = 0; + tiers.forEach((urls, priority) => { + for (const url of urls) { + if (queued.has(url)) continue; + queued.add(url); + (queues[priority] ??= []).push(url); + added++; + } + }); + if (added === 0) return; + totalQueued += added; + armOnlineResume(); + + // Offline right now: don't burn a pass failing every fetch, just wait for the + // `online` event. + if (!navigator.onLine) { + for (const tier of queues) if (tier) deferred.push(...tier.splice(0)); + console.info( + `[warm-images] offline, ${deferred.length} image(s) queued until connection returns` + ); + return; + } + + if (running) { + // A later dataset resolved mid-warm; it just extends the queue. + console.info(`[warm-images] +${added} queued (${totalQueued} total)`); + return; + } + console.info(`[warm-images] warming ${added} image(s)`); + void run(); +} + +/** + * Warm images in the background, in priority order. + * + * Pass an array of groups, most important first — the runner drains group 0 + * before group 1, and a group that fills in later still takes precedence over + * anything queued below it. Safe to call with empty or changing groups. + */ +export function useWarmImages(tiers: (string | null | undefined)[][]): void { + // Absolute http(s) URLs (API data) and root-relative paths (static assets in + // public/) are both warmable; data: URIs are already inline. Relative paths + // are resolved against the origin so the cache keys match what the + // requests produce. + const normalize = (url: string | null | undefined): string[] => { + if (!url) return []; + if (/^https?:\/\//i.test(url)) return [url]; + if (url.startsWith("/") && !url.startsWith("//")) { + return typeof window === "undefined" + ? [] + : [`${window.location.origin}${url}`]; + } + return []; + }; + const groups = tiers.map((group) => group.flatMap(normalize)); + const candidates = groups.flat(); + // A stable key so this only reacts when the *set* changes, not every render. + const key = `${candidates.length}|${candidates[0] ?? ""}|${candidates[candidates.length - 1] ?? ""}`; + + useEffect(() => { + if (candidates.length === 0) return; + // Without a controlling service worker nothing would cache the responses, + // so warming would be pure waste. Also covers dev, where the SW is off. + if (!navigator.serviceWorker?.controller) return; + if (!connectionAllowsWarming()) return; + // No cleanup/abort on purpose: the queue is app-wide and idle-chunked, and + // aborting on every dataset arrival is exactly what broke the cold warm. + enqueue(groups); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); +} diff --git a/event-app/src/hooks/useRetryOnReconnect.ts b/event-app/src/hooks/useRetryOnReconnect.ts new file mode 100644 index 000000000..1c90abd6e --- /dev/null +++ b/event-app/src/hooks/useRetryOnReconnect.ts @@ -0,0 +1,52 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +/** + * Retry a failed image (or any resource) once the connection is back. + * + * An `` that fails while offline stays broken for the life of the page: + * nothing re-requests it, so reconnecting doesn't repair the view. This gives a + * component a `failed` flag to render a fallback from, plus an `attempt` counter + * to use as a `key` so a reconnect forces a fresh load of the *same* URL. + * + * Deliberately not a cache-busting query param: that would change the URL, miss + * the service worker's cached copy, and pile up duplicate cache entries. + * + * One shared `online` listener for the whole app, and only failed components + * subscribe — with ~700 avatars on screen, a listener each would be wasteful. + */ + +const subscribers = new Set<() => void>(); +let listening = false; + +function ensureListener(): void { + if (listening || typeof window === "undefined") return; + listening = true; + window.addEventListener("online", () => { + // Copy first: a callback may unsubscribe while we iterate. + for (const notify of [...subscribers]) notify(); + }); +} + +export function useRetryOnReconnect() { + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + + const markFailed = useCallback(() => setFailed(true), []); + + useEffect(() => { + if (!failed) return; + ensureListener(); + const retry = () => { + setFailed(false); + setAttempt((n) => n + 1); + }; + subscribers.add(retry); + return () => { + subscribers.delete(retry); + }; + }, [failed]); + + return { failed, attempt, markFailed }; +} diff --git a/event-app/src/routing/index.tsx b/event-app/src/routing/index.tsx index ab84a66e3..611040c70 100644 --- a/event-app/src/routing/index.tsx +++ b/event-app/src/routing/index.tsx @@ -62,10 +62,23 @@ export function Link({ href, children, className, ...nextLinkProps }: LinkProps const finalHref = mounted ? withCarriedParams(href) : href; if (nativeNav) { + // Spread the rest props so accessibility attributes (aria-label on + // icon-only links, etc.) survive the native branch; role=button also + // activates on Space, not just Enter. NextLink-only props that aren't + // valid DOM attributes are pulled out first. + const { prefetch, replace, scroll, shallow, ...rest } = + nextLinkProps as Record; + void prefetch, replace, scroll, shallow; return (
nativeNav.navigate(withCarriedParams(href))} - onKeyDown={(e) => e.key === "Enter" && nativeNav.navigate(withCarriedParams(href))} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + nativeNav.navigate(withCarriedParams(href)); + } + }} role="button" tabIndex={0} className={className} diff --git a/event-app/src/sw.ts b/event-app/src/sw.ts index 0f7a1235d..9ba11e83c 100644 --- a/event-app/src/sw.ts +++ b/event-app/src/sw.ts @@ -121,26 +121,42 @@ const serwist = new Serwist({ }), }, { - // Match by request destination, not URL extension: speaker avatars and - // other images come from cross-origin CDNs and often have no file - // extension (or carry query strings, or go through /_next/image), so an - // extension-only matcher missed them and they never cached for offline. - // `destination === "image"` covers every / next/image request - // regardless of URL shape or origin. The extension test is a fallback for - // images referenced where the destination isn't reported (e.g. some CSS - // background-image fetches). + // Match by request destination, not URL extension: speaker avatars, + // highlight and featured images come from Supabase Storage and often have + // no file extension (or carry query strings, or go through /_next/image), + // so an extension-only matcher missed them and they never cached for + // offline. `destination === "image"` covers every / next/image + // request regardless of URL shape or origin. The extension test is a + // fallback for images referenced where the destination isn't reported + // (e.g. some CSS background-image fetches). matcher: ({ request, url }) => request.destination === "image" || /\.(?:png|jpg|jpeg|svg|gif|webp|avif|ico)$/i.test(url.pathname), handler: new CacheFirst({ + // Name is also referenced by src/data/hooks/use-warm-images.ts, + // which reads this cache to warm only what's missing. Rename both. cacheName: "static-images", plugins: [ + // REQUIRED for cross-origin images, and the reason they silently + // never cached before. Serwist only skips its status-200-only filter + // when some plugin implements `cacheWillUpdate`; ExpirationPlugin + // doesn't (it hooks cachedResponseWillBeUsed / cacheDidUpdate), so + // the default applied and every opaque response (status 0) was + // dropped. A no-cors to another origin is always opaque, so + // that was every avatar and every highlight image. Allowing status 0 + // keeps them cacheable even when a request isn't made with CORS. + new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ - maxEntries: 400, + // Enough for the full speaker roster (~750 avatars) plus + // highlights, venue art and local assets. Avatars are small webp + // thumbnails, so this is tens of MB, not hundreds. + maxEntries: 1200, maxAgeSeconds: 30 * 24 * 60 * 60, - // Cross-origin images are opaque responses, which count heavily - // toward the storage quota — drop the cache rather than error out - // if we ever hit the limit. + // Opaque responses are quota-padded well beyond their real size, so + // a cache full of them can hit the limit unexpectedly. Our images + // request with CORS (see `crossOrigin` on the tags) to avoid + // that; this stays as the last resort if some other origin's + // images ever fill it. purgeOnQuotaError: true, }), ], diff --git a/package.json b/package.json index 8f3d506ad..95ad9bd62 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ ] }, "devDependencies": { + "playwright-core": "^1.62.1", "typescript": "^5.9.3" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d5f3513a..a1ccfb921 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: specifier: 2.0.0-alpha.70 version: 2.0.0-alpha.70(@farcaster/miniapp-sdk@0.1.8(encoding@0.1.13)(typescript@5.9.3)(zod@4.4.3))(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) devDependencies: + playwright-core: + specifier: ^1.62.1 + version: 1.62.1 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -6571,7 +6574,7 @@ packages: '@paulmillr/qr@0.2.1': resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} - deprecated: 'The package is now available as "qr": npm install qr' + deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr' '@pcd/gpc@0.4.1': resolution: {integrity: sha512-ltdoAe3+umiKoqVoiLL2lAvXD9svbVXkCczv9qDg6qd9M9GUzzSKDImeBfDUZHhotekg8g5acOCDbREBrqZg6A==} @@ -13234,10 +13237,12 @@ packages: conventional-changelog-atom@2.0.8: resolution: {integrity: sha512-xo6v46icsFTK3bb7dY/8m2qvc8sZemRgdqLb/bjpBsH2UyOS8rKNTgcb5025Hri6IpANPApbXMg15QLb1LJpBw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-codemirror@2.0.8: resolution: {integrity: sha512-z5DAsn3uj1Vfp7po3gpt2Boc+Bdwmw2++ZHa5Ak9k0UKsYAO5mH1UBTN0qSCuJZREIhX6WU4E1p3IW2oRCNzQw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-conventionalcommits@4.6.3: resolution: {integrity: sha512-LTTQV4fwOM4oLPad317V/QNQ1FY4Hju5qeBIM1uTHbrnCE+Eg4CdRZ3gO2pUeR+tzWdp80M2j3qFFEDWVqOV4g==} @@ -13246,26 +13251,32 @@ packages: conventional-changelog-core@4.2.4: resolution: {integrity: sha512-gDVS+zVJHE2v4SLc6B0sLsPiloR0ygU7HaDW14aNJE1v4SlqJPILPl/aJC7YdtRE4CybBf8gDwObBvKha8Xlyg==} engines: {node: '>=10'} + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-ember@2.0.9: resolution: {integrity: sha512-ulzIReoZEvZCBDhcNYfDIsLTHzYHc7awh+eI44ZtV5cx6LVxLlVtEmcO+2/kGIHGtw+qVabJYjdI5cJOQgXh1A==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-eslint@3.0.9: resolution: {integrity: sha512-6NpUCMgU8qmWmyAMSZO5NrRd7rTgErjrm4VASam2u5jrZS0n38V7Y9CzTtLT2qwz5xEChDR4BduoWIr8TfwvXA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-express@2.0.6: resolution: {integrity: sha512-SDez2f3iVJw6V563O3pRtNwXtQaSmEfTCaTBPCqn0oG0mfkq0rX4hHBq5P7De2MncoRixrALj3u3oQsNK+Q0pQ==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jquery@3.0.11: resolution: {integrity: sha512-x8AWz5/Td55F7+o/9LQ6cQIPwrCjfJQ5Zmfqi8thwUEKHstEn4kTIofXub7plf1xvFA2TqhZlq7fy5OmV6BOMw==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-jshint@2.0.9: resolution: {integrity: sha512-wMLdaIzq6TNnMHMy31hql02OEQ8nCQfExw1SE0hYL5KvU+JCTuPaDO+7JiogGT2gJAxiUGATdtYYfh+nT+6riA==} engines: {node: '>=10'} + deprecated: This preset is deprecated. Please use conventional-changelog-conventionalcommits or conventional-changelog-angular instead. conventional-changelog-preset-loader@2.3.4: resolution: {integrity: sha512-GEKRWkrSAZeTq5+YjUZOYxdHq+ci4dNwHvpaBC3+ENalzFWuCWa9EZXSuZBpkr72sMdKB+1fyDV4takK1Lf58g==} @@ -13459,6 +13470,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} @@ -15521,7 +15533,7 @@ packages: git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -15531,7 +15543,7 @@ packages: git-semver-tags@4.1.1: resolution: {integrity: sha512-OWyMt5zBe7xFs8vglMmhM9lRQzCWL3WjHtxNNfJTMngGym7pC1kh8sP6jevfydJ6LP3ZvGxfb6ABYgPUM0mtsA==} engines: {node: '>=10'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true gitconfiglocal@1.0.0: @@ -17205,6 +17217,7 @@ packages: jsrsasign@11.1.0: resolution: {integrity: sha512-Ov74K9GihaK9/9WncTe1mPmvrO7Py665TUfUKvraXBpu+xcTWitrtuOwcjf4KMU9maPaYn0OuaWy0HOzy/GBXg==} + deprecated: This package is no longer maintained. jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -19885,6 +19898,11 @@ packages: platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + plimit-lit@1.6.1: resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} engines: {node: '>=12'} @@ -28954,7 +28972,7 @@ snapshots: libphonenumber-js: 1.12.10 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-components: 6.4.2(css-to-react-native@3.2.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.80.2(@babel/core@7.28.4)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3) + styled-components: 6.5.3(css-to-react-native@3.2.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.80.2(@babel/core@7.28.4)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3) ua-parser-js: 2.0.4(encoding@0.1.13) transitivePeerDependencies: - '@farcaster/miniapp-sdk' @@ -31282,7 +31300,7 @@ snapshots: '@npmcli/fs@5.0.0': dependencies: - semver: 7.8.1 + semver: 7.8.5 '@npmcli/git@7.0.1': dependencies: @@ -32830,7 +32848,7 @@ snapshots: debug: 4.4.3 invariant: 2.2.4 metro: 0.82.5(bufferutil@4.0.9)(utf-8-validate@5.0.10) - metro-config: 0.82.5(bufferutil@4.0.9)(utf-8-validate@5.0.10) + metro-config: 0.82.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) metro-core: 0.82.5 semver: 7.8.5 transitivePeerDependencies: @@ -51018,7 +51036,7 @@ snapshots: eslint: 8.8.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 2.7.1(eslint-plugin-import@2.32.0(eslint@8.8.0))(eslint@8.8.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.8.0)(typescript@5.8.3))(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.32.0(eslint@8.8.0))(eslint@8.8.0))(eslint@8.8.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.8.0)(typescript@5.8.3))(eslint-import-resolver-typescript@2.7.1)(eslint@8.8.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.8.0) eslint-plugin-react: 7.37.5(eslint@8.8.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.8.0) @@ -51075,7 +51093,7 @@ snapshots: eslint: 8.8.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@8.8.0))(eslint@8.8.0) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.8.0)(typescript@5.2.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.8.0))(eslint@8.8.0))(eslint@8.8.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.8.0)(typescript@5.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.8.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.8.0) eslint-plugin-react: 7.37.5(eslint@8.8.0) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.8.0) @@ -51162,7 +51180,7 @@ snapshots: dependencies: debug: 4.4.1(supports-color@5.5.0) eslint: 8.8.0 - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.8.0)(typescript@5.8.3))(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.32.0(eslint@8.8.0))(eslint@8.8.0))(eslint@8.8.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.8.0)(typescript@5.8.3))(eslint-import-resolver-typescript@2.7.1)(eslint@8.8.0) glob: 7.2.3 is-glob: 4.0.3 resolve: 1.22.10 @@ -51362,7 +51380,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.8.0)(typescript@5.8.3))(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.32.0(eslint@8.8.0))(eslint@8.8.0))(eslint@8.8.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.8.0)(typescript@5.8.3))(eslint-import-resolver-typescript@2.7.1)(eslint@8.8.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -51420,7 +51438,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.8.0)(typescript@5.2.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@8.8.0))(eslint@8.8.0))(eslint@8.8.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0(eslint@8.8.0)(typescript@5.2.2))(eslint-import-resolver-typescript@3.10.1)(eslint@8.8.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -56331,21 +56349,6 @@ snapshots: transitivePeerDependencies: - supports-color - metro-config@0.82.5(bufferutil@4.0.9)(utf-8-validate@5.0.10): - dependencies: - connect: 3.7.0 - cosmiconfig: 5.2.1 - flow-enums-runtime: 0.0.6 - jest-validate: 29.7.0 - metro: 0.82.5(bufferutil@4.0.9)(utf-8-validate@5.0.10) - metro-cache: 0.82.5 - metro-core: 0.82.5 - metro-runtime: 0.82.5 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - metro-config@0.82.5(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: connect: 3.7.0 @@ -56360,7 +56363,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true metro-core@0.82.5: dependencies: @@ -56472,7 +56474,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true metro@0.82.5(bufferutil@4.0.9)(utf-8-validate@5.0.10): dependencies: @@ -56500,7 +56501,7 @@ snapshots: metro-babel-transformer: 0.82.5 metro-cache: 0.82.5 metro-cache-key: 0.82.5 - metro-config: 0.82.5(bufferutil@4.0.9)(utf-8-validate@5.0.10) + metro-config: 0.82.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) metro-core: 0.82.5 metro-file-map: 0.82.5 metro-resolver: 0.82.5 @@ -56567,7 +56568,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true micro-ftch@0.3.1: {} @@ -57855,7 +57855,7 @@ snapshots: make-fetch-happen: 15.0.3 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.8.1 + semver: 7.8.5 tar: 7.5.2 tinyglobby: 0.2.15 which: 6.0.0 @@ -57953,7 +57953,7 @@ snapshots: npm-install-checks@8.0.0: dependencies: - semver: 7.8.1 + semver: 7.8.5 npm-normalize-package-bin@5.0.0: {} @@ -59133,6 +59133,8 @@ snapshots: platform@1.3.6: {} + playwright-core@1.62.1: {} + plimit-lit@1.6.1: dependencies: queue-lit: 1.5.2 @@ -62392,6 +62394,17 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.80.2(@babel/core@7.28.4)(@types/react@19.1.8)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@5.0.10) + styled-components@6.5.3(css-to-react-native@3.2.0)(react-dom@19.2.3(react@19.2.3))(react-native@0.80.2(@babel/core@7.28.4)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10))(react@19.2.3): + dependencies: + '@emotion/is-prop-valid': 1.4.0 + csstype: 3.2.3 + react: 19.2.3 + stylis: 4.3.6 + optionalDependencies: + css-to-react-native: 3.2.0 + react-dom: 19.2.3(react@19.2.3) + react-native: 0.80.2(@babel/core@7.28.4)(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.3)(utf-8-validate@5.0.10) + styled-jsx@5.1.1(react@19.2.3): dependencies: client-only: 0.0.1 diff --git a/scripts/shot.mjs b/scripts/shot.mjs new file mode 100644 index 000000000..e37ca9ccd --- /dev/null +++ b/scripts/shot.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// Screenshot harness for visual verification of local dev servers. +// Standard tool for agent-driven UI checks (see /.claude/skills/verify). +// +// Usage: +// node scripts/shot.mjs --port [options] +// +// Route to capture, e.g. / or /schedule (or a full http:// URL) +// --port REQUIRED. Dev server port. Deliberately has no default: +// devcon and event-app both default to 3000 and whichever +// started second lands on 3001 — confirm which server owns +// the port before shooting. +// --widths Comma-separated viewport widths (default: 390,768,1440). +// Widths < 768 are captured with mobile emulation +// (isMobile + hasTouch) so (hover: none)/(pointer: coarse) +// media queries match, same as a real phone. +// --out Output directory (default: .screenshots, relative to cwd) +// --full-page Capture the full scrollable page instead of the viewport +// --selector Capture just the first element matching this selector +// --wait Extra settle time after load (default: 500) +// --mock-now Appended as ?mockNow= (event-app/devcon time mocking) +// +// Output files: /-[-full|-el].png (route slashes become dashes) + +import os from "node:os"; +import fs from "node:fs"; +import path from "node:path"; +import { chromium } from "playwright-core"; + +function fail(msg) { + console.error(`shot.mjs: ${msg}`); + process.exit(1); +} + +// --- arg parsing ----------------------------------------------------------- +const argv = process.argv.slice(2); +const opts = { widths: [390, 768, 1440], out: ".screenshots", wait: 500 }; +let route; +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--port") opts.port = Number(argv[++i]); + else if (a === "--widths") opts.widths = argv[++i].split(",").map(Number); + else if (a === "--out") opts.out = argv[++i]; + else if (a === "--full-page") opts.fullPage = true; + else if (a === "--selector") opts.selector = argv[++i]; + else if (a === "--wait") opts.wait = Number(argv[++i]); + else if (a === "--mock-now") opts.mockNow = argv[++i]; + else if (a.startsWith("--")) fail(`unknown flag ${a}`); + else route = a; +} +if (!route) fail("missing argument (e.g. / or /schedule)"); +if (!route.startsWith("http") && !opts.port) { + fail("--port is required. Check which dev server owns the port first (devcon vs event-app both default to 3000)."); +} +if (opts.widths.some(Number.isNaN)) fail("--widths must be comma-separated numbers"); + +let url = route.startsWith("http") ? route : `http://localhost:${opts.port}${route.startsWith("/") ? route : `/${route}`}`; +if (opts.mockNow) url += `${url.includes("?") ? "&" : "?"}mockNow=${encodeURIComponent(opts.mockNow)}`; + +// --- locate cached headless chromium --------------------------------------- +const cacheDir = path.join(os.homedir(), "Library/Caches/ms-playwright"); +const shells = fs.existsSync(cacheDir) + ? fs.readdirSync(cacheDir).filter((d) => d.startsWith("chromium_headless_shell-")).sort() + : []; +if (!shells.length) fail(`no chromium_headless_shell-* found in ${cacheDir} — run: npx playwright install chromium --only-shell`); +const shellDir = path.join(cacheDir, shells[shells.length - 1]); +const exe = fs + .readdirSync(shellDir, { recursive: true }) + .map(String) + .find((f) => f.endsWith("chrome-headless-shell") || f.endsWith("headless_shell.exe")); +if (!exe) fail(`no headless shell binary inside ${shellDir}`); +const executablePath = path.join(shellDir, exe); + +// --- capture ---------------------------------------------------------------- +fs.mkdirSync(opts.out, { recursive: true }); +const slug = (route.startsWith("http") ? new URL(route).pathname : route.split("?")[0]) + .replace(/^\/+|\/+$/g, "") + .replace(/\//g, "-") || "home"; + +const browser = await chromium.launch({ executablePath }); +try { + for (const width of opts.widths) { + const mobile = width < 768; + const context = await browser.newContext({ + viewport: { width, height: mobile ? Math.round(width * 2.16) : 900 }, + isMobile: mobile, + hasTouch: mobile, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + const resp = await page.goto(url, { waitUntil: "networkidle", timeout: 60000 }).catch((e) => { + fail(`navigation to ${url} failed (${e.message}) — is the dev server running on port ${opts.port}?`); + }); + if (resp && resp.status() >= 400) fail(`${url} returned HTTP ${resp.status()} at ${width}px`); + await page.waitForTimeout(opts.wait); + + const suffix = opts.selector ? "-el" : opts.fullPage ? "-full" : ""; + const file = path.join(opts.out, `${slug}-${width}${suffix}.png`); + if (opts.selector) { + const el = page.locator(opts.selector).first(); + await el.scrollIntoViewIfNeeded(); + await el.screenshot({ path: file }); + } else { + await page.screenshot({ path: file, fullPage: !!opts.fullPage }); + } + console.log(`${file} (${width}px${mobile ? ", mobile emulation" : ""})`); + await context.close(); + } +} finally { + await browser.close(); +}