open-design/nix
Sid eda182c8a1
refactor(web): UI polish for v0.7.0 — neutralised palette, official brand glyphs, lucide (#1522)
* refactor(web): adopt lucide-react for the inline Icon component

The hand-rolled `<Icon>` set drifted in stroke weight and proportion across
its 50+ glyphs as new icons were added. Swap the implementation to dispatch
to `lucide-react` while keeping the same `<Icon name="..." size={X} />` API
so the 246 existing call sites stay untouched.

- Adds `lucide-react` as a dependency (tree-shaken; ~30KB gzipped for the
  ~50 icons we actually import).
- `discord` and `x-brand` keep their bespoke inline SVG paths since lucide
  intentionally does not ship brand artwork.
- `spinner` continues to use the existing `.icon-spin` className for its
  rotation; under the hood it now renders lucide's `Loader2`.
- New `paw` glyph (lucide `PawPrint`) so the Pets nav item stops sharing
  the `sparkles` icon with External MCP.

No behaviour change: the prop surface is identical, fill follows
`currentColor` exactly as before, and aria-hidden / focusable defaults are
preserved. Visual deltas are limited to the strokes themselves (slightly
finer endcaps, more consistent baseline weights) — exactly the
consistency upgrade lucide gives us.

* feat(web): bundle official brand assets for agent icons

`AgentIcon` previously approximated each agent's brand with hand-drawn
SVG (orange Anthropic-ish sparkle, OpenAI-knot ellipses, etc). Replace
those approximations with the real, vendor-published artwork shipped as
static assets under `apps/web/public/agent-icons/`.

- 13 SVG marks sourced from `@lobehub/icons-static-svg` (MIT) — color
  variants where the vendor published one (Claude, Codex, Gemini,
  Copilot, Qwen, Qoder, DeepSeek, Kimi, Mistral/Vibe), monochrome marks
  for the rest (Cursor, OpenCode, Hermes, MiMo, Pi, Kilo).
- 1 PNG mark (Devin) sourced from devin.ai/icon.png, resized to 96×96
  via `sips` since Cognition doesn't publish an SVG.
- Each SVG was cleaned (stripped `<title>` brand text and the library's
  internal `style="flex:none;..."` ; dropped `width/height="1em"` so
  `viewBox` governs sizing) and run through `svgo --multipass`. Total
  bundle footprint: ~36 KB for all 17 files, only loaded on the agent
  cards that render them.
- `AgentIcon` now resolves brands via a small `ICON_EXT` table and
  renders `<img src="/agent-icons/<id>.<ext>">`. Agents without an asset
  (`devin` is the lone outlier removed in this commit because PNG; new
  agents with no shipped artwork at all) fall back to an initial-letter
  pill that reads as "no official mark yet" rather than inventing
  brand artwork.
- Removes the `simple-icons` dependency from a previous iteration since
  `AgentIcon` was its only consumer.

Public-API stable: `<AgentIcon id={a.id} size={X} />` still accepts the
same prop shape; `AvatarMenu`'s small-size usage continues to work.

* refactor(web): polish entry view + Settings dialog UI for v0.7.0

A sweep over the two surfaces that have the most visual surface area in
the app (the entry sidebar / New Project panel on the left, and the
Settings modal). The work converged on a single neutral palette + a
small set of shared dimensional standards documented in CSS, so future
sections that get added slot into the same rhythm.

New Project panel (apps/web/src/components/NewProjectPanel.tsx +
.newproj* rules in index.css)
- Adds a spec comment block at the top of the .newproj rules listing
  the canonical heights (input 30, dropdown 38, compact toggle 36,
  popover item 38) and the neutral colour rules.
- Rebuilds PlatformPicker as a DS-picker-style dropdown trigger +
  popover (the previous 6-card 2×3 grid was ~280px tall; the dropdown
  collapses to a single 38px row with the same multi-select semantics).
- Replaces SurfaceOptions' two heavy `ToggleRow` cards with the new
  compact one-line `CompactToggle`; the descriptive hint moves to a
  native `title` tooltip.
- Compresses the Fidelity card grid (thumb aspect 16/7 → 16/5, tighter
  padding, smaller label).
- Neutralises every selected/active state inside the panel: removes the
  orange accent fills and rings from `.newproj-card.active`,
  `.newproj-title-badge`, `.compact-toggle.on`, `.toggle-row.on`, the
  DS picker popover items + radio/check marks, the trigger open border
  and shadow, and the search-bar background. The Create CTA stays the
  only orange element on the panel.
- Aligns the project-name input focus state across the sidebar:
  border `var(--text)` + 8% black halo (rgba is written out because the
  CSS pipeline collapses `color-mix(... 8%, transparent)` down to a
  solid `var(--text)`, which would render as a 3px solid black band).
- Switches the body card from `flex: 1 1 auto` to `flex: 0 1 auto` so a
  short form variant doesn't leave a white void at the bottom of the
  card, and disables overscroll-bounce on the card so a fast scroll
  doesn't briefly expose the page-level gray under the white surface.
- Pins the privacy footer below the card with a fixed 0 margin-top +
  shorter padding-top so it reads as a label of the card rather than a
  centred dialog footer.

Entry sidebar footer (apps/web/src/components/EntryView.tsx +
.entry-side-foot* rules)
- Replaces the X social pill's `external-link` placeholder glyph with a
  bespoke filled `x-brand` SVG that mirrors the `discord` mark already
  in the icon set.
- Wraps Discord + X in `.entry-side-foot-social` and lets that group
  flex-margin to the right of the row, so the two social pills read as
  a tight pair instead of a fourth pill stuck to the Pet pill.
- Drops the "unadopted" red dot on the Pet pill (it duplicated the call
  to action that the label already carried).
- Shrinks the footer icons to 10px and dims them to 55% / 75% opacity
  on hover so the labels are clearly the focal point — `currentColor`
  on the lucide-rendered SVGs would otherwise make the glyphs full
  black on hover.
- Tightens the env-pill version text cap (180 → 142) so the top row
  ends close to the right edge of the Language + Pet group below it.

Settings dialog (apps/web/src/components/SettingsDialog.tsx +
.modal-settings / .settings-* / .seg-* / .agent-* rules)
- Removes the "SETTINGS" kicker eyebrow above each section title (the
  big-typography title and modal context already make it redundant).
- Switches the sidebar from a card-per-item layout to ChatGPT-style
  single-line pills: hides the `<small>` description, swaps the
  sidebar bg from gray to white, makes the active item a gray pill (no
  border, no shadow) so all items keep a consistent row height
  regardless of state.
- Drops the modal-body's top border (already separated by the
  whitespace between modal-head and the body grid) and pins
  `.modal-settings { height: min(720px, 100vh - 64px) }` so the
  dialog no longer resizes when the user switches between short and
  long sections.
- Compresses the Local CLI / BYOK seg-control from a 2-line ~52px card
  pair to a 1-line ~42px segmented pill that height-matches the active
  sidebar nav-item, and aligns the `.settings-content` padding-top
  with `.settings-sidebar` (22 → 16) so the first content row sits
  level with the first sidebar item.
- Neutralises agent-card selected state, install/docs link colour, and
  protocol-chip active state — same accent-stripping pattern as the
  New Project panel.
- Uniform agent-card height via `min-height: 64px` so installed cards
  (icon + name + version) align with unavailable cards (icon + name +
  not-installed + Install/Docs row).

No prop-API changes, no business-logic edits — this is a pure visual
refactor. Existing tests, providers and daemon contracts are untouched.
2026-05-13 13:59:19 +08:00
..
home-manager.nix feat(nix): Add official flake with home-manager and NixOS support (#402) 2026-05-09 23:50:16 +08:00
module-common.nix feat(nix): Add official flake with home-manager and NixOS support (#402) 2026-05-09 23:50:16 +08:00
nixos.nix feat(nix): Add official flake with home-manager and NixOS support (#402) 2026-05-09 23:50:16 +08:00
package-daemon.nix refactor(web): UI polish for v0.7.0 — neutralised palette, official brand glyphs, lucide (#1522) 2026-05-13 13:59:19 +08:00
package-web.nix refactor(web): UI polish for v0.7.0 — neutralised palette, official brand glyphs, lucide (#1522) 2026-05-13 13:59:19 +08:00
README.md feat(nix): Add official flake with home-manager and NixOS support (#402) 2026-05-09 23:50: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

Both nix/package-daemon.nix and nix/package-web.nix vendor the pnpm store via a fixed-output derivation (pnpmDeps). The outputHash defaults to lib.fakeSha256 so nix build will fail with the expected hash printed. Copy that value into the matching pnpmDepsHash constant at the top of each file and re-run. Bump the hash whenever pnpm-lock.yaml changes.

CI

.github/workflows/nix-check.yml runs nix flake check followed by separate nix build .#daemon and nix build .#web steps on each push that touches the flake or the lockfile. Build artifacts are cached on the nexu-open-design Cachix instance — PRs from forks read from the cache without needing the auth token.