palinxdocs

Styling

Palinx links your stylesheets for you. Put plain CSS in src/styles/global.css, or compile Sass or Tailwind to public/styles.css, and every page gets them, in development and in production, in the same order.

Where styles live

Palinx looks for two files and links each one that exists, on every page:

File What it's for
src/styles/global.css Plain CSS you write by hand. Served as it is; there is no build step.
public/styles.css The output of a CSS tool such as Sass or Tailwind. Your tool writes it; palinx serves it like any other file in public/.

You don't add <link> tags yourself. A project can use either file, both, or neither.

Pages link stylesheets in this order, under both px dev and the built app:

  1. src/styles/global.css
  2. public/styles.css
  3. Component styles from css`...` templates (below)

Later stylesheets win when two rules have the same specificity, so component styles override global ones.

Component styles

A component can carry its own styles with the css`...` tag from @palinx/core. The rules are scoped: palinx prefixes each selector with a generated class, and you put that class on the component's root element.

import { Component, html, css } from "@palinx/core";

const styles = css`
  .badge { color: rgb(109, 92, 255); }
`;

export class Badge extends Component {
  render() {
    return html`<span class=${styles.scope}><b class="badge">New</b></span>`;
  }
}

The rule becomes .ov-… .badge { … }, so it only matches inside elements carrying styles.scope. Wrap a selector in :global(...) to leave it unscoped.

When a template has no ${} expressions, palinx extracts it ahead of time, in px dev as well as px build, into one component stylesheet that is linked with the others, and the server-rendered HTML already carries the scope class. The component is styled at first paint, before any JavaScript runs, on prerendered pages, on server-only pages and on every page under px dev. In production, pages with route parameters (such as /items/[id]) aren't server-rendered, so their components are styled as soon as the client renders them.

A template that does use ${} stays a runtime call: it adds its own <style> tag when the component's module loads, so its rules apply from hydration.

Which tags palinx compiles

palinx only compiles a template when it can tell the tag is its own.

  • css is extracted when it is imported from @palinx/core in the same file, under its own name or an alias (import { css as style } from "@palinx/core"). A css reached any other way, such as through import * as core, through a module of yours that re-exports it, or under a name the file also uses for something else (a parameter called css, say), isn't extracted. It still works: it runs as a runtime call and its rules apply from hydration.
  • html is compiled when its tag is imported from @palinx/core by name, by alias or through a namespace (core.html), or from a module of yours that re-exports it. If palinx can't confirm a tag is its own and the template has @event handlers, the build fails with the file, the line and the reason, for example that html is passed as a value at line 12. Uncompiled, those handlers would run while the page renders instead of on the event. Rename the other use, or import html directly from @palinx/core.

Live reload

Under px dev, saving a style change applies it without reloading the page:

  • a css`...` template in a component;
  • src/styles/global.css;
  • public/styles.css when your Sass or Tailwind watcher rewrites it.

Each changed stylesheet is swapped in once the new copy has loaded, so the page doesn't flash unstyled.

Choosing styling when you scaffold

webapp and frontend ask which styling to use. Plain CSS is the default:

px scaffold webapp my-app                    # plain CSS
px scaffold webapp my-app --styles sass
px scaffold webapp my-app --styles tailwind

Run px scaffold without a template and the menu asks after you pick webapp or frontend, with plain CSS preselected. The other templates don't take --styles: minimal has no styling, and api and taskrunner style their status page inline.

All three options use the same markup and look the same. The template's pages use short class names, defined once in a shared stylesheet. Here is the home page's feature card and its rules:

src/routes/index.route.ts (excerpt, lines 6–13 of 53)
  private feature(icon: Node, title: string, desc: string) {
    return html`
      <div class="card">
        <div class="card-icon">${icon}</div>
        <h3 class="card-title">${title}</h3>
        <p class="card-text">${desc}</p>
      </div>
    `;
src/styles/app.css (excerpt, lines 93–104 of 194)
/* Cards */
.card {
  display: flex; flex-direction: column; gap: 0.5rem; padding: 1.25rem;
  border-radius: 0.75rem; border: 1px solid var(--color-surface-border); background-color: var(--color-surface);
}
.card-icon {
  display: flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem; border-radius: 0.5rem;
  border: 1px solid var(--color-surface-border); background-color: var(--color-surface-raised); color: var(--color-accent);
}
.card-title { font-size: 0.875rem; line-height: 1.25rem; font-weight: 600; color: var(--color-text-primary); }
.card-text { font-size: 0.75rem; line-height: 1.625; color: var(--color-text-muted); }

What each option adds:

  • Plain CSS: that stylesheet is your src/styles/global.css. No build step and no extra dependencies.
  • Sass: the stylesheet is src/styles/app.css, and src/styles/main.scss pulls it in and holds your own Sass. It compiles to public/styles.css.
  • Tailwind: the stylesheet is src/styles/app.css, and src/styles/input.css imports Tailwind and the app's styles. It compiles to public/styles.css.

Adding Sass to an existing project

Install Sass, and concurrently to run its watcher next to px dev:

bun add -d sass@^1 concurrently@^9

Create src/styles/main.scss. This is the one the webapp template ships; @use "app" loads src/styles/app.css, so drop that line if you don't have one:

styles/sass/src/styles/main.scss
// Sass entry, compiled to public/styles.css by `bun run build:css`
// (and by `bun run dev:css` while you develop).

// The app's own styles.
@use "app";

// Add your own rules below. A variable and nesting, for example:
$accent: #6d5cff;

.callout {
  padding: 1rem 1.25rem;
  border-left: 3px solid $accent;
  border-radius: 0.5rem;

  strong {
    color: $accent;
  }
}

Then set these scripts in package.json:

{
  "scripts": {
    "dev": "concurrently \"px dev\" \"bun run dev:css\"",
    "build": "bun run build:css && px build",
    "dev:css": "sass --watch --no-source-map src/styles/main.scss public/styles.css",
    "build:css": "sass --style=compressed --no-source-map src/styles/main.scss public/styles.css"
  }
}

The watcher skips source maps on purpose: a styles.css.map left in public/ would be copied into your production build and publish your Sass source. Add public/styles.css to .gitignore, since it's build output.

Adding Tailwind to an existing project

bun add -d tailwindcss@^4 @tailwindcss/cli@^4 concurrently@^9

Create src/styles/input.css. This is the webapp template's; @import "./app.css" layer(components) loads src/styles/app.css, so drop that line if you don't have one:

styles/tailwind/src/styles/input.css
/* Tailwind entry, compiled to public/styles.css by `bun run build:css`
   (and by `bun run dev:css` while you develop). */
@import "tailwindcss";

/* The app's own styles. In the components layer, so a utility class you add
   to an element still wins over them. */
@import "./app.css" layer(components);

/* The app's colours as Tailwind tokens, so utilities like bg-surface or
   text-accent match the rest of the app. */
@theme {
  --color-surface: #14161f;
  --color-surface-raised: #191c27;
  --color-surface-border: #242736;
  --color-text-primary: #f2f3f9;
  --color-text-secondary: #b6b9cc;
  --color-text-muted: #757994;
  --color-accent: #6d5cff;
  --color-accent-hover: #8b7dff;
}

It imports the app's own styles into Tailwind's components layer, so a utility class you add to an element still overrides them. The @theme block makes the app's colours available as utilities such as bg-surface and text-accent.

If you keep your rules in src/styles/global.css instead, palinx still links it, but those rules sit outside Tailwind's layers, and unlayered CSS beats every layer: bg-red-500 on an element that global.css also styles won't apply. To let utilities win, rename it to src/styles/app.css and keep the @import line above, as described in Switching between options.

Then set these scripts:

{
  "scripts": {
    "dev": "concurrently \"px dev\" \"bun run dev:css\"",
    "build": "bun run build:css && px build",
    "dev:css": "tailwindcss -i src/styles/input.css -o public/styles.css --watch",
    "build:css": "tailwindcss -i src/styles/input.css -o public/styles.css --minify"
  }
}

As with Sass, public/styles.css is build output; keep it out of version control.

Switching between options

The markup doesn't change between options, only where the rules live and what compiles them:

  • Plain CSS to Sass or Tailwind: rename src/styles/global.css to src/styles/app.css, then follow the setup above. Delete global.css if you kept a copy, or palinx links both.
  • Sass or Tailwind to plain CSS: rename src/styles/app.css to src/styles/global.css, move any rules you wrote in main.scss or input.css into it as plain CSS, then delete the entry file, public/styles.css, the dev:px, dev:css and build:css scripts, and the tool's dependencies. Set dev back to px dev and build to px build.
  • Sass to Tailwind, or back: replace the entry file and the two scripts, and swap the dependencies. src/styles/app.css stays as it is.

Next

Services & dependency injection covers the classes that hold your app's logic.