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/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." + } + } +}