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 inittemplate — there isn't one upstream for Preact — so its own README spells out the equivalent by hand: installtailwindcss @tailwindcss/vite tw-animate-css shadcn, add the Tailwind Vite plugin and a@/*path alias invite.config.ts/tsconfig*.json, and writecomponents.jsonpointingregistries."@preactcn"athttps://maeshinshin.github.io/preactcn/r/{name}.json. Thatcomponents.jsonis 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:ButtonandSeparatorbuild the nav shell,Cardis the page container every placeholder screen uses. The CLI wrotesrc/components/ui/{button,card,separator}.tsxplus the Radix-for-Preact interop it depends on undersrc/lib/radix/andsrc/lib/utils.ts— all generated, all Preact, noreactorreact-domanywhere (verified againstpackage-lock.json). - The shadcn
addstep does not also write Tailwind's:root/.darkvariable block (there is no Preactinitto do it, andaddonly touches component files) — without it, the components'bg-primary/border-borderetc. classes exist in markup but resolve to nothing. Theshadcnnpm package exports its shared utility layer asshadcn/tailwind.css(confirmed innode_modules/shadcn/package.json'sexportsmap), and ui.shadcn.com/docs/theming publishes the standard oklch:root/.darkvariable block for Tailwind v4.src/index.cssimports 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.FSneeds something to embed at compile time.webfs_dev.go(//go:build !uiembed, the default) embeds a committedcmd/ichor-ui/placeholder/index.html.webfs_embed.go(//go:build uiembed) embedscmd/ichor-ui/dist, which only exists aftermake ui-build— Vite'sbuild.outDirpoints straight at it, no copy step. Because the two files carry mutually exclusive build tags, only one//go:embeddirective is ever evaluated, so the missingdist/directory is never a problem for the default build.make build-ichor-uirunsui-buildthengo build -tags uiembed.- Separately:
go build ./...,go vet ./...andgo test ./...walk the whole module by directory, and npm dependencies are not obliged to contain only JavaScript —ui/node_modules/flattedvendors a Go port of itself, and it showed up ingo test ./...output and in a first, brokengolangci-lint run(forbid-sort-pkgtripped inside it).ui/go.modgivesui/its own module boundary, which is enough for./...from the repo root to skip it entirely — confirmed bygo list ./...andgolangci-lint runbefore and after, with no further.golangci.ymlchange needed: golangci-lint resolves packages the same waygo listdoes, so the module boundary alone was sufficient. An explicit^ui/path exclusion in.golangci.ymlwas tried first and found to add nothing once the module boundary was in place, so it was dropped —.golangci.ymlships 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.modmeansui/packages (there are none, and shouldn't be any) would need an explicitGOWORK/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 synthetictesting/fstest.MapFS— an existing path is served as-is, an unknown path falls back toindex.htmlwith200. It doesn't depend on eitherembed.FSvariant, so it runs the same under the default build and-tags uiembed.- There is currently no path that ships
ichor-uianywhere.-tags uiembedis wired into exactly one place,make build-ichor-ui;.goreleaser.yamlbuilds only./cmd/ichor, and no container image or chartDeploymentreferencesichor-uiat all. That's correct for a skeleton phase — there's nothing worth shipping yet — but it means today's repo can produce abin/managerand a CLI release without ever touching the UI, and abuild-ichor-uirun that silently used the default tag would ship the placeholder page instead of an error. The phase that actually ships the UI needs to addichor-uito goreleaser, a container image, and aDeploymentin 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).