Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4ff3dd5
Add screenshot verification harness and agent workflow skills
Scott1UP Aug 25, 2026
021f079
event-app: add Chloe font, Devanagari Poppins subset, and home redesi…
Scott1UP Aug 25, 2026
1ea14d9
event-app: redesign home page per Figma (greeting, featured, tickets,…
Scott1UP Aug 25, 2026
13232d0
event-app: redesign announcements per Figma, migrate to dc-* tokens
Scott1UP Aug 25, 2026
27a98de
event-app: home/announcements polish round 2 (Scott's feedback)
Scott1UP Aug 25, 2026
5e07c60
event-app: home polish round 3
Scott1UP Aug 25, 2026
eed49d4
event-app: soften interactive tag hover to white/30
Scott1UP Aug 25, 2026
030edb3
event-app: nudge desktop tickets-banner art lower (object-y 85%)
Scott1UP Aug 25, 2026
c3b0317
event-app: purple hover outline on linked highlight cards
Scott1UP Aug 25, 2026
9304758
event-app: tickets banner hovers with an image pan-in instead of card…
Scott1UP Aug 25, 2026
0fc96ca
event-app: inbox cards keep border+underline hover only; park footer art
Scott1UP Aug 25, 2026
150353a
Polish & Refinement pass
Scott1UP Aug 25, 2026
9ee418f
event-app: fix review findings — offline/auth correctness, links, fades
Scott1UP Aug 25, 2026
b702737
event-app: slim fonts, strip parked footer-art assets
Scott1UP Aug 25, 2026
9a9fa2c
event-app: a11y fixes from review
Scott1UP Aug 25, 2026
f824505
event-app: unify home section titles with speakers/schedule 20px style
Scott1UP Aug 25, 2026
0a21664
event-app: update type-checking instructions and refactor Announcemen…
didierkrux Aug 25, 2026
f3f6536
Merge branch 'main' into redesign-home-announcements
didierkrux Aug 25, 2026
54affe0
Remove Mumbai travel guide image and refactor FeaturedCard component …
didierkrux Aug 25, 2026
82477fb
Enhance image caching and offline support in the event app
didierkrux Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .claude/skills/figma-impl/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <route> --port <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.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 16 additions & 16 deletions devcon/.claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 "<title>[^<]*</title>"` (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 `<file>-module-scss-module__<hash>__<local-name>` — 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 `<file>-module-scss-module__<hash>__<local-name>` — the local name is a **suffix**, so select with `[class$="__track-card"]` / `[class*="track-card-inner"]`, not `[class*="track-card__"]`.

## Gotchas

Expand Down
55 changes: 55 additions & 0 deletions event-app/.claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 "<title>[^<]*</title>"
# 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).
43 changes: 43 additions & 0 deletions event-app/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,49 @@ 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 `<img>`.** 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. **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`).

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`.
Expand Down
Binary file added event-app/public/home/tickets-banner.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
84 changes: 44 additions & 40 deletions event-app/src/app/(page-layout)/announcements/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,51 +62,55 @@ export default function AnnouncementsPage() {
}, [announcements, nowMs]);

return (
<main className="py-6">
{/* Mobile title comes from AppHeader (routeChrome); page h1 is desktop-only. */}
<h1 className="mb-4 hidden text-2xl font-bold text-gray-900 lg:block">
Announcements
</h1>
// Escape the 680px `.section` column to the 1312px desktop content box
// (same pattern as Ticket.tsx / Schedule).
<main className="expand py-6">
<div className="px-4 lg:mx-auto lg:w-full lg:max-w-[1312px] lg:px-8 xl:px-0">
{/* Mobile title comes from AppHeader (routeChrome); page h1 is desktop-only. */}
<h1 className="mb-4 hidden font-heading text-2xl font-extrabold tracking-[-0.5px] text-dc-fg2 lg:block">
Announcements
</h1>

<PushOptIn />
<PushOptIn />

{isLoading && (
<p className="text-sm text-gray-500">Loading announcements…</p>
)}
{isLoading && (
<p className="text-sm text-dc-muted">Loading announcements…</p>
)}

{!isLoading && error && announcements.length === 0 && (
<p className="text-sm text-gray-500">
Couldn&apos;t load announcements. Check your connection and try
again.
</p>
)}

{!isLoading && !error && announcements.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-[#E1E4EA] py-12 text-center">
<Megaphone className="h-6 w-6 text-gray-300" />
<p className="text-sm text-gray-500">
Nothing yet — announcements from the team will show up here.
{!isLoading && error && announcements.length === 0 && (
<p className="text-sm text-dc-muted">
Couldn&apos;t load announcements. Check your connection and try
again.
</p>
</div>
)}
)}

<div className="flex flex-col gap-6">
{groups.map(([label, items]) => (
<section key={label}>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">
{label}
</h2>
<div className="flex flex-col gap-3">
{items.map((a) => (
<AnnouncementCard
key={a.id}
announcement={a}
seen={seenAtEntry.current?.has(a.id) ?? true}
/>
))}
</div>
</section>
))}
{!isLoading && !error && announcements.length === 0 && (
<div className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-dc-border py-12 text-center">
<Megaphone className="h-6 w-6 text-dc-muted/50" />
<p className="text-sm text-dc-muted">
Nothing yet — announcements from the team will show up here.
</p>
</div>
)}

<div className="flex flex-col gap-8">
{groups.map(([label, items]) => (
<section key={label}>
<h2 className="mb-3 font-heading text-xs font-bold uppercase leading-[18px] tracking-[0.5px] text-dc-muted">
{label}
</h2>
<div className="flex flex-col gap-3">
{items.map((a) => (
<AnnouncementCard
key={a.id}
announcement={a}
seen={seenAtEntry.current?.has(a.id) ?? true}
/>
))}
</div>
</section>
))}
</div>
</div>
</main>
);
Expand Down
6 changes: 3 additions & 3 deletions event-app/src/app/(page-layout)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Menu } from "@/components/Menu";
import { Home } from "@/components/home/Home";

export default function Home() {
return <Menu />;
export default function HomePage() {
return <Home />;
}
11 changes: 10 additions & 1 deletion event-app/src/app/api/announcements/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface NotionRow {
sortOrder: number;
push: boolean;
visible: boolean;
featured: boolean;
}

let supabase: SupabaseClient | null = null;
Expand Down Expand Up @@ -237,6 +238,7 @@ async function fetchNotionRows(): Promise<NotionRow[]> {
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;
Expand Down Expand Up @@ -269,6 +271,11 @@ export async function syncAnnouncements(): Promise<number> {
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.
Expand All @@ -286,6 +293,7 @@ export async function syncAnnouncements(): Promise<number> {
sort_order: row.sortOrder,
push: row.push,
visible: row.visible,
featured,
status: locked ? status : armed ? "scheduled" : "draft",
updated_at: now,
};
Expand Down Expand Up @@ -326,7 +334,7 @@ export async function getAnnouncements(
): Promise<Announcement[]> {
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);
Expand All @@ -346,5 +354,6 @@ export async function getAnnouncements(
image: r.image,
sendAt: r.send_at,
sortOrder: r.sort_order,
featured: r.featured ?? false,
}));
}
7 changes: 4 additions & 3 deletions event-app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading
Loading