Files
open-design/nix
lefarcenandGitHub 7f03030f3f perf(landing): self-host fonts + inline critical CSS (#2599)
* perf(landing): self-host fonts + inline critical CSS

PageSpeed Insights flagged ~2.3s of render-blocking on /:
  globals.css   12.9 KB external link, 160ms
  fonts CSS     2.2 KB  fonts.googleapis.com, 750ms
  + 4 woff2     ~1200ms each from fonts.gstatic.com

Two changes drop that whole chain:

1. Self-host fonts via @fontsource-variable/{inter,inter-tight,
   playfair-display,jetbrains-mono}. Each family ships a single variable
   woff2 (covers all weights we use) that Astro bundles into /_astro/*
   alongside the rest of the build, served same-origin through CF Pages —
   no separate TLS handshake, no Google Fonts CSS round-trip. The CSS
   variable names get an extra alias in front (`'Inter Tight Variable',
   'Inter Tight', ...`) so a system fallback still works if the package
   ever ships under a different family name.

2. `astro.config.ts: build.inlineStylesheets: 'always'` inlines every
   emitted <style> into the HTML <head> instead of emitting a separate
   /_astro/*.css link. The HTML grows from ~13KB to ~28KB (gzip) but
   loses one stylesheet round-trip + the entire @font-face chain that
   used to gate text rendering.

Component cleanup: the `<FontStylesheet>` component (preconnect + link to
fonts.googleapis.com) is no longer needed and is deleted, removed from
all 7 places that mounted it. og.astro keeps its own font setup since
it renders to a screenshot.

Expected effect (from PageSpeed Insights "Render-blocking requests"
diagnostic on the previous build):
  FCP  1.9s → ~1.2s
  LCP  2.2s → ~1.5s

Verified: pnpm typecheck 0 errors, pnpm build 1853 pages 78s, preview
serves /_astro/*.woff2 as font/woff2 same-origin, 0 fonts.googleapis or
fonts.gstatic references in the built HTML.

* perf(landing): include Playfair italic + bump nix pnpm-deps hash

Two follow-ups on the self-host fonts PR:

1. globals.css imported only `@fontsource-variable/playfair-display`,
   which ships @font-face for font-style: normal only. The previous
   Google Fonts URL included the italic axis (`ital,wght@0,500;1,400;
   ...`) and several rules (.roman, .work-rule .roman, .sec-rule .roman,
   plus 8 other italics across globals.css + sub-pages.css) render
   Playfair italics via `font-family: var(--serif); font-style: italic`.
   Without the italic face self-hosted, those would fall through to
   Times New Roman italic or browser synthesis. Adding
   `wght-italic.css` keeps the typography visually equivalent.

2. nix/pnpm-deps.nix uses a fixed-output derivation hash that has to
   match the pnpm vendored store; adding the four fontsource packages
   changed pnpm-lock.yaml so the hash has to be bumped to the value Nix
   reported in CI.

Codex (Looper reviewer) flagged #1 as non-blocking.

* perf(landing): pin fontsource versions exactly per repo guard

`pnpm add` defaulted to caret ranges (`^5.2.8`) but repo guard rejects
non-exact specs ("dependency specs must be exact versions like 1.2.3 or
workspace:*"). That was the actual cause of the Preflight + Validate
workspace failures — pinning to the locked versions Codex reviewer
called out:

  @fontsource-variable/inter             5.2.8
  @fontsource-variable/inter-tight       5.2.7
  @fontsource-variable/jetbrains-mono    5.2.8
  @fontsource-variable/playfair-display  5.2.8

`pnpm guard` now passes locally (6/6 tests).
2026-05-22 11:49:16 +08:00
..

Open Design — Nix flake

This flake exposes Open Design as a reproducible package, a nix run entry point, a dev shell, and Home Manager / NixOS modules. The architecture mirrors the runtime: the daemon (od CLI, Express API on /api/*) and the web frontend (Next.js static SPA at apps/web/out/) are separate packages and separate services — you can run either or both.

Outputs

Output What it is
packages.<system>.daemon The @open-design/daemon package — produces bin/od. Default output.
packages.<system>.web The Next.js static export (apps/web/out/) ready to drop into any static file server.
apps.<system>.default nix run github:nexu-io/open-design — boots the daemon.
devShells.<system>.default Node 24 + Corepack-pinned pnpm 10.33 — reproduces pnpm install locally.
homeManagerModules.{default,open-design} Home Manager module — primary individual-developer interface.
nixosModules.{default,open-design} NixOS module — secondary, for shared/server installs.

Try it without installing

nix run github:nexu-io/open-design        # boots the daemon on :7457
nix develop github:nexu-io/open-design    # drop into the dev shell

For an individual workstation, add the flake as an input and import the default module:

{
  inputs.open-design.url = "github:nexu-io/open-design";

  outputs = { self, home-manager, open-design, ... }: {
    homeConfigurations.you = home-manager.lib.homeManagerConfiguration {
      modules = [
        open-design.homeManagerModules.default
        {
          services.open-design = {
            enable = true;
            autoStart = true;            # systemd --user / launchd agent
            webFrontend.enable = true;   # also run the static SPA on :5174
          };
        }
      ];
    };
  };
}

What this wires up:

  • Linux: systemd --user units open-design.service and (optionally) open-design-web.service. systemctl --user status open-design.
  • macOS: launchd agents io.nexu.open-design and (optionally) io.nexu.open-design-web. launchctl print gui/$UID/io.nexu.open-design.
  • Data lives in $HOME/.od/ by default — override dataDir to relocate.

(2) NixOS — for shared/server installs

{
  imports = [ inputs.open-design.nixosModules.default ];

  services.open-design = {
    enable = true;
    autoStart = true;
    openFirewall = true;
    webFrontend.enable = true;
    user = "open-design";
    group = "open-design";
  };
}

This creates a system user, drops a tmpfiles rule for /var/lib/open-design, and runs the daemon under hardened systemd (ProtectSystem=strict, PrivateTmp, ReadWritePaths scoped to the data directory). Use this when you want a single shared instance — for individual user configuration prefer the Home Manager module.

(3) webFrontend — when to use it, when to bring your own server

Open Design's frontend is a static SPA that issues relative /api/*, /artifacts/*, and /frames/* requests. Three serving options:

Option When
webFrontend.enable = true You want one-line setup. The module spawns a tiny Caddy file server on webFrontend.port (default 5174) that serves the SPA and reverse-proxies the three path prefixes to the daemon.
webFrontend.enable = false (default) You're running nginx / Caddy / Apache / Traefik yourself. Point your server's document root at ${pkgs.open-design.web} (or the packages.<system>.web output) and replicate the proxy contract in section (4).
Skip the frontend entirely You only need the daemon's API for headless agent dispatch.

The two services are independent. autoStart controls the daemon; webFrontend.enable controls the static server. Mix freely.

Bring-your-own-server gotcha: if your proxy listens on any origin that differs from the daemon's bind (different host or different port — even loopback split-port like http://127.0.0.1:8080 while the daemon stays on :7457), the daemon's same-origin gate will 403 the SPA's writes until you tell it about that origin. Either set services.open-design.webFrontend.allowedOrigins = [ "<your-proxy-origin>" ] (which feeds OD_ALLOWED_ORIGINS) or, for the loopback-only split-port case, set extraEnv.OD_WEB_PORT = "<proxy-port>". See section (4) for the full decision tree.

Exposing the bundled frontend on a non-loopback host

By default webFrontend.host = "127.0.0.1" so enabling the bundled caddy does not publish anything beyond loopback. To intentionally share with a LAN, two settings must be widened together — the modules assert at eval time that the second is set whenever the first is widened:

services.open-design.webFrontend = {
  enable = true;
  host = "0.0.0.0";  # caddy listener
  # Every external origin browsers will load the SPA from. The daemon
  # matches each entry against the browser's `Origin` header AND adds
  # its host:port to the `Host`-header allowlist (Caddy v2 reverse_proxy
  # preserves the original Host upstream by default), so list each
  # scheme + hostname combo you actually use.
  allowedOrigins = [
    "http://laptop.local:5174"
    "https://laptop.local:5174"
  ];
};
# On NixOS you also need:
services.open-design.openFirewall = true;

Under the hood allowedOrigins is forwarded to the daemon as the OD_ALLOWED_ORIGINS environment variable (comma-separated). If you run the daemon outside the modules — for example, behind your own nginx/caddy — set OD_ALLOWED_ORIGINS directly in the daemon's environment with the same shape:

OD_ALLOWED_ORIGINS=http://host1:port,https://host1:port,http://host2:port

Each entry must be a bare origin (scheme://host[:port]); only http:// and https:// schemes are accepted, and the daemon refuses to start if any entry fails to parse. The variable widens only the general /api/* same-origin gate — connector-credential and live-artifact preview/refresh routes stay strictly loopback-only by design.

(4) Same-origin proxying contract

The web package is built with OD_DAEMON_URL = "" so the bundled JS issues relative requests — /api/*, /artifacts/*, /frames/* — instead of baking a daemon URL into the export. There is no runtime config endpoint; the SPA does not read OD_DAEMON_URL from the serving environment.

The serving contract is therefore: the static export must be served same-origin with a reverse proxy to the daemon. The bundled caddy service does exactly this — webFrontend listens on webFrontend.port and reverse-proxies the three path prefixes above to 127.0.0.1:<cfg.port>, with flush_interval -1 and no encode on /api/* so SSE streams flush immediately (gzip would buffer chunked responses for ~80s and surface as ERR_INCOMPLETE_CHUNKED_ENCODING).

If you serve the static bundle yourself, replicate that shape:

  • Document root → ${pkgs.open-design.web} (or packages.<system>.web).
  • Reverse-proxy /api/*, /artifacts/*, /frames/* to the daemon's bind address; /api/* must stream chunks immediately and skip response compression.
  • SPA fallback for unmatched paths → index.html.

The static-server's environment does not need any Open Design env vars — but the daemon's environment usually does, because its same-origin gate is built from OD_BIND_HOST:port (loopback hosts included). The browser's Origin and Host are whatever your proxy exposes, so unless that matches 127.0.0.1:<daemon-port> exactly, the daemon will 403 every PUT/POST until told otherwise:

Your custom-server setup What to set on the daemon
Proxy at http://127.0.0.1:<daemon-port> (same host, same port — unusual) Nothing.
Proxy at a loopback host but different port (e.g. http://127.0.0.1:8080 while daemon is on :7457) Either extraEnv.OD_WEB_PORT = "8080" (whitelists 8080 on every loopback host) or services.open-design.webFrontend.allowedOrigins.
Proxy on any non-loopback host (LAN IP, mDNS name, Tailscale name, public domain — https://od.example.com, http://laptop.local:5174, …) services.open-design.webFrontend.allowedOrigins = [ "<full origin>" ]. List every scheme + host[:port] combo a browser might load the SPA from.

webFrontend.allowedOrigins is forwarded to the daemon as OD_ALLOWED_ORIGINS; if you run the daemon outside the modules, export OD_ALLOWED_ORIGINS directly with the same shape (see section (3)). The variable widens only the general /api/* gate — connector-credential and live-artifact preview/refresh routes stay strictly loopback-only by design.

(5) Secrets — DO NOT put them in your Nix config

The environmentFile option takes a path to a KEY=VALUE file that the service unit reads. Use it for BYOK API keys (Anthropic, OpenAI, Gemini), provider tokens, and anything else you do not want world-readable in /nix/store.

Recommended secret managers:

  • sops-nix — age- or PGP-encrypted YAML, decrypted into runtime files at activation.
  • agenix — age-encrypted single files, dropped into /run/agenix/ at boot.

Either renders to a file like /run/secrets/open-design.env; pass that path:

services.open-design.environmentFile = "/run/secrets/open-design.env";

Never inline a secret with pkgs.writeText or home.file.

First-build hash pinning

nix/pnpm-deps.nix is the single source of truth for the vendored pnpm store hash used by both nix/package-daemon.nix and nix/package-web.nix. If pnpm-lock.yaml changes, run:

pnpm nix:update-hash

The script temporarily swaps one consumer to lib.fakeHash, runs nix build .#web --print-build-logs, extracts the expected hash from the failure output, writes it back into nix/pnpm-deps.nix, and restores the consumer file.

CI

.github/workflows/nix-check.yml runs nix flake check on pushes to main and can also be started manually with workflow_dispatch.

Pull requests that touch Nix or dependency inputs are validated earlier in .github/workflows/ci.yml via the required Validate workspace gate. That PR path runs nix flake check when pnpm-lock.yaml, package manifests, flake.*, nix/**, or the Nix workflows change, so fixed- output hash drift is caught before merge while keeping unrelated PRs off the slower Nix path.