Styling

Tailwind CSS v4, CSS-first configuration, no tailwind.config.js.

Setup

assets/styles.css is three imports and nothing else:

@import "tailwindcss";
@import "./alpha/design-system.css"; /* scoped to #alpha */
@import "./home.css"; /* scoped to #home  */

Both partials are scoped to an id, and that is load-bearing rather than tidy. Anything targeting body, html or ::selection unscoped would follow every business card and links page served from the same deployment. A rule in home.css may only match inside #home; a rule in the Alpha design system may only match inside #alpha.

Only what utilities cannot express belongs in either file: theme variables, @keyframes, the home page's animated background layers and preview-stage scaling, and the handful of element rules that style rawHtml output — markup that arrives from a stored record and therefore cannot carry a class.

That is Tailwind v4's CSS-first entry point — it replaces the v3 @tailwind base; @tailwind components; @tailwind utilities; triple and the JavaScript config file. There is no tailwind.config.js in this project and adding one would have no effect; v4 customisation goes in CSS via @theme.

It is wired up in three steps:

  1. vite.config.ts registers the plugin:
    export default defineConfig({ plugins: [fresh(), tailwindcss()] });
    
  2. client.ts imports the stylesheet so Vite can see it and hot-reload it:
    // Import CSS files here for hot module reloading to work.
    import "./assets/styles.css";
    
  3. The Fresh Vite plugin injects the built stylesheet into the page. Note that routes/_app.tsx contains no <link rel="stylesheet"> — do not add one.

Content scanning is automatic in v4; there is no content: [] array to maintain.

Theming: why colours are inline styles

config.primaryColor is a per-link value read from JSON at request time. Tailwind generates classes at build time by scanning source files, so it can never produce a class for a colour it has not seen. Every brand colour is therefore applied as an inline style:

<div class="sticky top-0 z-10 h-28 md:h-40 …" style={{ backgroundColor: config.primaryColor }}>

Where primaryColor is applied:

Surface Property Component(s)
Sticky header background backgroundColor all three
Save Contact button backgroundColor BusinessCard
Tile icon circles backgroundColor LinksPage
Card action buttons backgroundColor ShowcasePage
Feature checkmarks color ShowcasePage
Social bar icons color ShowcasePage

The consequence: Tailwind variants do not apply to these properties. You cannot write hover: or dark: against primaryColor. Components work around it with opacity/transform utilities that are static — hover:opacity-90, hover:scale-105, active:scale-[0.98] — which is the pattern to follow.

Everything that is not brand-coloured uses the standard Tailwind palette: slate for surfaces and text, green-500 for WhatsApp, red-500 for errors, blue/purple/indigo accents for icon chips.

v4 syntax in use

Several utilities in this codebase are v4 spellings and will look wrong if you are used to v3. Do not "fix" them:

v4 (used here) v3 equivalent Where
bg-linear-to-br bg-gradient-to-br every page background
shrink-0 flex-shrink-0 BusinessCard, ShowcasePage
grow flex-grow ShowcasePage cards

Also present: arbitrary values (top-[72px], active:scale-[0.98]) and the line-clamp-2 utility, which is core in v4 rather than a plugin.

Responsive strategy

Mobile-first. Base classes target phones; md: (768 px) is the primary breakpoint, with lg: used only for the showcase-page card grid. Recurring patterns:

The card shell — full-bleed on phones, floating sheet on desktop:

min-h-screen … md:flex md:items-center md:justify-center md:p-4
w-full md:max-w-md bg-white md:rounded-2xl md:shadow-xl … min-h-screen md:min-h-0

Header sizingh-28 md:h-40, logos h-16 md:h-24 (h-12 md:h-16 on showcase-page).

Thumb-reachable CTA — the Save Contact button is fixed bottom-0 left-0 right-0 on mobile and md:relative on desktop, with pb-16 md:pb-6 on the scroll area to clear it.

Progressive disclosure — the "Powered by onnne.link" strip is hidden md:block.

Grid densitylinks-page stays at grid-cols-2 on all sizes; showcase-page goes grid-cols-1 md:grid-cols-2 lg:grid-cols-3.

Hardcoded sticky offsets

ShowcasePage's social bar is positioned with top-[72px] md:top-[88px], matching the rendered height of the header above it. These are magic numbers coupled to the header's padding and logo height. If you change py-4 md:py-6 or the logo's h-12 md:h-16 in that header, you must recompute these offsets or the two sticky bars will overlap.

JSX precompilation and the skip list

deno.json sets:

"jsx": "precompile",
"jsxImportSource": "preact",
"jsxPrecompileSkipElements": [
  "a", "img", "source", "body", "html", "head", "title", "meta",
  "script", "link", "style", "base", "noscript", "template", "lottie-player"
]

precompile mode compiles static JSX subtrees straight to HTML strings, which is a large SSR speed win. The skip list names elements that must stay real vnodes instead:

  • a and img — Fresh needs them as vnodes to rewrite href/src (client-side navigation, asset hashing). These matter here: every page is mostly links and logos.
  • html, head, body, title, meta, link, script, … — required so Fresh's <Head> component can hoist and merge head content from route files.
  • lottie-player — a custom element the precompiler does not know; skipping it keeps the tag and its attributes intact.

If you introduce another custom element, add it to this list and declare it in types.d.ts, the same way lottie-player is.

Formatting

deno fmt owns formatting for .tsx, .ts, .json, .md — 2-space indent, double quotes, 80-column preference. deno task check runs deno fmt --check and fails CI-style on drift, so run deno fmt . before committing, including after editing these docs.

Long Tailwind class strings are left on one line; deno fmt does not wrap string literals.

.vscode/settings.json sets the Deno extension as the default formatter for TS/TSX/JS/JSX, and associates *.css with the tailwindcss language mode so the Tailwind IntelliSense extension understands @import "tailwindcss".

Adding styles

In order of preference:

  1. Tailwind utility classes — the default for everything.
  2. Inline style — only for values that come from LinkConfig at runtime.
  3. @theme in assets/styles.css — for genuinely global design tokens (a brand font, a shared custom colour). This is the v4 way to extend the palette; there is currently no @theme block.
  4. Raw CSS in assets/styles.css — last resort.

There are no CSS modules, no styled-components, and no component-scoped stylesheets.