From a017e1050e2cc8835467afc39ebad6c9c70e77d1 Mon Sep 17 00:00:00 2001 From: Eric Manganaro Date: Fri, 24 Jul 2026 17:46:29 -0400 Subject: [PATCH 1/3] feat: add playable card system and Coatcheck suite --- .github/README.md | 5 + .github/workflows/README.md | 7 + .github/workflows/ci.yml | 15 + .gitignore | 5 + Cargo.lock | 676 +++++++++- Cargo.toml | 2 +- Coatcheck.toml | 15 + README.md | 48 +- apps/README.md | 7 + apps/coldfront/README.md | 8 + apps/coldfront/src/README.md | 6 + bun.lock | 25 + coatcheck/README.md | 24 + coatcheck/card-system.spec.ts | 170 +++ crates/README.md | 6 + crates/coldfront-ui/README.md | 8 + crates/coldfront-ui/src/README.md | 14 + crates/coldfront-ui/src/atoms.rs | 147 +++ crates/coldfront-ui/src/coldfront.css | 329 +++++ crates/coldfront-ui/src/glossary.rs | 62 + crates/coldfront-ui/src/molecules.rs | 460 ++++++- crates/coldfront-ui/src/tokens.rs | 8 +- docs/COATCHECK.md | 26 +- docs/COMPONENTS.md | 48 +- docs/PLAN.md | 3 +- docs/README.md | 6 + package.json | 13 + scripts/README.md | 9 + scripts/bootstrap | 33 +- scripts/dev | 8 +- scripts/test-coatcheck | 13 + tools/README.md | 6 + tools/coldfront-coatcheck/README.md | 11 + tools/coldfront-coatcheck/src/README.md | 10 + .../src/frame-interactions.js | 56 + tools/coldfront-coatcheck/src/main.rs | 1107 ++++++++++++++++- 36 files changed, 3347 insertions(+), 49 deletions(-) create mode 100644 .github/README.md create mode 100644 .github/workflows/README.md create mode 100644 Coatcheck.toml create mode 100644 apps/README.md create mode 100644 apps/coldfront/README.md create mode 100644 apps/coldfront/src/README.md create mode 100644 bun.lock create mode 100644 coatcheck/README.md create mode 100644 coatcheck/card-system.spec.ts create mode 100644 crates/README.md create mode 100644 crates/coldfront-ui/README.md create mode 100644 crates/coldfront-ui/src/README.md create mode 100644 docs/README.md create mode 100644 package.json create mode 100644 scripts/README.md create mode 100755 scripts/test-coatcheck create mode 100644 tools/README.md create mode 100644 tools/coldfront-coatcheck/README.md create mode 100644 tools/coldfront-coatcheck/src/README.md create mode 100644 tools/coldfront-coatcheck/src/frame-interactions.js diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 0000000..8457e14 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,5 @@ +# GitHub automation + +Repository automation is kept under [workflows](workflows/README.md). + +[Back to the repository root](../README.md). diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..d916f8e --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,7 @@ +# Workflows + +`ci.yml` runs the workspace’s formatting, Clippy, Rust tests, JavaScript syntax +gate, and all manifest-driven/custom Coatcheck browser checks in Chromium. + +- [GitHub automation](../README.md) +- [Repository root](../../README.md) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c111c49..ff7ebb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,18 @@ jobs: - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - run: cargo test --workspace --all-features - run: node --check crates/coldfront-ui/src/auth.js + + coatcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.97.1 + - uses: Swatinem/rust-cache@v2 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.1 + - run: cargo install topcoat-cli --version 0.4.0 --locked --root .tools + - run: cargo install coatcheck --version 0.2.0 --locked --root .tools + - run: bun install --frozen-lockfile + - run: bunx playwright install --with-deps chromium + - run: ./scripts/test-coatcheck diff --git a/.gitignore b/.gitignore index c42d8c6..6c93f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ /target /.tools +/node_modules +/.coatcheck +/playwright-report +/test-results +/tools/tophat-testing .DS_Store .env .env.* diff --git a/Cargo.lock b/Cargo.lock index 8df6559..1983fd4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -163,6 +213,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -190,11 +250,59 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "coatcheck" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae029b999da0a63ffe152f59706e456d13dfa0c5053118b0bf80f28a425c0ee" +checksum = "914c41ca8613fdda651bf3a0215ee5619094e6b6077ff9cfc52f476f68ab9c9e" +dependencies = [ + "clap", + "serde", + "serde_json", + "toml 0.9.12+spec-1.1.0", + "ureq", + "url", +] [[package]] name = "coldfront" @@ -222,6 +330,12 @@ dependencies = [ "topcoat", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compression-codecs" version = "0.4.38" @@ -264,6 +378,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -354,6 +486,26 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -367,9 +519,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -651,6 +809,109 @@ dependencies = [ "tokio", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -679,6 +940,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -702,6 +969,18 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "log" version = "0.4.33" @@ -738,7 +1017,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -753,6 +1032,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -783,6 +1068,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -933,6 +1227,55 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1053,6 +1396,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1088,9 +1437,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1125,6 +1486,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "thiserror" version = "2.0.19" @@ -1175,6 +1547,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.53.1" @@ -1188,7 +1570,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1228,6 +1610,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -1237,10 +1634,19 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -1259,9 +1665,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1270,7 +1676,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -1320,7 +1726,7 @@ dependencies = [ "sha2 0.11.0", "thiserror", "tokio", - "toml", + "toml 1.1.3+spec-1.1.0", "topcoat-core", "topcoat-router", "topcoat-view", @@ -1711,6 +2117,75 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" @@ -1789,12 +2264,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1804,6 +2297,76 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.4" @@ -1819,6 +2382,35 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.55" @@ -1839,6 +2431,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 1ff227c..ac6c3d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ rust-version = "1.95" version = "0.1.0" [workspace.dependencies] -coatcheck = "0.1.0" +coatcheck = "0.2.0" coldfront-ui = { path = "crates/coldfront-ui" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } topcoat = "0.4.0" diff --git a/Coatcheck.toml b/Coatcheck.toml new file mode 100644 index 0000000..cc69a4f --- /dev/null +++ b/Coatcheck.toml @@ -0,0 +1,15 @@ +version = 1 +name = "Cold Front" + +[host] +command = ["env", "HOST=127.0.0.1", "PORT=3101", "./.tools/bin/topcoat", "dev", "--package", "coldfront-coatcheck"] +url = "http://127.0.0.1:3101" +health = "/health" +manifest = "/__coatcheck/manifest.json" +startup_timeout_seconds = 120 + +[test] +story_globs = ["coatcheck/**/*.spec.ts"] +artifacts = ".coatcheck/artifacts" +timeout_ms = 30000 +axe_impacts = ["serious", "critical"] diff --git a/README.md b/README.md index 6a0fcff..e7f3d62 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ the whiteout. This repository contains two servers that share one real component library: -- `coldfront` — the public game website on port `3000` -- `coldfront-coatcheck` — a Storybook-like component workbench on port `3001` +- `coldfront` — the public game website on port `3100` +- `coldfront-coatcheck` — a Storybook-like component workbench on port `3101` The component library lives in `crates/coldfront-ui` and follows an atomic structure: @@ -30,10 +30,16 @@ and visual language. ./scripts/dev ``` +`bootstrap` installs the project-local CLI with +`cargo install coatcheck --version 0.2.0 --locked`. For global use, run +`cargo install coatcheck --locked`. + Then open: -- Cold Front: -- Coatcheck: +- Cold Front: +- Coatcheck: +- Coatcheck manifest: +- Direct story frame: `http://127.0.0.1:3101/__coatcheck/frame/{story_id}` Topcoat rebuilds and reloads each server when Rust or component files change. @@ -43,10 +49,16 @@ Topcoat rebuilds and reloads each server when Rust or component files change. ./scripts/check ``` -The initial component glossary is in -[`docs/COMPONENTS.md`](docs/COMPONENTS.md), and the proposed product/design -sequence is in [`docs/PLAN.md`](docs/PLAN.md). The Storybook-to-Coatcheck -workbench model is documented in [`docs/COATCHECK.md`](docs/COATCHECK.md). +Run the selector-driven browser checks separately: + +```sh +./scripts/test-coatcheck +``` + +They exercise the route and generic decision cards with pointer, keyboard, +disabled, and reduced-motion cases. Screenshots are review artifacts, not +pixel baselines. Coatcheck generates its Playwright configuration from +`Coatcheck.toml` and includes the project-specific checks under `coatcheck/`. ## Authentication @@ -60,3 +72,23 @@ session JWTs at the Rust boundary. Topcoat `0.4.0` is intentionally pinned because the framework is early-stage and its maintainers warn that breaking changes should be expected. The workspace toolchain is pinned separately for reproducible builds. + +## Repository map + +- [Applications](apps/README.md) + - [Cold Front server](apps/coldfront/README.md) + - [Cold Front server source](apps/coldfront/src/README.md) +- [Shared crates](crates/README.md) + - [Cold Front UI crate](crates/coldfront-ui/README.md) + - [UI source](crates/coldfront-ui/src/README.md) +- [Coatcheck browser checks](coatcheck/README.md) +- [Documentation](docs/README.md) + - [Component glossary](docs/COMPONENTS.md) + - [Coatcheck integration](docs/COATCHECK.md) + - [Product plan](docs/PLAN.md) +- [Workspace scripts](scripts/README.md) +- [Developer tools](tools/README.md) + - [Cold Front Coatcheck host](tools/coldfront-coatcheck/README.md) + - [Coatcheck host source](tools/coldfront-coatcheck/src/README.md) +- [GitHub automation](.github/README.md) + - [Workflows](.github/workflows/README.md) diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 0000000..89cad78 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,7 @@ +# Applications + +Runnable product servers live here. Cold Front currently has one public +Topcoat application. + +- [Cold Front server](coldfront/README.md) +- [Repository root](../README.md) diff --git a/apps/coldfront/README.md b/apps/coldfront/README.md new file mode 100644 index 0000000..1d4461b --- /dev/null +++ b/apps/coldfront/README.md @@ -0,0 +1,8 @@ +# Cold Front server + +This package owns the public game website and composes the production +components from `coldfront-ui`. + +- [Server source](src/README.md) +- [Applications](../README.md) +- [Repository root](../../README.md) diff --git a/apps/coldfront/src/README.md b/apps/coldfront/src/README.md new file mode 100644 index 0000000..901e9f0 --- /dev/null +++ b/apps/coldfront/src/README.md @@ -0,0 +1,6 @@ +# Cold Front server source + +`main.rs` builds the public Topcoat router and page composition. + +- [Cold Front package](../README.md) +- [Repository root](../../../README.md) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0ee252b --- /dev/null +++ b/bun.lock @@ -0,0 +1,25 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "coldfront", + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.61.1", + }, + }, + }, + "packages": { + "@axe-core/playwright": ["@axe-core/playwright@4.12.1", "", { "dependencies": { "axe-core": "~4.12.1" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw=="], + + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], + + "axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + } +} diff --git a/coatcheck/README.md b/coatcheck/README.md new file mode 100644 index 0000000..f8358c5 --- /dev/null +++ b/coatcheck/README.md @@ -0,0 +1,24 @@ +# Coatcheck browser checks + +This folder contains Cold Front’s project-specific Playwright checks for the +isolated Coatcheck frames. The tests assert stable routes, selectors, native +pointer and keyboard behavior, disabled controls, and reduced-motion behavior. + +Screenshots are review artifacts written under `.coatcheck/artifacts`; they are +not pixel baselines. The suite intentionally avoids timing assertions, raw HTML +snapshots, and DOM implementation details. + +The installed Coatcheck CLI generates Playwright configuration from +`Coatcheck.toml`, combines manifest-driven checks with this custom glob, and +runs them together. Run it from the [repository root](../README.md): + +```sh +./scripts/test-coatcheck +``` + +When developing the adjacent Coatcheck crate before installing its CLI, the +equivalent fallback is: + +```sh +cargo run --manifest-path ../coatcheck/Cargo.toml -- test +``` diff --git a/coatcheck/card-system.spec.ts b/coatcheck/card-system.spec.ts new file mode 100644 index 0000000..0407610 --- /dev/null +++ b/coatcheck/card-system.spec.ts @@ -0,0 +1,170 @@ +import { expect, test, type Page, type TestInfo } from "@playwright/test"; + +const host = process.env.COATCHECK_URL ?? "http://127.0.0.1:3101"; + +async function openStory(page: Page, storyId: string) { + await page.goto(`${host}/__coatcheck/frame/${storyId}`); + await expect(page.locator("[data-coatcheck-frame]")).toHaveAttribute( + "data-story-id", + storyId + ); + await expect(page.locator("[data-coatcheck-ready]")).toBeAttached(); + await expect(page.locator("[data-coatcheck-error]")).toHaveCount(0); +} + +async function capture(page: Page, testInfo: TestInfo, name: string) { + await page.screenshot({ + path: testInfo.outputPath(`${name}.png`), + fullPage: true + }); +} + +test("manifest advertises stable story frames and card-system cases", async ({ + page, + request +}, testInfo) => { + const response = await request.get(`${host}/__coatcheck/manifest.json`); + expect(response.ok()).toBe(true); + expect(response.headers()["content-type"]).toContain("application/json"); + + const manifest = await response.json(); + expect(manifest.frame).toMatchObject({ + path: "/__coatcheck/frame", + story_query: "story", + root_selector: "[data-coatcheck-frame]", + ready_selector: "[data-coatcheck-ready]", + error_selector: "[data-coatcheck-error]" + }); + + const ids = manifest.stories.map((story: { id: string }) => story.id); + expect(ids).toEqual( + expect.arrayContaining([ + "companion-card", + "resource-card", + "route-option", + "decision-card", + "companion-card-matrix", + "resource-card-matrix", + "route-option-matrix", + "decision-card-matrix", + "field-choice-scene" + ]) + ); + + await openStory(page, "field-choice-scene"); + await expect(page.getByTestId("field-choice-scene")).toBeVisible(); + await capture(page, testInfo, "field-choice-scene"); +}); + +test("workbench switches card stories without reloading the page", async ({ + page +}) => { + await page.goto(host); + const workbenchUrl = page.url(); + + await page.getByRole("button", { name: "Companion card", exact: true }).click(); + await expect(page.getByTestId("companion-card-playground")).toBeVisible(); + expect(page.url()).toBe(workbenchUrl); + + await page.getByRole("button", { name: "Route option", exact: true }).click(); + await expect(page.getByTestId("route-option-playground")).toBeVisible(); + expect(page.url()).toBe(workbenchUrl); +}); + +test("route choices support pointer and keyboard input while disabled stays inert", async ({ + page +}, testInfo) => { + await openStory(page, "route-option"); + + const selection = page.getByTestId("route-selection"); + const ridge = page.getByTestId("route-ridge"); + const river = page.getByTestId("route-river"); + const tunnel = page.getByTestId("route-tunnel"); + + await ridge.click(); + await expect(selection).toHaveAttribute("data-selection", "ridge"); + await expect(ridge).toHaveAttribute("aria-pressed", "true"); + await expect(river).toHaveAttribute("aria-pressed", "false"); + + await river.focus(); + await page.keyboard.press("Enter"); + await expect(selection).toHaveAttribute("data-selection", "river"); + await expect(river).toHaveAttribute("aria-pressed", "true"); + + await ridge.focus(); + await page.keyboard.press("Space"); + await expect(selection).toHaveAttribute("data-selection", "ridge"); + await expect(ridge).toHaveAttribute("aria-pressed", "true"); + + await expect(tunnel).toBeDisabled(); + await tunnel.evaluate((button: HTMLButtonElement) => button.click()); + await expect(selection).toHaveAttribute("data-selection", "ridge"); + + await capture(page, testInfo, "route-option-interactions"); +}); + +test("decision choices support pointer and keyboard input while disabled stays inert", async ({ + page +}, testInfo) => { + await openStory(page, "decision-card"); + + const selection = page.getByTestId("decision-selection"); + const share = page.getByTestId("decision-share"); + const save = page.getByTestId("decision-save"); + const flare = page.getByTestId("decision-flare"); + + await share.click(); + await expect(selection).toHaveAttribute("data-selection", "share"); + await expect(share).toHaveAttribute("aria-pressed", "true"); + + await save.focus(); + await page.keyboard.press("Enter"); + await expect(selection).toHaveAttribute("data-selection", "save"); + await expect(save).toHaveAttribute("aria-pressed", "true"); + + await share.focus(); + await page.keyboard.press("Space"); + await expect(selection).toHaveAttribute("data-selection", "share"); + + await expect(flare).toBeDisabled(); + await flare.evaluate((button: HTMLButtonElement) => button.click()); + await expect(selection).toHaveAttribute("data-selection", "share"); + + await capture(page, testInfo, "decision-card-interactions"); +}); + +test("route and decision interactions remain operable with reduced motion", async ({ + browser +}, testInfo) => { + const context = await browser.newContext({ reducedMotion: "reduce" }); + const page = await context.newPage(); + + expect( + await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches) + ).toBe(true); + + await openStory(page, "route-option"); + const ridge = page.getByTestId("route-ridge"); + await ridge.click(); + await expect(page.getByTestId("route-selection")).toHaveAttribute( + "data-selection", + "ridge" + ); + await expect(ridge).toHaveCSS("transform", "none"); + expect( + await ridge.evaluate((element) => getComputedStyle(element).transitionProperty) + ).not.toContain("transform"); + + await openStory(page, "decision-card"); + const save = page.getByTestId("decision-save"); + await save.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("decision-selection")).toHaveAttribute( + "data-selection", + "save" + ); + await expect(save).toHaveCSS("transform", "none"); + + await capture(page, testInfo, "reduced-motion-interactions"); + await context.close(); +}); diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000..a6161d5 --- /dev/null +++ b/crates/README.md @@ -0,0 +1,6 @@ +# Shared crates + +Reusable Rust libraries live here. + +- [Cold Front UI](coldfront-ui/README.md) +- [Repository root](../README.md) diff --git a/crates/coldfront-ui/README.md b/crates/coldfront-ui/README.md new file mode 100644 index 0000000..7b3ceaa --- /dev/null +++ b/crates/coldfront-ui/README.md @@ -0,0 +1,8 @@ +# Cold Front UI + +This crate contains the production design tokens, atomic components, composed +patterns, and stylesheet shared by the public app and Coatcheck host. + +- [UI source](src/README.md) +- [Shared crates](../README.md) +- [Repository root](../../README.md) diff --git a/crates/coldfront-ui/src/README.md b/crates/coldfront-ui/src/README.md new file mode 100644 index 0000000..caeb448 --- /dev/null +++ b/crates/coldfront-ui/src/README.md @@ -0,0 +1,14 @@ +# Cold Front UI source + +The source follows a one-way component dependency: + +```text +tokens -> atoms -> molecules -> organisms +``` + +Coatcheck imports these production components; it does not maintain demo +copies. + +- [UI crate](../README.md) +- [Component glossary](../../../docs/COMPONENTS.md) +- [Repository root](../../../README.md) diff --git a/crates/coldfront-ui/src/atoms.rs b/crates/coldfront-ui/src/atoms.rs index dc8c025..78a0f7e 100644 --- a/crates/coldfront-ui/src/atoms.rs +++ b/crates/coldfront-ui/src/atoms.rs @@ -3,6 +3,83 @@ use topcoat::{ view::{component, view}, }; +/// Semantic color roles shared by status-bearing components. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum StatusTone { + /// Informational state without an implied outcome. + #[default] + Neutral, + /// Stable or favorable state. + Positive, + /// State that needs attention soon. + Caution, + /// Immediate danger or failure. + Critical, +} + +impl StatusTone { + /// Returns the stable value rendered to `data-tone`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Neutral => "neutral", + Self::Positive => "positive", + Self::Caution => "caution", + Self::Critical => "critical", + } + } +} + +/// Semantic condition of a percentage-based vital. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VitalState { + /// The vital has comfortable capacity. + #[default] + Stable, + /// The vital is usable but under pressure. + Strained, + /// The vital is close to failure. + Critical, +} + +impl VitalState { + /// Derives a default state for a remaining-capacity percentage. + #[must_use] + pub const fn from_percent(percent: u8) -> Self { + match clamp_percent(percent) { + 0..=29 => Self::Critical, + 30..=64 => Self::Strained, + _ => Self::Stable, + } + } + + /// Returns the human-readable condition used by assistive technology. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Stable => "Stable", + Self::Strained => "Strained", + Self::Critical => "Critical", + } + } + + /// Returns the semantic tone associated with the condition. + #[must_use] + pub const fn tone(self) -> StatusTone { + match self { + Self::Stable => StatusTone::Positive, + Self::Strained => StatusTone::Caution, + Self::Critical => StatusTone::Critical, + } + } +} + +/// Clamps a percentage to the range accepted by the meter atom. +#[must_use] +pub const fn clamp_percent(value: u8) -> u8 { + if value > 100 { 100 } else { value } +} + #[component] pub async fn wordmark() -> Result { view! { @@ -36,6 +113,47 @@ pub async fn signal_chip(#[into] label: String) -> Result { } } +/// Renders a concise, non-interactive status label. +#[component] +pub async fn status_badge(#[into] label: String, #[default] tone: StatusTone) -> Result { + view! { + + + (label) + + } +} + +/// Renders a labeled percentage meter with explicit accessible values. +#[component] +pub async fn vital_meter(#[into] label: String, value: u8, #[default] state: VitalState) -> Result { + let value = clamp_percent(value); + let value_label = format!("{value}%"); + let value_text = format!("{value}% — {}", state.label()); + let style = format!("--cf-vital-value: {value}%"); + + view! { +
+
+ (label.clone()) + (value_label) +
+
+ +
+
+ } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ActionTone { #[default] @@ -61,3 +179,32 @@ pub async fn action_link( } } + +#[cfg(test)] +mod tests { + use super::{StatusTone, VitalState, clamp_percent}; + + #[test] + fn status_tones_have_stable_attribute_values() { + assert_eq!(StatusTone::Neutral.as_str(), "neutral"); + assert_eq!(StatusTone::Positive.as_str(), "positive"); + assert_eq!(StatusTone::Caution.as_str(), "caution"); + assert_eq!(StatusTone::Critical.as_str(), "critical"); + } + + #[test] + fn vital_state_thresholds_cover_the_meter_range() { + assert_eq!(VitalState::from_percent(0), VitalState::Critical); + assert_eq!(VitalState::from_percent(29), VitalState::Critical); + assert_eq!(VitalState::from_percent(30), VitalState::Strained); + assert_eq!(VitalState::from_percent(64), VitalState::Strained); + assert_eq!(VitalState::from_percent(65), VitalState::Stable); + assert_eq!(VitalState::from_percent(100), VitalState::Stable); + } + + #[test] + fn percentages_are_clamped_before_rendering() { + assert_eq!(clamp_percent(73), 73); + assert_eq!(clamp_percent(255), 100); + } +} diff --git a/crates/coldfront-ui/src/coldfront.css b/crates/coldfront-ui/src/coldfront.css index 4ba9a1a..3e9975f 100644 --- a/crates/coldfront-ui/src/coldfront.css +++ b/crates/coldfront-ui/src/coldfront.css @@ -29,9 +29,12 @@ --cf-shadow-signal-tight: 0 0 10px rgba(187, 255, 69, .5); --cf-shadow-raised: 0 12px 40px rgba(25, 35, 30, .13); --cf-shadow-focus: 0 0 0 2px rgba(111, 150, 47, .12); + --cf-motion-press: 120ms; --cf-motion-quick: 160ms; --cf-motion-base: 180ms; --cf-ease-standard: ease; + --cf-ease-out: cubic-bezier(.23, 1, .32, 1); + --cf-ease-in-out: cubic-bezier(.77, 0, .175, 1); } *, @@ -367,6 +370,275 @@ select { line-height: 1.55; } +.cf-status-badge { + --cf-status-color: var(--cf-muted); + display: inline-flex; + align-items: center; + gap: var(--cf-space-xs); + min-height: 24px; + padding: 0 9px; + border: 1px solid color-mix(in srgb, var(--cf-status-color) 38%, transparent); + border-radius: var(--cf-radius-xs); + color: var(--cf-status-color); + background: color-mix(in srgb, var(--cf-status-color) 7%, transparent); + font: 650 9px/1 var(--cf-mono); + letter-spacing: .08em; + text-transform: uppercase; + transition: + color var(--cf-motion-quick) var(--cf-ease-out), + border-color var(--cf-motion-quick) var(--cf-ease-out), + background-color var(--cf-motion-quick) var(--cf-ease-out), + opacity var(--cf-motion-quick) var(--cf-ease-out); +} + +.cf-status-badge[data-tone="positive"] { + --cf-status-color: var(--cf-signal); +} + +.cf-status-badge[data-tone="caution"], +.cf-status-badge[data-tone="critical"] { + --cf-status-color: var(--cf-warning); +} + +.cf-status-badge[data-tone="critical"] { + background: color-mix(in srgb, var(--cf-warning) 14%, transparent); +} + +.cf-status-badge__mark { + width: 5px; + height: 5px; + border-radius: var(--cf-radius-round); + background: currentColor; +} + +.cf-vital-meter { + --cf-vital-color: var(--cf-muted); + display: grid; + gap: 7px; +} + +.cf-vital-meter[data-tone="positive"] { + --cf-vital-color: var(--cf-signal); +} + +.cf-vital-meter[data-tone="caution"], +.cf-vital-meter[data-tone="critical"] { + --cf-vital-color: var(--cf-warning); +} + +.cf-vital-meter__label { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--cf-space-md); + color: var(--cf-muted); + font: 600 9px/1 var(--cf-mono); + letter-spacing: .07em; + text-transform: uppercase; +} + +.cf-vital-meter__label strong { + color: var(--cf-vital-color); + font-size: 10px; +} + +.cf-vital-meter__track { + overflow: hidden; + height: 4px; + border-radius: var(--cf-radius-xs); + background: var(--cf-line); +} + +.cf-vital-meter__fill { + display: block; + width: var(--cf-vital-value); + height: 100%; + background: var(--cf-vital-color); +} + +.cf-info-card { + display: grid; + gap: var(--cf-space-lg); + padding: var(--cf-space-lg); + border: 1px solid var(--cf-line); + border-radius: var(--cf-radius-md); + background: + linear-gradient(145deg, rgba(220, 232, 223, .04), transparent 58%), + var(--cf-ink-soft); + color: var(--cf-ice); +} + +.cf-info-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--cf-space-md); +} + +.cf-info-card__body h3 { + margin: 0; + color: var(--cf-ice-bright); + font-family: var(--cf-display); + font-size: 34px; + font-weight: 800; + letter-spacing: -.035em; + line-height: 1; + text-transform: uppercase; +} + +.cf-info-card__body p { + max-width: 42ch; + margin: var(--cf-space-sm) 0 0; + color: var(--cf-muted); + font-size: 12px; + line-height: 1.55; +} + +.cf-info-card__vitals { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--cf-space-lg); + padding-top: var(--cf-space-lg); + border-top: 1px solid var(--cf-line); +} + +.cf-resource-card__body { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--cf-space-lg); + align-items: start; +} + +.cf-resource-card__amount { + color: var(--cf-ice-bright); + font: 800 30px/1 var(--cf-display); + letter-spacing: -.04em; + white-space: nowrap; +} + +.cf-decision-card { + position: relative; + display: grid; + width: 100%; + min-height: 230px; + grid-template-rows: auto 1fr auto auto; + gap: var(--cf-space-md); + padding: var(--cf-space-lg); + border: 1px solid var(--cf-line-strong); + border-radius: var(--cf-radius-md); + appearance: none; + color: var(--cf-ice); + background: + linear-gradient(145deg, rgba(220, 232, 223, .045), transparent 62%), + var(--cf-ink-soft); + text-align: left; + cursor: pointer; + transition: + transform var(--cf-motion-quick) var(--cf-ease-out), + border-color var(--cf-motion-base) var(--cf-ease-out), + background-color var(--cf-motion-base) var(--cf-ease-in-out), + color var(--cf-motion-quick) var(--cf-ease-out), + opacity var(--cf-motion-quick) var(--cf-ease-out); +} + +.cf-decision-card:focus-visible { + outline: 2px solid var(--cf-signal); + outline-offset: 3px; +} + +.cf-decision-card[aria-pressed="true"] { + border-color: var(--cf-signal); + background: + linear-gradient(145deg, rgba(187, 255, 69, .09), transparent 62%), + var(--cf-ink-soft); +} + +.cf-decision-card:disabled { + color: var(--cf-muted); + cursor: not-allowed; + opacity: .52; +} + +.cf-decision-card__selected { + position: absolute; + top: -1px; + right: -1px; + display: grid; + width: 29px; + height: 29px; + place-items: center; + border: 1px solid var(--cf-signal); + color: var(--cf-ink); + background: var(--cf-signal); + font: 800 12px/1 var(--cf-mono); + opacity: 0; + transition: opacity var(--cf-motion-quick) var(--cf-ease-out); +} + +.cf-decision-card[aria-pressed="true"] .cf-decision-card__selected { + opacity: 1; +} + +.cf-decision-card__header, +.cf-decision-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--cf-space-md); +} + +.cf-decision-card__code, +.cf-decision-card__footer { + color: var(--cf-muted); + font: 600 9px/1 var(--cf-mono); + letter-spacing: .08em; + text-transform: uppercase; +} + +.cf-decision-card__title { + align-self: end; + color: var(--cf-ice-bright); + font-family: var(--cf-display); + font-size: clamp(30px, 4cqw, 46px); + font-weight: 800; + letter-spacing: -.045em; + line-height: .95; + text-transform: uppercase; +} + +.cf-decision-card__summary { + max-width: 46ch; + color: var(--cf-muted); + font-size: 12px; + line-height: 1.55; +} + +.cf-decision-card__footer { + padding-top: var(--cf-space-md); + border-top: 1px solid var(--cf-line); +} + +.cf-decision-card__footer span:last-child { + color: var(--cf-signal); + font-size: 14px; +} + +@media (hover: hover) and (pointer: fine) { + .cf-decision-card:not(:disabled):hover { + border-color: var(--cf-ice); + transform: translateY(-2px); + } + + .cf-decision-card[aria-pressed="true"]:not(:disabled):hover { + border-color: var(--cf-signal); + } +} + +.cf-decision-card:not(:disabled):active { + transform: scale(.985); + transition-duration: var(--cf-motion-press); +} + .cf-briefing { display: grid; grid-template-columns: minmax(260px, .85fr) 1.4fr; @@ -1328,6 +1600,7 @@ select { .cc-preview-stage { position: relative; + container-type: inline-size; width: 100%; height: 100%; min-height: 230px; @@ -1565,6 +1838,42 @@ select { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.cc-choice-grid, +.cc-field-choice__status, +.cc-field-choice__routes { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.cc-field-choice { + display: grid; + width: min(100%, 1180px); + gap: 24px; +} + +.cc-field-choice__status { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.cc-choice-output { + display: block; + margin-top: 20px; +} + +.cc-field-choice > .cc-choice-output { + margin-top: 0; +} + +@container (max-width: 720px) { + .cc-choice-grid, + .cc-field-choice__status, + .cc-field-choice__routes, + .cc-contract-matrix--cards > div { + grid-template-columns: 1fr; + } +} + @keyframes cc-token-pulse { to { transform: translateX(28px); @@ -1903,3 +2212,23 @@ select { grid-template-columns: repeat(2, minmax(130px, 1fr)); } } + +@media (prefers-reduced-motion: reduce) { + .cf-decision-card { + transform: none !important; + transition: + border-color var(--cf-motion-press) var(--cf-ease-out), + background-color var(--cf-motion-press) var(--cf-ease-out), + color var(--cf-motion-press) var(--cf-ease-out), + opacity var(--cf-motion-press) var(--cf-ease-out) !important; + } + + .cf-decision-card__selected, + .cf-status-badge { + transition: + color var(--cf-motion-press) var(--cf-ease-out), + border-color var(--cf-motion-press) var(--cf-ease-out), + background-color var(--cf-motion-press) var(--cf-ease-out), + opacity var(--cf-motion-press) var(--cf-ease-out) !important; + } +} diff --git a/crates/coldfront-ui/src/glossary.rs b/crates/coldfront-ui/src/glossary.rs index 0163321..ed56b92 100644 --- a/crates/coldfront-ui/src/glossary.rs +++ b/crates/coldfront-ui/src/glossary.rs @@ -39,6 +39,14 @@ pub const COMPONENTS: &[ComponentDoc] = &[ source: "crates/coldfront-ui/src/coldfront.css", states: &["ink", "ice", "signal", "warning", "muted"], }, + ComponentDoc { + id: "motion-tokens", + name: "Motion tokens", + tier: Tier::Foundation, + purpose: "Brief interaction timings with explicit ease-out and ease-in-out curves.", + source: "crates/coldfront-ui/src/tokens.rs", + states: &["press", "quick", "base", "ease-out", "ease-in-out"], + }, ComponentDoc { id: "wordmark", name: "Wordmark", @@ -63,6 +71,22 @@ pub const COMPONENTS: &[ComponentDoc] = &[ source: "crates/coldfront-ui/src/atoms.rs", states: &["active"], }, + ComponentDoc { + id: "status-badge", + name: "Status badge", + tier: Tier::Atom, + purpose: "A concise semantic label for informational component state.", + source: "crates/coldfront-ui/src/atoms.rs", + states: &["neutral", "positive", "caution", "critical"], + }, + ComponentDoc { + id: "vital-meter", + name: "Vital meter", + tier: Tier::Atom, + purpose: "An accessible percentage readout for remaining condition or capacity.", + source: "crates/coldfront-ui/src/atoms.rs", + states: &["stable", "strained", "critical"], + }, ComponentDoc { id: "action-link", name: "Action link", @@ -87,6 +111,44 @@ pub const COMPONENTS: &[ComponentDoc] = &[ source: "crates/coldfront-ui/src/molecules.rs", states: &["default"], }, + ComponentDoc { + id: "companion-card", + name: "Companion card", + tier: Tier::Molecule, + purpose: "An informational readout for companion condition and trust.", + source: "crates/coldfront-ui/src/molecules.rs", + states: &["steady", "alert", "distressed"], + }, + ComponentDoc { + id: "resource-card", + name: "Resource card", + tier: Tier::Molecule, + purpose: "An informational readout for a carried survival resource.", + source: "crates/coldfront-ui/src/molecules.rs", + states: &["ready", "low", "critical", "depleted"], + }, + ComponentDoc { + id: "decision-card", + name: "Decision card", + tier: Tier::Molecule, + purpose: "A native button card whose selection state is owned by its parent.", + source: "crates/coldfront-ui/src/molecules.rs", + states: &["available", "selected", "unavailable"], + }, + ComponentDoc { + id: "route-option", + name: "Route option", + tier: Tier::Molecule, + purpose: "A route-specific decision card with distance and risk metadata.", + source: "crates/coldfront-ui/src/molecules.rs", + states: &[ + "low-risk", + "elevated-risk", + "severe-risk", + "selected", + "unavailable", + ], + }, ComponentDoc { id: "command-header", name: "Command header", diff --git a/crates/coldfront-ui/src/molecules.rs b/crates/coldfront-ui/src/molecules.rs index de79b0f..18383c5 100644 --- a/crates/coldfront-ui/src/molecules.rs +++ b/crates/coldfront-ui/src/molecules.rs @@ -1,9 +1,249 @@ use topcoat::{ Result, - view::{component, view}, + view::{Attributes, component, view}, }; -use crate::atoms::eyebrow; +use crate::atoms::{StatusTone, VitalState, eyebrow, status_badge, vital_meter}; + +/// High-level state of the player's companion. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CompanionState { + /// The companion is calm and responsive. + #[default] + Steady, + /// The companion has noticed a threat or route change. + Alert, + /// The companion needs immediate help. + Distressed, +} + +impl CompanionState { + /// Returns the stable value rendered to `data-state`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Steady => "steady", + Self::Alert => "alert", + Self::Distressed => "distressed", + } + } + + /// Returns the short label rendered on the card. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Steady => "Steady", + Self::Alert => "Alert", + Self::Distressed => "Distressed", + } + } + + /// Returns the semantic tone for this state. + #[must_use] + pub const fn tone(self) -> StatusTone { + match self { + Self::Steady => StatusTone::Positive, + Self::Alert => StatusTone::Caution, + Self::Distressed => StatusTone::Critical, + } + } +} + +/// Readiness of a carried survival resource. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ResourceState { + /// The resource has sufficient capacity. + #[default] + Ready, + /// The resource should be conserved. + Low, + /// The resource is nearly exhausted. + Critical, + /// No usable capacity remains. + Depleted, +} + +impl ResourceState { + /// Returns the stable value rendered to `data-state`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::Low => "low", + Self::Critical => "critical", + Self::Depleted => "depleted", + } + } + + /// Returns the short label rendered on the card. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Ready => "Ready", + Self::Low => "Low", + Self::Critical => "Critical", + Self::Depleted => "Depleted", + } + } + + /// Returns the semantic tone for this state. + #[must_use] + pub const fn tone(self) -> StatusTone { + match self { + Self::Ready => StatusTone::Positive, + Self::Low => StatusTone::Caution, + Self::Critical | Self::Depleted => StatusTone::Critical, + } + } +} + +/// Visual and semantic state of an interactive decision. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DecisionState { + /// The decision can be selected. + #[default] + Available, + /// The parent currently owns this as the selected decision. + Selected, + /// The decision cannot currently be selected. + Unavailable, +} + +impl DecisionState { + /// Returns the stable value rendered to `data-state`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Available => "available", + Self::Selected => "selected", + Self::Unavailable => "unavailable", + } + } + + /// Returns the value for the button's `aria-pressed` state. + #[must_use] + pub const fn is_selected(self) -> bool { + matches!(self, Self::Selected) + } + + /// Returns whether native button interaction must be disabled. + #[must_use] + pub const fn is_disabled(self) -> bool { + matches!(self, Self::Unavailable) + } +} + +/// Threat level associated with a route. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RouteRisk { + /// The route has no known immediate threat. + #[default] + Low, + /// The route has a known exposure or supply cost. + Elevated, + /// The route carries a likely critical consequence. + Severe, +} + +impl RouteRisk { + /// Returns the short label rendered on a route option. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Low => "Low risk", + Self::Elevated => "Elevated risk", + Self::Severe => "Severe risk", + } + } + + /// Returns the semantic tone for the risk. + #[must_use] + pub const fn tone(self) -> StatusTone { + match self { + Self::Low => StatusTone::Positive, + Self::Elevated => StatusTone::Caution, + Self::Severe => StatusTone::Critical, + } + } +} + +/// Content rendered by [`decision_card`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DecisionCard { + /// Stable DOM id for the button and its description. + pub id: String, + /// Short operational code displayed in the header. + pub code: String, + /// Primary decision label. + pub title: String, + /// Consequence or route explanation. + pub summary: String, + /// Status badge label. + pub meta_label: String, + /// Supporting value displayed in the footer. + pub meta_value: String, +} + +impl DecisionCard { + /// Creates content for a generic decision card. + #[must_use] + pub fn new( + id: impl Into, + code: impl Into, + title: impl Into, + summary: impl Into, + meta_label: impl Into, + meta_value: impl Into, + ) -> Self { + Self { + id: id.into(), + code: code.into(), + title: title.into(), + summary: summary.into(), + meta_label: meta_label.into(), + meta_value: meta_value.into(), + } + } +} + +/// Content rendered by [`route_option`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouteOption { + /// Stable DOM id for the button and its description. + pub id: String, + /// Bearing or directional code. + pub bearing: String, + /// Primary route label. + pub title: String, + /// Route consequence or terrain explanation. + pub summary: String, + /// Human-readable route distance. + pub distance: String, + /// Threat level associated with the route. + pub risk: RouteRisk, +} + +impl RouteOption { + /// Creates content for a route-specific decision. + #[must_use] + pub fn new( + id: impl Into, + bearing: impl Into, + title: impl Into, + summary: impl Into, + distance: impl Into, + risk: RouteRisk, + ) -> Self { + Self { + id: id.into(), + bearing: bearing.into(), + title: title.into(), + summary: summary.into(), + distance: distance.into(), + risk, + } + } +} #[component] pub async fn signal_card( @@ -41,3 +281,219 @@ pub async fn dispatch_item( } } + +/// Informational readout for companion condition and trust. +#[component] +pub async fn companion_card( + #[into] name: String, + #[into] call_sign: String, + #[into] detail: String, + condition: u8, + trust: u8, + #[default] state: CompanionState, +) -> Result { + view! { +
+
+ eyebrow(label: call_sign) + status_badge(label: state.label(), tone: state.tone()) +
+
+

(name)

+

(detail)

+
+
+ vital_meter( + label: "Condition", + value: condition, + state: VitalState::from_percent(condition), + ) + vital_meter( + label: "Trust", + value: trust, + state: VitalState::from_percent(trust), + ) +
+
+ } +} + +/// Informational readout for one carried survival resource. +#[component] +pub async fn resource_card( + #[into] code: String, + #[into] label: String, + #[into] amount: String, + #[into] detail: String, + reserve: u8, + #[default] state: ResourceState, +) -> Result { + view! { +
+
+ eyebrow(label: code) + status_badge(label: state.label(), tone: state.tone()) +
+
+
+

(label)

+

(detail)

+
+ (amount) +
+ vital_meter( + label: "Reserve", + value: reserve, + state: VitalState::from_percent(reserve), + ) +
+ } +} + +/// Generic pressable decision card. +/// +/// `attrs` is intentionally forwarded to the native button. A parent can own +/// Topcoat signals and pass `@click`, `:aria-pressed`, and `:data-state` +/// bindings without the card creating local state. +#[component] +pub async fn decision_card( + card: DecisionCard, + #[default] tone: StatusTone, + #[default] state: DecisionState, + #[default] attrs: Attributes, +) -> Result { + let DecisionCard { + id, + code, + title, + summary, + meta_label, + meta_value, + } = card; + let detail_id = format!("{id}-detail"); + + view! { + + } +} + +/// Domain-specific decision card for a travel route. +/// +/// Like [`decision_card`], selection remains parent-owned through `attrs`. +#[component] +pub async fn route_option( + option: RouteOption, + #[default] state: DecisionState, + #[default] attrs: Attributes, +) -> Result { + let RouteOption { + id, + bearing, + title, + summary, + distance, + risk, + } = option; + + view! { + decision_card( + card: DecisionCard::new( + id, + bearing, + title, + summary, + risk.label(), + distance, + ), + tone: risk.tone(), + state: state, + attrs: attrs, + ) + } +} + +#[cfg(test)] +mod tests { + use super::{ + CompanionState, DecisionCard, DecisionState, ResourceState, RouteOption, RouteRisk, + }; + use crate::atoms::StatusTone; + + #[test] + fn decision_state_matches_native_button_semantics() { + assert!(!DecisionState::Available.is_selected()); + assert!(!DecisionState::Available.is_disabled()); + assert!(DecisionState::Selected.is_selected()); + assert!(!DecisionState::Selected.is_disabled()); + assert!(!DecisionState::Unavailable.is_selected()); + assert!(DecisionState::Unavailable.is_disabled()); + } + + #[test] + fn informational_states_map_to_semantic_tones() { + assert_eq!(CompanionState::Steady.tone(), StatusTone::Positive); + assert_eq!(CompanionState::Alert.tone(), StatusTone::Caution); + assert_eq!(CompanionState::Distressed.tone(), StatusTone::Critical); + + assert_eq!(ResourceState::Ready.tone(), StatusTone::Positive); + assert_eq!(ResourceState::Low.tone(), StatusTone::Caution); + assert_eq!(ResourceState::Critical.tone(), StatusTone::Critical); + assert_eq!(ResourceState::Depleted.tone(), StatusTone::Critical); + } + + #[test] + fn route_risks_have_distinct_labels_and_tones() { + assert_eq!(RouteRisk::Low.label(), "Low risk"); + assert_eq!(RouteRisk::Elevated.label(), "Elevated risk"); + assert_eq!(RouteRisk::Severe.label(), "Severe risk"); + assert_eq!(RouteRisk::Low.tone(), StatusTone::Positive); + assert_eq!(RouteRisk::Elevated.tone(), StatusTone::Caution); + assert_eq!(RouteRisk::Severe.tone(), StatusTone::Critical); + } + + #[test] + fn card_content_models_preserve_story_inputs() { + let card = DecisionCard::new( + "shelter", + "DEC / 04", + "Hold position", + "Spend fuel and wait for visibility.", + "Fuel cost", + "12%", + ); + assert_eq!(card.id, "shelter"); + assert_eq!(card.meta_value, "12%"); + + let route = RouteOption::new( + "ridge", + "N / 018", + "Take the ridge", + "Higher exposure, stable snowpack.", + "4.2 KM", + RouteRisk::Elevated, + ); + assert_eq!(route.bearing, "N / 018"); + assert_eq!(route.risk, RouteRisk::Elevated); + } +} diff --git a/crates/coldfront-ui/src/tokens.rs b/crates/coldfront-ui/src/tokens.rs index 865c0ee..4ff1bd1 100644 --- a/crates/coldfront-ui/src/tokens.rs +++ b/crates/coldfront-ui/src/tokens.rs @@ -155,11 +155,17 @@ pub mod shadow { pub mod motion { use super::{DesignToken, Motion}; + pub const PRESS: DesignToken = DesignToken::new("--cf-motion-press", "120ms"); pub const QUICK: DesignToken = DesignToken::new("--cf-motion-quick", "160ms"); pub const BASE: DesignToken = DesignToken::new("--cf-motion-base", "180ms"); pub const STANDARD_EASE: DesignToken = DesignToken::new("--cf-ease-standard", "ease"); + pub const EASE_OUT: DesignToken = + DesignToken::new("--cf-ease-out", "cubic-bezier(0.23, 1, 0.32, 1)"); + pub const EASE_IN_OUT: DesignToken = + DesignToken::new("--cf-ease-in-out", "cubic-bezier(0.77, 0, 0.175, 1)"); - pub const ALL: &[DesignToken] = &[QUICK, BASE, STANDARD_EASE]; + pub const ALL: &[DesignToken] = + &[PRESS, QUICK, BASE, STANDARD_EASE, EASE_OUT, EASE_IN_OUT]; } /// All token families in one iterable registry. diff --git a/docs/COATCHECK.md b/docs/COATCHECK.md index b17724e..a11f130 100644 --- a/docs/COATCHECK.md +++ b/docs/COATCHECK.md @@ -42,7 +42,11 @@ and global toolbar controls provide ## What works now -- Seven selectable stories across atoms, molecules, and organisms +- Twenty-two registered stories across foundations, atoms, molecules, + organisms, and product scenes +- Four card-system playgrounds: companion, resource, route, and generic decision +- Four state matrices for the same card families +- One combined field-choice scene with companion, supplies, and route selection - Live string and variant controls in a persistent bottom panel - Prop name, Rust type, control type, default, source, and purpose - Wide (1280), tablet (768), and mobile (393) scenes @@ -51,6 +55,11 @@ and global toolbar controls provide - Reset all args - Registry validation for duplicate stories and props - Container-aware responsive previews +- Versioned JSON manifest at `/__coatcheck/manifest.json` +- Runner-compatible query frames at `/__coatcheck/frame?story={story_id}` +- Stable human-facing frames at `/__coatcheck/frame/{story_id}` +- Pointer, keyboard, disabled, and reduced-motion checks using stable selectors +- PNG review artifacts without pixel baselines ## Why it does not reload @@ -72,10 +81,21 @@ The next order should follow the same value curve as Storybook: 2. Story IDs and args encoded in the URL for shareable states 3. Save current args as a named Rust story 4. Actions panel for emitted events -5. Interaction scripts with step-by-step results -6. Render, accessibility, and screenshot checks +5. More interaction scripts with step-by-step results +6. Broader render and accessibility checks Storybook treats every rendered story as a basic render test and adds interaction scripts for stateful behavior. Coatcheck should take the same path after the live story/args loop is stable: [Storybook testing model](https://storybook.js.org/docs/writing-tests). + +## Manifest adapter boundary + +Cold Front converts its static `Catalog` into Coatcheck’s version-1 JSON wire +contract in one `manifest_adapter` module inside the host. The current +Coatcheck 0.2 runner constructs query-based frame URLs, so the manifest keeps +`/__coatcheck/frame?story=…` as its machine contract. The host also exposes the +clearer direct path form for people and project-specific tests. + +If the 0.2 contract later gains path templates, only this adapter and the query +compatibility route need to change; the catalog and component renderers do not. diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 66adae0..d28de98 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -11,7 +11,7 @@ rendered by Coatcheck. | Color tokens | Atmosphere | Ink, ice, signal, warning, line, and muted values are the only brand colors used by components. | | Type tokens | Voice | Condensed display type for the title; neutral system sans and mono for operational copy. | | Spacing tokens | Rhythm | A compact 4/8/12/16 scale grows into 24/32/48/64 section spacing. | -| Motion tokens | Behavior | Short, deliberate transitions only. Respect reduced-motion preferences. | +| Motion tokens | Behavior | 120–180ms feedback with custom ease-out and ease-in-out curves. Reduced motion removes spatial transforms while retaining brief color and opacity cues. | ## Atoms @@ -20,6 +20,8 @@ rendered by Coatcheck. | `wordmark` | Compact Cold Front identity for headers and footers | Default | | `eyebrow` | Labels sections as transmissions, sectors, or editions | Default | | `signal_chip` | Shows a live/active signal without becoming a button | Active | +| `status_badge` | Shows semantic state without becoming an action | Neutral, positive, caution, critical | +| `vital_meter` | Exposes a clamped percentage with ARIA meter semantics | Stable, strained, critical | | `action_link` | Primary or secondary navigation action | Primary, quiet | ## Molecules @@ -28,6 +30,50 @@ rendered by Coatcheck. | --- | --- | --- | | `signal_card` | Eyebrow + metric + detail | Small piece of world-state telemetry | | `dispatch_item` | Code + title + summary | Editorial/game update row | +| `companion_card` | Eyebrow + status badge + vital meters | Informational companion condition and trust readout | +| `resource_card` | Eyebrow + status badge + vital meter | Informational carried-resource readout | +| `decision_card` | Status badge + title + summary + metadata | Native pressable decision with parent-owned selection | +| `route_option` | Decision card + risk and distance | Route-specific decision using the same interaction contract | + +### Card interaction contract + +- `companion_card` and `resource_card` are informational `
` elements. + They never lift or scale on hover. +- `decision_card` and `route_option` render native + ` +

"CARD SYSTEM"

+ + + + + + + + + +

"ORGANISMS"

+ (slot.await?) diff --git a/crates/coldfront-ui/src/auth.js b/crates/coldfront-ui/src/auth.js index 2da2421..c6d0920 100644 --- a/crates/coldfront-ui/src/auth.js +++ b/crates/coldfront-ui/src/auth.js @@ -35,13 +35,13 @@ function loadScript(src, attributes = {}) { async function startClerk() { if (!root) return; if (!publishableKey) { - showUnavailable("Access not configured"); + showUnavailable("Channel not configured"); return; } const encodedDomain = publishableKey.split("_")[2]; if (!encodedDomain) { - showUnavailable("Access unavailable"); + showUnavailable("Channel unavailable"); return; } @@ -78,7 +78,7 @@ async function startClerk() { const signIn = document.createElement("button"); signIn.type = "button"; - signIn.textContent = "Crew access"; + signIn.textContent = "Agent access"; signIn.addEventListener("click", () => window.Clerk.openSignIn()); root.appendChild(signIn); } @@ -90,5 +90,5 @@ async function startClerk() { } startClerk().catch(() => { - showUnavailable("Access unavailable"); + showUnavailable("Channel unavailable"); }); diff --git a/crates/coldfront-ui/src/organisms.rs b/crates/coldfront-ui/src/organisms.rs index 0a2bcf2..6fc6bb1 100644 --- a/crates/coldfront-ui/src/organisms.rs +++ b/crates/coldfront-ui/src/organisms.rs @@ -14,11 +14,11 @@ pub async fn command_header() -> Result {
wordmark() - signal_chip(label: "Transmission open") + signal_chip(label: "Secure channel live")
} } @@ -28,40 +28,40 @@ pub async fn hero_transmission() -> Result { view! {
- eyebrow(label: "Field transmission 001") + eyebrow(label: "Eyes only / Operation Cold Front")

"COLD" "FRONT"

- "The world went white." + "Your cover is blown."
- "You and the dog kept moving." + "The dog knows the extraction route."

- action_link(href: "#briefing", label: "Enter the briefing") + action_link(href: "#briefing", label: "Open mission dossier") action_link( href: "#contact", - label: "Track the signal", + label: "Trace the dead drop", tone: ActionTone::Quiet, )
- @@ -75,18 +75,18 @@ pub async fn briefing_grid() -> Result {
eyebrow(label: "Declassified mission dossier") -

"One burned agent. One loyal dog. Seventy-two hours to stop the handoff."

+

"One burned agent. One compromised channel. Seventy-two hours to stop the handoff."

dispatch_item( code: "01 / OBJECTIVE", - title: "Recover the Black Ledger", - summary: "A dead station transmitted one final coordinate. Reach it before the Directorate erases the evidence.", + title: "Intercept the courier", + summary: "A dead station transmitted one final coordinate. Reach the exchange before the Directorate erases the evidence.", ) dispatch_item( code: "02 / COUNTERINTEL", title: "Trust nobody on the channel", - summary: "Every voice may be compromised. Read the dog's reactions before you follow an order.", + summary: "Every voice may be compromised. Verify the code phrase before you follow an order.", ) dispatch_item( code: "03 / EXTRACTION", @@ -104,7 +104,7 @@ pub async fn field_footer() -> Result {