steid

@jamesgill /

feat: tailwind theme and the first components

Decision 0005. Tailwind through Topcoat's build script -- no Node, which was
the sole reason attempt #2 dropped Tailwind. build.rs runs the standalone
CLI, the asset bundler serves the result content-hashed from /_topcoat/.

Components come from the registry by copy, not by dependency. button, input,
label, textarea and card are borrowed boilerplate; flash is written by hand
because the registry has no alert. That distinction is thin -- copied source
is ours the moment it lands -- but it matters that a framework two weeks old
and expecting breaking changes cannot alter our UI on a version bump.

The theme is ours from the start rather than adopted as-is. Topcoat's neutral
theme was the scaffold; tokens were retuned to attempt #1's palette, which is
already OKLCH at hue 260, and extended with surface, success, warning and a
monospace family. neutral has no success or warning, and flash needs both now
while git diff semantics will need them later.

The rule that makes any of this hold: components reference tokens, never raw
colours. A hardcoded colour neither follows a palette change nor adapts to
the colour scheme.

flash carries the accessibility bit the styling would otherwise hide -- an
error announces as role="alert" and interrupts, a confirmation as
role="status" and waits. That distinction is invisible in a screenshot and
makes a form unusable without sight if it is wrong.

Verified: stylesheet serves at 200 with the new tokens present, dark class on
<html>, profile renders with the borrowed button and mono handle.

111 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 25 days agoparent3954b45Browse filesaf126c7704b91896c347742dbf91c9abac20cb99

19 files changed+992 −45

Cargo.lock+203 −5View file
@@ -499,7 +499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
499499 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
500500 dependencies = [
501501 "libc",
502 "windows-sys",
502+ "windows-sys 0.61.2",
503503 ]
504504
505505 [[package]]
@@ -509,7 +509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
509509 checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96"
510510 dependencies = [
511511 "cfg-if",
512 "windows-sys",
512+ "windows-sys 0.61.2",
513513 ]
514514
515515 [[package]]
@@ -1165,7 +1165,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
11651165 dependencies = [
11661166 "libc",
11671167 "wasi",
1168 "windows-sys",
1168+ "windows-sys 0.61.2",
11691169 ]
11701170
11711171 [[package]]
@@ -1448,6 +1448,55 @@ dependencies = [
14481448 "syn 3.0.3",
14491449 ]
14501450
1451+[[package]]
1452+name = "ring"
1453+version = "0.17.14"
1454+source = "registry+https://github.com/rust-lang/crates.io-index"
1455+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1456+dependencies = [
1457+ "cc",
1458+ "cfg-if",
1459+ "getrandom 0.2.17",
1460+ "libc",
1461+ "untrusted",
1462+ "windows-sys 0.52.0",
1463+]
1464+
1465+[[package]]
1466+name = "rustls"
1467+version = "0.23.43"
1468+source = "registry+https://github.com/rust-lang/crates.io-index"
1469+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
1470+dependencies = [
1471+ "log",
1472+ "once_cell",
1473+ "ring",
1474+ "rustls-pki-types",
1475+ "rustls-webpki",
1476+ "subtle",
1477+ "zeroize",
1478+]
1479+
1480+[[package]]
1481+name = "rustls-pki-types"
1482+version = "1.15.1"
1483+source = "registry+https://github.com/rust-lang/crates.io-index"
1484+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
1485+dependencies = [
1486+ "zeroize",
1487+]
1488+
1489+[[package]]
1490+name = "rustls-webpki"
1491+version = "0.103.13"
1492+source = "registry+https://github.com/rust-lang/crates.io-index"
1493+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
1494+dependencies = [
1495+ "ring",
1496+ "rustls-pki-types",
1497+ "untrusted",
1498+]
1499+
14511500 [[package]]
14521501 name = "rustversion"
14531502 version = "1.0.23"
@@ -1629,7 +1678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
16291678 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
16301679 dependencies = [
16311680 "libc",
1632 "windows-sys",
1681+ "windows-sys 0.61.2",
16331682 ]
16341683
16351684 [[package]]
@@ -1976,7 +2025,7 @@ dependencies = [
19762025 "signal-hook-registry",
19772026 "socket2",
19782027 "tokio-macros",
1979 "windows-sys",
2028+ "windows-sys 0.61.2",
19802029 ]
19812030
19822031 [[package]]
@@ -2104,6 +2153,8 @@ dependencies = [
21042153 "topcoat-runtime",
21052154 "topcoat-runtime-macro",
21062155 "topcoat-session",
2156+ "topcoat-tailwind",
2157+ "topcoat-ui-registry",
21072158 "topcoat-view",
21082159 "topcoat-view-macro",
21092160 ]
@@ -2388,6 +2439,24 @@ dependencies = [
23882439 "web-time",
23892440 ]
23902441
2442+[[package]]
2443+name = "topcoat-tailwind"
2444+version = "0.5.0"
2445+source = "registry+https://github.com/rust-lang/crates.io-index"
2446+checksum = "22411d4f2c79840b58b86da8bb18d34e2de55286885d4f57835223f6fae30b80"
2447+dependencies = [
2448+ "sha2 0.11.0",
2449+ "thiserror",
2450+ "topcoat-core",
2451+ "ureq",
2452+]
2453+
2454+[[package]]
2455+name = "topcoat-ui-registry"
2456+version = "0.5.0"
2457+source = "registry+https://github.com/rust-lang/crates.io-index"
2458+checksum = "8abcbbdc85084d6ebde84f83f6781a799258b60ca4c381f50c66953edab25e36"
2459+
23912460 [[package]]
23922461 name = "topcoat-view"
23932462 version = "0.5.0"
@@ -2563,6 +2632,41 @@ dependencies = [
25632632 "subtle",
25642633 ]
25652634
2635+[[package]]
2636+name = "untrusted"
2637+version = "0.9.0"
2638+source = "registry+https://github.com/rust-lang/crates.io-index"
2639+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2640+
2641+[[package]]
2642+name = "ureq"
2643+version = "3.3.0"
2644+source = "registry+https://github.com/rust-lang/crates.io-index"
2645+checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
2646+dependencies = [
2647+ "base64 0.22.1",
2648+ "flate2",
2649+ "log",
2650+ "percent-encoding",
2651+ "rustls",
2652+ "rustls-pki-types",
2653+ "ureq-proto",
2654+ "utf8-zero",
2655+ "webpki-roots",
2656+]
2657+
2658+[[package]]
2659+name = "ureq-proto"
2660+version = "0.6.0"
2661+source = "registry+https://github.com/rust-lang/crates.io-index"
2662+checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
2663+dependencies = [
2664+ "base64 0.22.1",
2665+ "http",
2666+ "httparse",
2667+ "log",
2668+]
2669+
25662670 [[package]]
25672671 name = "url"
25682672 version = "2.5.8"
@@ -2575,6 +2679,12 @@ dependencies = [
25752679 "serde",
25762680 ]
25772681
2682+[[package]]
2683+name = "utf8-zero"
2684+version = "0.8.1"
2685+source = "registry+https://github.com/rust-lang/crates.io-index"
2686+checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
2687+
25782688 [[package]]
25792689 name = "utf8_iter"
25802690 version = "1.0.4"
@@ -2675,6 +2785,15 @@ dependencies = [
26752785 "wasm-bindgen",
26762786 ]
26772787
2788+[[package]]
2789+name = "webpki-roots"
2790+version = "1.0.9"
2791+source = "registry+https://github.com/rust-lang/crates.io-index"
2792+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
2793+dependencies = [
2794+ "rustls-pki-types",
2795+]
2796+
26782797 [[package]]
26792798 name = "whoami"
26802799 version = "2.1.2"
@@ -2687,6 +2806,15 @@ version = "0.2.1"
26872806 source = "registry+https://github.com/rust-lang/crates.io-index"
26882807 checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
26892808
2809+[[package]]
2810+name = "windows-sys"
2811+version = "0.52.0"
2812+source = "registry+https://github.com/rust-lang/crates.io-index"
2813+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2814+dependencies = [
2815+ "windows-targets",
2816+]
2817+
26902818 [[package]]
26912819 name = "windows-sys"
26922820 version = "0.61.2"
@@ -2696,6 +2824,70 @@ dependencies = [
26962824 "windows-link",
26972825 ]
26982826
2827+[[package]]
2828+name = "windows-targets"
2829+version = "0.52.6"
2830+source = "registry+https://github.com/rust-lang/crates.io-index"
2831+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2832+dependencies = [
2833+ "windows_aarch64_gnullvm",
2834+ "windows_aarch64_msvc",
2835+ "windows_i686_gnu",
2836+ "windows_i686_gnullvm",
2837+ "windows_i686_msvc",
2838+ "windows_x86_64_gnu",
2839+ "windows_x86_64_gnullvm",
2840+ "windows_x86_64_msvc",
2841+]
2842+
2843+[[package]]
2844+name = "windows_aarch64_gnullvm"
2845+version = "0.52.6"
2846+source = "registry+https://github.com/rust-lang/crates.io-index"
2847+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2848+
2849+[[package]]
2850+name = "windows_aarch64_msvc"
2851+version = "0.52.6"
2852+source = "registry+https://github.com/rust-lang/crates.io-index"
2853+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2854+
2855+[[package]]
2856+name = "windows_i686_gnu"
2857+version = "0.52.6"
2858+source = "registry+https://github.com/rust-lang/crates.io-index"
2859+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2860+
2861+[[package]]
2862+name = "windows_i686_gnullvm"
2863+version = "0.52.6"
2864+source = "registry+https://github.com/rust-lang/crates.io-index"
2865+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2866+
2867+[[package]]
2868+name = "windows_i686_msvc"
2869+version = "0.52.6"
2870+source = "registry+https://github.com/rust-lang/crates.io-index"
2871+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2872+
2873+[[package]]
2874+name = "windows_x86_64_gnu"
2875+version = "0.52.6"
2876+source = "registry+https://github.com/rust-lang/crates.io-index"
2877+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2878+
2879+[[package]]
2880+name = "windows_x86_64_gnullvm"
2881+version = "0.52.6"
2882+source = "registry+https://github.com/rust-lang/crates.io-index"
2883+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2884+
2885+[[package]]
2886+name = "windows_x86_64_msvc"
2887+version = "0.52.6"
2888+source = "registry+https://github.com/rust-lang/crates.io-index"
2889+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2890+
26992891 [[package]]
27002892 name = "winnow"
27012893 version = "1.0.4"
@@ -2781,6 +2973,12 @@ dependencies = [
27812973 "synstructure",
27822974 ]
27832975
2976+[[package]]
2977+name = "zeroize"
2978+version = "1.9.0"
2979+source = "registry+https://github.com/rust-lang/crates.io-index"
2980+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
2981+
27842982 [[package]]
27852983 name = "zerotrie"
27862984 version = "0.2.4"
Cargo.toml+4 −1View file
@@ -12,5 +12,8 @@ serde = { version = "1.0.229", features = ["derive"] }
1212 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
1313 subtle = "2.6.1"
1414 tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] }
15topcoat = "0.5.0"
15+topcoat = { version = "0.5.0", features = ["tailwind", "ui"] }
1616 uuid = { version = "1.24.0", features = ["v4"] }
17+
18+[build-dependencies]
19+topcoat = { version = "0.5.0", default-features = false, features = ["tailwind"] }
build.rs+11 −0View file
@@ -0,0 +1,11 @@
1+//! Runs Tailwind over `styles.css`.
2+//!
3+//! Topcoat wraps the standalone Tailwind CLI — no Node, no PostCSS. The generated CSS
4+//! lands in `OUT_DIR` and is served by the asset bundler with a content-hashed URL.
5+
6+fn main() {
7+ topcoat::tailwind::BuildConfig::new()
8+ .input("styles.css")
9+ .render()
10+ .unwrap();
11+}
components.toml+29 −0View file
@@ -0,0 +1,29 @@
1+# Topcoat UI install state. Managed by `topcoat ui`.
2+version = 1
3+components_dir = "src/components"
4+
5+[theme]
6+name = "neutral"
7+registry = "topcoat"
8+hash = "sha256:c7bdcb5ea4ff757611ba616e92178169b262be98155c06dfa2407584d51e7459"
9+file = "styles.css"
10+
11+[registries.topcoat.components.button]
12+hash = "sha256:8dcc9609183d0a51138c7a15de06d5525388f25a3958071fab25b96efaa2b160"
13+file = "src/components/button.rs"
14+
15+[registries.topcoat.components.card]
16+hash = "sha256:f7baecc0a71b59126d311ac504c36a14fa98d438d18230b807f10f7e976a30b7"
17+file = "src/components/card.rs"
18+
19+[registries.topcoat.components.input]
20+hash = "sha256:7769330ae511769c1c50b39c75b7475b676af9072d2156398762cf505a3bf306"
21+file = "src/components/input.rs"
22+
23+[registries.topcoat.components.label]
24+hash = "sha256:cd8e7569717c82abad745f0586f03fbc759e6000988a82c8dce15182a409d0d6"
25+file = "src/components/label.rs"
26+
27+[registries.topcoat.components.textarea]
28+hash = "sha256:852407d012c458ea3b43fafa69fda9fa09287071dc0e1d2367671fe2c9173309"
29+file = "src/components/textarea.rs"
plans/current.md+6 −4View file
@@ -36,7 +36,8 @@ Shippable on its own: a public profile that renders signed out.
3636
3737 A profile you can't change is a stub. This is what makes it a portfolio page.
3838
39- [ ] Decide the styling approach — Tailwind and Topcoat UI, or hand-rolled CSS
39+- [x] Styling: Tailwind via Topcoat, theme retuned, primitives copied in, `flash`
40+ written by hand ([0005](decisions/0005-tailwind-and-copied-components.md))
4041 - [ ] Flash messages — the first edit form needs success and failure feedback, and
4142 every form after it inherits whatever we build here
4243 - [ ] `/{handle}/settings` — edit display name and bio, owner only, enforced in the use
@@ -98,9 +99,10 @@ on the page. `/api/users/{handle}` returns the same public view.
9899 enforced on read, so this is tidiness, not a hole.
99100 - **CSRF.** `SameSite=Lax` covers the common case; whether forms also want tokens is
100101 still undecided. Phase 2 adds a form, so this is the natural time to settle it.
101- **Styling.** Everything is unstyled HTML. Topcoat ships Tailwind without Node, and
102 `steid-backup/AGENTS/UI.md` has a full OKLCH system to mine. Cheaper now than at ten
103 pages — see [ui.md](ui.md).
102+- **Light mode is untested.** The palette defines it, but every page has been looked at
103+ dark-only. There is no toggle yet either.
104+- **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; neither is
105+ installed, so both fall back. `topcoat`'s `font-fontsource` feature handles this.
104106
105107 ## Backlog
106108
plans/decisions/0005-tailwind-and-copied-components.md+70 −0View file
@@ -0,0 +1,70 @@
1+# 0005 — Tailwind via Topcoat, with components copied rather than depended on
2+
3+**Status:** accepted · **Date:** 2026-08-11
4+
5+## Context
6+
7+Everything was unstyled HTML. Milestone 2 phase 2 adds the first form that needs
8+success and failure feedback, so a visual language was needed before more pages
9+inherited the absence of one — three pages is the cheapest this decision ever gets.
10+
11+Attempt #1 had a complete OKLCH design system for Tailwind and DaisyUI. Attempt #2
12+dropped Tailwind entirely, for one reason: the npm build step. Topcoat's Tailwind
13+integration wraps the standalone Tailwind CLI from a Cargo build script — no Node, no
14+PostCSS — which removes that objection.
15+
16+Topcoat also ships `topcoat ui`, a component registry that **copies source into the
17+project** rather than being a library dependency.
18+
19+## Decision
20+
21+Tailwind through Topcoat's build-script integration, with `styles.css` as the theme.
22+
23+Components come from two places:
24+
25+- **Borrowed primitives**, added with `topcoat ui add` — button, input, label,
26+ textarea, card. Boilerplate worth not writing.
27+- **Steid's own**, written by hand — starting with `flash`, which the registry has no
28+ equivalent for. Anything Steid-specific (profile header, repo row, commit bar) is
29+ ours.
30+
31+The distinction is thin, because copied source is ours the moment it lands. A borrowed
32+component that stops fitting gets edited or deleted, not worked around.
33+
34+**The theme is ours from the start.** Topcoat's `neutral` theme was the scaffold;
35+its tokens were retuned to attempt #1's palette — dark-first at hue 260, blue primary
36+— and extended with `--surface`, `--success`, `--warning` and a monospace family.
37+Success and warning were absent from `neutral` but are needed for flash messages now
38+and git diff semantics later.
39+
40+**The rule that makes this work: components reference tokens, never raw colours.** A
41+hardcoded colour does not follow a palette change and does not adapt to the colour
42+scheme. This is the one convention worth enforcing in review.
43+
44+## Alternatives considered
45+
46+- **Hand-rolled CSS, no Tailwind.** No build script, no download, full control.
47+ Rejected: it also means hand-rolling the token structure, the colour-scheme
48+ switching, and the focus-ring conventions that the theme already solves.
49+- **Tailwind only, without the `ui` feature.** Skips `components.toml` and the
50+ registry. Rejected narrowly — the borrowed primitives are genuinely boring code, and
51+ their house style is a useful model for ours.
52+- **Depending on a component library.** Rejected: Topcoat is weeks old and expects
53+ breaking changes. Copied source cannot break underneath us on a version bump, which
54+ matters more here than in a mature ecosystem.
55+- **DaisyUI, as attempt #1 planned.** Rejected: a second styling dependency on top of
56+ Tailwind, and not bundled by Topcoat.
57+
58+## Consequences
59+
60+- **Slower builds.** A build script runs Tailwind on every change. The CLI is
61+ downloaded once and cached under the target directory.
62+- **`topcoat asset bundle` is required for manual builds** before assets resolve;
63+ `topcoat dev` does it automatically. A stale bundle serves stale CSS.
64+- **Utility classes live in markup.** That is the Tailwind bargain.
65+- **`src/components.rs` is rewritten by `topcoat ui add`**, so hand-written entries can
66+ be reordered. Keep them alphabetical and expect churn there.
67+- **A palette change is a one-file edit**, provided the token rule holds.
68+- **The theme is versioned in `components.toml`** by hash. A registry theme update will
69+ not silently overwrite local retuning.
70+- `plans/ui.md` now describes this rather than attempt #1's DaisyUI plan.
plans/ui.md+38 −18View file
@@ -31,25 +31,43 @@ a GitHub-shaped forge needs checking against that before it's copied.
3131
3232 ## Stack
3333
34Topcoat bundles assets and ships Tailwind **without Node**, plus htmx and Alpine
35integrations, Fontsource, and Iconify.
34+Settled in [0005](decisions/0005-tailwind-and-copied-components.md).
3635
37That's worth noting: the previous attempt dropped Tailwind purely to avoid an npm
38build step and hand-rolled CSS instead. Topcoat removes that objection, so Tailwind is
39back on the table at no tooling cost.
36+| Concern | Choice |
37+|---|---|
38+| Styling | Tailwind via Topcoat's build script — no Node |
39+| Theme | `styles.css` at the package root: design tokens, dark-first |
40+| Components | `topcoat ui add` for primitives, hand-written for Steid's own |
41+| Mono | IBM Plex Mono, for paths, hashes, branches, handles |
4042
41Suggested, none of it settled until Milestone 5 needs real pages:
43+### Tokens
4244
43| Concern | Candidate |
44|---|---|
45| Styling | Tailwind via Topcoat's bundler |
46| UI font | Inter (Fontsource) |
47| Mono font | IBM Plex Mono |
48| Icons | Iconify |
49| Interactivity | Topcoat signals; htmx where signals fall short |
45+Defined in `styles.css` on `:root` (light) and `.dark` (dark, the default):
46+
47+```
48+background surface foreground muted-foreground
49+primary primary-foreground
50+success success-foreground confirmations, git additions
51+warning warning-foreground caution, git modifications
52+destructive destructive-foreground errors, git deletions
53+border ring shadow-xs shadow-sm
54+```
55+
56+`surface`, `success` and `warning` are Steid's additions to Topcoat's `neutral` theme;
57+the rest were retuned to the palette below.
58+
59+**Components reference tokens, never raw colours.** A hardcoded colour will not follow
60+a palette change and will not adapt to the colour scheme. This is the convention to
61+enforce in review.
62+
63+### Working on it
5064
51Topcoat's own client reactivity is early and acknowledged as limited, which is why the
52htmx integration exists. Reach for signals first, fall back without ceremony.
65+```bash
66+topcoat dev # rebuilds CSS and re-bundles on change
67+topcoat asset bundle # manual builds only; a stale bundle serves stale CSS
68+topcoat ui list # what the registry offers
69+topcoat ui add <name> # copies source into src/components/
70+```
5371
5472 ## Prior art
5573
@@ -58,9 +76,11 @@ OKLCH light/dark palettes with concrete token values, a type scale, spacing scal
5876 component markup for sidebar, file tree, commit bar, breadcrumbs, badges, empty
5977 states. It was written for Tailwind + DaisyUI.
6078
61It is worth mining when Milestone 5 arrives, with two caveats: it specifies DaisyUI,
62which is a separate choice from Tailwind and not bundled by Topcoat; and it was
63written for a GitHub-shaped forge rather than a portfolio-first one.
79+Its palette has been mined already — the dark-first OKLCH values at hue 260 are what
80+`styles.css` was retuned to. Its component markup has not, and comes with two caveats:
81+it specifies DaisyUI, which is a separate choice from Tailwind and not bundled by
82+Topcoat; and it was written for a GitHub-shaped forge rather than a portfolio-first
83+one.
6484
6585 It is also, on its own, longer than every other doc in this directory combined — for
6686 an app that had about nine pages. Take the palette and the principles. Don't
src/components.rs+19 −0View file
@@ -0,0 +1,19 @@
1+//! UI components.
2+//!
3+//! Primitives from the `topcoat` registry are copied in by `topcoat ui add`, not
4+//! depended on — once here they are ours to edit, and a framework release cannot change
5+//! them underneath us. Steid-specific components are written by hand alongside them.
6+//!
7+//! **Components reference theme tokens, never raw colours.** A hardcoded colour will
8+//! not follow a palette change and will not adapt to the colour scheme. See
9+//! `styles.css`.
10+//!
11+//! `topcoat ui add` rewrites the module list below, so keep hand-written entries
12+//! alphabetical among the rest and expect it to reorder them.
13+
14+pub mod button;
15+pub mod card;
16+pub mod flash;
17+pub mod input;
18+pub mod label;
19+pub mod textarea;
src/components/button.rs+161 −0View file
@@ -0,0 +1,161 @@
1+use topcoat::{
2+ Result,
3+ view::{Attributes, View, class, component, view},
4+};
5+
6+/// The visual style of a [`button`].
7+///
8+/// [`Default`] is `ButtonVariant::Primary`, used when no variant is given.
9+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10+#[allow(dead_code)]
11+pub enum ButtonVariant {
12+ /// The primary-filled button for the main action.
13+ #[default]
14+ Primary,
15+ /// A muted, tinted fill for secondary actions.
16+ Secondary,
17+ /// A hairline-bordered button on the page background.
18+ Outline,
19+ /// No fill until hovered, for toolbars and inline actions.
20+ Ghost,
21+ /// A destructive-filled button for actions such as deleting data.
22+ Destructive,
23+}
24+
25+impl ButtonVariant {
26+ /// The Tailwind classes for this variant.
27+ ///
28+ /// Hover and press states apply the fill or foreground color at reduced
29+ /// opacity, so they hold up in both color schemes without `dark:`
30+ /// overrides. Every variant with a resting fill or border casts the
31+ /// theme's control shadow; `Ghost` is flat until hovered, so it casts
32+ /// none.
33+ ///
34+ /// Each variant sets its own border color rather than inheriting a
35+ /// transparent one from [`BASE`]: with two border-color classes on the
36+ /// same element, stylesheet order (not class order) would decide the
37+ /// winner.
38+ fn classes(self) -> &'static str {
39+ match self {
40+ Self::Primary => {
41+ "border-transparent bg-primary text-primary-foreground shadow-xs \
42+ hover:bg-primary/90 active:bg-primary/80"
43+ }
44+ Self::Secondary => {
45+ "border-transparent bg-foreground/5 text-foreground shadow-xs \
46+ hover:bg-foreground/10 active:bg-foreground/15"
47+ }
48+ Self::Outline => {
49+ "border-border text-foreground shadow-xs hover:bg-foreground/5 \
50+ active:bg-foreground/10"
51+ }
52+ Self::Ghost => {
53+ "border-transparent text-foreground hover:bg-foreground/5 active:bg-foreground/10"
54+ }
55+ Self::Destructive => {
56+ "border-transparent bg-destructive text-destructive-foreground shadow-xs \
57+ hover:bg-destructive/90 active:bg-destructive/80"
58+ }
59+ }
60+ }
61+}
62+
63+/// The size of a [`button`].
64+///
65+/// [`Default`] is `ButtonSize::Md`, used when no size is given.
66+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67+#[allow(dead_code)]
68+pub enum ButtonSize {
69+ /// A compact button.
70+ Sm,
71+ /// The standard button size.
72+ #[default]
73+ Md,
74+ /// A prominent button.
75+ Lg,
76+ /// A square button sized for a single icon.
77+ Icon,
78+}
79+
80+impl ButtonSize {
81+ /// The Tailwind classes for this size.
82+ ///
83+ /// Each size sets a text size, which also scales any icons inside: the
84+ /// `icon` component is `1em` square by default.
85+ fn classes(self) -> &'static str {
86+ match self {
87+ Self::Sm => "h-8 gap-1.5 rounded-md px-3 text-xs",
88+ Self::Md => "h-9 gap-2 rounded-lg px-4 text-sm",
89+ Self::Lg => "h-10 gap-2 rounded-lg px-5 text-base",
90+ Self::Icon => "size-9 rounded-lg text-base",
91+ }
92+ }
93+}
94+
95+/// The classes shared by every button, regardless of variant or size.
96+///
97+/// Every button carries a border (colored per variant) so that the `Outline`
98+/// variant, which only recolors it, does not change the button's dimensions.
99+const BASE: &str = "inline-flex shrink-0 items-center justify-center border \
100+ font-medium whitespace-nowrap transition-colors outline-none select-none \
101+ focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 \
102+ focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50";
103+
104+/// Builds the full class string for a button of the given `variant` and `size`.
105+///
106+/// Use it to give button styling to an element that is not a `<button>`, such
107+/// as a link styled as a button:
108+///
109+/// ```ignore
110+/// view! {
111+/// <a href="/login" class=(button_variants(ButtonVariant::Outline, ButtonSize::Md))>
112+/// "Sign in"
113+/// </a>
114+/// }
115+/// ```
116+#[must_use]
117+pub fn button_variants(variant: ButtonVariant, size: ButtonSize) -> String {
118+ format!("{BASE} {} {}", variant.classes(), size.classes())
119+}
120+
121+/// A button component.
122+///
123+/// The `variant` and `size` parameters select the styling, defaulting to
124+/// `Primary` and `Md`. The `attrs` (such as `class`, `type`, `disabled`, or
125+/// event handlers) are forwarded to the underlying `<button>`; a `class` among
126+/// them is appended to the computed classes. Child nodes become the button's
127+/// content.
128+///
129+/// ```ignore
130+/// view! {
131+/// button(
132+/// variant: ButtonVariant::Destructive,
133+/// attrs: attributes! { type="submit" },
134+/// "Delete"
135+/// )
136+/// }
137+/// ```
138+///
139+/// To style a non-`<button>` element like a button, use [`button_variants`]
140+/// directly.
141+#[component]
142+pub async fn button(
143+ #[default] variant: ButtonVariant,
144+ #[default] size: ButtonSize,
145+ #[default] mut attrs: Attributes,
146+ #[default] child: View,
147+) -> Result {
148+ view! {
149+ <button
150+ class=(class!(
151+ BASE,
152+ variant.classes(),
153+ size.classes(),
154+ attrs.remove("class"),
155+ ))
156+ (attrs)
157+ >
158+ (child)
159+ </button>
160+ }
161+}
src/components/card.rs+98 −0View file
@@ -0,0 +1,98 @@
1+use topcoat::{
2+ Result,
3+ view::{Attributes, View, class, component, view},
4+};
5+
6+/// The classes for the [`card`] container.
7+///
8+/// The card is a column of sections separated by a uniform gap. It carries
9+/// vertical padding only; each section brings its own horizontal padding, so
10+/// full-bleed content such as an image can span the card's width. The card
11+/// casts the theme's raised-surface shadow and sets its own background and
12+/// text color, so it reads as a card on any ancestor.
13+const CARD: &str = "flex flex-col gap-5 rounded-xl border border-border bg-background py-6 \
14+ text-foreground shadow-sm";
15+
16+/// A card component: a bordered, raised surface grouping related content.
17+///
18+/// A card stacks sections vertically: typically a [`card_header`], then a
19+/// [`card_content`], closed by a [`card_footer`]. Any section can be omitted.
20+/// The `attrs` (such as `class` or event handlers) are forwarded to the
21+/// underlying `<div>`; a `class` among them is appended to the computed
22+/// classes. Child nodes become the card's sections.
23+///
24+/// ```ignore
25+/// view! {
26+/// card(
27+/// attrs: attributes! { class="max-w-sm" },
28+/// card_header(
29+/// card_title("Delete workspace")
30+/// card_description("This cannot be undone.")
31+/// )
32+/// card_footer(
33+/// attrs: attributes! { class="justify-end" },
34+/// button(variant: ButtonVariant::Destructive, "Delete")
35+/// )
36+/// )
37+/// }
38+/// ```
39+#[component]
40+pub async fn card(#[default] mut attrs: Attributes, #[default] child: View) -> Result {
41+ view! { <div class=(class!(CARD, attrs.remove("class"))) (attrs)>(child)</div> }
42+}
43+
44+/// The opening section of a [`card`], stacking a [`card_title`] and an
45+/// optional [`card_description`].
46+#[component]
47+pub async fn card_header(#[default] mut attrs: Attributes, #[default] child: View) -> Result {
48+ view! {
49+ <div
50+ class=(class!("flex flex-col gap-1.5 px-6", attrs.remove("class")))
51+ (attrs)
52+ >
53+ (child)
54+ </div>
55+ }
56+}
57+
58+/// The heading of a [`card`], rendered as an `<h3>`.
59+#[component]
60+pub async fn card_title(#[default] mut attrs: Attributes, #[default] child: View) -> Result {
61+ view! {
62+ <h3 class=(class!("leading-none font-semibold", attrs.remove("class"))) (attrs)>
63+ (child)
64+ </h3>
65+ }
66+}
67+
68+/// The supporting text under a [`card_title`].
69+#[component]
70+pub async fn card_description(#[default] mut attrs: Attributes, #[default] child: View) -> Result {
71+ view! {
72+ <p
73+ class=(class!("text-sm text-muted-foreground", attrs.remove("class")))
74+ (attrs)
75+ >
76+ (child)
77+ </p>
78+ }
79+}
80+
81+/// The main body of a [`card`].
82+#[component]
83+pub async fn card_content(#[default] mut attrs: Attributes, #[default] child: View) -> Result {
84+ view! { <div class=(class!("px-6", attrs.remove("class"))) (attrs)>(child)</div> }
85+}
86+
87+/// The closing section of a [`card`], a horizontal row for actions.
88+#[component]
89+pub async fn card_footer(#[default] mut attrs: Attributes, #[default] child: View) -> Result {
90+ view! {
91+ <div
92+ class=(class!("flex items-center gap-2 px-6", attrs.remove("class")))
93+ (attrs)
94+ >
95+ (child)
96+ </div>
97+ }
98+}
src/components/flash.rs+87 −0View file
@@ -0,0 +1,87 @@
1+//! Steid's own component. Not from the `topcoat` registry — it has no alert.
2+//!
3+//! Follows the same house style as the borrowed primitives: a variant enum returning
4+//! Tailwind classes, tokens only, and `Attributes` forwarded to the root element.
5+
6+use topcoat::{
7+ Result,
8+ view::{Attributes, View, class, component, view},
9+};
10+
11+/// What kind of message this is.
12+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13+#[allow(dead_code)]
14+pub enum FlashKind {
15+ /// Something worked.
16+ Success,
17+ /// Something failed and the reader can fix it.
18+ #[default]
19+ Error,
20+ /// Neutral context, neither good nor bad.
21+ Info,
22+}
23+
24+impl FlashKind {
25+ /// The Tailwind classes for this kind.
26+ ///
27+ /// A tinted fill at low opacity rather than a solid one, so the message reads as
28+ /// part of the page rather than an interruption, and stays legible in both colour
29+ /// schemes without `dark:` overrides.
30+ fn classes(self) -> &'static str {
31+ match self {
32+ Self::Success => "border-success/30 bg-success/10 text-foreground",
33+ Self::Error => "border-destructive/30 bg-destructive/10 text-foreground",
34+ Self::Info => "border-border bg-foreground/5 text-foreground",
35+ }
36+ }
37+
38+ /// The accent applied to the leading marker.
39+ fn marker(self) -> &'static str {
40+ match self {
41+ Self::Success => "bg-success",
42+ Self::Error => "bg-destructive",
43+ Self::Info => "bg-muted-foreground",
44+ }
45+ }
46+
47+ /// What a screen reader announces this as.
48+ ///
49+ /// Errors interrupt; confirmations wait for a pause. Getting this wrong makes a
50+ /// form unusable without sight, which is invisible in a screenshot.
51+ fn aria_role(self) -> &'static str {
52+ match self {
53+ Self::Error => "alert",
54+ _ => "status",
55+ }
56+ }
57+}
58+
59+const BASE: &str = "flex items-start gap-3 rounded-lg border px-4 py-3 text-sm";
60+
61+/// A message about what just happened.
62+///
63+/// ```ignore
64+/// view! {
65+/// flash(kind: FlashKind::Success, "Profile updated.")
66+/// }
67+/// ```
68+#[component]
69+pub async fn flash(
70+ #[default] kind: FlashKind,
71+ #[default] mut attrs: Attributes,
72+ #[default] child: View,
73+) -> Result {
74+ view! {
75+ <div
76+ role=(kind.aria_role())
77+ class=(class!(BASE, kind.classes(), attrs.remove("class")))
78+ (attrs)
79+ >
80+ <span
81+ aria-hidden="true"
82+ class=(class!("mt-1.5 size-2 shrink-0 rounded-full", kind.marker()))
83+ ></span>
84+ <div>(child)</div>
85+ </div>
86+ }
87+}
src/components/input.rs+33 −0View file
@@ -0,0 +1,33 @@
1+use topcoat::{
2+ Result,
3+ view::{Attributes, class, component, view},
4+};
5+
6+/// The classes for the [`input`] control.
7+///
8+/// The height, text size, radius, shadow, and focus ring match the `Md`
9+/// button, so an input and a button sit flush in a row. File inputs restyle
10+/// the browser's upload button into quiet, borderless text.
11+const INPUT: &str = "h-9 w-full min-w-0 rounded-lg border border-border bg-background px-3 \
12+ text-sm shadow-xs transition-colors outline-none \
13+ placeholder:text-muted-foreground \
14+ file:mr-3 file:h-full file:border-0 file:bg-transparent file:text-sm file:font-medium \
15+ focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 \
16+ focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50";
17+
18+/// A text input component.
19+///
20+/// The `attrs` (such as `type`, `name`, `placeholder`, `disabled`, or event
21+/// handlers) are forwarded to the underlying `<input>`; a `class` among them
22+/// is appended to the computed classes. The input fills its container, so
23+/// size it through the container or with a width class.
24+///
25+/// ```ignore
26+/// view! {
27+/// input(attrs: attributes! { type="email" placeholder="you@example.com" })
28+/// }
29+/// ```
30+#[component]
31+pub async fn input(#[default] mut attrs: Attributes) -> Result {
32+ view! { <input class=(class!(INPUT, attrs.remove("class"))) (attrs)> }
33+}
src/components/label.rs+35 −0View file
@@ -0,0 +1,35 @@
1+use topcoat::{
2+ Result,
3+ view::{Attributes, View, class, component, view},
4+};
5+
6+/// The classes for the [`label`] element.
7+///
8+/// The label lays out its content in a centered row, so an inline icon or a
9+/// wrapped control lines up with the text. It dims and stops receiving
10+/// pointer events when its control is disabled: a wrapped control is matched
11+/// with `has-[:disabled]`, a preceding sibling control marked `peer` with
12+/// `peer-disabled`.
13+const LABEL: &str = "flex items-center gap-2 text-sm leading-none font-medium select-none \
14+ peer-disabled:pointer-events-none peer-disabled:opacity-50 \
15+ has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50";
16+
17+/// A caption for a form control, rendered as a `<label>`.
18+///
19+/// Associate it with a control either by wrapping the control or by pointing
20+/// a `for` attribute at the control's `id`. The `attrs` (such as `class` or
21+/// `for`) are forwarded to the underlying `<label>`; a `class` among them is
22+/// appended to the computed classes. Child nodes become the label's content.
23+///
24+/// ```ignore
25+/// view! {
26+/// <div class="flex flex-col gap-2">
27+/// label(attrs: attributes! { for="email" }, "Email")
28+/// input(attrs: attributes! { id="email" type="email" })
29+/// </div>
30+/// }
31+/// ```
32+#[component]
33+pub async fn label(#[default] mut attrs: Attributes, #[default] child: View) -> Result {
34+ view! { <label class=(class!(LABEL, attrs.remove("class"))) (attrs)>(child)</label> }
35+}
src/components/textarea.rs+39 −0View file
@@ -0,0 +1,39 @@
1+use topcoat::{
2+ Result,
3+ view::{Attributes, View, class, component, view},
4+};
5+
6+/// The classes for the [`textarea`] control.
7+///
8+/// The text size, radius, shadow, and focus ring match the input control.
9+/// `field-sizing-content` lets the control grow with its content, from the
10+/// two-line minimum height; browsers without support keep the fixed minimum
11+/// and scroll.
12+const TEXTAREA: &str = "field-sizing-content min-h-16 w-full rounded-lg border border-border \
13+ bg-background px-3 py-2 text-sm shadow-xs transition-colors outline-none \
14+ placeholder:text-muted-foreground \
15+ focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 \
16+ focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50";
17+
18+/// A multi-line text input component.
19+///
20+/// The `attrs` (such as `name`, `placeholder`, `rows`, `disabled`, or event
21+/// handlers) are forwarded to the underlying `<textarea>`; a `class` among
22+/// them is appended to the computed classes. Child nodes become the control's
23+/// initial value. The textarea fills its container, so size it through the
24+/// container or with a width class; it grows with its content from a
25+/// two-line minimum.
26+///
27+/// ```ignore
28+/// view! {
29+/// textarea(attrs: attributes! { name="feedback" placeholder="Tell us more" })
30+/// }
31+/// ```
32+#[component]
33+pub async fn textarea(#[default] mut attrs: Attributes, #[default] child: View) -> Result {
34+ view! {
35+ <textarea class=(class!(TEXTAREA, attrs.remove("class"))) (attrs)>
36+ (child)
37+ </textarea>
38+ }
39+}
src/infrastructure/web/layout.rs+7 −3View file
@@ -1,25 +1,29 @@
1use topcoat::{Result, router::layout, view::view};
1+use topcoat::{Result, router::layout, tailwind, view::view};
22
33 /// The HTML shell every page renders inside.
44 ///
55 /// A layout rather than a helper function: `view!` needs the request context in scope,
66 /// so a plain `fn shell(..)` cannot build one. Layouts wrap by path prefix, and this
77 /// one is at `/`, so it wraps everything.
8+///
9+/// `class="dark"` opts the whole document into the dark palette. Steid is dark-first —
10+/// developers live there — and light mode is a later toggle rather than the default.
811 #[layout("/")]
912 async fn root_layout(slot: Result) -> Result {
1013 let content = slot?;
1114
1215 view! {
1316 <!DOCTYPE html>
14 <html lang="en">
17+ <html lang="en" class="dark">
1518 <head>
1619 <meta charset="utf-8" />
1720 <meta name="viewport" content="width=device-width, initial-scale=1" />
1821 <title>"steid"</title>
22+ <link rel="stylesheet" href=(tailwind::stylesheet!()) />
1923 topcoat::dev::script()
2024 </head>
2125 <body>
22 <main>(content)</main>
26+ <main class="mx-auto max-w-3xl px-6 py-10">(content)</main>
2327 </body>
2428 </html>
2529 }
src/infrastructure/web/profile.rs+34 −14View file
@@ -16,6 +16,7 @@ use topcoat::{
1616
1717 use crate::{
1818 application::{PublicProfile, view_profile},
19+ components::button::{ButtonSize, ButtonVariant, button_variants},
1920 domain::OrgName,
2021 };
2122
@@ -49,19 +50,26 @@ async fn profile(cx: &Cx) -> Result {
4950 let profile = profile_for(cx).await?;
5051
5152 view! {
52 <header>
53 <h1>(&profile.label)</h1>
54 <p><code>"@" (profile.handle.as_str())</code></p>
53+ <header class="mb-10">
54+ <h1 class="text-2xl font-semibold tracking-tight">(&profile.label)</h1>
55+ <p class="mt-1 font-mono text-sm text-muted-foreground">
56+ "@" (profile.handle.as_str())
57+ </p>
5558 ({
5659 match &profile.bio {
57 Some(bio) => view! { <p>(bio)</p> },
60+ Some(bio) => view! { <p class="mt-3 text-sm leading-relaxed">(bio)</p> },
5861 None => view! {},
5962 }
6063 }?)
6164 ({
6265 if profile.viewer_is_owner {
6366 view! {
64 <p><a href=(format!("/{}/settings", profile.handle))>"Edit profile"</a></p>
67+ <p class="mt-4">
68+ <a
69+ href=(format!("/{}/settings", profile.handle))
70+ class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
71+ >"Edit profile"</a>
72+ </p>
6573 }
6674 } else {
6775 view! {}
@@ -69,19 +77,31 @@ async fn profile(cx: &Cx) -> Result {
6977 }?)
7078 </header>
7179
72 <section>
73 <h2>"Repositories"</h2>
74 <p>"Nothing here yet."</p>
80+ <section class="mt-8">
81+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
82+ "Repositories"
83+ </h2>
84+ <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
85+ "Nothing here yet."
86+ </p>
7587 </section>
7688
77 <section>
78 <h2>"Writing"</h2>
79 <p>"Nothing here yet."</p>
89+ <section class="mt-8">
90+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
91+ "Writing"
92+ </h2>
93+ <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
94+ "Nothing here yet."
95+ </p>
8096 </section>
8197
82 <section>
83 <h2>"Projects"</h2>
84 <p>"Nothing here yet."</p>
98+ <section class="mt-8">
99+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
100+ "Projects"
101+ </h2>
102+ <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
103+ "Nothing here yet."
104+ </p>
85105 </section>
86106 }
87107 }
src/lib.rs+1 −0View file
@@ -5,5 +5,6 @@
55 //! [`infrastructure`] implements those ports and exposes the web surface.
66
77 pub mod application;
8+pub mod components;
89 pub mod domain;
910 pub mod infrastructure;
src/main.rs+2 −0View file
@@ -8,6 +8,7 @@ use steid::{
88 },
99 };
1010 use topcoat::{
11+ asset::{AssetBundle, RouterBuilderAssetExt},
1112 cookie::RouterBuilderCookieExt,
1213 router::{Router, RouterBuilderDiscoverExt},
1314 session::{RouterBuilderSessionExt, SessionConfig},
@@ -21,6 +22,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
2122 let pool = infrastructure::database::connect(&config.database_url).await?;
2223
2324 let mut builder = Router::builder()
25+ .assets(AssetBundle::load()?)
2426 .cookies()
2527 .sessions(session_config(config.insecure_cookies))
2628 .discover()
styles.css+115 −0View file
@@ -0,0 +1,115 @@
1+/* Steid's theme.
2+ Started from Topcoat's `neutral` theme and retuned; see plans/ui.md.
3+
4+ The theme is a small set of design tokens. Components refer to tokens only,
5+ never to raw colors, so the whole product restyles by editing `:root` and
6+ `.dark` below. A component that hardcodes a color is a bug — it will not
7+ follow a palette change and it will not adapt to the color scheme.
8+
9+ --background the page background
10+ --surface raised panels: cards, menus, flash messages
11+ --foreground text and iconography on the background
12+ --muted-foreground secondary text: captions, descriptions, placeholders
13+ --primary the emphasis color: primary actions, selection, links
14+ --primary-foreground text on a primary-filled surface
15+ --success confirmations, and git additions
16+ --success-foreground text on a success-filled surface
17+ --warning caution, and git modifications
18+ --warning-foreground text on a warning-filled surface
19+ --destructive destructive actions, errors, and git deletions
20+ --destructive-foreground text on a destructive-filled surface
21+ --border hairline borders and dividers
22+ --ring the focus ring
23+ --shadow-xs a subtle shadow for controls: buttons, inputs
24+ --shadow-sm a shadow for raised surfaces: cards, menus
25+
26+ Interactive states (hover, press) have no tokens of their own: components
27+ derive them by applying the fill or foreground color at reduced opacity,
28+ which adapts to both color schemes automatically.
29+
30+ Steid is dark-first — the layout sets `class="dark"` on <html>. Light mode
31+ is supported and secondary. */
32+
33+@import "tailwindcss";
34+
35+/* Scan the project's Rust sources for the Tailwind classes they use. This
36+ file is installed at the project root, so `./src` is the crate's source. */
37+@source "./src/**/*.rs";
38+
39+/* Dark mode is opted into by putting the `dark` class on an ancestor,
40+ typically <html>. */
41+@custom-variant dark (&:is(.dark *));
42+
43+/* Expose the tokens as Tailwind utilities (`bg-background`, `text-primary`,
44+ `border-border`, ...). The indirection through plain variables on `:root`
45+ and `.dark` is what lets the values swap when the color scheme changes. */
46+@theme inline {
47+ --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
48+ /* Paths, commit hashes, branch names, and code. */
49+ --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", monospace;
50+ --color-background: var(--background);
51+ --color-surface: var(--surface);
52+ --color-foreground: var(--foreground);
53+ --color-muted-foreground: var(--muted-foreground);
54+ --color-primary: var(--primary);
55+ --color-primary-foreground: var(--primary-foreground);
56+ --color-success: var(--success);
57+ --color-success-foreground: var(--success-foreground);
58+ --color-warning: var(--warning);
59+ --color-warning-foreground: var(--warning-foreground);
60+ --color-destructive: var(--destructive);
61+ --color-destructive-foreground: var(--destructive-foreground);
62+ --color-border: var(--border);
63+ --color-ring: var(--ring);
64+ --shadow-xs: var(--shadow-xs);
65+ --shadow-sm: var(--shadow-sm);
66+}
67+
68+/* Light mode. Secondary, but it should not look like an afterthought. */
69+:root {
70+ --background: oklch(0.99 0.003 260);
71+ --surface: oklch(1 0 0);
72+ --foreground: oklch(0.24 0.012 260);
73+ --muted-foreground: oklch(0.51 0.014 260);
74+ --primary: oklch(0.5 0.18 250);
75+ --primary-foreground: oklch(0.99 0.003 260);
76+ --success: oklch(0.52 0.15 145);
77+ --success-foreground: oklch(0.99 0.01 145);
78+ --warning: oklch(0.62 0.14 85);
79+ --warning-foreground: oklch(0.99 0.01 85);
80+ --destructive: oklch(0.55 0.2 27);
81+ --destructive-foreground: oklch(0.99 0.012 27);
82+ --border: oklch(0.9 0.008 260);
83+ --ring: oklch(0.5 0.18 250);
84+ --shadow-xs: 0 1px 2px oklch(0.24 0.02 260 / 12%);
85+ --shadow-sm:
86+ 0 1px 2px oklch(0.24 0.02 260 / 10%),
87+ 0 2px 8px -2px oklch(0.24 0.02 260 / 9%);
88+}
89+
90+/* Dark mode. The primary surface for this product. */
91+.dark {
92+ --background: oklch(0.16 0.02 260);
93+ --surface: oklch(0.2 0.02 260);
94+ --foreground: oklch(0.9 0.01 260);
95+ --muted-foreground: oklch(0.66 0.014 260);
96+ --primary: oklch(0.65 0.15 250);
97+ --primary-foreground: oklch(0.16 0.02 260);
98+ --success: oklch(0.7 0.17 145);
99+ --success-foreground: oklch(0.16 0.02 145);
100+ --warning: oklch(0.8 0.15 85);
101+ --warning-foreground: oklch(0.16 0.02 85);
102+ --destructive: oklch(0.65 0.2 25);
103+ --destructive-foreground: oklch(0.14 0.02 25);
104+ --border: oklch(0.3 0.012 260);
105+ --ring: oklch(0.65 0.15 250);
106+ /* Light-mode shadows disappear on a dark background, so these are deeper. */
107+ --shadow-xs: 0 1px 2px oklch(0 0 0 / 35%);
108+ --shadow-sm: 0 1px 2px oklch(0 0 0 / 35%), 0 2px 8px -2px oklch(0 0 0 / 30%);
109+}
110+
111+@layer base {
112+ body {
113+ @apply bg-background font-sans text-foreground;
114+ }
115+}