From 1a07bfde84e2f25fd2753aa609307ca352131e84 Mon Sep 17 00:00:00 2001 From: spagero763 Date: Tue, 28 Jul 2026 11:52:56 +0100 Subject: [PATCH 1/2] docs(patterns): write the on-chain / off-chain boundary guide Adds docs/ONCHAIN_OFFCHAIN_BOUNDARY.md, the guide recommended in docs/strategy/04-onchain-gaming-research.md problem #1 and listed as the second-priority Learn-section gap in the documentation architecture. The guide gives a five-question framework for placing a single piece of game state or logic, then works it through three shipped examples: battleship (commitments on-chain, placement and Merkle tree off-chain), snake (a full on-chain simulation that is turn-based rather than real-time, stated honestly), and blind_auction (a proof standing in for data, with the Experimental maturity of Groth16 verification named). Every claim is drawn from the current source of the example it describes, including the shortcuts: battleship does not validate fleet legality, and snake has no authorization and derives food placement from the tick counter. It closes with an explicit list of what Soroban throughput cannot support, so the guide does not imply on-chain real-time gameplay is achievable. Resource cost and privacy tiers are cross-linked to PERFORMANCE.md and PRIVACY_MODEL.md rather than re-derived, and the Phase 2 GameHarness resource-reporting work is referenced as planned rather than available. Linked from the README documentation list, the PATTERNS.md problem table, and a Related section in PERFORMANCE.md. --- README.md | 1 + docs/ONCHAIN_OFFCHAIN_BOUNDARY.md | 307 ++++++++++++++++++++++++++++++ docs/PATTERNS.md | 1 + docs/PERFORMANCE.md | 6 + 4 files changed, 315 insertions(+) create mode 100644 docs/ONCHAIN_OFFCHAIN_BOUNDARY.md diff --git a/README.md b/README.md index 03fada14..eaeef46b 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/ONCHAIN_OFFCHAIN_BOUNDARY.md b/docs/ONCHAIN_OFFCHAIN_BOUNDARY.md new file mode 100644 index 00000000..80e06a0b --- /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 e639ded5..dbe43d06 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 761ad081..eca0cadd 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). From a1f6870d7c22c973006144cd22ae024ad2c7d7ba Mon Sep 17 00:00:00 2001 From: spagero763 Date: Thu, 30 Jul 2026 08:24:23 +0100 Subject: [PATCH 2/2] feat(design-system): build a shared, versioned tokens package Adds packages/tokens (cougr-tokens), a versioned artifact encoding every value defined in docs/BRAND.md so the documentation site and the showcase consume one source instead of hand-copying values. Format decision: tokens.json is the single hand-edited source, and a zero-dependency build script emits both dist/tokens.css and dist/tokens.js. CSS custom properties alone are not sufficient because some consumers need literal values at generation time: anything producing a standalone artifact is consumed outside a document, so custom properties declared by a host page never reach it. Both outputs come from the same build, so they cannot disagree. dist/ is generated, not committed. It is built by npm run build, and by the prepare script on install and before pack/publish, so an installing consumer gets built output without running the build and nothing in git can fall out of step with the source. Because there is no committed dist/ to diff against, --check no longer compares build output. It now validates the source and a dry-run build, and fails when tokens.json has drifted from docs/BRAND.md. That moves the guarantee to where the risk actually is: BRAND.md is the declared source of truth and nothing otherwise stops the two being edited apart. Light and dark sets are complete for every themed token. Light is the default on :root, dark applies under prefers-color-scheme unless the document opts out, and an explicit data-theme attribute on the root element always wins. Adds a path-filtered Design Tokens workflow that runs the drift check, builds, loads the built module to confirm both modes resolve, and asserts dist/ is not tracked. --- .github/workflows/design-tokens.yml | 67 ++++++++ docs/BRAND.md | 4 +- packages/tokens/.gitignore | 3 + packages/tokens/CHANGELOG.md | 21 +++ packages/tokens/README.md | 139 ++++++++++++++++ packages/tokens/build.js | 244 ++++++++++++++++++++++++++++ packages/tokens/package.json | 33 ++++ packages/tokens/tokens.json | 88 ++++++++++ 8 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/design-tokens.yml create mode 100644 packages/tokens/.gitignore create mode 100644 packages/tokens/CHANGELOG.md create mode 100644 packages/tokens/README.md create mode 100644 packages/tokens/build.js create mode 100644 packages/tokens/package.json create mode 100644 packages/tokens/tokens.json diff --git a/.github/workflows/design-tokens.yml b/.github/workflows/design-tokens.yml new file mode 100644 index 00000000..62aaf173 --- /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/docs/BRAND.md b/docs/BRAND.md index d4171e02..95ff8246 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/packages/tokens/.gitignore b/packages/tokens/.gitignore new file mode 100644 index 00000000..8a365f71 --- /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 00000000..0e5369e5 --- /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 00000000..1c11e441 --- /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 00000000..3aabc0c6 --- /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 00000000..10180b9c --- /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 00000000..0b01d8c3 --- /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." + } + } +}