274 lines
16 KiB
Markdown
274 lines
16 KiB
Markdown
# @wrnexus/styles
|
|
|
|
## Reusable layers and presets
|
|
|
|
Compose local or package foundations in order; later layers override earlier
|
|
ones and the application has final base-config precedence:
|
|
|
|
```ts
|
|
export default defineConfig({
|
|
extends: ["@workroot/wrnexus-enterprise", "./layers/company"],
|
|
profiles: { production: { port: 8080 } },
|
|
});
|
|
```
|
|
|
|
A directory layer exports `wrnexus.layer.ts` (JavaScript/MJS are supported).
|
|
A package can provide that conventional file or declare
|
|
`wrnexus.layer` in its `package.json`. Layers may extend other layers and carry
|
|
the complete app configuration, including plugins that contribute layouts,
|
|
components, routes, middleware, and migrations. `plugins` and `head` compose;
|
|
other arrays intentionally replace earlier values. Cycles and missing/invalid
|
|
entries fail with stable `WRN-CONFIG-LAYER-*` diagnostics. `wrnexus config
|
|
--explain` lists every resolved layer source.
|
|
|
|
> Global CSS bundling, the `--wire-*` design-token theme system, and the `wrnexus.config.ts` app-config loader for WrNexus apps.
|
|
|
|
Part of the **WrNexus** framework — an SSR-first, Bun-native full-stack web framework.
|
|
|
|
## Overview
|
|
|
|
This package owns three server-side concerns that shape every page a WrNexus app renders:
|
|
|
|
1. **Global stylesheet pipeline** — finds `app/styles/global.css` (or aggregates `app/styles/*.css`), bundles it with Bun's CSS bundler (which resolves `@import`, including from `node_modules`), and produces one stylesheet that is `<link>`ed into every page's `<head>`. Because it is a plain global sheet, it styles server-rendered markup and hydrated client islands identically. A custom `process` hook lets you swap in Tailwind / PostCSS / Sass.
|
|
2. **Theme system** — design tokens exposed as CSS custom properties (`--wire-<key>`), with built-in `light`/`dark` sets, deep-merged user overrides, an SSR `<html data-theme>` render (no flash), and a tiny client runtime to toggle/persist the choice.
|
|
3. **App config** — loads `wrnexus.config.ts` (the `AppConfig` type), applies named profile overrides, and loads the `.env` cascade.
|
|
|
|
It runs server-side / at build time. Reach for it when configuring an app, defining themes, or customising how global CSS is produced.
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
bun add @wrnexus/styles
|
|
```
|
|
|
|
> Private package — the machine must be authenticated to the `wrnexus` npm org
|
|
> (a read token in `~/.npmrc`). Requires **Bun** (Node is not supported).
|
|
|
|
## API
|
|
|
|
Everything is exported from the package root (`@wrnexus/styles`).
|
|
|
|
### Config loading
|
|
|
|
| Export | Signature | Purpose |
|
|
| ---------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `loadAppConfig` | `(appRoot: string, profile?: string) => Promise<AppConfig>` | Load `wrnexus.config.*` with the active profile deep-merged in (`profiles` stripped from the result). |
|
|
| `loadRawConfig` | `(appRoot: string) => Promise<AppConfig>` | Load the raw config with the `profiles` map intact; returns `{}` if no config file exists. |
|
|
| `resolveProfile` | `(options?: { explicit?; mode? }) => string` | Resolve the active profile: explicit arg > `WRNEXUS_PROFILE` env var > mode-based default (`production` in prod, else `development`). |
|
|
| `loadEnv` | `(appRoot: string, profile: string) => Record<string, string>` | Load the `.env` cascade for a profile into `process.env` without clobbering real env vars. Returns what it loaded. |
|
|
| `headToString` | `(head?: string \| string[]) => string` | Flatten `AppConfig.head` into a single HTML string. |
|
|
|
|
Config file names probed, in order: `wrnexus.config.ts`, `wrnexus.config.js`, `wrnexus.config.mjs`.
|
|
|
|
`.env` cascade precedence (low → high): `.env` < `.env.<profile>` < `.env.local` < `.env.<profile>.local`. Variables already present in the real environment always win.
|
|
|
|
### `AppConfig`
|
|
|
|
The type of the object your `wrnexus.config.ts` default-exports. Every field is optional.
|
|
|
|
| Field | Type | Description |
|
|
| ----------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `head` | `string \| string[]` | Raw HTML appended to every page's `<head>` (e.g. CDN stylesheet/script links). |
|
|
| `seo` | `SeoConfig` | Global SEO defaults, merged with each page's exported `meta`. (from `@wrnexus/core`) |
|
|
| `security` | `SecurityConfig` | Framework security headers and optional CORS policy. (from `@wrnexus/core`) |
|
|
| `styles` | `StylesConfig` | Global stylesheet pipeline config (see below). |
|
|
| `theme` | `ThemeConfig` | Design-token themes, deep-merged over the built-in light/dark. |
|
|
| `i18n` | `{ default?: string; locales?: string[] }` | Default language + supported locales (strings live in `app/locales/*.json`). |
|
|
| `db` | `{ driver: "sqlite" \| "postgres" \| "mysql" \| "mongo"; url: string }` | Default database connection; reached with `getDb()`. |
|
|
| `databases` | `Record<string, { driver; url }>` | Additional named databases, reached with `getDb("<name>")`; each has its own `app/db/<name>/` migrations/queries. |
|
|
| `realtime` | `{ scale?: boolean; redisUrl?: string }` | When `scale` is true (or `redisUrl` is set), room broadcasts bridge over Redis pub/sub so they reach clients on every app process. |
|
|
| `port` | `number` | Default server port. |
|
|
| `profiles` | `Record<string, Partial<Omit<AppConfig, "profiles">>>` | Named profiles (dev, prod, uat, test, …). The active profile's overrides are deep-merged over the base config. Selected via `--profile=<name>` or `WRNEXUS_PROFILE`. |
|
|
|
|
### Styles pipeline
|
|
|
|
| Export | Signature | Purpose |
|
|
| ---------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `findStyleEntry` | `(appDir, appRoot, override?) => string \| null` | Resolve the CSS entry: `override` (relative to `appRoot`) → `app/styles/global.css` → an aggregate of all `app/styles/*.css` (written to `app/.wrnexus/styles-entry.css`). `null` if the app has no styles. |
|
|
| `bundleCss` | `(entryPath: string, mode: Mode) => Promise<string>` | Bundle an entry with `Bun.build` (CSS bundler). Resolves `@import` (local + node_modules), handles nesting, minifies when `mode === "production"`. |
|
|
| `renderStyles` | `(ctx: StyleProcessContext, styles?: StylesConfig) => Promise<string>` | Produce final CSS: runs `styles.process(ctx)` if provided, else `bundleCss`. Returns `""` when `ctx.entryPath` is null. |
|
|
|
|
`StylesConfig`:
|
|
|
|
```ts
|
|
interface StylesConfig {
|
|
/** CSS entry path relative to the app root. Default: app/styles/global.css */
|
|
entry?: string;
|
|
/** Custom processor — return the final CSS string (Tailwind/PostCSS/Sass). */
|
|
process?: (ctx: StyleProcessContext) => string | Promise<string>;
|
|
}
|
|
|
|
interface StyleProcessContext {
|
|
entryPath: string | null; // resolved absolute CSS entry, or null
|
|
appDir: string;
|
|
appRoot: string;
|
|
mode: Mode; // "development" | "production"
|
|
}
|
|
```
|
|
|
|
### Theme system
|
|
|
|
| Export | Type / Signature | Purpose |
|
|
| -------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| `DEFAULT_THEMES` | `Record<string, ThemeTokens>` | Built-in `light` and `dark` token maps. |
|
|
| `THEME_COOKIE` | `"wire-theme"` | Cookie the resolved theme is read from / persisted to. |
|
|
| `THEME_CSS_HREF` | `"/__wrnexus/theme.css"` | URL the generated theme stylesheet is served at. |
|
|
| `THEME_JS_HREF` | `"/__wrnexus/theme.js"` | URL the client theme runtime is served at. |
|
|
| `resolveThemeConfig` | `(config?: ThemeConfig) => ResolvedTheme` | Deep-merge the user's `theme` config over the defaults; pick the default theme (config's `default` if valid, else `dark`, else the first). |
|
|
| `resolveThemeName` | `(cookieValue: string \| undefined, theme: ResolvedTheme) => string` | Pick a valid theme name from a cookie, falling back to `theme.default`. |
|
|
| `renderThemeCss` | `(theme: ResolvedTheme) => string` | Generate the theme stylesheet: a `:root{…}` default plus one `[data-theme="<name>"]{…}` block per theme. |
|
|
| `renderThemeRuntime` | `(theme: ResolvedTheme) => string` | Generate the client runtime (see below). |
|
|
|
|
Tokens are emitted as `--wire-<key>` custom properties, **except** the reserved key `color-scheme`, which is emitted as the native `color-scheme` CSS property so form controls and scrollbars match the theme.
|
|
|
|
`ThemeConfig` / `ThemeTokens` / `ResolvedTheme`:
|
|
|
|
```ts
|
|
type ThemeTokens = Record<string, string>;
|
|
|
|
interface ThemeConfig {
|
|
palette?: ThemePaletteName | CustomThemePalette;
|
|
default?: string; // theme used when no cookie is present
|
|
themes?: Record<string, ThemeTokens>; // deep-merged over built-in light/dark
|
|
}
|
|
|
|
interface ResolvedTheme {
|
|
default: string;
|
|
names: string[];
|
|
themes: Record<string, ThemeTokens>;
|
|
}
|
|
```
|
|
|
|
Built-in palettes are `blue`, `indigo`, `violet`, `emerald`, `cyan`, `rose`,
|
|
`amber`, and `slate`. Each supplies primary, secondary, info, success, warning,
|
|
danger/error, hover, and contrast colors to every light/dark theme. Built-in
|
|
token keys also include surfaces, text, borders, radii, fonts, and shadows.
|
|
|
|
A custom palette is intentionally complete, so components never fall back to an
|
|
unrelated blue status or action color:
|
|
|
|
```ts
|
|
theme: {
|
|
palette: {
|
|
primary: "#7c3aed",
|
|
primaryHover: "#6d28d9",
|
|
primaryContrast: "#ffffff",
|
|
secondary: "#db2777",
|
|
secondaryHover: "#be185d",
|
|
secondaryContrast: "#ffffff",
|
|
info: "#2563eb",
|
|
success: "#059669",
|
|
warning: "#d97706",
|
|
danger: "#dc2626",
|
|
},
|
|
}
|
|
```
|
|
|
|
The client runtime (`renderThemeRuntime`) exposes `window.wireTheme` with `{ get, set, toggle, bind, themes }`, wires up any `[data-wire-theme-toggle]` and `[data-wire-theme-set]` elements on load, and persists the choice to the `wire-theme` cookie (`max-age` 1 year, `samesite=lax`). `toggle()` cycles through the configured theme names in order.
|
|
|
|
## Usage
|
|
|
|
### `wrnexus.config.ts`
|
|
|
|
```ts
|
|
import type { AppConfig } from "@wrnexus/styles";
|
|
|
|
export default {
|
|
head: [
|
|
'<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css">',
|
|
],
|
|
port: 3000,
|
|
db: { driver: "sqlite", url: "app.db" },
|
|
theme: {
|
|
palette: "violet",
|
|
default: "dark",
|
|
themes: {
|
|
light: { "color-primary": "#7c3aed" }, // override one token; rest inherited
|
|
brand: {
|
|
// add a whole new theme
|
|
"color-scheme": "dark",
|
|
"color-bg": "#0a0a0a",
|
|
"color-primary": "#22d3ee",
|
|
},
|
|
},
|
|
},
|
|
styles: {
|
|
entry: "app/styles/main.css",
|
|
},
|
|
profiles: {
|
|
production: {
|
|
db: { driver: "postgres", url: process.env.DATABASE_URL! },
|
|
},
|
|
},
|
|
} satisfies AppConfig;
|
|
```
|
|
|
|
### Loading config + producing CSS
|
|
|
|
```ts
|
|
import {
|
|
loadAppConfig,
|
|
resolveProfile,
|
|
loadEnv,
|
|
findStyleEntry,
|
|
renderStyles,
|
|
} from "@wrnexus/styles";
|
|
|
|
const appRoot = process.cwd();
|
|
const mode = "production" as const;
|
|
|
|
const profile = resolveProfile({ mode });
|
|
loadEnv(appRoot, profile);
|
|
|
|
const config = await loadAppConfig(appRoot, profile);
|
|
|
|
const appDir = `${appRoot}/app`;
|
|
const entryPath = findStyleEntry(appDir, appRoot, config.styles?.entry);
|
|
const css = await renderStyles({ entryPath, appDir, appRoot, mode }, config.styles);
|
|
```
|
|
|
|
### Rendering the theme
|
|
|
|
```ts
|
|
import {
|
|
resolveThemeConfig,
|
|
resolveThemeName,
|
|
renderThemeCss,
|
|
renderThemeRuntime,
|
|
THEME_COOKIE,
|
|
} from "@wrnexus/styles";
|
|
|
|
const theme = resolveThemeConfig(config.theme);
|
|
|
|
// Server: pick the active theme from the request cookie (no flash).
|
|
const active = resolveThemeName(cookies[THEME_COOKIE], theme);
|
|
// → render <html data-theme={active}>
|
|
|
|
const themeCss = renderThemeCss(theme); // served at THEME_CSS_HREF
|
|
const themeJs = renderThemeRuntime(theme); // served at THEME_JS_HREF
|
|
```
|
|
|
|
In templates, consume tokens via the custom properties:
|
|
|
|
```css
|
|
.card {
|
|
background: var(--wire-color-surface);
|
|
color: var(--wire-color-text);
|
|
border: 1px solid var(--wire-color-border);
|
|
border-radius: var(--wire-radius);
|
|
box-shadow: var(--wire-shadow-1);
|
|
}
|
|
```
|
|
|
|
```html
|
|
<button data-wire-theme-toggle>Toggle theme</button>
|
|
<button data-wire-theme-set="brand">Brand theme</button>
|
|
```
|
|
|
|
## Requirements / Notes
|
|
|
|
- **Bun-only.** `bundleCss` uses `Bun.build`'s CSS bundler for `@import` resolution, nesting, and minification. Node is not supported.
|
|
- Config and env loading use `node:fs` / `node:path` / `node:url` and read from `process.env`.
|
|
- Peer package: `@wrnexus/core` supplies the `SeoConfig` and `SecurityConfig` types referenced by `AppConfig`.
|
|
- The bundled global stylesheet, the theme stylesheet (`THEME_CSS_HREF`), and the theme runtime (`THEME_JS_HREF`) are wired into pages by the framework's server; this package only produces their contents.
|