diff --git a/CLAUDE.md b/CLAUDE.md index 5268535d..0a33ee3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` 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. @@ -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). @@ -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. diff --git a/src/components/UpdateCard.tsx b/src/components/UpdateCard.tsx index 1d94ee7a..5ca964c4 100644 --- a/src/components/UpdateCard.tsx +++ b/src/components/UpdateCard.tsx @@ -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. */ @@ -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 = ( -
+
{category.map((cat) => ( @@ -79,8 +89,10 @@ export default function UpdateCard({

{title}

+ {/* No measure cap: `max-w-prose` (65ch) left 154px dead on the right once the feed card went + to 840px. */}

@@ -89,42 +101,50 @@ export default function UpdateCard({

); - /* 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. */ -
+ 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. */ +
{title}
) : ( -
- {title} - {/* Vignette settles the photo into the glass instead of ending on a hard edge. */} -