Images & Assets
TVfolio has three kinds of image, and they are handled differently on purpose: collection images go through astro:assets, textures are CSS backgrounds, and public/ holds the three files that must keep a stable URL.
Collection images
Every image in the blog and work collections is declared with the schema’s image() helper:
preview: image(),
hero: tubeFigure(image),
gallery: z.array(image()).default([]),
That gives you three things at once: the image is optimized at build time by sharp, its real width and height are known (which is what lets og:image emit correct dimensions), and a missing file is a build error rather than a broken image discovered in production.
Paths are relative to the entry, and the images live in the entry’s own folder:
src/data/work/neon-flux/
├── index.md
├── preview.webp
├── hero.webp
├── still-01.webp
└── still-02.webp
The demo content ships .webp throughout, which is a sensible default — but any format sharp reads will work, and it will be converted during the build regardless.
TubeStill — the framed capture
Sections/Global/TubeStill.astro is the component every framed image in the tube goes through. It exists because four sections drew the identical frame-plus-image cluster by copy, and because it owns two constraints that a fifth copy kept getting wrong.
<TubeStill
{src} {alt} {loading}
widths={[640, 1024, 1904]}
sizes="(min-width: 1024px) 952px, 92vw"
class="aspect-[952/300] w-full"
/>
The two constraints
overflow-clip, never overflow-hidden. This is subtle and it is the whole reason the component exists. A hidden box is a scroll container — so view() would track this frame, which never scrolls, instead of the tube. The Ken Burns zoom would simply freeze. clip does not create a scroll container, so the timeline resolves against the actual scrolling ancestor.
motion-reduce:animate-none is required here, not redundant. The global reduced-motion guard zeroes time durations — and a scroll-driven animation has none to zero. Its progress is tied to scroll position, so it keeps scrubbing. This is the single most reusable lesson in the theme’s motion layer, and Motion & Animation covers it.
What stays with the caller
Geometry. The component owns the frame — border, clip, radius — and the caller passes an aspect-[…] or a measured width/height pair through class. TubeFigure passes aspect-[952/300], because the frames keep one aspect at both cabinet widths (952:300 desktop equals 314:99 mobile, both 3.17), which is why the box is an aspect ratio rather than a height with a floor.
widths and sizes are the caller’s too, chosen from the drawn size, because only the caller knows how wide the image actually renders.
The as prop switches between div and figure, so a still carrying a <figcaption> gets correct semantics.
Loading strategy
The default is loading="lazy". eager is passed only when the figure is the page’s LCP — a hero directly under the title. That is a per-caller decision rather than a heuristic, and it is worth keeping deliberate: making everything eager is how a page gets slower, not faster.
The three textures
src/assets/textures/ holds brick-wall.webp, wood-grain.webp and chassis-grain.webp. They are CSS background-image values in TvSet, not <Image> components, and they are the only photographic layers in the whole cabinet.
Everything else the Figma file rasterized — the body shading, the specular top edge, the scanlines — turned out to be a pure vertical gradient with no horizontal variation, so each is a CSS gradient in the theme. Same pixels, roughly 90 KB lighter.
Each texture is tiled at a specific natural size that matters:
background: url("…/brick-wall.webp") top left / 672px 224px repeat;
mix-blend-mode: overlay;
672×224 makes one brick course 56px, which is the pitch the Figma frame renders. The overlay blend against the near-black backdrop is what leaves the wall barely legible rather than a literal brick photo.
The wood grain gets its own pseudo-element rather than background-blend-mode, because the look depends on per-layer opacity — 45% on the tabletop, 35% on the edge — and background-blend-mode has no way to express that. Measured against the Figma export, dropping the opacity leaves the wood 1.6× too contrasty.
If you recolour the cabinet, the textures still work: they are blend-mode overlays on top of the colour tokens, so changing --color-table-top changes the wood’s colour and keeps its grain.
The two shipped images
src/assets/images/ holds portrait.webp — the persona’s face, drawn on / and /about/ — and test-card.webp. Replace the portrait with your own; it is on the “before you deploy” list.
public/
Three files, and they are in public/ rather than src/assets/ because each must keep a stable, predictable URL:
| File | Why it must be stable |
|---|---|
og.jpg |
referenced as an absolute URL in OG tags and as the JSON-LD logo |
favicon.svg |
<link rel="icon"> |
favicon.ico |
<link rel="icon" sizes="any"> |
Anything under src/assets/ gets a content hash in its filename, which is exactly what you want for cache-busting and exactly what you do not want for a social image whose URL may already be cached by a platform.
og.jpg is a placeholder and needs replacing with a real 1200×630 image. It is also currently doing double duty as the JSON-LD logo, which BaseHead marks as a ponytail: shortcut — swap it for a real brand logo before deploy if you have one.
Adding an image to a page
Inside the tube, use TubeStill (or TubeFigure if it needs a FIG caption row) so you get the frame, the Ken Burns scroll behaviour and both constraints for free.
Outside the tube, import <Image> from astro:assets directly and give it widths and sizes matched to how it actually renders. The ui/card/CardImage.astro primitive notes that it is a thin wrapper and that dropping a plain <Image> in its place is fine.
Either way, import the image rather than referencing a public/ path — that is what gets you optimization, real dimensions and a build-time error when the file is missing.