Components

Everything in components/. All of it is server-rendered only — see ARCHITECTURE.md §3 before adding interactivity.

File Export Kind Used by
BusinessCard.tsx BusinessCard (named) component /o/[uuid]
LinksPage.tsx LinksPage (named) component /o/[uuid]
ShowcasePage.tsx ShowcasePage (named) component /o/[uuid]
ErrorDisplay.tsx ErrorDisplay (default) component /o/[uuid], _404, _500
QRCode.tsx QRCodeSVG (named) plain function /o/[uuid]
LinkPageView.tsx LinkPageView, LinkUnavailable (named) component /o/[uuid], /builder/preview

ErrorDisplay is the only default export; the rest are named. Match the surrounding style when adding files — named exports for components that take a link prop.

Two subtrees have their own conventions and are documented elsewhere: components/alpha/ (ALPHA.md) and components/builder/ (BUILDER.md).


BusinessCard

interface BusinessCardProps {
  link: BusinessCardLink;
  qrCodeSvg: string;
}

Renders one person's contact card. The qrCodeSvg string is produced by the route, not by this component, and is injected raw:

<div
  class="bg-white p-3 rounded-xl shadow-sm border border-slate-100"
  dangerouslySetInnerHTML={{ __html: qrCodeSvg }}
/>;

This is the only use of dangerouslySetInnerHTML in the codebase. It is safe because the SVG is generated server-side by qrcode-svg from a URL the server itself constructs — no user input reaches it. If the QR source ever becomes data-driven, this becomes an injection point.

Derived values at the top of the component:

const fullName = `${data.firstName} ${data.lastName}`;
const whatsappUrl = `https://wa.me/${data.phone.replace("+", "")}`;
const phoneUrl = `tel:${data.phone}`;
const emailUrl = `mailto:${data.email}`;
const vcardUrl = `/o/${link.uuid}/vcard`;

vcardUrl uses link.uuid — the field inside the JSON, not the filename. They must match (see DATA-MODEL.md).

.replace("+", "") replaces only the first occurrence, which is correct for E.164 but differs from the global .replace(/\+/g, "") used in LinksPage.

Local icon components: WhatsAppIcon, PhoneIcon, EmailIcon, SaveContactIcon — each a bare <svg viewBox="0 0 24 24" class="w-5 h-5 fill-current"> with one <path>.

Layout notes:

  • Header is sticky top-0 z-10, height h-28md:h-40, background config.primaryColor.
  • The logo sits on a white rounded plate so dark and light logos both read against the coloured header.
  • Save Contact is fixed bottom-0 left-0 right-0 on mobile, md:relative on desktop; the scroll region carries pb-16 md:pb-6 to clear it.
  • The "Powered by onnne.link" strip is hidden md:block — invisible on phones.

LinksPage

interface LinksPageProps {
  link: LinksPageLink;
}

A two-column grid of action tiles. Contains two local helpers.

Icons

Both grids draw from the shared ICON_PATHS map in components/icons.ts — 14 names, each a single 24x24 SVG path string:

globe · phone · email · whatsapp · instagram · linkedin · twitter · facebook · youtube · snapchat · map · download · calendar · menu

getIconPath(name, fallback) takes the fallback as an argument rather than hardcoding one, because the two grids genuinely disagree: a links-page tile falls back to globe, a showcase-page social icon to instagram.

getActionUrl(action)

Switches over the discriminated action union to produce an href:

case "link":     return action.url;
case "phone":    return `tel:${action.value}`;
case "email":    return `mailto:${action.value}`;
case "whatsapp": return `https://wa.me/${action.value.replace(/\+/g, "")}`;
default:         return "#";

isExternal is action.type === "link", and drives target="_blank" + rel="noopener noreferrer".

WhatsAppButton

The floating chat button is the shared components/WhatsAppButton.tsx, rendered whenever config.whatsapp is set. It builds its URL through waMeUrl() from lib/phone.ts, which normalises the number once for every caller.


ShowcasePage

interface ShowcasePageProps {
  link: ShowcasePageLink;
}

Full-width campaign page. The most structurally complex component: two stacked sticky bars above a responsive card grid.

Shares ICON_PATHS and WhatsAppButton with LinksPage; it passes "instagram" as its getIconPath fallback, where the tile grid passes "globe". Only five of the fourteen icons are meaningful here — the social platforms instagram, linkedin, twitter, facebook and snapchat.

Structural details:

  • Header sticky top-0 z-20, inner max-w-7xl mx-auto, logos in a justify-between flex — main logo left, secondaryLogo right when present.
  • Social bar sticky top-[72px] md:top-[88px] z-10. These offsets are hardcoded to the header's rendered height. Changing header padding or logo size without updating them causes the bars to overlap.
  • Card grid: grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6.
  • Cards are flex flex-col; the features <ul> takes grow and the button takes mt-auto, so buttons align across a row regardless of description length.
  • Feature checkmarks are a 20×20 viewBox="0 0 20 20" inline SVG tinted with style={{ color: config.primaryColor }} — note this differs from the 24×24 viewBox used everywhere else.
  • Footer is bg-slate-900 — dark, unlike the light footer on LinksPage.

This is the only component that uses JSX comments ({/* … */}) to label sections.


ErrorDisplay

interface ErrorDisplayProps {
  title: string;
  message: string;
  code?: string | number;
}

Default export. Used in three places: _404.tsx, _500.tsx, and the not-found branch of /o/[uuid].

The embedded Lottie animation

Roughly 200 lines of the file are errorAnimation, an inline Lottie JSON object — a red circle that scales in, then a rotating X mark. It is serialised into a data URI at render time:

<lottie-player
  src={`data:application/json;base64,${btoa(JSON.stringify(errorAnimation))}`}
  background="transparent"
  speed="1"
  style="width: 192px; height: 192px; margin: 0 auto;"
  loop
  autoplay
/>;

Three things make this work:

  1. lottie-player is declared as a JSX intrinsic element in types.d.ts.
  2. It is listed in jsxPrecompileSkipElements in deno.json, so the JSX precompiler does not try to optimise the unknown tag.
  3. The web component itself is loaded from unpkg in routes/_app.tsx.

btoa + JSON.stringify run on every error render. Cheap, but avoidable — the string is constant and could be hoisted to a module-level const.

The card offers a single "Go Home" <a href="/">.

There used to be a "Go Back" button beside it with an onClick={() => globalThis.history.back()} handler. In a server-only component with an empty islands/, that handler was never shipped to the browser: the button rendered, styled, hovered — and did nothing. It has been removed rather than converted into the project's first island, which would have meant shipping a hydration bundle on error pages to duplicate the browser's own back button.

It remains the clearest illustration of why the no-islands rule matters: nothing failed loudly.


QRCodeSVG

Not a component. A plain function returning a string:

export const QRCodeSVG = ({ url, size = 200 }: QRCodeProps): string => {
  const qr = new QRCodeSVGLib({
    content: url,
    width: size,
    height: size,
    padding: 1,
    color: "#1a1a1a",
    background: "#ffffff",
    ecl: "M",
  });
  return qr.svg();
};

Called from the route, not from JSX, and its result is passed to BusinessCard as a prop. The default size is 200 but the only caller passes 160.

The import goes through the imports map in deno.json, with a @ts-types directive pointing at lib/qrcode-svg.d.ts — the package ships no declarations and has no @types counterpart, so without that directive deno check cannot resolve the module.


LinkPageView

Given a validated link, returns its <Head> — title, description and <SocialMeta> — and the component for its type. For alpha it first re-validates the node tree through the registry, falling back to LinkUnavailable if the registry no longer has a node the stored document names.

It exists so that routes/o/[uuid].tsx and the builder's /builder/preview render through one code path. A preview that re-implements the dispatch is a preview that will eventually disagree with the page it claims to show, and the disagreement would only surface after someone published. Loading, status codes and the mikro branch stay in the route, because they are about answering a request rather than rendering a link.

LinkUnavailable is exported separately because two callers need exactly that markup: a record that fails parseLink at load, and one that passes but whose document the registry then rejects.

components/builder/

The builder's form controls — Field.tsx (labelled inputs), ImageField.tsx (upload or URL, with a thumbnail), Repeater.tsx (any list, with add/remove/reorder), Shell.tsx (the frame), the per-type data forms, and custom/ for the block-tree editor. Every one is presentational and stateless: a value in, a change out, with all state in islands/builder/Builder.tsx. A form that owns no state cannot hold a value that disagrees with the record being built.

They are the one place in components/ where onClick is expected to work, because they are only ever rendered inside that island. They also import nothing from the rest of the app — that boundary is enforced by tests/builder_boundary_test.ts. Full design: BUILDER.md.

Shell.tsx carries a warning worth repeating: the min-h-0 classes on its flex children are load-bearing. Without them an overflow-y-auto pane never becomes a scroll container and the editor is clipped at the viewport, which is how the first version shipped.

Shared conventions

Patterns every component follows. Keep to them.

Icons are inline SVG, never a library. Each icon is a 24×24 viewBox with a single hardcoded <path>, either as a tiny local component (BusinessCard) or as an entry in a getIconPath map (LinksPage, ShowcasePage). This keeps the pages dependency-free and zero-request, at the cost of long path strings in source and the duplication noted above.

Theming is inline style, not Tailwind classes. config.primaryColor arrives at runtime, so Tailwind cannot generate a class for it:

<div style={{ backgroundColor: config.primaryColor }}>

See STYLING.md.

The mobile card shell. BusinessCard and LinksPage share this outer structure:

<div class="min-h-screen bg-linear-to-br from-slate-50 to-slate-100 md:flex md:items-center md:justify-center md:p-4">
  <div class="w-full md:max-w-md bg-white md:rounded-2xl md:shadow-xl overflow-hidden flex flex-col min-h-screen md:min-h-0">

Full-bleed on phones, a centred floating sheet from md up. ShowcasePage deliberately breaks this pattern with max-w-7xl.

Optional config is guarded with &&. {config.footer && (…)}, {config.whatsapp && (…)}, {config.socialLinks && config.socialLinks.length > 0 && (…)}.

Keys on mapped lists use `${block.title}-${index}` rather than the bare index.

class, not className. Preact accepts both; this codebase uses class throughout.

Test coverage

Every page component here is rendered to HTML and snapshotted in tests/render_test.ts, against synthetic fixtures chosen to exercise the optional-field branches no live link reaches. A changed snapshot is a rendering change.

components/builder/ has no snapshots — it renders only inside the builder island, and what matters about it is tested through the state helpers in tests/builder_test.ts and the preview route's contract in tests/app_test.ts.