Lossless Changelog
product

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.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 4.7

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>.md carrying imagekit_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) and Mia 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 images and cover_image fields became relation widgets 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.css two-tier tokens, exposed to Tailwind v4 via @theme, loaded site-wide from Google Fonts in BaseLayout.

  • 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:

YAML
---
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:

YAML
---
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:

TS
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:

HTML
<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 image

This 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_path field 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 of lib/imagekit.ts + CatalogImage.astro once 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/