# 0017. UI skeleton: ui/, ichor-ui as a separate binary, building without it

Status: Accepted · Date: 2026-10-25 · Design section: CLI and UI

Decided: Settled `ui/`'s directory, `cmd/ichor-ui` as a second binary, and how the Go side builds without the UI present

## Context

Phase 5 is the UI skeleton only: scaffolding, not screens. The design
(`docs/design/architecture.md`, "CLI and UI" and "Overview") already settles
several things that would otherwise need deciding here: the frontend is "a
Vite + Preact + TypeScript SPA built with preactcn, embedded in the Go
backend"; the backend is a named deliverable, `ichor-ui`, "a Go backend that
embeds the Preact SPA. It reads and writes CRs, reads logs and state from the
Talos API, and never writes to nodes"; and "the CLI and the UI backend are
added beside [`cmd/main.go`]". What was left open: the frontend directory
name, where `ichor-ui`'s source lives, how `npx shadcn@latest add
@preactcn/...` is actually wired up for a non-React project, which preactcn
components to install for a skeleton, and — the one with teeth — how to keep
`go build ./...`/`go vet ./...`/golangci-lint working for anyone who never
touches the UI, given the Go side embeds its output.

Constraints already fixed by `CLAUDE.md`: preactcn only, no React, no
`preact/compat`; components come from the shadcn CLI against the `@preactcn`
registry, never hand-written; the browser talks only to the backend, never to
a third-party host directly.

## Decision

**Directory and binary.** The frontend lives at `ui/` (plain, matches the
design's "the UI" with no other candidate name in play). The backend is
`cmd/ichor-ui/`, a second `main` package beside the controller's
`cmd/main.go`, per the design's explicit "added beside it" and the `ichor-ui`
name it already assigns. It does not import `pkg/`, `internal/` or `api/`
yet — this phase has no Connect API client and no CR reads/writes to wire up,
so there is nothing shared to pull in. It imports nothing from `cmd/` either,
matching "deliverables don't import each other."

**Scaffolding, done with the CLIs the rules require.**
- `npm create vite@latest ui -- --template preact-ts` (create-vite 9.3.1) —
  Preact 10.29.8, Vite 8.3.4, zero React.
- preactcn (https://github.com/maeshinshin/preactcn) ships no Vite/Preact
  `shadcn init` template — there isn't one upstream for Preact — so its own
  README spells out the equivalent by hand: install `tailwindcss
  @tailwindcss/vite tw-animate-css shadcn`, add the Tailwind Vite plugin and a
  `@/*` path alias in `vite.config.ts`/`tsconfig*.json`, and write
  `components.json` pointing `registries."@preactcn"` at
  `https://maeshinshin.github.io/preactcn/r/{name}.json`. That
  `components.json` is configuration, not a component, so writing it by hand
  from preactcn's own documented recipe doesn't violate "scaffold with the
  tools, never by hand" — there is no tool for it.
- Components: `npx shadcn@latest add @preactcn/button @preactcn/card
  @preactcn/separator` (shadcn CLI 4.22.1). Three, matching "レイアウトやボタン"
  in the task: `Button` and `Separator` build the nav shell, `Card` is the
  page container every placeholder screen uses. The CLI wrote
  `src/components/ui/{button,card,separator}.tsx` plus the Radix-for-Preact
  interop it depends on under `src/lib/radix/` and `src/lib/utils.ts` — all
  generated, all Preact, no `react` or `react-dom` anywhere (verified against
  `package-lock.json`).
- The shadcn `add` step does not also write Tailwind's `:root`/`.dark`
  variable block (there is no Preact `init` to do it, and `add` only touches
  component files) — without it, the components' `bg-primary`/`border-border`
  etc. classes exist in markup but resolve to nothing. The `shadcn` npm
  package exports its shared utility layer as `shadcn/tailwind.css`
  (confirmed in `node_modules/shadcn/package.json`'s `exports` map), and
  ui.shadcn.com/docs/theming publishes the standard oklch `:root`/`.dark`
  variable block for Tailwind v4. `src/index.css` imports the former and
  defines the latter; both are checked in since generating them isn't a
  component-install step either.

**Building without the UI.** `cmd/ichor-ui` has no screens to speak of yet,
but it still has to compile for anyone who only runs `go build`/`go
vet`/golangci-lint, before `ui/` is ever touched. Two separate problems, two
separate fixes:
- `embed.FS` needs *something* to embed at compile time. `webfs_dev.go`
  (`//go:build !uiembed`, the default) embeds a committed
  `cmd/ichor-ui/placeholder/index.html`. `webfs_embed.go` (`//go:build
  uiembed`) embeds `cmd/ichor-ui/dist`, which only exists after `make
  ui-build` — Vite's `build.outDir` points straight at it, no copy step.
  Because the two files carry mutually exclusive build tags, only one
  `//go:embed` directive is ever evaluated, so the missing `dist/` directory
  is never a problem for the default build. `make build-ichor-ui` runs
  `ui-build` then `go build -tags uiembed`.
- Separately: `go build ./...`, `go vet ./...` and `go test ./...` walk the
  whole module by directory, and npm dependencies are not obliged to contain
  only JavaScript — `ui/node_modules/flatted` vendors a Go port of itself,
  and it showed up in `go test ./...` output and in a first, broken
  `golangci-lint run` (`forbid-sort-pkg` tripped inside it). `ui/go.mod`
  gives `ui/` its own module boundary, which is enough for `./...` from the
  repo root to skip it entirely — confirmed by `go list ./...` and
  `golangci-lint run` before and after, with no further `.golangci.yml`
  change needed: golangci-lint resolves packages the same way `go list`
  does, so the module boundary alone was sufficient. An explicit `^ui/`
  path exclusion in `.golangci.yml` was tried first and found to add
  nothing once the module boundary was in place, so it was dropped —
  `.golangci.yml` ships from this phase unchanged.

**Serving.** `cmd/ichor-ui/main.go` is deliberately small: one
`http.FileServerFS` over whichever `fs.FS` the build tag selected, wrapped so
a path that doesn't exist in that FS is rewritten to `/` and re-served —
the standard SPA fallback, so client-side routes 200 instead of 404. A
`-addr` flag and `signal.NotifyContext`/`srv.Shutdown` round it out. No
health endpoint, no metrics, no streaming: none of that exists to serve yet.

**App shell.** `src/router.tsx` is a ~30-line History-API router (`usePath`,
`navigate`, `Link`) rather than a dependency — `preact-router` or similar
would be the obvious reach, but a route table with one page component
doesn't need one, and ponytail's "does this need to exist" answer here is no.
`src/pages/Placeholder.tsx` is the one page component every route renders
("Coming soon" in a `Card`); `src/app.tsx` wires two routes (`/`, `/gitops`)
through it with `Button`/`Separator` for the nav, standing in for the real
nav this skeleton doesn't build.

## Consequences

- Adding a fourth screen later is "add a route table entry and a page
  component," not a framework change.
- `ui/go.mod` means `ui/` packages (there are none, and shouldn't be any)
  would need an explicit `GOWORK`/replace to be imported from the main
  module — moot today since nothing does, or should.
- The base Tailwind theme is the stock shadcn neutral/oklch palette, picked
  for being well-documented and exactly reproducible, not reviewed for
  Ichor's actual visual identity. Expected to be revisited once there's a
  real screen to look at.
- `spaHandler`'s fallback branch is covered by a table test
  (`cmd/ichor-ui/main_test.go`, `TestSpaHandler`) against a synthetic
  `testing/fstest.MapFS` — an existing path is served as-is, an unknown path
  falls back to `index.html` with `200`. It doesn't depend on either
  `embed.FS` variant, so it runs the same under the default build and
  `-tags uiembed`.
- There is currently no path that ships `ichor-ui` anywhere. `-tags uiembed`
  is wired into exactly one place, `make build-ichor-ui`; `.goreleaser.yaml`
  builds only `./cmd/ichor`, and no container image or chart `Deployment`
  references `ichor-ui` at all. That's correct for a skeleton phase — there's
  nothing worth shipping yet — but it means today's repo can produce a
  `bin/manager` and a CLI release without ever touching the UI, and a
  `build-ichor-ui` run that silently used the default tag would ship the
  placeholder page instead of an error. The phase that actually ships the UI
  needs to add `ichor-ui` to goreleaser, a container image, and a
  `Deployment` in the chart (with the two-replica/PDB/anti-affinity shape
  "CLI and UI" describes), and should make building it the wrong way loud
  rather than silent.

## Alternatives considered

**Running `npx shadcn@latest init -t vite`.** Targets React's Vite template;
it would scaffold React-flavored files and fight preactcn's own documented
manual setup rather than complementing it. Not attempted beyond confirming
via its `--help` that `-t`/`-b` offer no Preact option.

**Copying `ui/dist` into `cmd/ichor-ui/dist` as a build step.** Works, but is
one more moving part than pointing Vite's `outDir` at the destination
directly.

**One binary for the controller and the UI backend.** Rejected by the design
itself (`ichor-controller`, `ichor-ui` and `ichor` are three deliverables);
also wrong on trust grounds — "only the controller holds an
`os:admin`-equivalent Talos identity; the UI backend holds a read-only one."

**A `.golangci.yml` path exclusion instead of (or alongside) `ui/go.mod`.**
Tried first; redundant once the module boundary was added, since
golangci-lint's package discovery follows the same module rules `go list`
does. Dropped in favor of touching one file (`ui/go.mod`, new) instead of
two (`ui/go.mod` plus a shared config file other agents are also editing).
