Files
open-design/nix
elihahah666GitHubqiongyu1999open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
2471996f26 feat(web): add product-wide UI animations (#3294)
* feat(web): add product-wide UI animations

Add motion library (Framer Motion) for modal/toast/popover exit
animations via AnimatePresence, and a comprehensive CSS entrance
animation system (entrance.css) for all other UI surfaces.

Motion is limited to low-frequency mount/unmount components (modals,
toasts, popovers) to avoid re-render overhead. All high-frequency
components (grids, lists, nav, inputs) use pure CSS @keyframes with
staggered nth-child delays — zero JS cost.

Covers: home hero sequence, card grids, design kanban, marketplace,
settings, memory panels, MCP picker, inspector/comment slide-in,
chat history, file viewer, connector drawer, error banners, agent
cards, automation history, skill rows, breadcrumbs, workspace tabs,
staged attachments, code viewer, and button press feedback.

Respects prefers-reduced-motion system preference.

* fix(web): prevent thumbnail flash on tab switch

Replace individual card/row stagger animations (opacity 0→1 per child)
with container-level fades. The child animations replayed on every
React remount when switching tabs, causing all preview thumbnails to
flash from invisible.

Now only containers animate (fast fade-in), individual items inside
grids/lists stay fully visible immediately.

* fix: remove fade animations from tab-switchable content to prevent flash on remount

Grids, tab panels, entry sections, viewer panels, and other content that
remounts on tab switch no longer start from opacity:0. Only true one-time
mounts (page load, home hero), overlays/popovers, slide-in panels, and
error banners retain entrance animations.

* fix: keep tab content mounted with display:none to prevent thumbnail reload on tab switch

Projects, Tasks, Plugins, and Design Systems tabs now stay mounted in the
DOM when inactive, hidden via display:none. This preserves loaded images
and prevents the visible flash caused by thumbnails re-fetching on every
tab switch. Home and Integrations tabs keep conditional rendering since
they have no persistent media to preserve.

* fix: keep HomeView mounted to preserve recent project thumbnails on tab switch

* perf: use content-visibility:hidden for inactive tabs + add card press feedback

Replace display:none with content-visibility:hidden so the browser skips
rendering computation for hidden tabs while preserving the full DOM tree.
Add :active scale(0.98) press feedback to design-card, plugins-home__card,
and recent-projects__card for tactile click response.

* fix: pin dependency versions to exact (no caret ranges)

* chore: update lockfile for pinned dependency versions

* fix: replace PreviewModal motion with CSS animation, add motion test mock

- PreviewModal no longer uses motion/react — prevents test failures from
  AnimatePresence exit animations never completing in test env
- Add CSS animations for .ds-modal-backdrop (fade-in) and .ds-modal (scale-in)
- Add vitest alias to mock motion/react so AnimatePresence in other
  components (UpdaterPopup, ExamplesTab) completes synchronously in tests

* chore(nix): refresh pnpm deps hash

* test(e2e): scope visual-home locators to their entry view

The redesigned entry shell keeps every view mounted (only the active one
is visible) so tab switches don't reload thumbnails. That makes testids
like `plugins-home-section` and text like "Launchpad dashboard" exist in
more than one view at once, breaking Playwright strict-mode locators.
Tag each view container with `data-testid="entry-view-<name>"` and scope
the affected visual specs to the relevant view so the locators stay
unambiguous.

* test(e2e): scope entry-chrome home starters locators to their view

The redesigned entry shell keeps every view mounted (only the active one
is visible), so `plugins-home-section` and its children render in both the
home and plugins views at once, breaking Playwright strict-mode locators.
Scope the home starters assertions in entry-chrome-flows to the
`entry-view-home` container so the locators stay unambiguous, matching the
existing visual-home spec fix.

Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code)

* fix(web): close a11y/reduced-motion/exit gaps in animation layer

Address three review findings on the product-wide animation PR:

- EntryShell kept every tab view mounted via content-visibility:hidden,
  but that only skips paint — inactive Home/Projects/Plugins subtrees
  stayed in the tab order and accessibility tree. Mark inactive view
  wrappers inert + aria-hidden so keyboard users and screen readers only
  see the active view while the cached DOM survives.
- The motion/react variants animated unconditionally; the CSS
  prefers-reduced-motion block did not cover them. Wrap the app root in
  MotionConfig reducedMotion="user" so dialogs/toasts/popovers honor the
  OS preference, and add a focused regression test for the wiring.
- NewProjectModal short-circuited with `if (!open) return null`, so its
  exit variants never ran. Render the body inside AnimatePresence gated
  on `open` so the close animation plays before unmount.

Generated-By: looper 0.8.1 (runner=fixer, agent=claude-code)

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
2026-06-03 13:20:18 +00: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 generated single source of truth for the vendored pnpm store hash used by both nix/package-daemon.nix and nix/package-web.nix. Treat it like a lock artifact, not a hand-edited source file. If pnpm-lock.yaml changes and you are intentionally maintaining the Nix packaging, run:

pnpm nix:update-hash

The script temporarily swaps one consumer to lib.fakeHash, runs the matching nix build .#<consumer> --print-build-logs, extracts the expected hash from the failure output, writes it back into nix/pnpm-deps.nix, and restores the consumer file. The script runs via node --experimental-strip-types, so CI can invoke it without first installing the workspace.

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 inputs, daemon/web Nix build closures, or the generated hash maintenance workflows are validated earlier in .github/workflows/ci.yml via the required Validate workspace gate. That PR path runs nix flake check for flake.*, nix/**, root lock and workspace manifests, and files that are actually in the daemon/web Nix closures. The flake also filters each derivation down to only the workspace packages it actually installs, so unrelated package/tool changes stay off the slower Nix path and do not churn the other derivation's pnpm store hash.

When a PR run fails because nix/pnpm-deps.nix is stale, the CI job also tries to regenerate a hash-only patch:

  • same-repo PRs get a bot-authored commit pushed back to the PR branch when the generated patch only touches nix/pnpm-deps.nix;
  • fork PRs get a PR comment plus a workflow artifact containing the patch;
  • the failing run still stays red until the generated patch lands and a fresh validation run passes.