Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ Recurring details — email, phone, résumé path, social URLs, role, location

**Update categories** are the keys of `categoryTones` in `UpdateCard.tsx`; `UpdateCategory` is derived from them, so a typo in `updateEntries.ts` is a type error and the Updates filter row is generated from the same list.

**Update photos** (`UpdateCard.tsx`) follow one rule, driven by **container** queries on the card rather than viewport ones — so a card in a narrow column behaves like a phone even on a wide screen.

**Above a 48rem card the photo floats left and the text wraps around it**, continuing underneath once it outruns the photo. Floats are used deliberately over a flex row: they're self-adjusting, so a short entry sits alongside the photo and a long one wraps under, with no threshold to tune. Three consequences to know:

- The body must not be a flex container at that width (`@min-[48rem]:block`), or it becomes its own formatting context and refuses to wrap around the float.
- The wrapper needs `flow-root` so the card encloses the float. It has to sit on the inner wrapper, not the `@container` article — an element can't respond to a container query it declares itself, and `@min-[48rem]:flex-row` on the article silently never matched.
- No vertical divider between photo and text; a full-height rule would cut through wrapped text.

`imageFit` on the entry picks the size cap, not the layout. `cover` (default) caps **width** at 400px inside a 16:9 frame — capping a 16:9 photo's height at 400px would make it 711px wide and swallow the card. `tall` is for portraits and caps **height** at 400px. Below 48rem both go full-bleed and stacked, and `tall` additionally drops its cap below 28rem so a phone gets the whole photo at full card width — uncropped on purpose, since a center crop into a landscape frame cuts the tops of heads off a 3:4 photo. Updates is `max-w-5xl` specifically so its 840px card clears the 48rem float stage.

The body text carries no measure cap. `max-w-prose` (65ch) used to be there and left 154px dead on the right of every landscape card once Updates widened; the paragraph now settles around 780px on its own.

**ProjectCard**: Accepts an optional `deprecated` boolean that renders a "Deprecated" badge. Its exported `Props` type is what `Projects.tsx` types the `projects` array with, so the two can't drift. Both the card and the featured hero honor an optional `imageDark`: the light image gets `dark:hidden` and the dark one `not-dark:hidden`, only when a dark variant exists — otherwise the light image would vanish in dark mode. Both render, so the theme toggle is instant.

**Theme system**: Dark/light mode uses a CSS custom property design token system defined in `src/styles/styles.css` (HSL channel triplets like `--background`, `--foreground`, `--accent`, so every token composes with `/ alpha`). The `dark` class on the `<html>` element switches palettes. Theme is persisted to `localStorage` under the `theme` key and applied by an inline script in `index.html` before first paint to prevent a flash of the wrong theme.
Expand All @@ -53,7 +65,7 @@ Note `@source not "../../**/*.md"` at the top of `styles.css`: Tailwind v4 auto-
**Résumé PDF** (`public/zm-resume.pdf`): generated, not hand-made. `src/pages/Resume/ResumePrint.tsx` is a Letter-sized print sheet that `scripts/generate-resume-pdf.ts` renders in headless Chromium via `page.pdf()`. Résumé content lives in `src/pages/Resume/resumeData.ts` + `jobEntries.ts`, so the page and the PDF can't disagree.

- The print route is registered **only when `import.meta.env.DEV`**, so it's absent from production builds (the module tree-shakes out). That's why the generator renders against the dev server rather than a preview of `dist`.
- The sheet clips overflow so nothing can spill onto page two, which means an overflow would silently *lose* content. The generator measures both axes and fails with the exact pixel overrun instead. It also prints each column's fill percentage: the main column is 85% full with the current role's bullets empty, and every two-line bullet costs ~37px of the 154px that leaves, so a populated current role runs it to 96–100%. Treat the printed overrun as the limit rather than a percentage target, and buy room for a new bullet by trimming an older entry — Canopy and Rescue River carry five each.
- The sheet clips overflow so nothing can spill onto page two, which means an overflow would silently *lose* content. The generator measures both axes and fails with the exact pixel overrun instead. It also prints each column's fill percentage: the main column is 85% full, with the current role's bullets empty, and every two-line bullet costs ~37px of the 154px that leaves, so a populated current role runs it to 96–100%. Treat the printed overrun as the limit rather than a percentage target, and buy room for a new bullet by trimming an older entry — Canopy and Rescue River carry five each.
- Only current roles appear; `olderExperience` entries are excluded by design. `additionalSkills` in `resumeData.ts` works the same way for skills — the four `skillGroups` are all the sidebar has room for, so anything beyond them lives there and surfaces only in the `/resume` "More Skills" disclosure.
- After editing résumé data, run `bun run resume:pdf` and commit both the PDF and `scripts/zm-resume.hash`. `tests/resume-pdf.test.ts` compares that hash against the current sources, so CI fails if you forget. The hash covers typography too — `styles.css` and `index.html` — since a font change alters the render without touching any résumé text.
- `tests/resume-pdf.test.ts` guards the properties that matter: one Letter page, real font resources, and that the file hasn't regressed to an image-only export (the version this replaced was a single raster with zero extractable text, invisible to ATS parsers).
Expand All @@ -64,4 +76,4 @@ Tests live in `tests/`, owned by `tsconfig.node.json` rather than the app projec

**Components vs Pages**: Shared primitives live in `src/components/ui/` — `Reveal` (scroll-reveal wrapper; put grid layout classes like `h-full` on it, since it becomes the grid child), `SectionHeading` (eyebrow + `h2` + description) which also exports `Eyebrow` for page heads that render their own `h1`, `Tag` (pill; also exports `toneText(tone)` for text-only tone colors), `Button` (a `Link` for `to`, an anchor for `href` — exactly one is required), `Timeline` (`TimelineRail` + `TimelineNode`, shared by the Resume experience list and the Updates feed), and `Clause` (+ `Item`, shared by the Terms and Privacy pages). Reuse these rather than re-implementing them. Other reusable display components live in `src/components/`; route-specific page components live in `src/pages/` and own local UI state (e.g. mobile menu open/closed, category filter selection).

**Conventions**: section eyebrows are numbered (`01 — About`) while page-head eyebrows are not; content page titles are `text-4xl md:text-6xl` (the Home hero and the 404 are deliberately larger); major sections use `py-20 md:py-28` and page heads `pt-14 md:pt-20`; Home and Projects are `max-w-7xl`, Resume/Updates/Terms/Privacy/404 are `max-w-4xl`; `Reveal` staggers step by 80ms; `Button` icons are `size={16}`; decorative layers carry `aria-hidden="true"`, plus `pointer-events-none` whenever they overlay content.
**Conventions**: section eyebrows are numbered (`01 — About`) while page-head eyebrows are not; content page titles are `text-4xl md:text-6xl` (the Home hero and the 404 are deliberately larger); major sections use `py-20 md:py-28` and page heads `pt-14 md:pt-20`; Home and Projects are `max-w-7xl`, Updates is `max-w-5xl`, Resume/Terms/Privacy/404 are `max-w-4xl`; `Reveal` staggers step by 80ms; `Button` icons are `size={16}`; decorative layers carry `aria-hidden="true"`, plus `pointer-events-none` whenever they overlay content.
78 changes: 49 additions & 29 deletions src/components/UpdateCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@ export interface UpdateEntry {
category: UpdateCategory[];
image?: string;
/**
* How the photo fills its frame. `cover` (default) bleeds edge to edge in a 16:9 frame, which
* suits landscape shots. Use `contain` for portrait or otherwise tall photos — it shows the
* whole image, centered, rather than cropping the subject out of a wide frame.
* Which dimension the photo is capped on, not the layout: every photo floats left with the text
* wrapping around it above a 48rem card, and is full-bleed and stacked below that.
*
* - `cover` (default), landscape: a 16:9 frame capped at 400px wide. Capping height instead would
* make a 16:9 photo 711px wide and swallow the card.
* - `tall`, portraits: capped at 400px tall, so a 3:4 photo lands at 300px wide. Below a 28rem
* card it drops the cap too and fills the full width uncropped — a center crop into a landscape
* frame takes the tops of heads off a 3:4 photo.
*
* Container queries throughout, and they read the content box: with `.panel`'s 1px border, every
* threshold lands ~2px later than the card's outer width implies.
*/
imageFit?: 'cover' | 'contain';
imageFit?: 'cover' | 'tall';
}

/** An entry plus the per-placement presentation choices the pages make. */
Expand Down Expand Up @@ -60,8 +68,10 @@ export default function UpdateCard({
year: 'numeric',
});

/* `@min-[48rem]:block`: a flex container is its own formatting context, so its lines would refuse
to wrap around the float. Harmless elsewhere — the children are blocks either way. */
const body = (
<div className="flex min-w-0 flex-1 flex-col p-6 md:p-7">
<div className="flex min-w-0 flex-1 flex-col p-6 md:p-7 @min-[48rem]:block">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex flex-wrap gap-2">
{category.map((cat) => (
Expand All @@ -79,8 +89,10 @@ export default function UpdateCard({
<h3 className="mt-4 text-xl font-semibold text-card-foreground text-balance md:text-2xl">
{title}
</h3>
{/* No measure cap: `max-w-prose` (65ch) left 154px dead on the right once the feed card went
to 840px. */}
<p
className={`mt-3 max-w-prose text-muted-foreground leading-relaxed whitespace-pre-line ${
className={`mt-3 text-muted-foreground leading-relaxed whitespace-pre-line ${
clamp ? 'line-clamp-3' : ''
}`}
>
Expand All @@ -89,42 +101,50 @@ export default function UpdateCard({
</div>
);

/* Contained photos keep their own proportions, so nothing gets cropped and the hover zoom
which would crop — is left off. Cover photos bleed edge to edge in a 16:9 frame. */
/* Tall photos skip the hover zoom: uncropped above 28rem, so there's no overflow to zoom into
without clipping. Cover photos keep it — their frame always crops. */
const picture =
imageFit === 'contain' ? (
/* Width-capped rather than height-capped, so the photo always fills its box at its own
proportions and is never letterboxed. Container queries — not viewport ones — drive the
padding and rounding, so the moment the card itself is narrower than the cap the photo
goes fully flush, exactly like the 16:9 covers above. */
<div className="border-b border-border @min-[28rem]:py-5">
imageFit === 'tall' ? (
/* Float rather than a flex column: long text wraps underneath instead of leaving a blank
column beside the photo, short text just sits alongside — no threshold to tune. `pt-7`
aligns the photo's top with the tags. No divider; it would cut through wrapped text. */
<div className="border-b border-border @min-[28rem]:py-5 @min-[48rem]:float-left @min-[48rem]:mr-7 @min-[48rem]:mb-2 @min-[48rem]:border-b-0 @min-[48rem]:pt-7 @min-[48rem]:pl-7">
<img
src={image}
alt={title}
loading="lazy"
className="mx-auto h-auto w-full max-w-md @min-[28rem]:rounded-xl"
className="mx-auto h-auto w-full @min-[28rem]:max-h-100 @min-[28rem]:w-auto @min-[28rem]:rounded-xl"
/>
</div>
) : (
<div className="relative aspect-video overflow-hidden border-b border-border">
<img
src={image}
alt={title}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]"
/>
{/* Vignette settles the photo into the glass instead of ending on a hard edge. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-linear-to-t from-background/25 to-transparent"
/>
/* Two nested boxes so the float can be inset without disturbing the stacked case below 48rem:
the outer carries position and padding, the inner stays a 16:9 frame with a width cap. */
<div className="border-b border-border @min-[48rem]:float-left @min-[48rem]:mr-7 @min-[48rem]:mb-2 @min-[48rem]:border-b-0 @min-[48rem]:pt-7 @min-[48rem]:pl-7">
<div className="relative aspect-video overflow-hidden @min-[48rem]:w-100 @min-[48rem]:rounded-xl">
<img
src={image}
alt={title}
loading="lazy"
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]"
/>
{/* Vignette settles the photo into the glass instead of ending on a hard edge. */}
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-linear-to-t from-background/25 to-transparent"
/>
</div>
</div>
);

/* `flow-root` so the card encloses the float instead of collapsing behind it. On this wrapper,
not the article: an element can't respond to a container query it declares itself, so
`@min-[48rem]:` on the `@container` article resolves against an ancestor and never matches. */
return (
<article className="panel panel-interactive group @container flex flex-col overflow-hidden">
{image && picture}
{body}
<div className="flex flex-1 flex-col @min-[48rem]:flow-root">
{image && picture}
{body}
</div>
</article>
);
}
4 changes: 2 additions & 2 deletions src/pages/Updates/Updates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function Updates() {
<>
{/* Page head */}
<section className="pt-14 md:pt-20" aria-labelledby="updates-title">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<Reveal>
<Eyebrow>Life updates</Eyebrow>
<h1
Expand All @@ -45,7 +45,7 @@ export default function Updates() {

{/* Feed */}
<section className="pb-20 pt-10 md:pb-28 md:pt-14" aria-labelledby="feed-heading">
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<h2 id="feed-heading" className="sr-only">
Update feed
</h2>
Expand Down
Binary file modified src/pages/Updates/imgs/uganda.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion src/pages/Updates/updateEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const updates: UpdateEntry[] = [
date: new Date(2026, 6, 17),
category: ['personal'],
image: imgs.Dad,
imageFit: 'contain',
imageFit: 'tall',
},
{
title: 'Flybox 2.0',
Expand Down Expand Up @@ -85,6 +85,7 @@ const updates: UpdateEntry[] = [
date: new Date(2026, 0, 3),
category: ['personal', 'travel'],
image: imgs.Uganda,
imageFit: 'tall',
},
];

Expand Down
Loading