diff --git a/.github/workflows/design-tokens.yml b/.github/workflows/design-tokens.yml
new file mode 100644
index 0000000..62aaf17
--- /dev/null
+++ b/.github/workflows/design-tokens.yml
@@ -0,0 +1,67 @@
+name: Design Tokens
+
+on:
+ push:
+ branches: [main, develop]
+ paths:
+ - 'packages/tokens/**'
+ - 'docs/BRAND.md'
+ - '.github/workflows/design-tokens.yml'
+ pull_request:
+ branches: [main, develop]
+ paths:
+ - 'packages/tokens/**'
+ - 'docs/BRAND.md'
+ - '.github/workflows/design-tokens.yml'
+
+concurrency:
+ group: design-tokens-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ tokens:
+ name: Build and verify tokens
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20.x
+
+ # docs/BRAND.md is the source of truth; this fails when tokens.json has
+ # drifted from it, which is the drift a generated-and-ignored dist/ cannot
+ # catch on its own.
+ - name: Verify tokens match docs/BRAND.md
+ working-directory: ./packages/tokens
+ run: node build.js --check
+
+ - name: Build
+ working-directory: ./packages/tokens
+ run: node build.js
+
+ - name: Confirm the built outputs load
+ working-directory: ./packages/tokens
+ run: |
+ test -s dist/tokens.css
+ node --input-type=module -e "
+ import { light, dark, tokens, theme, version } from './dist/tokens.js';
+ const names = Object.keys(light);
+ if (names.length === 0) throw new Error('no tokens exported');
+ for (const mode of ['light', 'dark']) {
+ const missing = names.filter((n) => tokens[mode][n] === undefined);
+ if (missing.length) throw new Error(\`\${mode} is missing: \${missing.join(', ')}\`);
+ }
+ if (theme('dark') !== dark) throw new Error('theme(\"dark\") did not resolve');
+ console.log(\`ok: v\${version}, \${names.length} tokens in both modes\`);
+ "
+
+ - name: Confirm dist/ is not tracked
+ run: |
+ if git ls-files --error-unmatch packages/tokens/dist >/dev/null 2>&1; then
+ echo "packages/tokens/dist is generated and must not be committed."
+ exit 1
+ fi
+ echo "dist/ is untracked, as expected."
diff --git a/README.md b/README.md
index 03fada1..eaeef46 100644
--- a/README.md
+++ b/README.md
@@ -329,4 +329,5 @@ stellar contract build
- [docs/PRIVACY_MODEL.md](docs/PRIVACY_MODEL.md) — ZK proof tiers
- [docs/ACCOUNT_KERNEL.md](docs/ACCOUNT_KERNEL.md) — session keys and recovery
- [docs/PATTERNS.md](docs/PATTERNS.md) — recommended gameplay patterns
+- [docs/ONCHAIN_OFFCHAIN_BOUNDARY.md](docs/ONCHAIN_OFFCHAIN_BOUNDARY.md) — deciding what belongs on-chain
- [examples/README.md](examples/README.md) — example catalog and usage guide
diff --git a/docs/BRAND.md b/docs/BRAND.md
index d4171e0..95ff824 100644
--- a/docs/BRAND.md
+++ b/docs/BRAND.md
@@ -146,6 +146,8 @@ if a future pass wants a further-simplified glyph specifically for 16px contexts
written vocabulary list) to be their own follow-up rather than folded into this color/type/logo
pass.
- Consuming these tokens as an actual code package (CSS variables / Tailwind config / etc.) —
- explicitly out of scope per #259, tracked as a sibling sub-issue.
+ explicitly out of scope per #259, and now shipped separately as
+ [`packages/tokens`](../packages/tokens). This document stays the source of truth; that package
+ encodes it. Change a value here first, then mirror it there and rebuild.
- Any change to `README.md`'s current logo usage — a follow-up application of this system, not
part of defining it.
diff --git a/docs/ONCHAIN_OFFCHAIN_BOUNDARY.md b/docs/ONCHAIN_OFFCHAIN_BOUNDARY.md
new file mode 100644
index 0000000..80e06a0
--- /dev/null
+++ b/docs/ONCHAIN_OFFCHAIN_BOUNDARY.md
@@ -0,0 +1,307 @@
+# On-Chain / Off-Chain Boundary Guide
+
+## Purpose
+
+Deciding what belongs on-chain and what belongs off-chain is the first design decision a team
+makes when building on Cougr, and the one most likely to produce a design that cannot ship. The
+two failure modes are symmetric:
+
+- putting too much on-chain, producing a contract that is correct in a unit test and too expensive
+ or too slow to run as a real game
+- putting too much off-chain, producing a game that is described as on-chain but whose outcome is
+ actually decided by a server the players have to trust
+
+This guide gives a repeatable way to make that call for each piece of state and logic in a
+specific game, and then works through three shipped examples to show the call being made in
+practice.
+
+It does not restate Cougr's performance model or its privacy guarantees. Those live in
+[PERFORMANCE.md](./PERFORMANCE.md) and [PRIVACY_MODEL.md](./PRIVACY_MODEL.md), and this guide links
+into them rather than duplicating them.
+
+## How to read the examples
+
+Every claim in the worked examples below is drawn from the current source of the example it
+describes, not from an idealized version of it. Where an example takes a shortcut, the shortcut is
+named. That is deliberate: a boundary guide that only shows the clean case is not usable for the
+decision it is supposed to help with.
+
+## The framework
+
+Take one piece of game state or one rule at a time and ask these five questions in order. The
+first question that produces a clear answer usually settles the placement.
+
+### 1. Does a player need to verify this without trusting anyone?
+
+If a player has to be able to check the result themselves, with no trusted operator in the loop,
+the check belongs on-chain. This is the only question that can force state on-chain on its own,
+and it applies to a smaller share of game state than most teams initially assume.
+
+Typical yes: win conditions, balances and prize distribution, anything a player could be cheated
+out of. Typical no: cosmetic state, replay history, lobby membership, matchmaking.
+
+### 2. What specific cheat does this prevent?
+
+Name the cheat. If you cannot name a concrete way a player profits from tampering with a piece of
+state, that state does not need the chain's tamper resistance, and paying for it on-chain buys
+nothing.
+
+"Someone could change it" is not a cheat. "The defender could claim a miss on a cell that actually
+holds a ship, and never lose" is a cheat, and it tells you exactly what the contract has to
+enforce.
+
+### 3. Can a commitment stand in for the data?
+
+This is the question that resolves most of the apparent conflicts between questions 1 and 2. The
+chain often does not need the data, only a binding promise about it, so that a later reveal can be
+checked against the promise.
+
+A hash commitment, a Merkle root, or a proof is a fixed-size on-chain stand-in for state that
+stays off-chain until it is needed. Cougr's `privacy::stable` surface (Stable) provides the
+commit-reveal and Merkle primitives for this; see [PRIVACY_MODEL.md](./PRIVACY_MODEL.md) for what
+each tier promises. When this substitution works, it is almost always the right answer: the
+verification property from question 1 is preserved while the storage cost collapses to 32 bytes.
+
+### 4. What does it cost in Soroban's resource dimensions?
+
+Soroban meters CPU instructions, ledger entry reads and writes, read and write bytes, and
+transaction size, each against its own limit. A design can be well inside the fee budget and still
+fail because it crosses one dimension's ceiling. This is different from a single gas number, and
+it is the reason a cost intuition carried over from an EVM chain does not transfer cleanly.
+
+The current limits and the fee model are documented upstream in
+[Fees, resource limits, and metering](https://developers.stellar.org/docs/learn/fundamentals/fees-resource-limits-metering).
+Read them there rather than from a copy in this repository, because they change with protocol
+versions.
+
+Two Cougr-specific points affect the arithmetic:
+
+- The choice between `SimpleWorld` and `ArchetypeWorld`, and between table and sparse component
+ storage, changes how much of the world a query touches per invocation. The decision heuristics
+ are in [PERFORMANCE.md](./PERFORMANCE.md).
+- `src/incremental/` dirty-tracking means unchanged component data is not rewritten on every tick,
+ so the cost of on-chain state is closer to the cost of the state you actually mutate than to the
+ total size of the world. This lowers the penalty for keeping state on-chain, but it does not
+ remove it.
+
+Per-system resource reporting in `GameHarness`, which would let you answer this question from a
+test rather than by reasoning, is planned work rather than something you can use today. It is
+tracked as Phase 2 of the [roadmap](./strategy/13-roadmap.md) and analyzed in
+[04-onchain-gaming-research.md](./strategy/04-onchain-gaming-research.md). Until it lands, treat
+the resource question as a design-time estimate to be confirmed against a real network.
+
+### 5. Who submits the transaction, and how often?
+
+Every on-chain state change is a transaction that someone signs, pays for, and waits a ledger
+close for. State that changes many times per second cannot be advanced by a transaction per
+change, regardless of how cheap each one is.
+
+If the answer is "many times per second", the state is not going on-chain in its raw form. Either
+the game becomes turn-based, or the frequent state moves off-chain and the chain holds periodic
+checkpoints, commitments, or a final result.
+
+### Summary
+
+| Answer pattern | Placement |
+|---|---|
+| Player must verify it, and it is small | On-chain, directly |
+| Player must verify it, but it is large or secret | Off-chain, with an on-chain commitment or proof |
+| No nameable cheat, and no verification need | Off-chain |
+| Changes faster than one transaction per change | Off-chain, with on-chain checkpoints or a final result |
+| Needed only to render or explain the game | Off-chain, ideally rebuilt from on-chain events |
+
+## Worked example: Battleship
+
+Source: [`examples/battleship`](../examples/battleship). Turn-based, two players, hidden board
+layout. This is the reference case for question 3.
+
+### What the contract holds
+
+From `src/lib.rs`, `GameState` in instance storage holds, per player, a `commitment` and a
+`merkle_root` (both `BytesN<32>`), an `AttackGrid` of resolved cells, a `ShipStatus` remaining
+count, the `TurnState`, and the `winner`. `BoardCommitment` is registered as a table-storage
+component through `impl_component!`.
+
+Nothing in that list is the board. The contract never learns where the ships are, and never needs
+to.
+
+### What stays off the chain
+
+Ship placement, the salt, the full board array, and the Merkle tree built over it are all client
+side. The player computes a commitment over the board and salt, builds a Merkle tree whose leaves
+are the individual cells, and sends only the two 32-byte roots on-chain via `commit_board`.
+Generating the per-cell proof at reveal time is also client-side work.
+
+### Why the split holds
+
+Run question 2 against it. The cheat is a defender who answers "miss" for a cell that holds a
+ship. `reveal_cell` closes it: the defender must supply an `OnChainMerkleProof` for the exact
+coordinate the attacker named, the leaf is recomputed on-chain by `leaf_hash` from the coordinate
+and the claimed value, and `Sha256MerkleProofVerifier` checks it against the `merkle_root`
+recorded during setup. A defender who lies about a cell cannot produce a proof that verifies
+against a root they committed to before the attack was made.
+
+The board itself never had to be on-chain for that property to hold. A 10x10 board reduces to 32
+bytes of commitment plus one proof per attacked cell, and the proofs are paid for only on the
+cells actually attacked rather than on all one hundred up front.
+
+### What the split does not buy
+
+The contract verifies that each revealed cell matches the committed board. It does not verify that
+the committed board is a legal fleet. `TOTAL_SHIP_CELLS` is a constant of 17, and `ShipStatus`
+starts both players there by assumption; nothing checks that the committed Merkle tree actually
+contains 17 cells with value 1, or that they form valid ship shapes. A player who commits a board
+with fewer ship cells than the rules require never reaches zero remaining and therefore cannot
+lose.
+
+That is the honest state of the example, and it is a useful illustration of the general point: a
+commitment binds a player to whatever they committed, not to the rules. Enforcing fleet legality
+needs either a validity proof supplied at commit time or a full reveal and check at the end of the
+game, and each of those is a separate boundary decision with its own cost.
+
+## Worked example: Snake
+
+Source: [`examples/snake`](../examples/snake). Single player, real-time in its original arcade
+form. This is the reference case for question 5, and the example where the honest framing matters
+most.
+
+### What is genuinely on-chain
+
+More than a reader might expect. The entire simulation runs in the contract: `SimpleWorld` in
+persistent storage holds the snake head, the segments, and the food as entities with `Position`
+and direction components, and `update_tick` builds a `GameApp`, runs `move_snake` in `Update`,
+then `self_collision` and `food_collision` in `PostUpdate`, and writes the world back. Score,
+game-over state, and every segment position are on-chain and independently checkable. There is no
+off-chain simulator that the chain trusts.
+
+### What is approximated
+
+The game is not real-time. Each tick is a separate contract invocation, and each direction change
+is another one, so the game advances exactly as fast as someone submits transactions and ledgers
+close. The README states this plainly: rendering and real-time scheduling are out of scope, and
+callers drive ticks through contract invocations. A client can render at sixty frames per second,
+but it is rendering interpolation between on-chain ticks, not the game state itself.
+
+This is the distinction the guide exists to make. Snake is not a real-time game that was made
+on-chain. It is a turn-based game with a single-player arcade presentation, and calling it
+anything else would set an expectation the network cannot meet.
+
+### Two further shortcuts worth naming
+
+Food placement is derived from the tick counter: `spawn_food` computes candidate positions from
+`tick` and an attempt counter with fixed multipliers. That is deterministic and reproducible,
+which is what an example needs, and it is fully predictable to anyone who can read the contract.
+Question 2 applied to a single-player example with no stake gives "no cheat worth preventing", so
+the shortcut is appropriate here. It would not be appropriate the moment food placement affects a
+prize, at which point the placement needs `circuits::FairDiceBuilder` (Experimental) or a
+commit-reveal scheme rather than a tick hash.
+
+There is also no authorization anywhere in the example: `require_auth` does not appear in
+`examples/snake/src/`, so any account can call `change_direction` or `update_tick` on any game.
+For a single-player reference with one game per contract instance this is a deliberate
+simplification, and it is one of the first things a real deployment would have to change.
+
+### The general shape for real-time games
+
+When a game genuinely needs sub-second simulation, the boundary moves rather than disappearing:
+simulate off-chain, and put on-chain only what question 1 demands. In practice that is the entry
+stake, periodic checkpoints, and a final score commitment, with the disputed cases resolved by
+replaying a committed input log against the same deterministic systems. Cougr's per-tick
+determinism is what makes that replay possible, but the architecture around it is multiplayer
+synchronization, which is a distinct topic from the boundary itself and is not covered here.
+
+## Worked example: Blind Auction
+
+Source: [`examples/blind_auction`](../examples/blind_auction). Sealed-bid auction, hidden bid
+values. This is the reference case for a proof standing in for data rather than a hash.
+
+### The split
+
+The commit phase is off-chain. The README says so directly: bidders record hash commitments of
+their bids off-chain or in standard storage, and the contract's only entry points are
+`init_auction`, `reveal_bid`, and `bid_reveal`.
+
+On reveal, the bidder submits the commitment, the claimed bid value, and a `Groth16Proof`.
+`reveal_bid` loads the auction config, rebuilds the circuit spec through
+`circuits::sealed_bid(&env, max_bid)`, and calls `verify_bid_reveal` against the auction ID, the
+commitment, and the revealed value. The reveal is recorded only if verification returns true.
+
+### Why a proof rather than a plain hash
+
+A plain hash commitment proves the revealed bid is the committed bid. It proves nothing about the
+bid's relationship to the auction's rules. The `sealed_bid` circuit binds the reveal to the
+auction ID and checks the value against `max_bid` as part of the same verification, so a bidder
+cannot reuse a commitment across auctions or reveal a bid outside the allowed range. That is the
+extra property the proof buys over a hash, and it is the criterion for choosing one over the
+other.
+
+[`examples/hidden_hand`](../examples/hidden_hand) applies the same pattern to card deals through
+`circuits::hidden_cards`, verifying a hand commitment against a deck root without the contract
+learning the hand.
+
+### The maturity caveat, stated plainly
+
+Groth16 verification and the prebuilt circuits are **Experimental** in
+[PRIVACY_MODEL.md](./PRIVACY_MODEL.md), and they are excluded from the 1.0 stable privacy
+contract. The circuits ship with test-only proving keys and there is no production trusted setup
+today, which is tracked as a Phase 3 roadmap item. Commitments, commit-reveal, and Merkle
+inclusion are **Stable** by contrast.
+
+The practical consequence for a boundary decision: if hidden information can be handled with a
+commitment and a Merkle proof, as Battleship does, that path is Stable today. Reach for a circuit
+when you need a property a commitment cannot express, and plan for the trusted-setup gap before
+mainnet.
+
+### What the example does not do
+
+Winner computation is not in the contract. `blind_auction` verifies that each revealed bid is
+valid and stores it; comparing bids and settling the auction is left out. A production auction
+would have to decide where that comparison happens, and it is a good exercise for the framework
+above: the comparison is small, needs no secret input once reveals are on-chain, and directly
+determines who gets paid, so questions 1 and 2 both point on-chain.
+
+## What Soroban cannot support
+
+Stated directly, so that nothing above is read as a promise it does not make:
+
+- **Frame-rate gameplay on-chain is not achievable.** State advances one transaction per ledger
+ close. Any game loop that needs to advance faster than that runs off-chain, and the chain holds
+ commitments, checkpoints, or results.
+- **There is no on-chain clock a game can tick against.** Contracts execute when someone invokes
+ them. A game that must advance on its own needs an off-chain caller, and that caller is part of
+ the trust model whether or not the design acknowledges it.
+- **Simultaneous action is not free.** Two players acting in the same instant are two transactions
+ whose order is decided by the network. If simultaneity matters to fairness, it needs
+ commit-reveal, not a hope about ordering.
+- **Large per-tick state is limited by resource ceilings, not just by fees.** A world that fits in
+ a test can exceed a read-bytes or ledger-entry limit on-chain. Incremental persistence reduces
+ this pressure; it does not remove the ceiling.
+- **Nothing on-chain hides data.** Contract state is public. Values are hidden by not putting them
+ on-chain and committing to them instead, which is what the Battleship and Blind Auction examples
+ do.
+
+None of this makes on-chain games impractical. It makes the boundary the design decision rather
+than an implementation detail, which is why it is worth making explicitly and early.
+
+## A short checklist
+
+For each piece of state or rule in your game:
+
+1. Name the cheat it prevents. No cheat means no reason for it to be on-chain.
+2. If a player must verify it, check whether a commitment or proof can carry the property instead
+ of the raw data.
+3. Count the transactions its normal use implies. More than one per player action is a warning.
+4. Estimate its cost across Soroban's resource dimensions, not as a single number, and confirm
+ against a real network before committing to the design.
+5. Write down what the placement does not protect, the way the Battleship fleet-legality gap is
+ written down above. That note is what a future reader needs most.
+
+## Related documents
+
+| Document | What it covers that this guide does not |
+|---|---|
+| [PERFORMANCE.md](./PERFORMANCE.md) | Backend and storage choice, query cost model, benchmark interpretation |
+| [PRIVACY_MODEL.md](./PRIVACY_MODEL.md) | What each privacy tier promises, and the Experimental boundary around Groth16 |
+| [PATTERNS.md](./PATTERNS.md) | Which Cougr module answers a given gameplay problem |
+| [strategy/04-onchain-gaming-research.md](./strategy/04-onchain-gaming-research.md) | Why this decision ranks as the highest-impact friction point |
+| [strategy/13-roadmap.md](./strategy/13-roadmap.md) | Phase 2 resource-cost reporting in `GameHarness` |
diff --git a/docs/PATTERNS.md b/docs/PATTERNS.md
index e639ded..dbe43d0 100644
--- a/docs/PATTERNS.md
+++ b/docs/PATTERNS.md
@@ -23,6 +23,7 @@ Start here if you know what you're trying to build but not which Cougr module an
| **To serialize mutations / guard against reentrancy-like issues** | `ExecutionGuard` | — | [STANDARDS_LAYER.md § ExecutionGuard](./STANDARDS_LAYER.md#executionguard) |
| **Delayed or timelocked execution** | `DelayedExecutionPolicy` | — | [STANDARDS_LAYER.md § DelayedExecutionPolicy](./STANDARDS_LAYER.md#delayedexecutionpolicy) |
| **To batch several operations safely** | `BatchExecutor` | — | [STANDARDS_LAYER.md § BatchExecutor](./STANDARDS_LAYER.md#batchexecutor) |
+| **To decide what belongs on-chain at all** (which state and rules justify their cost, and which should stay client-side) | The five-question boundary framework, applied per piece of state | [`battleship`](../examples/battleship), [`snake`](../examples/snake), [`blind_auction`](../examples/blind_auction) | [ONCHAIN_OFFCHAIN_BOUNDARY.md](./ONCHAIN_OFFCHAIN_BOUNDARY.md) |
| **To know whether I even need ECS** | Direct contract model for small/config-driven contracts | — | [When Not To Use ECS](#when-not-to-use-ecs) below |
| **To pick table vs. sparse storage** | Table for hot-loop state, sparse for infrequent markers | — | [Storage Guidance](#storage-guidance) below |
| **A thin, explicit contract entrypoint / gameplay loop** | `GameApp` + explicit stage placement | [`spawn_and_move`](../examples/spawn_and_move), [`snake`](../examples/snake) | [Default Entry Point](#default-entry-point) and [Stage Layout](#stage-layout) below |
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index 761ad08..eca0cad 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -140,3 +140,9 @@ For real contracts, evaluate:
Performance guidance should always be tied back to those conditions.
If benchmark results and your data shape disagree, trust the data shape first.
+
+## Related
+
+This guide answers where a component should live once you have decided it belongs on-chain. For
+the prior decision, whether a piece of state or logic justifies being on-chain in the first place,
+see [ONCHAIN_OFFCHAIN_BOUNDARY.md](./ONCHAIN_OFFCHAIN_BOUNDARY.md).
diff --git a/examples/battleship/preview.svg b/examples/battleship/preview.svg
index 4108295..b59d174 100644
--- a/examples/battleship/preview.svg
+++ b/examples/battleship/preview.svg
@@ -1,278 +1,396 @@
+ COUGR · BATTLESHIP · COMMIT-REVEAL
+
\ No newline at end of file
diff --git a/examples/checkers/preview.svg b/examples/checkers/preview.svg
index 6580121..110900f 100644
--- a/examples/checkers/preview.svg
+++ b/examples/checkers/preview.svg
@@ -1,138 +1,129 @@
+ COUGR · CHECKERS · MOVE 1
+
\ No newline at end of file
diff --git a/examples/tic_tac_toe/preview.svg b/examples/tic_tac_toe/preview.svg
index 0c1cec8..98f1da6 100644
--- a/examples/tic_tac_toe/preview.svg
+++ b/examples/tic_tac_toe/preview.svg
@@ -1,66 +1,79 @@
+ COUGR · TIC-TAC-TOE
+
\ No newline at end of file
diff --git a/packages/tokens/.gitignore b/packages/tokens/.gitignore
new file mode 100644
index 0000000..8a365f7
--- /dev/null
+++ b/packages/tokens/.gitignore
@@ -0,0 +1,3 @@
+# Generated by build.js. Built on demand (npm run build) and on install or
+# publish via the prepare script, so it is never committed.
+dist/
diff --git a/packages/tokens/CHANGELOG.md b/packages/tokens/CHANGELOG.md
new file mode 100644
index 0000000..0e5369e
--- /dev/null
+++ b/packages/tokens/CHANGELOG.md
@@ -0,0 +1,21 @@
+# Changelog
+
+All notable changes to `cougr-tokens`. This package versions independently of `cougr-core`, per
+the policy in [README.md](./README.md#versioning-policy).
+
+## 1.0.0
+
+### Added
+
+- **`tokens.json`**: the token source of truth, encoding every value defined in
+ [docs/BRAND.md](../../docs/BRAND.md): four neutrals, primary and accent, three maturity-tier
+ colors, two font stacks, an eight-step spacing scale, four radii, and the four fixed logo tones
+- **`dist/tokens.css`**: built CSS custom properties with light and dark sets, switched by
+ `prefers-color-scheme` and overridable with a `data-theme` attribute on the root element
+- **`dist/tokens.js`**: built ESM module exporting `light`, `dark`, `tokens`, `theme(mode)`,
+ and `version`, for consumers that need literal values at build time
+- **`build.js`**: zero-dependency transform. `dist/` is generated rather than committed, produced
+ by `npm run build` and by the `prepare` script on install and publish. `--check` writes nothing
+ and fails when `tokens.json` has drifted from `docs/BRAND.md`, the source of truth
+- **CI**: a `Design Tokens` workflow that verifies the source against `docs/BRAND.md`, builds,
+ loads the built module, and asserts `dist/` is not tracked
diff --git a/packages/tokens/README.md b/packages/tokens/README.md
new file mode 100644
index 0000000..1c11e44
--- /dev/null
+++ b/packages/tokens/README.md
@@ -0,0 +1,139 @@
+# cougr-tokens
+
+The single, versioned source for Cougr's design tokens. Every surface that renders Cougr's visual
+identity imports these values instead of copying them, so the documentation site and the showcase
+cannot drift apart.
+
+The values themselves are specified and justified in [docs/BRAND.md](../../docs/BRAND.md),
+including the contrast measurements behind each color pair. This package encodes that document;
+it does not extend it.
+
+## What is in here
+
+| Path | Role |
+|---|---|
+| `tokens.json` | Source of truth. The only file edited by hand. |
+| `build.js` | Zero-dependency transform, `tokens.json` to `dist/`. |
+| `dist/tokens.css` | Built CSS custom properties, for static HTML/CSS consumers. |
+| `dist/tokens.js` | Built ESM module of literal values, for build-time consumers. |
+
+`dist/` is generated and is not committed:
+
+```bash
+npm run build # or: node build.js
+```
+
+The `prepare` script runs the same build on `npm install` and before
+`npm pack`/`npm publish`, so anyone installing this package gets built output
+without running the build themselves, including a consumer in a separate
+repository (see
+[docs/strategy/10-repository-strategy.md](../../docs/strategy/10-repository-strategy.md)).
+
+`node build.js --check` validates the source and a dry-run build without writing
+anything. It fails when `tokens.json` has drifted from `docs/BRAND.md`, which is the drift that
+matters once the built output is no longer in version control. CI runs it on any change to this
+package or to `docs/BRAND.md`.
+
+## Why two output formats
+
+CSS custom properties are the simpler option and are the right default for anything rendering in a
+browser. They are not sufficient on their own, because some consumers need literal values at
+generation time rather than at CSS resolution time: anything producing a standalone artifact (an
+SVG, a PNG, terminal output) is consumed outside a document, so custom properties declared by a
+host page never reach it. The showcase preview generator is the case this package was sized
+against. That is the build-time transform need that justifies shipping a package rather than a lone
+stylesheet.
+
+Both outputs come from the same source in the same build, so they cannot disagree.
+
+## Using the CSS
+
+```html
+
+```
+
+```css
+.card {
+ background: var(--color-surface);
+ color: var(--color-text);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+ font-family: var(--font-sans);
+}
+```
+
+Theming works in two layers:
+
+- Light is the default, declared on `:root`.
+- Dark applies automatically under `@media (prefers-color-scheme: dark)`, unless the document has
+ opted out with `data-theme="light"`.
+- An explicit `data-theme="light"` or `data-theme="dark"` on the root element always wins, which is
+ what a theme toggle sets.
+
+```html
+
+```
+
+## Using the JavaScript
+
+```js
+import { dark, light, theme, version } from 'cougr-tokens';
+
+dark.colorBg; // '#14100D'
+light.colorPrimary; // '#8A5A22'
+theme('dark').colorTierStable;
+```
+
+Token names are the CSS custom property names without the `--` prefix, camel-cased:
+`--color-text-secondary` becomes `colorTextSecondary`, `--space-4` becomes `space4`. Values that do
+not change between modes (typography, spacing, radius, logo tones) are present in both objects.
+
+## Consuming it
+
+A separate repository depends on the package normally and pins a version, and `prepare` builds
+`dist/` during install:
+
+```json
+{ "dependencies": { "cougr-tokens": "^1.0.0" } }
+```
+
+This repository has no npm workspace, so an in-repo consumer runs this package's build and then
+imports the output by relative path. Wire the build into whatever script produces the consumer's
+artifacts, so the two cannot be run out of order:
+
+```json
+{ "scripts": { "prebuild": "node ../../packages/tokens/build.js" } }
+```
+
+## Changing a token
+
+1. Update [docs/BRAND.md](../../docs/BRAND.md) first. It is the source of truth, and it carries the
+ contrast measurement that justifies the value.
+2. Mirror the change in `tokens.json` and bump `version` there and in `package.json`.
+3. Run `node build.js --check` to confirm the two agree, then `node build.js`. There is no built
+ output to commit.
+4. Add a `CHANGELOG.md` entry.
+5. Regenerate anything downstream that bakes token values into committed artifacts.
+
+`node build.js --check` writes nothing and exits non-zero if `tokens.json` has drifted from
+`docs/BRAND.md` or fails to build, which is the check to run before opening a pull request. CI runs
+it too.
+
+## Versioning policy
+
+Semantic versioning, against the token surface rather than the code:
+
+| Change | Bump |
+|---|---|
+| A token is removed, or renamed | Major |
+| A token's value changes enough to be a visible redesign | Major |
+| A new token is added | Minor |
+| A value is corrected without changing the design intent (a contrast fix, a rounding fix) | Patch |
+| Documentation, build script internals, output formatting | Patch |
+
+Consumers pin a range and upgrade deliberately. Because both the documentation site and the
+showcase resolve their own dependency, one can upgrade ahead of the other; the version each is on
+is visible in its lockfile, so a divergence is a fact someone can look up rather than something
+that has to be noticed by eye.
+
+Every release is recorded in [CHANGELOG.md](./CHANGELOG.md).
diff --git a/packages/tokens/build.js b/packages/tokens/build.js
new file mode 100644
index 0000000..3aabc0c
--- /dev/null
+++ b/packages/tokens/build.js
@@ -0,0 +1,244 @@
+#!/usr/bin/env node
+/**
+ * Cougr design tokens build.
+ *
+ * Reads `tokens.json` (the single source of truth, mirroring docs/BRAND.md) and
+ * writes two generated artifacts into `dist/`:
+ *
+ * dist/tokens.css CSS custom properties, for any static HTML/CSS consumer.
+ * dist/tokens.js an ESM module, for consumers that need literal values at
+ * build time rather than at CSS resolution time.
+ *
+ * Usage:
+ * node build.js build dist/
+ * node build.js --check validate the source and a dry-run build, write nothing
+ *
+ * `dist/` is generated, never committed. It is produced on demand by `npm run
+ * build`, and automatically on install or publish by the `prepare` script.
+ *
+ * Zero dependencies, so it runs with a bare `node` and no install step.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SOURCE = path.join(__dirname, 'tokens.json');
+const DIST = path.join(__dirname, 'dist');
+const BRAND_DOC = path.join(__dirname, '..', '..', 'docs', 'BRAND.md');
+
+const BANNER = 'Generated by packages/tokens/build.js from tokens.json. Do not edit by hand.';
+
+/** `color-text-secondary` -> `colorTextSecondary`, `space-1` -> `space1`. */
+function camelCase(name) {
+ return name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
+}
+
+/**
+ * Split the token table into the two shapes the outputs need:
+ * shared tokens with one value in both modes
+ * themed tokens with a distinct light and dark value
+ */
+function partition(tokens) {
+ const shared = [];
+ const themed = [];
+
+ for (const [name, spec] of Object.entries(tokens)) {
+ const hasModes = 'light' in spec || 'dark' in spec;
+
+ if (hasModes) {
+ if (!spec.light || !spec.dark) {
+ throw new Error(`Token "${name}" defines one mode only; both light and dark are required.`);
+ }
+ themed.push({ name, light: spec.light, dark: spec.dark, comment: spec.comment });
+ } else {
+ if (spec.value === undefined) {
+ throw new Error(`Token "${name}" has neither a light/dark pair nor a "value".`);
+ }
+ shared.push({ name, value: spec.value, comment: spec.comment });
+ }
+ }
+
+ return { shared, themed };
+}
+
+function declarations(entries, pick, indent) {
+ return entries
+ .map(({ name, ...rest }) => `${indent}--${name}: ${pick(rest)};`)
+ .join('\n');
+}
+
+function buildCss({ version, shared, themed }) {
+ const sharedDecls = declarations(shared, (t) => t.value, ' ');
+ const lightDecls = declarations(themed, (t) => t.light, ' ');
+ const darkMediaDecls = declarations(themed, (t) => t.dark, ' ');
+ const darkDecls = declarations(themed, (t) => t.dark, ' ');
+ const lightOverrideDecls = declarations(themed, (t) => t.light, ' ');
+
+ return `/*
+ * Cougr design tokens v${version}
+ * ${BANNER}
+ *
+ * Theming contract:
+ * Light is the default. Dark applies automatically when the reader's system
+ * prefers it, unless the document has opted out with data-theme="light".
+ * An explicit data-theme attribute on the root element always wins.
+ */
+
+:root {
+${sharedDecls}
+
+${lightDecls}
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']) {
+${darkMediaDecls}
+ }
+}
+
+:root[data-theme='dark'] {
+${darkDecls}
+}
+
+:root[data-theme='light'] {
+${lightOverrideDecls}
+}
+`;
+}
+
+function jsObject(entries, pick) {
+ return entries
+ .map(({ name, ...rest }) => ` ${camelCase(name)}: ${JSON.stringify(pick(rest))},`)
+ .join('\n');
+}
+
+function buildJs({ version, shared, themed }) {
+ const sharedProps = jsObject(shared, (t) => t.value);
+ const lightProps = jsObject(themed, (t) => t.light);
+ const darkProps = jsObject(themed, (t) => t.dark);
+
+ return `/*
+ * Cougr design tokens v${version}
+ * ${BANNER}
+ *
+ * For consumers that need literal token values at build time: anything
+ * producing standalone artifacts (SVG, PNG, terminal output) where CSS custom
+ * properties cannot be resolved. Static HTML/CSS consumers should import
+ * dist/tokens.css instead.
+ */
+
+export const version = '${version}';
+
+/** Tokens whose value is the same in both modes. */
+export const shared = Object.freeze({
+${sharedProps}
+});
+
+export const light = Object.freeze({
+ ...shared,
+${lightProps}
+});
+
+export const dark = Object.freeze({
+ ...shared,
+${darkProps}
+});
+
+export const tokens = Object.freeze({ light, dark });
+
+/** Resolve a mode by name, defaulting to light. */
+export function theme(mode) {
+ return mode === 'dark' ? dark : light;
+}
+
+export default tokens;
+`;
+}
+
+/**
+ * Confirm the token source still says what docs/BRAND.md says.
+ *
+ * BRAND.md is the source of truth; this file only encodes it. Nothing stops the
+ * two drifting apart by hand, so every colour and every scale value documented
+ * there has to be present here. This is the guarantee that used to come from
+ * diffing a committed `dist/`, moved to where the actual risk is.
+ */
+function verifyAgainstBrandDoc(source) {
+ if (!fs.existsSync(BRAND_DOC)) {
+ return [`docs/BRAND.md not found at ${BRAND_DOC}`];
+ }
+
+ const doc = fs.readFileSync(BRAND_DOC, 'utf8');
+ const encoded = JSON.stringify(source.tokens).toUpperCase();
+ const problems = [];
+
+ const documentedColors = [...new Set((doc.match(/#[0-9A-Fa-f]{6}\b/g) || []).map((h) => h.toUpperCase()))];
+ const missingColors = documentedColors.filter((hex) => !encoded.includes(hex));
+ if (missingColors.length > 0) {
+ problems.push(`colours in docs/BRAND.md but not in tokens.json: ${missingColors.join(', ')}`);
+ }
+
+ // Table rows of the form: | `space-1` | 4px |
+ for (const [, name, value] of doc.matchAll(/\|\s*`((?:space|radius)-[a-z0-9]+)`\s*\|\s*(\S+?)\s*\|/g)) {
+ const token = source.tokens[name];
+ if (!token) {
+ problems.push(`${name} is documented in docs/BRAND.md but missing from tokens.json`);
+ } else if (token.value !== value) {
+ problems.push(`${name} is ${value} in docs/BRAND.md but ${token.value} in tokens.json`);
+ }
+ }
+
+ return problems;
+}
+
+function main() {
+ const check = process.argv.includes('--check');
+
+ const source = JSON.parse(fs.readFileSync(SOURCE, 'utf8'));
+ const { version } = source;
+ if (!version) {
+ console.error('tokens.json is missing a "version" field.');
+ process.exit(1);
+ }
+
+ const { shared, themed } = partition(source.tokens);
+ const outputs = {
+ 'tokens.css': buildCss({ version, shared, themed }),
+ 'tokens.js': buildJs({ version, shared, themed }),
+ };
+
+ if (check) {
+ // Building the outputs above already enforced the structural rules: every
+ // themed token carries both modes, every shared token carries a value.
+ const problems = verifyAgainstBrandDoc(source);
+
+ if (problems.length > 0) {
+ console.error('Token source does not match docs/BRAND.md:');
+ for (const problem of problems) console.error(` - ${problem}`);
+ console.error('\nBRAND.md is the source of truth. Update it first, then mirror it in tokens.json.');
+ process.exit(1);
+ }
+
+ for (const [file, contents] of Object.entries(outputs)) {
+ if (!contents.trim()) {
+ console.error(`Generated ${file} is empty.`);
+ process.exit(1);
+ }
+ }
+
+ console.log(`✓ tokens.json v${version} matches docs/BRAND.md and builds cleanly`);
+ console.log(` ${shared.length} shared + ${themed.length} themed tokens, nothing written`);
+ return;
+ }
+
+ fs.mkdirSync(DIST, { recursive: true });
+ for (const [file, contents] of Object.entries(outputs)) {
+ fs.writeFileSync(path.join(DIST, file), contents, 'utf8');
+ console.log(`✓ Written dist/${file}`);
+ }
+ console.log(` ${shared.length} shared + ${themed.length} themed tokens (v${version})`);
+}
+
+main();
diff --git a/packages/tokens/package.json b/packages/tokens/package.json
new file mode 100644
index 0000000..10180b9
--- /dev/null
+++ b/packages/tokens/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "cougr-tokens",
+ "version": "1.0.0",
+ "description": "Cougr design tokens: the single versioned source for color, type, spacing, radius, and logo values shared by the documentation site and the showcase.",
+ "type": "module",
+ "main": "./dist/tokens.js",
+ "exports": {
+ ".": "./dist/tokens.js",
+ "./css": "./dist/tokens.css",
+ "./tokens.css": "./dist/tokens.css",
+ "./source": "./tokens.json"
+ },
+ "files": [
+ "dist/",
+ "tokens.json",
+ "README.md",
+ "CHANGELOG.md"
+ ],
+ "scripts": {
+ "build": "node build.js",
+ "check": "node build.js --check",
+ "prepare": "node build.js"
+ },
+ "keywords": [
+ "cougr",
+ "design-tokens",
+ "css-custom-properties"
+ ],
+ "engines": {
+ "node": ">=18"
+ },
+ "license": "MIT"
+}
diff --git a/packages/tokens/tokens.json b/packages/tokens/tokens.json
new file mode 100644
index 0000000..0b01d8c
--- /dev/null
+++ b/packages/tokens/tokens.json
@@ -0,0 +1,88 @@
+{
+ "version": "1.0.0",
+ "source": "docs/BRAND.md",
+ "description": "Cougr design tokens. Single source of truth for generated CSS and JavaScript. Every value here is defined in docs/BRAND.md; change it there first, then mirror it here and rebuild.",
+ "tokens": {
+ "color-bg": {
+ "light": "#FFFFFF",
+ "dark": "#14100D",
+ "comment": "Page background."
+ },
+ "color-surface": {
+ "light": "#F6F4F1",
+ "dark": "#1F1A15",
+ "comment": "Cards, code blocks, panels raised off the page background."
+ },
+ "color-text": {
+ "light": "#1A1512",
+ "dark": "#F3EDE4",
+ "comment": "Body and heading text. 18.10:1 light, 16.26:1 dark on own background."
+ },
+ "color-text-secondary": {
+ "light": "#5B534B",
+ "dark": "#B7ABA0",
+ "comment": "Captions, labels, metadata. 7.54:1 light, 8.42:1 dark on own background."
+ },
+ "color-primary": {
+ "light": "#8A5A22",
+ "dark": "#D9A15C",
+ "comment": "Brand hue, taken from the logo coat tone. 5.89:1 light, 8.29:1 dark."
+ },
+ "color-accent": {
+ "light": "#2E5F8A",
+ "dark": "#6FA8D9",
+ "comment": "Interactive elements: links, focus states. Deliberately distinct from the brand hue. 6.73:1 light, 7.46:1 dark."
+ },
+ "color-tier-stable": {
+ "light": "#1C7A4D",
+ "dark": "#4FBE8A",
+ "comment": "Stable maturity tier. 5.33:1 light, 8.17:1 dark."
+ },
+ "color-tier-beta": {
+ "light": "#9A6B00",
+ "dark": "#E3A72E",
+ "comment": "Beta maturity tier. 4.69:1 light, 8.86:1 dark."
+ },
+ "color-tier-experimental": {
+ "light": "#8034B8",
+ "dark": "#C08DE8",
+ "comment": "Experimental maturity tier. Purple rather than red, because red is claimed by error states. 6.77:1 light, 7.40:1 dark."
+ },
+ "font-sans": {
+ "value": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
+ "comment": "Interface and documentation text. System-first, no webfont load."
+ },
+ "font-mono": {
+ "value": "ui-monospace, \"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", Consolas, \"SF Mono\", Menlo, monospace",
+ "comment": "Code blocks and terminal-adjacent output, identical on every surface."
+ },
+ "space-1": { "value": "4px" },
+ "space-2": { "value": "8px" },
+ "space-3": { "value": "12px" },
+ "space-4": { "value": "16px" },
+ "space-5": { "value": "24px" },
+ "space-6": { "value": "32px" },
+ "space-7": { "value": "48px" },
+ "space-8": { "value": "64px" },
+ "radius-sm": { "value": "4px", "comment": "Inline elements, tags." },
+ "radius-md": { "value": "8px", "comment": "Cards, inputs, buttons." },
+ "radius-lg": { "value": "12px", "comment": "Panels, modals." },
+ "radius-full": { "value": "9999px", "comment": "Pills, avatars, icon badges." },
+ "logo-ink": {
+ "value": "#171310",
+ "comment": "Logo outline, eye, nostril, mouth line, neck shadow. Fixed in both modes."
+ },
+ "logo-shadow": {
+ "value": "#6B4522",
+ "comment": "Logo mid-tone transition band. Fixed in both modes."
+ },
+ "logo-coat": {
+ "value": "#A06A2E",
+ "comment": "Logo top/back plane. Fixed in both modes."
+ },
+ "logo-cream": {
+ "value": "#F1DCB8",
+ "comment": "Logo front-facing plane. Fixed in both modes."
+ }
+ }
+}
diff --git a/tools/preview-gen/README.md b/tools/preview-gen/README.md
index db1bb48..ac0f809 100644
--- a/tools/preview-gen/README.md
+++ b/tools/preview-gen/README.md
@@ -15,6 +15,14 @@ Most Cougr examples are headless smart contracts with no frontend. A showcase ga
- Node.js ≥ 18 (ESM modules, no npm dependencies)
+Renderers read their colors from [`packages/tokens`](../../packages/tokens), whose `dist/` is
+generated rather than committed. `generate.js` builds it automatically on first run, so there is no
+setup step to remember. To build it yourself:
+
+```bash
+cd packages/tokens && node build.js
+```
+
---
## Usage
@@ -104,22 +112,21 @@ export function render(state) {
```
**SVG design guidelines:**
-- Dark background (`#0f172a`)
-- Board area with subtle grid lines (`#334155`)
+- Dark background (`BRAND.colorBg`)
+- Board area with subtle grid lines (`LINE.stroke` at `LINE.gridOpacity`)
- Status bar at the bottom (64–80px) showing turn/result
- `COUGR · GAME_NAME` watermark in the status bar
- No external image references or fonts (SVG must render without network access)
### Step 4 — Register the renderer
-In `generate.js`, add an import and register the renderer:
+In `generate.js`, register the renderer by path. It is imported on demand, after the design tokens
+it reads have been built:
```js
-import { render as renderYourGame } from './renderers/your_game.js';
-
const RENDERERS = {
// ... existing ...
- your_game: renderYourGame, // ← add this
+ your_game: './renderers/your_game.js', // ← add this
};
```
@@ -127,10 +134,12 @@ If your game is a real-time/physics game with no meaningful static state, add it
```js
const FALLBACK_GAMES = {
- your_game: { category: 'Arcade', color: '#6366f1', icon: '🚀' },
+ your_game: { category: 'Arcade', icon: '🚀' },
};
```
+Fallback cards take their accent from the brand tokens, so there is no per-game color to pick.
+
### Step 5 — Run and verify
```bash
@@ -168,17 +177,38 @@ The fallback is explicitly labeled "Real-time game — no static board state" so
---
-## Palette Reference
-
-| Token | Hex | Use |
-|---|---|---|
-| `bg` | `#0f172a` | SVG background |
-| `card` | `#1e293b` | Cell / status bar background |
-| `grid_line` | `#334155` | Board grid dividers |
-| `p1_accent` | `#f43f5e` | Player 1 / X pieces |
-| `p2_accent` | `#38bdf8` | Player 2 / O pieces |
-| `hit` | `#ef4444` | Battleship hit cell |
-| `miss` | `#334155` | Battleship miss cell |
-| `win_highlight` | `#fef08a22` | Winning-line cell tint |
-| `text_muted` | `#64748b` | Labels, watermark |
-| `text_primary` | `#f1f5f9` | Main status text |
+## Palette
+
+Renderers do not define their own colors. `theme.js` resolves them from
+[`packages/tokens`](../../packages/tokens), the shared design token package that also feeds the
+documentation site, so the showcase and the docs cannot drift apart. The values themselves are
+specified in [docs/BRAND.md](../../docs/BRAND.md).
+
+Import `theme.js` rather than writing a hex literal:
+
+```js
+import { BRAND, LINE, FONT_STYLE, TINT, px } from '../theme.js';
+
+const COLORS = {
+ bg: BRAND.colorBg,
+ card: BRAND.colorSurface,
+ p1: BRAND.colorPrimary,
+ p2: BRAND.colorAccent,
+ label: BRAND.colorTextSecondary,
+};
+```
+
+| Export | Use |
+|---|---|
+| `BRAND` | Brand tokens in dark mode. Previews are dark-surface artifacts so they sit on either a light or a dark gallery page. |
+| `GAME` | The few game-semantic values the brand palette deliberately does not define, currently the battleship hit colors. |
+| `LINE` | Stroke color plus grid and border opacities for hairlines. |
+| `FONT_STYLE` | The `
+
-
+
`;
@@ -206,11 +208,11 @@ export function render(state) {
svg += `
-
+
+ fill="${statusColor}${TINT}"/>
${statusText}
diff --git a/tools/preview-gen/renderers/category_fallback.js b/tools/preview-gen/renderers/category_fallback.js
index bb8b8d4..7af2717 100644
--- a/tools/preview-gen/renderers/category_fallback.js
+++ b/tools/preview-gen/renderers/category_fallback.js
@@ -8,12 +8,13 @@
* Input:
* game: string — game slug, e.g. "snake"
* category: string — e.g. "Arcade"
- * color: string — hex accent color
* icon: string — emoji icon
*
* Produces a 400×300 SVG.
*/
+import { BRAND, LINE, FONT_STYLE, TINT, px } from '../theme.js';
+
const W = 400;
const H = 300;
@@ -21,20 +22,23 @@ function slugToTitle(slug) {
return slug.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
-export function render({ game, category, color, icon }) {
+export function render({ game, category, icon }) {
const title = slugToTitle(game);
- // Darken version of color for gradient stop
- const darkBg = '#0f172a';
- const cardBg = '#1e293b';
- const border = '#334155';
- const textMuted = '#64748b';
- const textPrimary = '#f1f5f9';
+ // One brand accent for every fallback card, rather than a per-game hue: the
+ // palette in docs/BRAND.md is a small fixed vocabulary by design, and the
+ // icon and title already distinguish the games.
+ const color = BRAND.colorPrimary;
+ const darkBg = BRAND.colorBg;
+ const cardBg = BRAND.colorSurface;
+ const border = LINE.stroke;
+ const textMuted = BRAND.colorTextSecondary;
+ const textPrimary = BRAND.colorText;
return `