Editorial Type Lock, and a Catalog That Lives Apart from Its Pictures
Two threads landed in one afternoon: the typography for arthouse-site got picked from three live specimens and wired across the stack, and the gallery system stopped knowing about images entirely — it now references a catalog that resolves to ImageKit at render time.
Why Care?
If you've ever renamed a JPEG and watched three pages 404, this is the entry for you.
We separated two things that didn't need to be entangled: what an image is (its slug, its caption, its alt text, the series it belongs to) and where the bytes happen to live (today: ImageKit; tomorrow: maybe somewhere else). Galleries on arthouse-site no longer mention paths. They reference an image by name. The name resolves through a small library to a fully transformed, responsive ImageKit URL with srcset baked in.
The upshot for a portfolio site: a photographer can rename, re-crop, or move an asset on the CDN and every gallery pointing at it keeps working. The repo can stay public on GitHub while the imagery lives on a CDN with its own access controls. And whichever asset host we use can change without rewriting a single gallery.
We also locked the brand typography — Editorial Mistique — and rolled the new type system across the layout, nav, and home page. The arthouse-site finally looks like the editorial register the brief asked for.
What's New?
Image catalog content model. Each photograph or artwork gets a record in
src/content/images/<slug>.mdcarryingimagekit_path,kind,caption,aspect_ratio,palette_hint, and lineage fields. Six records seeded today.src/lib/imagekit.ts— the resolver.resolveImage(slug)for one,resolveImages(slugs)for many,imagekitUrl(record, transforms)to build a URL with width/quality/format,imageSrcSet(record, widths)for responsive<img>tags.<CatalogImage slug="…" />— a single component that takes a slug and renders a responsive, aspect-ratio-stable image. CLS pinned at zero.Two galleries migrated and one created.
Studio Sessions(Kiara × 3) andMia in Paris(× 3) — both now reference slugs only, no paths anywhere in the markdown.Sveltia CMS reshape. The images collection grew the new fields. Gallery
imagesandcover_imagefields becamerelationwidgets that typeahead-search the catalog by title or slug — she picks from a list of named records instead of fumbling through a media library.Editorial Mistique locked. Italiana display, Cormorant Garamond body, Marcellus SC eyebrow, Italianno script signature. Wired through
global.csstwo-tier tokens, exposed to Tailwind v4 via@theme, loaded site-wide from Google Fonts inBaseLayout.Brand palette swap. Out: placeholder purple/blue/coral. In: warm dark grounds (
--color__ink-plum,--color__aubergine), bone and porcelain neutrals, rose-dust + champagne accents. Three modes rebuilt —dark(canonical),vibrant(the brighter girly inversion),light.Typography specimen page at
/design-system/typography/— three font-stack options rendered live, side by side, with the same body of copy so the eye decides. The specimen page is staying live as a reference even now that Option A is locked.Portfolio page rebuilt to render galleries with editorial typography and proper responsive
<img>tags pointed at ImageKit transforms.
How It Works
The architecture has three layers and one rule.
Layer 1 — records. A record is just frontmatter:
---
title: "Kiara · Audrey Hepburn"
slug: studio-kiara-audrey-hepburn
kind: photograph
imagekit_path: /Arthouse-Embeds/portraitImage__Kiara--AudreyHepburn-Style.jpeg
aspect_ratio: "2:3"
alt: "Portrait styled as Audrey Hepburn — black gown, pearl strand, soft studio light"
palette_hint: gothic
series: studio-sessions
---That file is what the asset is. The file knows nothing about which gallery uses it or which page renders it.
Layer 2 — galleries reference by slug. No paths anywhere:
---
title: Studio Sessions
cover_image: studio-kiara-audrey-hepburn
images:
- studio-kiara-audrey-hepburn
- studio-kiara-steampunk
- studio-kiara-glitterbug
---If a slug points at a record that doesn't exist, the resolver returns null and the slot quietly renders nothing in production (with a visible diagnostic in dev). No build break, no 404.
Layer 3 — the resolver. Every render path goes through src/lib/imagekit.ts. The component layer doesn't construct URLs; it asks for one:
const records = await resolveImages(gallery.data.images);
const images = records.map((r) => ({
src: imagekitUrl(r, { width: 800, quality: 80, format: 'auto' }),
srcset: imageSrcSet(r, [400, 600, 800, 1200, 1600]),
alt: r.data.alt,
}));Which produces, for each image, real responsive markup:
<img
src="https://ik.imagekit.io/xvpgfijuw/Arthouse-Embeds/portraitImage__Kiara--AudreyHepburn-Style.jpeg?tr=w-800,q-80,f-auto"
srcset="… 400w, … 600w, … 800w, … 1200w, … 1600w"
sizes="(min-width: 1024px) 30vw, (min-width: 768px) 45vw, 85vw"
alt="Portrait styled as Audrey Hepburn — black gown, pearl strand, soft studio light"
loading="lazy" decoding="async" />ImageKit handles the transform on the fly. The browser picks the right width. We don't pre-bake anything; we don't ship anything; we don't even know the original dimensions.
The one rule. The repo never holds image bytes. Adding a new image is: upload to ImageKit, copy the path, create a record with that path. The record is small markdown; the bytes are someone else's problem.
Under the Hood — The Typography Decision
We didn't pick fonts from a screenshot. We built a specimen page that loaded all three candidate stacks at once — Editorial Mistique (Italiana + Cormorant), Botanical Atelier (Cormorant Light + Tenor Sans), Modern Boudoir (Playfair + DM Serif) — and rendered the same body of copy in each. Side-by-side, in the actual theme, in the actual browser:
ds-typography/
├── header "Three voices."
├── specimen-a Editorial Mistique
│ eyebrow + display + subhead + body + pullquote + signature
├── specimen-b Botanical Atelier
│ (same copy, different stack)
└── specimen-c Modern Boudoir
(same copy, different stack)This is the Astro-Knots discipline of doing-without-Storybook: the design system lives inside the actual site, in the actual runtime, under the actual theme tokens. No drift. The specimen page stays live at /design-system/typography/ as the locked-in reference.
Option A was the call. Italiana's hairline didone gives the display the editorial-magazine register we wanted. Cormorant carries body and pullquote without fighting it. Marcellus SC tracked at +0.3em on the eyebrow does the small-caps work. Italianno appears once, as a signature.
Under the Hood — Why a Resolver Instead of a Component
The early sketch had a single <CatalogImage slug="..." /> doing everything: resolve the slug, build the URL, render the <img>. Fine for static markdown pages. Less fine when a Svelte component needs the image data inside its own state machine — HorizontalSwipe does scroll-snap, current-index tracking, dot navigation, none of which is Astro's job.
So the resolver is its own module. Astro pages call it, transform records into { src, srcset, alt } shapes, pass those into the Svelte component. The Svelte component is again as dumb as it should be — it renders what it's given. The catalog-awareness lives where the data lives.
Astro page (resolves slugs, has content collection access)
↓ passes { src, srcset, alt }[] as props
Svelte component (renders, manages interaction state)
↓ outputs <img src srcset sizes alt>
Browser
↓ picks the right width, requests from ImageKit
ImageKit
↓ transforms on the fly, returns optimized imageThis is also the answer for the next site that wants the pattern. Lift src/lib/imagekit.ts and src/components/basics/CatalogImage.astro into a @knots/* reference (eventually). The Svelte gallery component doesn't need to come along — every site builds its own scroll/grid/lightbox shape anyway.
A CMS Detour Worth Capturing
Mid-build, we hit a wall worth a note in context-v/issues/: Sveltia CMS does not support custom media library plugins. The Decap CMS extension API (CMS.registerMediaLibrary) is documented as a no-op in Sveltia. We'd assumed otherwise and almost designed the upload UI inside the CMS. Once we read the actual docs, the path forward got clearer:
Sveltia handles records, galleries, marketing copy — everything text-shaped
ImageKit handles bytes — uploads happen there (dashboard today, custom uploader page tomorrow)
The
imagekit_pathfield is just a string Sveltia stores; the resolver does the work at render time
The relation widget turned out to be the load-bearing escape hatch: gallery editors pick from a typeahead of existing image records instead of a media picker. The constraint became a feature.
Full writeup: context-v/issues/Sveltia-Constraints-for-CMS.md.
What's Next
Portfolio polish — the page renders the two galleries today; needs hover treatment, dot-pagination styling pass, and a lightbox on click.
/services/and/contact/pages still stubs.WhatsApp CTA wiring (the marketing block has the variant; the contact-redirect endpoint needs sealing).
An uploader page that calls ImageKit's API directly so adding an image doesn't require jumping between two dashboards.
@knots/*extraction oflib/imagekit.ts+CatalogImage.astroonce a second site needs the catalog pattern. Premature today; obvious in a quarter.
References
Spec:
context-v/specs/Image-Catalog-and-ImageKit-Hosting-for-Arthouse.md(parent astro-knots context-v)Spec:
context-v/specs/Polish-Pass-for-Arthouse-Site.md(parent astro-knots context-v)Issue:
context-v/issues/Sveltia-Constraints-for-CMS.md(this repo)Live specimens:
/design-system/typography/Live galleries:
/portfolio/
