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 @@ + viewBox="0 0 440 912" width="440" height="912"> - + - - - - - - Player A attacks → - - - A - B - C - D - E - F - G - H - I - J - - - 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - 10 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Hits: 3 - Miss: 5 - Ships: 14/17 - - - - - Player B attacks → - - - 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - 10 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Hits: 3 - Miss: 3 - Ships: 14/17 + + +Player A attacks →ABCDEFGHIJ12345678910 + + + + Hits: 3 + + Miss: 3 + + Ships: 14/17 + +Player B attacks →ABCDEFGHIJ12345678910 + + + + Hits: 3 + + Miss: 5 + + Ships: 14/17 - - + + - - - Player A's turn to attack + + + Player A's turn to attack - COUGR · BATTLESHIP · COMMIT-REVEAL - + 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 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + a + 8 + b + 7 + c + 6 + d + 5 + e + 4 + f + 3 + g + 2 + h + 1 + - - + + - - Player 1 to move + + Player 1 to move - - 12 + + 12 pieces - - 12 + + 12 - COUGR · CHECKERS · MOVE 1 - + 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 @@ - + - + + + + - - - - - + + + + - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - + + - - ✕ wins + + ✕ wins - Move 5 + Move 5 - COUGR · TIC-TAC-TOE - + 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 ` - + @@ -46,11 +50,11 @@ export function render({ game, category, color, icon }) { - - + + - @@ -67,7 +71,7 @@ export function render({ game, category, color, icon }) { fill="${textPrimary}" letter-spacing="0.5">${title} - + ${category} diff --git a/tools/preview-gen/renderers/checkers.js b/tools/preview-gen/renderers/checkers.js index f7ec140..0549f76 100644 --- a/tools/preview-gen/renderers/checkers.js +++ b/tools/preview-gen/renderers/checkers.js @@ -16,31 +16,30 @@ * Produces a 480×560 SVG (8×8 board 480×480 + status bar 80px). */ +import { BRAND, LINE, FONT_STYLE, TINT, px } from '../theme.js'; + const COLS = 8; const SIZE = 480; const CELL = SIZE / COLS; // 60 const TOTAL_H = SIZE + 80; const COLORS = { - bg: '#0f172a', - light_sq: '#1e293b', - dark_sq: '#334155', - p1_piece: '#f43f5e', // rose-500 - p1_king: '#fda4af', // rose-300 - p1_stroke: '#9f1239', - p2_piece: '#38bdf8', // sky-400 - p2_king: '#93c5fd', // blue-300 - p2_stroke: '#0369a1', - crown: '#fbbf24', - status_bar: '#1e293b', - divider: '#475569', - text_primary: '#f1f5f9', - text_muted: '#64748b', - badge_p1: '#f43f5e', - badge_p2: '#38bdf8', - badge_draw: '#94a3b8', - label_p1: '#fda4af', - label_p2: '#7dd3fc', + bg: BRAND.colorBg, + light_sq: BRAND.colorSurface, + dark_sq: BRAND.colorBg, + p1_piece: BRAND.colorPrimary, + p2_piece: BRAND.colorAccent, + // Kings keep their side's color and are marked by the crown, rather than by + // a second tint per side that the brand palette does not define. + crown: BRAND.logoCream, + piece_stroke: BRAND.colorBg, + status_bar: BRAND.colorSurface, + divider: LINE.stroke, + text_primary: BRAND.colorText, + text_muted: BRAND.colorTextSecondary, + badge_p1: BRAND.colorPrimary, + badge_p2: BRAND.colorAccent, + badge_draw: BRAND.colorTextSecondary, }; function cellAt(cells, row, col) { @@ -58,23 +57,20 @@ function renderPiece(x, y, value) { const isKing = Math.abs(value) === 2; const isP1 = value > 0; - const fill = isKing - ? (isP1 ? COLORS.p1_king : COLORS.p2_king) - : (isP1 ? COLORS.p1_piece : COLORS.p2_piece); - const stroke = isP1 ? COLORS.p1_stroke : COLORS.p2_stroke; + const fill = isP1 ? COLORS.p1_piece : COLORS.p2_piece; let out = ''; // Drop shadow out += ``; // Main piece - out += ``; + out += ``; // Inner ring highlight - out += ``; + out += ``; - // King crown indicator — a smaller gold circle in the center + // King indicator — a cream disc in the center, legible on either side's color if (isKing) { - out += ``; + out += ``; } return out; @@ -109,11 +105,11 @@ export function render(state) { let svg = ` - + - + `; @@ -144,25 +140,25 @@ export function render(state) { // Board border svg += ` \n`; + stroke="${COLORS.divider}" stroke-opacity="${LINE.borderOpacity}" stroke-width="1.5" rx="0"/>\n`; // Status bar svg += ` - + - + ${statusText} - ${p1} pieces + ${p1} pieces - ${p2} + ${p2} `; @@ -82,13 +85,11 @@ export function render(state) { let svg = ` - + - + `; @@ -105,16 +106,16 @@ export function render(state) { // Cell background svg += ` \n`; + fill="${isWinCell ? COLORS.win_highlight : COLORS.empty}" rx="${px(BRAND.radiusSm)}"/>\n`; // Grid lines (right / bottom borders only, skip last) if (col < 2) { svg += ` \n`; + stroke="${COLORS.grid}" stroke-opacity="${LINE.gridOpacity}" stroke-width="3"/>\n`; } if (row < 2 && col === 0) { svg += ` \n`; + stroke="${COLORS.grid}" stroke-opacity="${LINE.gridOpacity}" stroke-width="3"/>\n`; } // Piece @@ -129,10 +130,10 @@ export function render(state) { svg += ` - + - + ${statusText} diff --git a/tools/preview-gen/theme.js b/tools/preview-gen/theme.js new file mode 100644 index 0000000..d04a547 --- /dev/null +++ b/tools/preview-gen/theme.js @@ -0,0 +1,65 @@ +/** + * Preview theme, resolved from the shared design tokens package. + * + * Previews are standalone SVG files consumed as images, so CSS custom + * properties from a host page never reach them. That is why this module reads + * literal values from `cougr-tokens` at generation time rather than emitting + * `var(--color-bg)` into the markup. + * + * The package is imported by relative path because this repository has no npm + * workspace and `tools/preview-gen` is intentionally install-free. A consumer + * in a separate repository (the documentation site, per + * docs/strategy/10-repository-strategy.md) pins `cougr-tokens` as a normal + * dependency instead. + * + * To change any color here, edit `packages/tokens/tokens.json`, run + * `node build.js` in that directory, then regenerate the previews. + */ + +import { dark, version as tokensVersion } from '../../packages/tokens/dist/tokens.js'; + +export { tokensVersion }; + +/** + * Brand tokens, dark mode. Previews are dark-surface artifacts so that they sit + * on either a light or a dark gallery page without a second render pass. + */ +export const BRAND = dark; + +/** + * Values docs/BRAND.md deliberately does not define. + * + * The brand palette is a small fixed vocabulary of interface colors; it has no + * "damage" hue, and inventing one there would widen the palette for a case only + * the previews have. These stay local and game-semantic. If a status palette is + * ever added to the brand, these should move into the tokens package. + */ +export const GAME = Object.freeze({ + /** A struck cell. Warm red, chosen to sit with the brand's warm neutrals. */ + hit: '#D2503C', + /** Marker stroke drawn over a struck cell. */ + hitMark: '#F2A79A', +}); + +/** Hairlines: grid dividers and board borders, as a stroke plus an opacity. */ +export const LINE = Object.freeze({ + stroke: BRAND.colorTextSecondary, + gridOpacity: 0.22, + borderOpacity: 0.35, +}); + +/** The one font declaration every renderer emits. */ +export const FONT_STYLE = `text { font-family: ${BRAND.fontSans}; }`; + +/** Tint suffix for badge fills, matching the existing `${color}22` idiom. */ +export const TINT = '22'; + +/** + * Strip the unit off a spacing or radius token. + * + * SVG 1.1 presentation attributes such as `rx` take a bare number, so `"12px"` + * has to become `12` before it reaches the markup. + */ +export function px(token) { + return parseFloat(token); +}