Skip to contentVibraUI
Media

File art

A drawn picture for an image file's name, as a URL, and the route that draws it — an interface, a mark or a chart, never a photograph.

Its own kit item, built like avatars and for the same reason: a page that shows image files — a files browser, a lightbox, a cropper's sample — needs a picture behind hero-banner-v2.png, and the kit carries no photography and asks no other server for one. fileArtFor(name) is a URL, not a picture — /file-art/<slug>.svg — that the route file this item also ships draws on request: what a file of that name would plausibly hold, in one of three families, chosen by the stem the name carries once its extension and its version are taken off. An interface — an app screenshot, a wireframe, a hero banner, a cover, an og-image, a product shot, an offsite slide — for wireframe, screenshot, og-image, hero-banner, cover, product-shot and team-offsite, and for a name that names none of them; a mark — a logo on dark or on light, an app icon at three sizes, an icon set — for logo-dark, logo-light, app-icon and icon-set; a chart for chart-export. The colours are fixed, greys at zero chroma and one blue, as an image file holds its own whatever theme shows it, so the dark page shows the picture the light one does. The same name draws the same picture every time, from its letters alone — an accent, a capital or a stray space changes nothing — and two names draw two: the layout, the sizes and the series are seeded from the slug. The route answers a 1600 × 1000 SVG for a name fileArtFor() could have written ([a-z0-9-]{1,80}.svg) and 404 for anything else, cached for a year as immutable, with a Content-Security-Policy of default-src 'none': an SVG opened on its own is a document, and this one can run nothing and fetch nothing. Its title carries the name, escaped. A missing picture is the browser's broken image; the lightbox and the cropper each draw their own "can't load" state over one.

Install

npx shadcn@latest add @vibra/file-art

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
fileArtFor()(name: string) => string—"Hero Banner v2.PNG" → "/file-art/hero-banner-v2-png.svg": the URL of the picture for a file name, the same one every time.
fileArtSvg()(name: string) => string—The SVG document the route answers, for a server that wants the picture itself; the slug in a URL draws the picture its name did.
FILE_ART_ROUTEstring"/file-art"Where the route that draws the pictures is mounted.

Dependencies

Registry

Source

lib/file-art.ts
/**
 * A picture for an image file, from its name.
 *
 * A files browser, a lightbox or a cropper needs a picture behind
 * `hero-banner-v2.png`, and the kit carries no photography and asks no other
 * server for one. So the picture is drawn: what a file of that name would
 * plausibly hold, in one of three families — an *interface* (a screenshot, a
 * wireframe, a hero banner, a cover, an og-image, a product shot, an offsite
 * slide), a *mark* (a logo on dark or light, an app icon, an icon set) or a
 * *chart* (a chart export) — in fixed neutrals and one blue, as an image file
 * holds its own colours whatever theme shows it. Never a photograph.
 *
 * What `fileArtFor` returns is a URL, not the picture: `/file-art/<slug>.svg`,
 * which `file-art-route.ts` draws on request, the way `avatars` names a face.
 * The same name always draws the same picture, from its letters alone.
 */

import { drawPicture, PICTURES } from "@/lib/file-art-pictures"

/** Where the route that draws the pictures is mounted. */
export const FILE_ART_ROUTE = "/file-art"

/** `Hero Banner v2.PNG` → `hero-banner-v2-png`: the seed, and the file name, of the picture. */
function slugOf(name: string): string {
  const slug = name
    .normalize("NFD")
    .replace(/\p{M}/gu, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 80)
    .replace(/-+$/, "")
  return slug || "file"
}

/** The URL of the picture for a file name. */
export function fileArtFor(name: string): string {
  return `${FILE_ART_ROUTE}/${slugOf(name)}.svg`
}

const FAMILY: Record<string, "interface" | "mark" | "chart"> = { "logo-dark": "mark", "logo-light": "mark", "app-icon": "mark", "icon-set": "mark", "chart-export": "chart" }

/** The stem a slug names — `hero-banner-v2-png` is a hero banner — or a screenshot, for a name that names none. */
function stemOf(slug: string): string {
  const bare = slug.replace(/-(png|jpe?g|svg|webp|gif|avif|heic|tiff?|bmp)$/, "").replace(/-v\d+$/, "")
  return Object.keys(PICTURES).find((stem) => `-${bare}-`.includes(`-${stem}-`)) ?? "screenshot"
}

const escapeXml = (text: string) =>
  text.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!)

/** The SVG document the route answers for a file name — or for the slug in its URL, which draws the same picture. */
export function fileArtSvg(name: string): string {
  const slug = slugOf(name)
  const stem = stemOf(slug)
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000" data-family="${FAMILY[stem] ?? "interface"}"><title>${escapeXml(name)}</title>${drawPicture(stem, slug)}</svg>`
}
lib/file-art-pictures.ts
/**
 * The pictures `fileArtSvg()` draws, one per stem the kit's image files carry
 * — the screenshots, wireframes, banners and covers of the interface family,
 * the logos, icon and icon set of the mark family, and the chart export —
 * each a function of a seeded random sequence, so a slug draws the same
 * picture for ever and two slugs draw two. Fixed colours: greys at zero
 * chroma and one blue, as an image file holds its own. Never a photograph.
 */

/** FNV-1a into mulberry32, as sample-data's `seeded()` — kept in the item so it stands alone, as `avatars` does. */
function seeded(name: string): () => number {
  let state = 2166136261
  for (let index = 0; index < name.length; index++) state = Math.imul(state ^ name.charCodeAt(index), 16777619)
  state >>>= 0
  return () => {
    state = (state + 0x6d2b79f5) | 0
    let t = Math.imul(state ^ (state >>> 15), 1 | state)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

type Rand = () => number
const between = (rand: Rand, min: number, max: number) => min + rand() * (max - min)
const whole = (rand: Rand, min: number, max: number) => Math.floor(between(rand, min, max + 1))

// The neutrals are greys at zero chroma; the blue is the only hue.
const PAPER = "#ffffff"
const GROUND = "#f5f5f5"
const LINE = "#e5e5e5"
const MID = "#a3a3a3"
const SOFT = "#737373"
const DEEP = "#404040"
const INK = "#171717"
const BLUE = "#2563eb"

type Attrs = Record<string, string | number>
const el = (tag: string, attrs: Attrs) =>
  `<${tag}${Object.entries(attrs)
    .map(([key, value]) => ` ${key}="${typeof value === "number" ? Math.round(value) : value}"`)
    .join("")}/>`
const box = (x: number, y: number, width: number, height: number, fill: string, rx = 0, extra: Attrs = {}) =>
  el("rect", { x, y, width, height, rx, fill, ...extra })
/** A line of text, drawn as the bar a wireframe would. */
const bar = (x: number, y: number, width: number, height: number, fill: string, extra: Attrs = {}) => box(x, y, width, height, fill, height / 2, extra)
const dot = (cx: number, cy: number, r: number, fill: string, extra: Attrs = {}) => el("circle", { cx, cy, r, fill, ...extra })
const outline = (stroke: string, width = 2, dashed = false): Attrs => ({ stroke, "stroke-width": width, ...(dashed ? { "stroke-dasharray": "14 10" } : {}) })
const tint = (opacity: number): Attrs => ({ "fill-opacity": String(opacity) })
const ground = (fill: string) => box(0, 0, 1600, 1000, fill)
/** `[[x, y], …]` → "x,y x,y", in whole pixels. */
const points = (pairs: [number, number][]) => pairs.map(([x, y]) => `${Math.round(x)},${Math.round(y)}`).join(" ")

/** A series that wanders but stays on the plot: values 0.15–0.95. */
function walk(rand: Rand, count: number): number[] {
  let value = between(rand, 0.3, 0.6)
  return Array.from({ length: count }, () => (value = Math.min(0.95, Math.max(0.15, value + between(rand, -0.16, 0.2)))))
}

/** An app window — its title bar, then a sidebar and a content column. */
function windowChrome(rand: Rand, sketch: boolean): string {
  const stroke = sketch ? outline(MID, 3, true) : outline(LINE)
  const fill = sketch ? "none" : GROUND
  const active = whole(rand, 0, 5)
  const nav = Array.from({ length: 6 }, (_, index) => {
    const y = 214 + index * 52
    const on = index === active && !sketch
    return (on ? box(136, y - 14, 208, 42, BLUE, 12, tint(0.12)) : "") + bar(152, y, between(rand, 96, 176), 14, on ? BLUE : sketch ? LINE : MID)
  })
  return [
    ground(sketch ? PAPER : GROUND),
    box(96, 72, 1408, 856, PAPER, 24, stroke),
    ...[136, 164, 192].map((cx) => dot(cx, 112, 8, sketch ? "none" : LINE, sketch ? outline(MID, 3) : {})),
    box(480, 98, 640, 28, fill, 14, sketch ? stroke : {}),
    el("line", { x1: 96, y1: 152, x2: 1504, y2: 152, ...outline(sketch ? MID : LINE, sketch ? 3 : 2, sketch) }),
    box(120, 176, 240, 728, fill, 16, sketch ? stroke : {}),
    ...nav,
    bar(424, 200, between(rand, 260, 420), 28, sketch ? MID : INK),
    bar(424, 244, between(rand, 180, 320), 14, sketch ? LINE : MID),
  ].join("")
}

function screenshot(rand: Rand, sketch = false): string {
  const count = whole(rand, 2, 4)
  const width = (1040 - (count - 1) * 24) / count
  const cards = Array.from({ length: count }, (_, index) => {
    const x = 424 + index * (width + 24)
    return [
      box(x, 292, width, 136, PAPER, 16, outline(sketch ? MID : LINE, sketch ? 3 : 2, sketch)),
      bar(x + 24, 320, width * 0.4, 12, sketch ? LINE : MID),
      bar(x + 24, 350, width * between(rand, 0.35, 0.6), 30, sketch ? MID : INK),
      index === 0 ? bar(x + 24, 398, 64, 14, BLUE, sketch ? {} : tint(0.7)) : "",
    ].join("")
  })
  const values = walk(rand, 12)
  const step = 960 / values.length
  const plot = sketch
    ? // A wireframe's chart is a placeholder: a crossed box.
      el("line", { x1: 464, y1: 492, x2: 1424, y2: 840, ...outline(LINE, 3) }) + el("line", { x1: 1424, y1: 492, x2: 464, y2: 840, ...outline(LINE, 3) })
    : values.map((value, index) => box(464 + index * step + step * 0.2, 840 - value * 300, step * 0.6, value * 300, index === values.length - 1 ? BLUE : LINE, 6)).join("")
  return windowChrome(rand, sketch) + cards.join("") + box(424, 452, 1040, 428, PAPER, 16, outline(sketch ? MID : LINE, sketch ? 3 : 2, sketch)) + plot
}

function heroBanner(rand: Rand): string {
  // The disc and its ring stay inside the frame at their largest.
  const cx = between(rand, 1040, 1160)
  const cy = between(rand, 420, 560)
  const r = between(rand, 230, 300)
  const cardX = between(rand, 880, 980)
  const cardY = between(rand, 560, 640)
  return [
    ground(PAPER),
    dot(cx, cy, r + 56, "none", { ...outline(BLUE, 2), "stroke-opacity": "0.25" }),
    dot(cx, cy, r, BLUE),
    box(cardX, cardY, 380, 210, PAPER, 20, outline(LINE)),
    bar(cardX + 32, cardY + 40, 180, 16, MID),
    bar(cardX + 32, cardY + 80, between(rand, 220, 300), 28, INK),
    bar(cardX + 32, cardY + 140, 120, 32, BLUE, tint(0.15)),
    bar(160, 300, 150, 28, BLUE, tint(0.12)),
    bar(160, 360, between(rand, 560, 700), 56, INK),
    bar(160, 436, between(rand, 400, 600), 56, INK),
    bar(160, 532, 520, 16, MID),
    bar(160, 564, between(rand, 340, 480), 16, MID),
    bar(160, 628, 200, 56, BLUE),
    bar(384, 628, 180, 56, PAPER, outline(LINE)),
  ].join("")
}

function cover(rand: Rand): string {
  const tilt = between(rand, -180, 180)
  const top = between(rand, 160, 320)
  return [
    ground(INK),
    el("polygon", { points: points([[0, top + tilt], [1600, top - tilt], [1600, top - tilt + 200], [0, top + tilt + 200]]), fill: BLUE }),
    dot(184, 120, 24, PAPER),
    bar(224, 108, 160, 24, SOFT),
    bar(160, 620, between(rand, 700, 1040), 64, PAPER),
    bar(160, 708, between(rand, 480, 820), 64, PAPER),
    bar(160, 860, 260, 16, SOFT),
    bar(1200, 860, 240, 16, DEEP),
  ].join("")
}

function ogImage(rand: Rand): string {
  return [
    ground(PAPER),
    box(0, 0, 24, 1000, BLUE),
    box(120, 120, 96, 96, BLUE, 24),
    dot(168, 168, 22, PAPER),
    bar(248, 156, between(rand, 160, 240), 24, MID),
    bar(120, 420, between(rand, 900, 1240), 72, INK),
    bar(120, 516, between(rand, 600, 1000), 72, INK),
    bar(120, 640, between(rand, 420, 640), 24, MID),
    bar(120, 840, 280, 24, MID),
    bar(1280, 836, 200, 32, BLUE, tint(0.12)),
  ].join("")
}

/** A device on a floor, with a page of the product on its screen. */
function productShot(rand: Rand): string {
  const phone = rand() < 0.4
  const [x, y, width, height] = phone ? [660, 150, 280, 610] : [440, 220, 720, 470]
  const pad = phone ? 16 : 24
  const inner = [x + pad, y + pad, width - pad * 2, height - pad * 2] as const
  const screen = [
    box(...inner, PAPER, phone ? 32 : 8),
    bar(inner[0] + 24, inner[1] + 32, inner[2] * 0.4, 16, INK),
    box(inner[0] + 24, inner[1] + 72, inner[2] - 48, inner[3] * between(rand, 0.32, 0.42), BLUE, 12),
    ...Array.from({ length: 3 }, (_, index) => bar(inner[0] + 24, inner[1] + inner[3] * 0.62 + index * 32, (inner[2] - 48) * between(rand, 0.5, 0.95), 14, LINE)),
  ]
  return [
    ground(GROUND),
    el("ellipse", { cx: 800, cy: phone ? 800 : 772, rx: phone ? 220 : 560, ry: 30, fill: LINE }),
    box(x, y, width, height, INK, phone ? 48 : 24),
    ...screen,
    phone ? "" : el("polygon", { points: points([[x - 80, y + height], [x + width + 80, y + height], [x + width + 140, y + height + 44], [x - 140, y + height + 44]]), fill: MID }),
  ].join("")
}

/** An offsite's slide: who is going, and the day's plan. */
function teamOffsite(rand: Rand): string {
  const going = whole(rand, 4, 7)
  const marked = whole(rand, 0, 2)
  return [
    ground(GROUND),
    box(120, 96, 1360, 808, PAPER, 24, outline(LINE)),
    bar(200, 184, between(rand, 480, 640), 48, INK),
    bar(200, 256, 320, 18, MID),
    ...Array.from({ length: going }, (_, index) => {
      const cx = 248 + index * 128
      const fill = index === going - 1 ? BLUE : [LINE, MID, SOFT][index % 3]
      return dot(cx, 404, 52, fill) + dot(cx, 390, 18, PAPER) + el("ellipse", { cx, cy: 438, rx: 30, ry: 16, fill: PAPER })
    }),
    ...[560, 652, 744].map((y, index) =>
      [
        index === marked ? box(184, y - 18, 1232, 72, BLUE, 16, tint(0.08)) : "",
        bar(208, y, 120, 36, index === marked ? BLUE : GROUND, index === marked ? tint(0.2) : {}),
        bar(360, y + 10, between(rand, 360, 680), 16, index === marked ? BLUE : SOFT),
      ].join("")
    ),
  ].join("")
}

/** A mark, centred on (cx, cy): one of four shapes (`kind` 0–3), in `ink` with one part in the accent. */
function mark(kind: number, cx: number, cy: number, size: number, ink: string, hole: string, accent: Attrs = { fill: BLUE }): string {
  const s = size / 260
  const tone = String(accent.fill)
  switch (kind) {
    case 0:
      return dot(cx, cy, 130 * s, ink) + dot(cx, cy, 66 * s, hole) + dot(cx + 92 * s, cy - 92 * s, 36 * s, tone, accent)
    case 1:
      return box(cx - 130 * s, cy - 130 * s, 170 * s, 170 * s, ink, 36 * s) + box(cx - 40 * s, cy - 40 * s, 170 * s, 170 * s, tone, 36 * s, { ...tint(0.92), ...accent })
    case 2:
      return [-80, 0, 80]
        .map((dy, index) =>
          el("polyline", {
            points: points([[cx - 110 * s, cy + (dy - 50) * s], [cx, cy + (dy + 40) * s], [cx + 110 * s, cy + (dy - 50) * s]]),
            fill: "none",
            ...outline(index === 1 ? tone : ink, 34 * s),
            ...(index === 1 && accent["fill-opacity"] ? { "stroke-opacity": accent["fill-opacity"] } : {}),
            "stroke-linecap": "round",
            "stroke-linejoin": "round",
          })
        )
        .join("")
    default:
      return [
        [-1, -1],
        [1, -1],
        [-1, 1],
        [1, 1],
      ]
        .map(([dx, dy], index) => {
          const [x, y] = [cx + dx * 8 * s, cy + dy * 8 * s]
          const [ex, ey] = [x + dx * 120 * s, y + dy * 120 * s]
          const d = `M${Math.round(x)} ${Math.round(y)}H${Math.round(ex)}A${Math.round(120 * s)} ${Math.round(120 * s)} 0 0 ${dx === dy ? 1 : 0} ${Math.round(x)} ${Math.round(ey)}Z`
          return el("path", index === 2 ? { d, ...accent } : { d, fill: ink })
        })
        .join("")
  }
}

function logo(rand: Rand, dark: boolean): string {
  const [paper, ink] = dark ? [INK, PAPER] : [PAPER, INK]
  return [ground(paper), mark(whole(rand, 0, 3), 520, 500, 260, ink, paper), bar(720, 444, between(rand, 380, 560), 72, ink), bar(720, 548, between(rand, 200, 300), 22, dark ? SOFT : MID)].join("")
}

/** One icon at three export sizes, each with its size written under it. */
function appIcon(rand: Rand): string {
  const kind = whole(rand, 0, 3)
  // On the blue tile the accent is the paper, half through: blue on blue would vanish.
  const tile = (x: number, y: number, size: number) =>
    box(x, y, size, size, BLUE, size * 0.225) + mark(kind, x + size / 2, y + size / 2, size * 0.55, PAPER, BLUE, { fill: PAPER, ...tint(0.5) })
  return [ground(GROUND), tile(300, 240, 520), tile(940, 400, 200), tile(1220, 450, 100), bar(300, 820, 160, 20, MID), bar(940, 660, 120, 16, MID), bar(1220, 610, 80, 16, MID)].join("")
}

const GLYPHS: ((x: number, y: number, stroke: Attrs) => string)[] = [
  (x, y, stroke) => dot(x, y, 34, "none", stroke),
  (x, y, stroke) => box(x - 32, y - 32, 64, 64, "none", 12, stroke),
  (x, y, stroke) => el("polygon", { points: `${x},${y - 36} ${x + 38},${y + 30} ${x - 38},${y + 30}`, fill: "none", ...stroke }),
  (x, y, stroke) => el("path", { d: `M${x - 34} ${y}H${x + 34}M${x} ${y - 34}V${y + 34}`, ...stroke }),
  (x, y, stroke) => el("path", { d: `M${x - 34} ${y - 22}H${x + 34}M${x - 34} ${y}H${x + 34}M${x - 34} ${y + 22}H${x + 14}`, ...stroke }),
  (x, y, stroke) => el("polyline", { points: `${x - 32},${y + 2} ${x - 8},${y + 26} ${x + 34},${y - 24}`, fill: "none", ...stroke }),
  (x, y, stroke) => el("polygon", { points: `${x},${y - 38} ${x + 38},${y} ${x},${y + 38} ${x - 38},${y}`, fill: "none", ...stroke }),
  (x, y, stroke) => el("path", { d: `M${x - 34} ${y + 24}A34 34 0 0 1 ${x + 34} ${y + 24}`, fill: "none", ...stroke }),
]

function iconSet(rand: Rand): string {
  const blue = [whole(rand, 0, 17), whole(rand, 0, 17)]
  const cells = Array.from({ length: 18 }, (_, index) => {
    const [x, y] = [220 + (index % 6) * 200, 210 + Math.floor(index / 6) * 200]
    const stroke = { ...outline(blue.includes(index) ? BLUE : INK, 10), "stroke-linecap": "round", "stroke-linejoin": "round" }
    return box(x, y, 160, 160, GROUND, 32) + GLYPHS[whole(rand, 0, GLYPHS.length - 1)](x + 80, y + 80, stroke)
  })
  return ground(PAPER) + cells.join("")
}

function chartExport(rand: Rand): string {
  const lines = rand() < 0.5
  const [base, ours] = [walk(rand, 12), walk(rand, 12)]
  const step = 1280 / 12
  const at = (index: number, value: number) => [200 + index * step + step / 2, 860 - value * 560]
  const path = (values: number[]) => values.map((value, index) => `${index ? "L" : "M"}${at(index, value).map(Math.round).join(" ")}`).join("")
  const plot = lines
    ? [
        el("path", { d: `${path(ours)}L${Math.round(at(11, 0)[0])} 860L${Math.round(at(0, 0)[0])} 860Z`, fill: BLUE, ...tint(0.1) }),
        el("path", { d: path(base), fill: "none", ...outline(MID, 6) }),
        el("path", { d: path(ours), fill: "none", ...outline(BLUE, 6) }),
        ...ours.map((value, index) => dot(at(index, value)[0], at(index, value)[1], 10, PAPER, outline(BLUE, 5))),
      ]
    : base.flatMap((value, index) => [
        box(200 + index * step + step * 0.18, 860 - value * 560, step * 0.3, value * 560, MID, 6),
        box(200 + index * step + step * 0.52, 860 - ours[index] * 560, step * 0.3, ours[index] * 560, BLUE, 6),
      ])
  return [
    ground(PAPER),
    bar(120, 96, between(rand, 380, 520), 32, INK),
    bar(120, 148, 260, 16, MID),
    dot(1180, 112, 10, MID),
    bar(1200, 104, 100, 16, SOFT),
    dot(1340, 112, 10, BLUE),
    bar(1360, 104, 100, 16, SOFT),
    ...[0, 1, 2, 3, 4].map((index) => el("line", { x1: 200, y1: 300 + index * 140, x2: 1480, y2: 300 + index * 140, ...outline(LINE, 2, index < 4) }) + bar(112, 294 + index * 140, 56, 12, LINE)),
    ...Array.from({ length: 12 }, (_, index) => bar(200 + index * step + step / 2 - 24, 892, 48, 12, LINE)),
    ...plot,
  ].join("")
}
/** Each stem the kit's files carry, and how it is drawn. */
export const PICTURES: Record<string, (rand: Rand) => string> = {
  screenshot: (rand) => screenshot(rand),
  wireframe: (rand) => screenshot(rand, true),
  "hero-banner": heroBanner,
  cover,
  "og-image": ogImage,
  "product-shot": productShot,
  "team-offsite": teamOffsite,
  "logo-dark": (rand) => logo(rand, true),
  "logo-light": (rand) => logo(rand, false),
  "app-icon": appIcon,
  "icon-set": iconSet,
  "chart-export": chartExport,
}

/** The picture for a known stem, seeded from a slug: the same slug, the same picture. */
export function drawPicture(stem: keyof typeof PICTURES, slug: string): string {
  return PICTURES[stem](seeded(slug))
}
app/file-art/[name]/route.ts
import { fileArtSvg } from "@/lib/file-art"

/**
 * The route that draws the pictures `fileArtFor()` names: `GET
 * /file-art/<slug>.svg` answers the SVG for that slug. Installed at
 * `app/file-art/[name]/route.ts`. Deterministic — the same slug is the same
 * picture for ever — so the browser may keep it for a year.
 *
 * An SVG opened on its own is a document, so it is served as one that may
 * do nothing: no script, no request, nothing but its own inline styles.
 */
export async function GET(_request: Request, context: { params: Promise<{ name: string }> }) {
  const { name } = await context.params
  // Only a name fileArtFor() could have written. Next hands the param over
  // decoded already, so it is read once, as it arrives — `%2e%2e.svg` is
  // refused rather than decoded a second time into a path.
  const match = /^([a-z0-9-]{1,80})\.svg$/.exec(name)
  if (!match) return new Response("Not found", { status: 404 })

  return new Response(fileArtSvg(match[1]), {
    headers: {
      "Content-Type": "image/svg+xml; charset=utf-8",
      "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
      "X-Content-Type-Options": "nosniff",
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  })
}