From 6f1ba9b10a07ac649dc8701d29942452c448f171 Mon Sep 17 00:00:00 2001 From: Zane Myers Date: Fri, 14 Aug 2026 13:19:55 -0500 Subject: [PATCH 1/4] feat(updates): seat portrait photos by card width, widen the feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both update portraits are 1536x2048. Stacked and width-capped, dad.jpg sat 448px wide in a 712px card — ~120px of dead gutter either side, 597px of photo, an 860px card, the tallest thing in the feed by a wide margin. And uganda.jpg, once its square crop was replaced with the uncropped original, carried no imageFit at all, so it fell through to the default 16:9 cover and got cropped to a band. `imageFit: 'contain'` becomes `'tall'`, with three stages on the card's own width rather than the viewport: - 48rem+: beside the text, capped at 400px tall. - 28rem-48rem: stacked, whole photo, capped at 400px. - under 28rem: no cap, no frame — full card width at the photo's own ratio. The phone stage is uncropped on purpose. A 4:3 centre crop was tried and took the tops of heads off; a tall card beats a decapitated one. Renamed from `contain` because below 28rem it is now object-cover, so the old name lied. Updates goes max-w-4xl to max-w-5xl. Not cosmetic: the side layout needs 48rem of card, and at 4xl the card maxed out at 712px and could never reach it. At 5xl it's 840px. That widening then exposed `max-w-prose` (65ch) on the body, which left 154px dead on the right of every landscape card, so the measure cap is gone — the paragraph settles around 780px on its own. Home gets a new `imageAside` prop, a placement choice rather than an entry one: its teaser card is ~1216px, wide enough that even a landscape photo reads as a short band over a short block of clamped text. Aside, it's 400x225 beside the text and the card drops from 461px to 267px. The feed leaves it off — 840px is narrow enough that a landscape photo fills the width properly stacked. One CSS trap worth recording, since it looks correct and silently does nothing: the row switch cannot live on the article. An element can't respond to a container query it declares itself, so `@min-[48rem]:flex-row` on the `@container` article resolves against an ancestor and never matches. It's on an inner wrapper, with a comment. Measured, feed: 840px card and side layout from ~820px viewport up; stacked and capped 480-816; filling below. Home: aside from 900px up. Landscape cards unchanged at every width. Residual, deliberately left: the dad entry's body is 876px against a 400px photo, so its side layout still has a blank left column. Uganda's body is 508px and reads well. No layout knob fixes a text-length mismatch — that one needs the copy split or the photo allowed to grow. Verified with biome ci, typecheck, bun run test, bun run test:browser, and a production build. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 9 ++- src/components/UpdateCard.tsx | 102 +++++++++++++++++++++-------- src/pages/Home/Home.tsx | 2 +- src/pages/Updates/Updates.tsx | 4 +- src/pages/Updates/imgs/uganda.jpg | Bin 237381 -> 289091 bytes src/pages/Updates/updateEntries.ts | 3 +- 6 files changed, 87 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5268535d..61e4f35b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,13 @@ 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`) are laid out by **container** queries on the card, never viewport ones, so a card in a narrow column behaves like a phone even on a wide screen. Two knobs: + +- `imageFit` on the entry itself. `cover` (default) is a 16:9 frame with `object-cover`, for landscape shots. `tall` is for portraits and has three stages: beside the text above 48rem, stacked and capped at 400px tall between 28rem and 48rem, and below 28rem no cap or frame at all — full card width at the photo's own ratio. That last stage is uncropped on purpose; a centre 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 stage. +- `imageAside`, passed by the placement rather than the entry. Puts *any* photo beside the text above 48rem, and only Home sets it — its teaser card is ~1216px, wide enough that a stacked landscape shot is a short band over a short block of clamped text. The feed leaves it off. + +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. @@ -64,4 +71,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..28d53278 100644 --- a/src/components/UpdateCard.tsx +++ b/src/components/UpdateCard.tsx @@ -25,11 +25,23 @@ 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. + * How the photo is seated. `cover` (default) bleeds edge to edge in a 16:9 frame and suits + * landscape shots. `tall` is for portraits, and has three stages keyed off the card's own width: + * + * - **48rem and up**: beside the text rather than above it, capped at 400px tall. Both Home's + * ~1216px teaser and the feed's 840px card reach this. Stacked this wide, a 300px photo sits in + * ~450px of empty gutter, which reads as a layout bug rather than a choice. + * - **28rem to 48rem**: stacked, the whole photo uncropped at its own proportions, centered and + * capped at 400px tall. Gutters remain, but the alternative is a 600px-tall photo. On the feed + * this is roughly a 480–816px viewport. + * - **Below 28rem**, a phone: no cap, no frame, no side layout. The photo fills the card's full + * width at its own ratio. Nothing is cropped, deliberately — a centre crop into a landscape + * frame takes the tops of heads off a 3:4 photo, which is worse than a tall card. + * + * All three are container queries, not viewport ones, so a card in a narrow column behaves like a + * phone even on a wide screen. */ - imageFit?: 'cover' | 'contain'; + imageFit?: 'cover' | 'tall'; } /** An entry plus the per-placement presentation choices the pages make. */ @@ -38,6 +50,16 @@ type UpdateCardProps = UpdateEntry & { clamp?: boolean; /** Hides the in-card date at lg+, where the feed shows it on the timeline rail instead. */ dateInRail?: boolean; + /** + * Seats the photo beside the text once the card clears 48rem, whatever shape the photo is. Set by + * Home, whose single teaser card is ~1216px wide — far wider than the photo wants to be, so a + * stacked landscape shot leaves a short, wide band above a short block of clamped text. + * + * Tall photos already move aside at 48rem on their own (see `imageFit`), so this only changes + * `cover` photos. The feed leaves it off: its cards are 840px, and a landscape photo there fills + * the width properly stacked. + */ + imageAside?: boolean; }; const pad = (value: number) => String(value).padStart(2, '0'); @@ -51,6 +73,7 @@ export default function UpdateCard({ imageFit = 'cover', clamp = false, dateInRail = false, + imageAside = false, }: UpdateCardProps) { // Built from local parts so the machine-readable date can't drift a day across time zones. const isoDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; @@ -79,8 +102,10 @@ export default function UpdateCard({

{title}

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

@@ -89,42 +114,63 @@ 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 change at two container widths, 28rem and 48rem — see `imageFit`. The hover zoom is + left off for them: above 28rem the photo is uncropped, so there's no overflow to zoom into + without clipping it. Cover photos keep the zoom, since 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' ? ( +
{title}
) : ( -
- {title} - {/* Vignette settles the photo into the glass instead of ending on a hard edge. */} -