diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml
new file mode 100644
index 0000000..21c9ff9
--- /dev/null
+++ b/.github/workflows/a11y.yml
@@ -0,0 +1,70 @@
+name: a11y
+
+on:
+ pull_request:
+ paths:
+ - client/**
+ - docs/accessibility.md
+ - .github/workflows/a11y.yml
+ push:
+ branches: [main]
+ paths:
+ - client/**
+ - docs/accessibility.md
+ - .github/workflows/a11y.yml
+
+permissions:
+ contents: read
+
+concurrency:
+ group: a11y-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ audit:
+ name: accessibility
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.14
+ - name: Install
+ working-directory: client
+ run: bun install --frozen-lockfile
+ - name: Contrast ratios
+ working-directory: client
+ run: bun run contrast
+ - name: Install Chrome
+ id: chrome
+ uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0
+ with:
+ chrome-version: 154.0.8037.92
+ install-dependencies: true
+ - name: Build
+ working-directory: client
+ run: bun run build
+ - name: Serve the production build
+ working-directory: client
+ run: |
+ bun run preview --host 127.0.0.1 --port 4173 --strictPort > "$RUNNER_TEMP/preview.log" 2>&1 &
+ for _ in $(seq 1 30); do
+ curl --silent --fail --output /dev/null http://127.0.0.1:4173/ && exit 0
+ sleep 1
+ done
+ cat "$RUNNER_TEMP/preview.log"
+ echo "Preview server did not start" >&2
+ exit 1
+ - name: Axe, targets, reduced motion and keyboard
+ working-directory: client
+ env:
+ CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }}
+ run: bun run a11y http://127.0.0.1:4173/ --json > "$RUNNER_TEMP/a11y.json"
+ - name: Upload report
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: a11y-report
+ path: ${{ runner.temp }}/a11y.json
+ if-no-files-found: ignore
diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml
new file mode 100644
index 0000000..d2f603c
--- /dev/null
+++ b/.github/workflows/perf.yml
@@ -0,0 +1,76 @@
+name: perf
+
+on:
+ pull_request:
+ paths:
+ - client/**
+ - docs/performance.md
+ - .github/workflows/perf.yml
+ push:
+ branches: [main]
+ paths:
+ - client/**
+ - docs/performance.md
+ - .github/workflows/perf.yml
+
+permissions:
+ contents: read
+
+concurrency:
+ group: perf-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ budget:
+ name: performance budget
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.14
+ - name: Install
+ working-directory: client
+ run: bun install --frozen-lockfile
+ - name: Install Chrome
+ id: chrome
+ uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0
+ with:
+ chrome-version: 154.0.8037.92
+ install-dependencies: true
+ - name: Build
+ working-directory: client
+ run: bun run build
+ - name: Serve the production build
+ working-directory: client
+ run: |
+ bun run preview --host 127.0.0.1 --port 4173 --strictPort > "$RUNNER_TEMP/preview.log" 2>&1 &
+ for _ in $(seq 1 30); do
+ curl --silent --fail --output /dev/null http://127.0.0.1:4173/ && exit 0
+ sleep 1
+ done
+ cat "$RUNNER_TEMP/preview.log"
+ echo "Preview server did not start" >&2
+ exit 1
+ - name: Check the budgets
+ working-directory: client
+ env:
+ CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }}
+ run: bun run perf http://127.0.0.1:4173/ --json --profile=ci > "$RUNNER_TEMP/perf.json"
+ - name: Summarize
+ if: always()
+ run: |
+ test -s "$RUNNER_TEMP/perf.json" || exit 0
+ {
+ echo "### Performance budget"
+ jq -r '.budgets.throttle as $t | "- Cold load: \(.load.bytesKB) KB of \(.budgets.transferKB) KB\n- Layout shift: \(.load.clsScore) of \(.budgets.layoutShift)\n- Longest task: \(.load.longTasks.maxMs) ms of \(.budgets.longTaskMs) ms\n- Frame p99 at \(.budgets.throttle)x throttle: \(([.frames[] | select(.throttle == $t)][0].p99)) ms of \(.budgets.frameP99Ms) ms"' "$RUNNER_TEMP/perf.json"
+ jq -r '.violations[] | "- **Over budget:** \(.message)"' "$RUNNER_TEMP/perf.json"
+ } >> "$GITHUB_STEP_SUMMARY"
+ - name: Upload report
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: perf-report
+ path: ${{ runner.temp }}/perf.json
+ if-no-files-found: ignore
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
index be26c37..8e12cbf 100644
--- a/README.md
+++ b/README.md
@@ -1,51 +1,99 @@
# Deepmark
-A turn-based dungeon crawler on Stellar testnet. Descend three floors, decide what to keep, and leave a result the contract can replay.
+A turn-based dungeon crawler where the Stellar contract replays every run, so a result or a relic cannot be claimed without playing for it.

-Built for the Find Your Way Meridian Hackathon. The game is live on Stellar testnet. The contract replays every run from its action log, so a result cannot be claimed without playing it, and a verified extraction mints relics as Stellar assets (`DMCOMMON`, `DMRARE`, `DMCORE`). No wallet or extension is needed. See `docs/architecture/onchain-boundary.md` for what is verified on-chain and what stays on the client.
+Built for the Find Your Way Meridian Hackathon. It runs on Stellar testnet. No wallet, extension or sign-up is needed: the game creates a testnet account in your browser on the first run.
-Contract (testnet): `CDL7OO7MGCNMHZBF5QPJCMBLLY3ORR44TJ54ON44M25YUX6UZ7L46UZW`
+## Play in one minute
-Forge a delver from predefined items, descend, and if you clear all three floors that exact delver becomes yours, as a verified token you can send down again as a veteran.
+The repository is private until the submission freeze, so the live link is added here when it goes public. Until then:
-
+```bash
+cd client
+bun install
+bun run dev # open the address it prints
+```
-Open floor is lighter than blocked stone, and a thin gray seam separates them. Gold corners mark the stairs.
+[Bun](https://bun.com) 1.3.14 or newer is the only requirement.
-
+1. Press **Enter** on the title, or choose **Forge a delver**. Pick a name, a delver, a weapon and a charm, then **Begin descent**.
+2. Move with the **arrow keys** or **WASD**. Walk onto a relic to take it. Gold corners mark the stairs.
+3. Walk into a guard to fight. **Enter** attacks and **Esc** retreats.
+4. When a floor is clear, **Enter** descends and **Esc** extracts. Extracting banks what you hold. Dying loses it.
+5. Clear all three floors and extract, and the contract replays your run and gives you that delver as a token you own. It can go down again as a veteran.
-The third floor is the way out. The stairs climb toward daylight, and the core relic is the gold stone beyond the boss. If Stellar cannot open the run, the game still plays and the floor label says Practice, so it is clear that result will not be recorded.
+Other keys: **L** leaderboard, **V** vault, **M** your record, **N** sound. On a phone, on-screen controls take over.
-The title also fits a phone. The menu stays on the dark side of the shaft, with targets large enough to tap.
+
-
+If Stellar cannot be reached, the game still plays as a practice run. The floor label says Practice, and that result is not recorded.
-## Run it locally
+## Why Stellar
-The client uses [Bun](https://bun.com) 1.3.14 or newer as its package manager.
+- **Verified results.** A run is two transactions: `start_run`, where the contract issues the seed, and `finish_run`, where it replays your action log and derives the floor, score and relics itself. A forged log fails or produces a legitimate run.
+- **Scarce relics.** `DMCOMMON`, `DMRARE` and `DMCORE` are Stellar assets wrapped by Stellar Asset Contracts that the game contract administers. Nothing is for sale, and no relic is minted without a verified extraction. The caveat for the current testnet issuer is under Limitations.
+- **A ladder anyone can audit.** `verify_run` replays any run with no wallet and no write, from the seed in the `run_started` event and the log.
-```bash
-cd client
-bun install
-bun run dev
-```
+The game is built so it is fun with the chain switched off in your head. Rendering, animation and sound never wait on a transaction, and the two calls happen while the descent plays and after the result is already on screen. The boundary and its reasons are in `docs/architecture/onchain-boundary.md`.
-`bun run typecheck` checks the TypeScript project. `bun run golden` regenerates the vectors that tie the TypeScript game to the contract's Rust replay. `bun run build` typechecks and writes the production bundle to `client/dist`.
+## On testnet
-WASD or arrow keys to move, Enter to confirm, attack or descend, Esc to retreat or extract. M opens your record: runs in this browser, and your verified record and relic balances on Stellar testnet. `/stats` is the public traction page: every address with a finished run, run totals and best floors, read from the testnet contract. On a narrow screen the board scales to the window and on-screen controls take over. A Stellar testnet account is created and funded automatically on first run.
+| Item | Id |
+|---|---|
+| Game contract | `CDEAFSBG7JBN6XMP4HRL7LFBL7L67K4RJK3CIRBV23W3TJQCEADZ7R6N` |
+| `DMCOMMON` contract | `CAAFYL2QZU274NLA2IFDB4L6SUNVQKT6OLB6UXGL5L5LMQCFJXSUYFO3` |
+| `DMRARE` contract | `CC4RQ4HLTPTXMCZ3OKDL546XXJGOQ5PI2ET6U33C2XTO4BTS6AJV4ANA` |
+| `DMCORE` contract | `CC27JVKNDSUM5FDGP4JZDZTKGHJ5RJV7XHJGITTUGOUFE4DVPN6PGLMI` |
+| Relic issuer (locked) | `GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK` |
+| Contract admin | `GCZCKB4FUIS5BGEMJOC3EQWKLGH662BOY3HFLH3WWPCGQJXOILDXP7GZ` |
-## Contract
+The setup and verification transactions are listed in `docs/deployments.md`. Look any of them up on [stellar.expert](https://stellar.expert/explorer/testnet). The same ids are in `client/src/stellar/config.ts`. `/stats` in the running client is a public page that lists every address with a finished run, read from the contract.
-```
+## Limitations
+
+- **Testnet only.** There is no mainnet deployment.
+- **The contract has an admin who can upgrade it.** Replay verification protects players from forged logs, not from the admin while the role exists. The role is meant to be renounced or moved to a multisig before any mainnet release. Details in `docs/threat-model.md`.
+- **Delvers are not standard NFTs.** They have an owner and transfer interface shaped like the Stellar standard and differ from it in several calls and events. Explorer support has not been tested and is not claimed. See `docs/architecture/nft-standard.md`.
+- **Seed rerolling is limited, not removed.** A cooldown and a visible abandoned run counter make it cost time. A solver can also play a legal perfect run.
+- **The key lives in the browser.** Clearing site data loses the account and its records. A passkey account is the planned replacement.
+- **No third party audit.** The review that exists is described in `docs/threat-model.md`.
+- **The board is not exposed to screen readers.** See `docs/accessibility.md`.
+
+## Check it yourself
+
+```bash
cd contracts/deepmark-contract
-cargo test
+cargo test # 63 tests plus 600 vectors played by the real client
stellar contract build
+
+cd ../../client
+bun run typecheck
+bun run build # typechecks, then writes client/dist
+bun run golden # regenerates the vectors the Rust replay is tested against
+bun run perf # performance budgets, needs a running preview and Chrome
+bun run a11y # accessibility checks, same requirements
+bun run contrast # contrast ratios from the stylesheet
```
-Deployed to testnet with three relic Stellar Asset Contracts. The contract API, the action bytes and the trust model are in `contracts/deepmark-contract/README.md`.
+GitHub Actions runs the client checks when `client/` changes, the contract checks when `contracts/` changes, and the performance and accessibility checks in a pinned Chrome.
+
+## Where things are
+
+| Path | What |
+|---|---|
+| `client/` | The game: TypeScript, Vite, canvas. `src/game` is the simulation, `src/render` the screens, `src/stellar` the chain code |
+| `contracts/deepmark-contract/` | The Soroban contract. `src/{rng,world,sim}.rs` is a bit exact port of `client/src/game/` |
+| `docs/architecture/onchain-boundary.md` | What is on chain, what is not, and why |
+| `docs/architecture/nft-standard.md` | Delvers compared with the Stellar NFT standard |
+| `docs/deployments.md` | Testnet ids and the transaction of every deployment step |
+| `docs/threat-model.md` | The attacks considered, what stops each, what remains |
+| `docs/performance.md`, `docs/accessibility.md` | Budgets and accessibility, with how they are measured |
+| `docs/submission-checklist.md` | What is done and what is still open |
+
+The contract API, the action encoding and the trust model are in `contracts/deepmark-contract/README.md`.
-## Checks
+## Licence
-GitHub Actions runs the client commands when `client/` changes, and the contract commands when `contracts/` changes. A change to either workflow file runs that workflow. The client commands are `bun run typecheck` and `bun run build`. The contract commands are `cargo test` and `stellar contract build`.
+Licensed under the [Apache License 2.0](LICENSE).
diff --git a/client/bun.lock b/client/bun.lock
index 39014e9..79ae9a4 100644
--- a/client/bun.lock
+++ b/client/bun.lock
@@ -9,6 +9,7 @@
},
"devDependencies": {
"@types/bun": "^1.4.2",
+ "axe-core": "^4.14.0",
"puppeteer-core": "^25.12.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
@@ -142,6 +143,8 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+ "axe-core": ["axe-core@4.14.0", "", {}, "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ=="],
+
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"bignumber.js": ["bignumber.js@11.1.5", "", {}, "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw=="],
diff --git a/client/index.html b/client/index.html
index 6aea494..2f19f7b 100644
--- a/client/index.html
+++ b/client/index.html
@@ -16,27 +16,27 @@
- Deepmark
-
+ Deepmark
+
Sound on
-
Deepmark needs a canvas-capable browser.
+
Deepmark needs a canvas-capable browser.
-
+
diff --git a/client/package.json b/client/package.json
index f635961..0b7de03 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,10 +15,13 @@
"golden": "bun run scripts/golden-vectors.ts",
"kits": "bun run scripts/kit-difficulty.ts",
"test": "bun test",
- "perf": "bun run scripts/perf.ts"
+ "perf": "bun run scripts/perf.ts",
+ "a11y": "bun run scripts/a11y.ts",
+ "contrast": "bun run scripts/contrast.ts"
},
"devDependencies": {
"@types/bun": "^1.4.2",
+ "axe-core": "^4.14.0",
"puppeteer-core": "^25.12.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
diff --git a/client/scripts/a11y.ts b/client/scripts/a11y.ts
new file mode 100644
index 0000000..cf3c03d
--- /dev/null
+++ b/client/scripts/a11y.ts
@@ -0,0 +1,309 @@
+/**
+ * Accessibility check. Runs axe-core in a real headless Chrome against every
+ * screen a player can reach, in three passes:
+ *
+ * desktop 1280 by 800, normal motion
+ * phone 390 by 844 with touch, where the touch controls show
+ * reduced desktop with prefers-reduced-motion: reduce
+ *
+ * It fails on any serious or critical axe violation, on a control smaller than
+ * 44 by 44 CSS pixels, and (in the reduced pass) on any animation still
+ * looping. A last pass drives the game with the keyboard only and checks that
+ * Tab reaches the menu, Enter activates the focused control, focus moves into
+ * a screen when it opens and back when it closes, and play events are
+ * announced.
+ *
+ * bun run build && bun run preview --port 4173 &
+ * bun run a11y [url] [--json]
+ *
+ * Title, creator, the offline vault, leaderboard and record, and the board in
+ * play come from the real game with Stellar blocked. The populated vault and
+ * leaderboard and the result screens come from the local preview routes, which
+ * need no wallet or network. With --json the report goes to stdout and the
+ * readable log to stderr.
+ */
+import axe from 'axe-core';
+import type { Page } from 'puppeteer-core';
+import { blockStellar, launchBrowser } from './browser';
+
+const ARGS = process.argv.slice(2);
+const JSON_OUTPUT = ARGS.includes('--json');
+const BASE = (ARGS.find((a) => !a.startsWith('--')) ?? 'http://localhost:4173/').replace(/\/?$/, '/');
+const BLOCKING_IMPACTS: readonly string[] = ['serious', 'critical'];
+const AXE_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'];
+/** Entrance animations settle well inside this. */
+const SETTLE_MS = 3500;
+const SHORT_SETTLE_MS = 1500;
+const PLAY_TIMEOUT_MS = 90_000;
+/** Smallest touch target the project allows, in CSS pixels. */
+const MIN_TARGET_PX = 44;
+
+interface Pass {
+ name: string;
+ width: number;
+ height: number;
+ touch: boolean;
+ reducedMotion: boolean;
+}
+
+const PASSES: Pass[] = [
+ { name: 'desktop', width: 1280, height: 800, touch: false, reducedMotion: false },
+ { name: 'phone', width: 390, height: 844, touch: true, reducedMotion: false },
+ { name: 'reduced motion', width: 1280, height: 800, touch: false, reducedMotion: true },
+];
+
+interface Scenario {
+ name: string;
+ open: (page: Page) => Promise
;
+}
+
+interface Finding {
+ kind: 'axe' | 'target' | 'motion' | 'keyboard';
+ rule: string;
+ impact: string;
+ detail: string;
+ nodes: string[];
+}
+
+interface ScreenReport {
+ pass: string;
+ screen: string;
+ findings: Finding[];
+ blocking: number;
+}
+
+function log(...parts: unknown[]): void {
+ (JSON_OUTPUT ? console.error : console.log)(...parts);
+}
+
+const settle = (ms = SETTLE_MS): Promise => new Promise((r) => setTimeout(r, ms));
+
+async function freshTitle(page: Page): Promise {
+ await page.goto(BASE, { waitUntil: 'load' });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: 'load' });
+ await page.waitForSelector('.title-screen');
+ await settle();
+}
+
+async function openByKey(page: Page, key: string, selector: string): Promise {
+ await freshTitle(page);
+ await page.keyboard.press(key);
+ await page.waitForSelector(selector);
+ await settle(SHORT_SETTLE_MS);
+}
+
+async function preview(page: Page, query: string, selector: string): Promise {
+ await page.goto(`${BASE}?preview=${query}`, { waitUntil: 'load' });
+ await page.waitForSelector(selector);
+ await settle();
+}
+
+const SCENARIOS: Scenario[] = [
+ { name: 'title', open: freshTitle },
+ {
+ name: 'creator',
+ open: async (page) => {
+ await freshTitle(page);
+ await page.click('[data-action="confirm"]');
+ await page.waitForSelector('form.creator');
+ await settle(SHORT_SETTLE_MS);
+ },
+ },
+ { name: 'vault (offline)', open: (page) => openByKey(page, 'KeyV', '.panel') },
+ { name: 'vault (two delvers)', open: (page) => preview(page, 'vault', '.vault-card') },
+ { name: 'leaderboard (offline)', open: (page) => openByKey(page, 'KeyL', '.panel') },
+ { name: 'leaderboard (ranked)', open: (page) => preview(page, 'leaderboard', '.board-row') },
+ { name: 'record', open: (page) => openByKey(page, 'KeyM', '.panel') },
+ {
+ name: 'board in play',
+ open: async (page) => {
+ await freshTitle(page);
+ await page.click('[data-action="confirm"]');
+ await page.waitForSelector('form.creator');
+ await page.evaluate(() => (document.querySelector('form.creator') as HTMLFormElement).requestSubmit());
+ await page.waitForFunction(() => !(document.getElementById('hud') as HTMLElement | null)?.hidden, { timeout: PLAY_TIMEOUT_MS });
+ await settle();
+ },
+ },
+ { name: 'descent scene', open: (page) => preview(page, 'descent', '.descent') },
+ { name: 'result (cleared)', open: (page) => preview(page, 'result&hero=1', '.result') },
+ { name: 'result (lost)', open: (page) => preview(page, 'result&loss=1', '.result') },
+];
+
+interface AxeNode {
+ target: unknown[];
+}
+interface AxeViolation {
+ id: string;
+ impact: string | null;
+ help: string;
+ nodes: AxeNode[];
+}
+
+async function axeFindings(page: Page): Promise {
+ await page.evaluate(axe.source);
+ const results = await page.evaluate(
+ (tags) =>
+ (window as unknown as { axe: { run: (ctx: Document, opts: object) => Promise<{ violations: unknown[] }> } }).axe.run(document, {
+ runOnly: { type: 'tag', values: tags },
+ }),
+ AXE_TAGS
+ );
+ return (results.violations as AxeViolation[]).map(
+ (v): Finding => ({
+ kind: 'axe',
+ rule: v.id,
+ impact: v.impact ?? 'minor',
+ detail: v.help,
+ nodes: v.nodes.map((n) => n.target.join(' ')),
+ })
+ );
+}
+
+/** Visible controls whose box is under the minimum in either direction. */
+async function targetFindings(page: Page): Promise {
+ const small = await page.evaluate((min) => {
+ const out: string[] = [];
+ const controls = document.querySelectorAll('button, a[href], input, select, textarea, [role="button"]');
+ for (const node of controls) {
+ const rect = node.getBoundingClientRect();
+ const style = getComputedStyle(node);
+ const hidden = rect.width === 0 || rect.height === 0 || style.visibility === 'hidden' || node.closest('[hidden]') !== null;
+ if (hidden || node.classList.contains('sr-only')) continue;
+ if (Math.round(rect.width) < min || Math.round(rect.height) < min) {
+ const label = node.getAttribute('aria-label') ?? node.textContent?.trim().slice(0, 30) ?? '';
+ out.push(`${node.tagName.toLowerCase()}.${node.className.toString().split(' ')[0]} "${label}" ${Math.round(rect.width)}x${Math.round(rect.height)}`);
+ }
+ }
+ return out;
+ }, MIN_TARGET_PX);
+ return small.length === 0
+ ? []
+ : [{ kind: 'target', rule: 'target-size-44', impact: 'serious', detail: `Controls under ${MIN_TARGET_PX} by ${MIN_TARGET_PX} px`, nodes: small }];
+}
+
+/** Animations still looping once the screen has settled. */
+async function motionFindings(page: Page): Promise {
+ const looping = await page.evaluate(() =>
+ document
+ .getAnimations()
+ .filter((a) => a.playState === 'running' && a.effect?.getComputedTiming().iterations === Infinity)
+ .map((a) => `${(a as CSSAnimation).animationName ?? 'animation'} on ${(a.effect as KeyframeEffect | null)?.target?.className ?? '?'}`)
+ );
+ return looping.length === 0
+ ? []
+ : [{ kind: 'motion', rule: 'reduced-motion-loop', impact: 'serious', detail: 'Animation still looping with reduced motion on', nodes: looping }];
+}
+
+async function auditScreen(pass: Pass, scenario: Scenario, browser: Awaited>): Promise {
+ const page = await browser.newPage();
+ await page.setViewport({ width: pass.width, height: pass.height, deviceScaleFactor: 1, hasTouch: pass.touch, isMobile: pass.touch });
+ await page.emulateMediaFeatures([{ name: 'prefers-reduced-motion', value: pass.reducedMotion ? 'reduce' : 'no-preference' }]);
+ await blockStellar(page);
+ await scenario.open(page);
+ const findings = [...(await axeFindings(page)), ...(await targetFindings(page)), ...(pass.reducedMotion ? await motionFindings(page) : [])];
+ await page.close();
+ return { pass: pass.name, screen: scenario.name, findings, blocking: findings.filter((f) => BLOCKING_IMPACTS.includes(f.impact)).length };
+}
+
+const MAX_TABS = 12;
+const MAX_PLAY_KEYS = 40;
+const PLAY_KEYS = ['ArrowRight', 'ArrowRight', 'Enter', 'ArrowUp', 'ArrowLeft', 'Enter'] as const;
+const KEY_SETTLE_MS = 250;
+
+const describeFocus = (page: Page): Promise =>
+ page.evaluate(() => {
+ const a = document.activeElement;
+ return a ? `${a.tagName.toLowerCase()}.${a.className.toString().split(' ')[0]} "${(a.textContent ?? '').trim().slice(0, 24)}"` : 'nothing';
+ });
+
+/** Tabs until a control matching the selector has focus. Returns false when it never does. */
+async function tabTo(page: Page, selector: string): Promise {
+ for (let i = 0; i < MAX_TABS; i += 1) {
+ await page.keyboard.press('Tab');
+ if (await page.evaluate((sel) => document.activeElement?.matches(sel) ?? false, selector)) return true;
+ }
+ return false;
+}
+
+/** Drives the game with the keyboard alone and reports what a keyboard or screen reader player would hit. */
+async function keyboardFindings(browser: Awaited>): Promise {
+ const page = await browser.newPage();
+ await blockStellar(page);
+ const problems: string[] = [];
+ const check = (ok: boolean, message: string): void => {
+ if (!ok) problems.push(message);
+ };
+ await freshTitle(page);
+
+ const reached = await tabTo(page, '.menu-item[data-action="vault"]');
+ check(reached, `Tab never reached the Vault menu item within ${MAX_TABS} presses`);
+ if (reached) {
+ await page.keyboard.press('Enter');
+ await page.waitForSelector('.panel-title', { timeout: SETTLE_MS }).catch(() => undefined);
+ const opened = await page.evaluate(() => document.querySelector('.panel-title')?.textContent ?? '');
+ check(opened === 'Your vault', `Enter on the focused Vault item opened "${opened || 'nothing'}" instead of the vault`);
+ check(await page.evaluate(() => document.activeElement?.classList.contains('panel-title') ?? false), `Focus did not move into the vault screen (on ${await describeFocus(page)})`);
+ await page.keyboard.press('Escape');
+ await settle(SHORT_SETTLE_MS);
+ check(await page.evaluate(() => document.activeElement?.matches('.menu-item.is-primary') ?? false), `Focus did not return to the menu after Escape (on ${await describeFocus(page)})`);
+ }
+
+ // Forge and descend without touching the pointer: Enter on the menu, Enter inside the name field.
+ await page.keyboard.press('Enter');
+ await page.waitForSelector('form.creator', { timeout: SETTLE_MS }).catch(() => undefined);
+ check(await page.evaluate(() => document.activeElement?.matches('input.input') ?? false), 'The creator did not focus the name field');
+ await page.keyboard.press('Enter');
+ await page
+ .waitForFunction(() => !(document.getElementById('hud') as HTMLElement | null)?.hidden, { timeout: PLAY_TIMEOUT_MS })
+ .catch(() => check(false, 'Enter in the name field did not start the descent'));
+ await settle(SHORT_SETTLE_MS);
+
+ const logRole = await page.evaluate(() => document.getElementById('hud-log')?.getAttribute('role') ?? '');
+ check(logRole === 'log', `The play log is not a live region (role is "${logRole}")`);
+ let announced = '';
+ for (let i = 0; i < MAX_PLAY_KEYS && announced === ''; i += 1) {
+ await page.keyboard.press(PLAY_KEYS[i % PLAY_KEYS.length] as string);
+ await settle(KEY_SETTLE_MS);
+ announced = await page.evaluate(() => document.getElementById('hud-log')?.textContent?.trim() ?? '');
+ }
+ check(announced !== '', `Playing with the keyboard produced no log line in ${MAX_PLAY_KEYS} key presses`);
+ const healthLabel = await page.evaluate(() => document.getElementById('hud-hp')?.getAttribute('aria-label') ?? '');
+ check(/^Health \d+ of \d+/.test(healthLabel), `The health bar has no spoken value (label is "${healthLabel}")`);
+ await page.close();
+
+ const findings: Finding[] =
+ problems.length === 0 ? [] : [{ kind: 'keyboard', rule: 'keyboard-flow', impact: 'serious', detail: 'Keyboard only flow', nodes: problems }];
+ return { pass: 'keyboard', screen: 'title to first move', findings, blocking: findings.length };
+}
+
+const browser = await launchBrowser();
+const reports: ScreenReport[] = [];
+try {
+ for (const pass of PASSES) {
+ log(`\n${pass.name.toUpperCase()}`);
+ for (const scenario of SCENARIOS) {
+ const report = await auditScreen(pass, scenario, browser);
+ reports.push(report);
+ log(`${report.blocking === 0 ? 'ok ' : 'FAIL'} ${scenario.name} (${report.findings.length} findings, ${report.blocking} blocking)`);
+ for (const f of report.findings) {
+ log(` [${f.impact}] ${f.rule}: ${f.detail}`);
+ for (const node of f.nodes.slice(0, 4)) log(` ${node}`);
+ }
+ }
+ }
+ log('\nKEYBOARD');
+ const keyboard = await keyboardFindings(browser);
+ reports.push(keyboard);
+ log(`${keyboard.blocking === 0 ? 'ok ' : 'FAIL'} ${keyboard.screen}`);
+ for (const f of keyboard.findings) for (const node of f.nodes) log(` ${node}`);
+} finally {
+ await browser.close();
+}
+
+const blocking = reports.reduce((sum, r) => sum + r.blocking, 0);
+if (JSON_OUTPUT) console.log(JSON.stringify({ url: BASE, axe: axe.version, reports }, null, 2));
+if (blocking > 0) console.error(`A11Y: ${blocking} serious or critical findings`);
+else log('A11Y: no serious or critical findings');
+process.exit(blocking === 0 ? 0 : 1);
diff --git a/client/scripts/browser.ts b/client/scripts/browser.ts
new file mode 100644
index 0000000..5e22b55
--- /dev/null
+++ b/client/scripts/browser.ts
@@ -0,0 +1,30 @@
+/** Shared headless Chrome setup for the measurement scripts (perf, a11y). */
+import puppeteer, { type Browser, type Page } from 'puppeteer-core';
+
+const DEFAULT_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 } as const;
+
+/** Hosts the game talks to. Blocking them forces the offline practice run. */
+export const STELLAR_HOSTS: readonly string[] = [
+ 'soroban-testnet.stellar.org',
+ 'horizon-testnet.stellar.org',
+ 'friendbot.stellar.org',
+];
+
+export async function launchBrowser(): Promise {
+ return puppeteer.launch({
+ executablePath: process.env.CHROME_PATH ?? DEFAULT_CHROME,
+ headless: true,
+ args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required', `--window-size=${VIEWPORT.width},${VIEWPORT.height}`],
+ defaultViewport: VIEWPORT,
+ });
+}
+
+/** Aborts every request to a Stellar host so the game runs fully offline. */
+export async function blockStellar(page: Page): Promise {
+ await page.setRequestInterception(true);
+ page.on('request', (req) => {
+ if (STELLAR_HOSTS.includes(new URL(req.url()).host)) void req.abort();
+ else void req.continue();
+ });
+}
diff --git a/client/scripts/budgets.ts b/client/scripts/budgets.ts
new file mode 100644
index 0000000..1682792
--- /dev/null
+++ b/client/scripts/budgets.ts
@@ -0,0 +1,75 @@
+/**
+ * Performance budgets, the single source for the numbers written in
+ * docs/performance.md. The harness imports this to fail a run that goes over.
+ */
+
+export const BUDGETS = {
+ /** Encoded bytes transferred on a cold load, in KB. */
+ transferKB: 1200,
+ /** Cumulative layout shift during load. */
+ layoutShift: 0.05,
+ /** Frame time in play at the profile's percentile, in ms. */
+ frameMs: 20,
+} as const;
+
+/**
+ * What differs between the machine the budgets were set on and a shared CI
+ * runner. Bytes and layout shift do not depend on the CPU, so they are the same
+ * everywhere. Timing does. The `laptop` profile is the reference: an Apple
+ * silicon laptop, with the CPU throttled 4 times to stand in for a mid range
+ * phone. A GitHub runner is already several times slower than that laptop and
+ * has no GPU, and the same 4 times throttle on top of it measured a 49.9 ms
+ * frame and a 126 ms load task for a build that holds 16.8 ms and no long task
+ * on the laptop. The `ci` profile therefore applies no throttle and allows a
+ * longer task during load. A shared runner also hiccups: one run of 705 frames
+ * had 4 frames over 33 ms, which moves the 99th percentile to 33.2 ms while the
+ * 95th stayed at 16.8 ms. The `ci` profile therefore judges the 95th percentile,
+ * which still fails a build that is slow in more than 1 frame in 20, and leaves
+ * the 99th percentile to the laptop profile.
+ */
+export const PROFILES = {
+ laptop: { throttle: 4, longTaskMs: 50, percentile: 'p99' },
+ ci: { throttle: 1, longTaskMs: 200, percentile: 'p95' },
+} as const;
+
+export type ProfileName = keyof typeof PROFILES;
+
+export function isProfileName(value: string): value is ProfileName {
+ return value in PROFILES;
+}
+
+export interface BudgetInput {
+ load: { bytesKB: number; clsScore: number; longTasks: { count: number; totalMs: number; maxMs: number } };
+ frames: { throttle: number; p95: number; p99: number }[];
+}
+
+export interface Violation {
+ metric: string;
+ actual: number;
+ limit: number;
+ message: string;
+}
+
+/** Compares a measured report with the budgets of a profile and returns one entry per breach. */
+export function checkBudgets(report: BudgetInput, profile: ProfileName): Violation[] {
+ const { throttle, longTaskMs, percentile } = PROFILES[profile];
+ const out: Violation[] = [];
+ const over = (metric: string, actual: number, limit: number, unit: string, hint: string): void => {
+ if (actual > limit) {
+ out.push({ metric, actual, limit, message: `${metric} is ${actual}${unit}, budget is ${limit}${unit}. ${hint}` });
+ }
+ };
+
+ over('Cold load transfer', report.load.bytesKB, BUDGETS.transferKB, ' KB', 'Check for a new or unoptimized asset.');
+ over('Cumulative layout shift', report.load.clsScore, BUDGETS.layoutShift, '', 'Reserve space for late content or load styles before first paint.');
+ if (report.load.longTasks.count > 0) {
+ over('Longest task during load', report.load.longTasks.maxMs, longTaskMs, ' ms', 'Split or defer the work that blocks the main thread.');
+ }
+ const sampled = report.frames.find((f) => f.throttle === throttle);
+ if (!sampled) {
+ out.push({ metric: 'Frame time', actual: 0, limit: BUDGETS.frameMs, message: `No frame sample at ${throttle}x CPU throttle was recorded.` });
+ } else {
+ over(`Frame time ${percentile} at ${throttle}x throttle`, sampled[percentile], BUDGETS.frameMs, ' ms', 'Profile the render loop for per frame allocations.');
+ }
+ return out;
+}
diff --git a/client/scripts/contrast.ts b/client/scripts/contrast.ts
new file mode 100644
index 0000000..6607464
--- /dev/null
+++ b/client/scripts/contrast.ts
@@ -0,0 +1,148 @@
+/**
+ * WCAG contrast check for the colours the stylesheet actually uses. Token
+ * values are read from src/styles.css, so the table cannot drift from the
+ * design. Translucent surfaces are composited over the backdrop they really
+ * sit on: panels sit on the opaque overlay, which is the page colour, and the
+ * combat and choice prompts sit over the board, so they are checked against
+ * the lightest board pixel under the dimming gradient.
+ *
+ * bun run contrast [--markdown]
+ *
+ * Exits 1 when a pair is under its threshold: 4.5 for text, 3 for large text
+ * and for the edges of controls (WCAG 1.4.3 and 1.4.11).
+ */
+import { readFileSync } from 'node:fs';
+
+const CSS = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8');
+const MARKDOWN = process.argv.includes('--markdown');
+
+const TEXT_MIN = 4.5;
+const GRAPHIC_MIN = 3;
+/** The lightest the board can be. Pure white is a bound, no floor tile is that bright. */
+const LIGHTEST_BOARD = '#ffffff';
+/**
+ * Share of page colour the `.overlay-prompt` gradient lays over the board where
+ * a prompt sits. The gradient runs from 0 at 20 percent of the height to 0.78 at
+ * the bottom, and a prompt starts well below the middle. 0.4 is a safe floor.
+ */
+const PROMPT_DIM = 0.4;
+
+type Rgba = { r: number; g: number; b: number; a: number };
+
+function tokenValue(name: string): string {
+ const match = new RegExp(`--${name}:\\s*([^;]+);`).exec(CSS);
+ if (!match?.[1]) throw new Error(`Token --${name} not found in styles.css`);
+ return match[1].trim();
+}
+
+function parseColor(value: string): Rgba {
+ const hex = /^#([0-9a-f]{6})$/i.exec(value);
+ if (hex?.[1]) {
+ const n = parseInt(hex[1], 16);
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, a: 1 };
+ }
+ const rgba = /^rgba?\(([^)]+)\)$/.exec(value);
+ if (rgba?.[1]) {
+ const [r, g, b, a] = rgba[1].split(',').map((part) => Number(part.trim()));
+ return { r: r ?? 0, g: g ?? 0, b: b ?? 0, a: a ?? 1 };
+ }
+ throw new Error(`Unsupported colour: ${value}`);
+}
+
+/** Resolves a token name or a literal colour. */
+function color(ref: string): Rgba {
+ return parseColor(ref.startsWith('--') ? tokenValue(ref.slice(2)) : ref);
+}
+
+function over(top: Rgba, under: Rgba): Rgba {
+ const a = top.a;
+ return {
+ r: top.r * a + under.r * (1 - a),
+ g: top.g * a + under.g * (1 - a),
+ b: top.b * a + under.b * (1 - a),
+ a: 1,
+ };
+}
+
+function luminance({ r, g, b }: Rgba): number {
+ const lin = (v: number): number => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
+}
+
+function ratio(a: Rgba, b: Rgba): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+interface Pair {
+ use: string;
+ fg: string;
+ /** Surface the foreground sits on. */
+ bg: string;
+ /** What lies behind the surface: the opaque overlay, or the board under a prompt. */
+ on?: 'prompt';
+ min: number;
+}
+
+const PAGE = '--bg';
+const PANEL = '--bg-raise';
+const PAIRS: Pair[] = [
+ { use: 'Body text on the page', fg: '--text', bg: PAGE, min: TEXT_MIN },
+ { use: 'Body text on a panel', fg: '--text', bg: PANEL, min: TEXT_MIN },
+ { use: 'Muted text on the page', fg: '--muted', bg: PAGE, min: TEXT_MIN },
+ { use: 'Muted text on a panel', fg: '--muted', bg: PANEL, min: TEXT_MIN },
+ { use: 'Cyan accent on the page', fg: '--cyan', bg: PAGE, min: TEXT_MIN },
+ { use: 'Cyan accent on a panel', fg: '--cyan', bg: PANEL, min: TEXT_MIN },
+ { use: 'Primary button label', fg: '--cyan', bg: '--cyan-deep', min: TEXT_MIN },
+ { use: 'Gold accent on the page', fg: '--gold', bg: PAGE, min: TEXT_MIN },
+ { use: 'Gold accent on a panel', fg: '--gold', bg: PANEL, min: TEXT_MIN },
+ { use: 'Danger text on the page', fg: '--danger', bg: PAGE, min: TEXT_MIN },
+ { use: 'Danger text on a panel', fg: '--danger', bg: PANEL, min: TEXT_MIN },
+ { use: 'Body text on a prompt over the board', fg: '--text', bg: PANEL, on: 'prompt', min: TEXT_MIN },
+ { use: 'Muted text on a prompt over the board', fg: '--muted', bg: PANEL, on: 'prompt', min: TEXT_MIN },
+ { use: 'Cyan accent on a prompt over the board', fg: '--cyan', bg: PANEL, on: 'prompt', min: TEXT_MIN },
+ { use: 'Danger text on a prompt over the board', fg: '--danger', bg: PANEL, on: 'prompt', min: TEXT_MIN },
+ { use: 'Safe text on the page', fg: '--safe', bg: PAGE, min: TEXT_MIN },
+ { use: 'Boss name', fg: '#d8c8ff', bg: PAGE, min: TEXT_MIN },
+ { use: 'Cleared title', fg: '#f7e9c0', bg: PAGE, min: TEXT_MIN },
+ { use: 'Lost title', fg: '#e9b9b3', bg: PAGE, min: TEXT_MIN },
+ { use: 'Focus ring on the page', fg: '--cyan', bg: PAGE, min: GRAPHIC_MIN },
+ { use: 'Focus ring on a panel', fg: '--cyan', bg: PANEL, min: GRAPHIC_MIN },
+ { use: 'Control edge (input and button border)', fg: '--line-control', bg: PANEL, min: GRAPHIC_MIN },
+];
+
+interface Row {
+ use: string;
+ fg: string;
+ bg: string;
+ ratio: number;
+ min: number;
+}
+
+function evaluate(pair: Pair): Row {
+ const page = color(PAGE);
+ const fg = color(pair.fg);
+ const surface = color(pair.bg);
+ const backdrop = pair.on === 'prompt' ? over({ ...page, a: PROMPT_DIM }, parseColor(LIGHTEST_BOARD)) : page;
+ const flatBg = over(surface, backdrop);
+ const value = ratio(over(fg, flatBg), flatBg);
+ return { use: pair.use, fg: pair.fg, bg: pair.bg, ratio: Math.round(value * 100) / 100, min: pair.min };
+}
+
+const rows = PAIRS.map(evaluate);
+const failing = rows.filter((row) => row.ratio < row.min);
+
+if (MARKDOWN) {
+ console.log('| Use | Foreground | Surface | Ratio | Needs | Result |');
+ console.log('|---|---|---|---|---|---|');
+ for (const row of rows) {
+ console.log(`| ${row.use} | \`${row.fg}\` | \`${row.bg}\` | ${row.ratio.toFixed(2)} | ${row.min} | ${row.ratio >= row.min ? 'pass' : 'fail'} |`);
+ }
+} else {
+ for (const row of rows) console.log(`${row.ratio >= row.min ? 'ok ' : 'FAIL'} ${row.ratio.toFixed(2).padStart(5)} (needs ${row.min}) ${row.use}`);
+ console.log(failing.length === 0 ? 'CONTRAST: all pairs pass' : `CONTRAST: ${failing.length} pairs below threshold`);
+}
+process.exit(failing.length === 0 ? 0 : 1);
diff --git a/client/scripts/perf.ts b/client/scripts/perf.ts
index ce035df..8523021 100644
--- a/client/scripts/perf.ts
+++ b/client/scripts/perf.ts
@@ -4,18 +4,30 @@
* the background.
*
* bun run build && bun run preview --port 4173 &
- * bun run perf [url]
+ * bun run perf [url] [--json] [--profile=laptop|ci]
+ *
+ * With --json the report goes to stdout as JSON and the human readable log to
+ * stderr. The exit code is 1 when a budget in scripts/budgets.ts is exceeded,
+ * which is what the perf workflow relies on.
*
* It measures a cold load (bytes, first paint, largest paint, long tasks, time
* until the title is usable) and then plays a practice run with the Stellar
* network blocked, sampling frame times at 1x and 4x CPU throttling.
*/
-import puppeteer, { type Page } from 'puppeteer-core';
-
-const URL_UNDER_TEST = process.argv[2] ?? 'http://127.0.0.1:4173/';
-const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+import type { Page } from 'puppeteer-core';
+import { blockStellar, launchBrowser } from './browser';
+import { BUDGETS, PROFILES, checkBudgets, isProfileName, type ProfileName, type Violation } from './budgets';
+
+const ARGS = process.argv.slice(2);
+const JSON_OUTPUT = ARGS.includes('--json');
+const URL_UNDER_TEST = ARGS.find((a) => !a.startsWith('--')) ?? 'http://127.0.0.1:4173/';
+const PROFILE_ARG = ARGS.find((a) => a.startsWith('--profile='))?.slice('--profile='.length) ?? 'laptop';
+if (!isProfileName(PROFILE_ARG)) {
+ console.error(`Unknown profile "${PROFILE_ARG}". Use one of: ${Object.keys(PROFILES).join(', ')}.`);
+ process.exit(2);
+}
+const PROFILE: ProfileName = PROFILE_ARG;
const SAMPLE_MS = 12_000;
-const STELLAR_HOSTS = ['soroban-testnet.stellar.org', 'horizon-testnet.stellar.org', 'friendbot.stellar.org'];
interface LoadReport {
bytesKB: number;
@@ -24,11 +36,16 @@ interface LoadReport {
fcpMs: number | null;
lcpMs: number | null;
clsScore: number;
- longTasks: { count: number; totalMs: number };
+ longTasks: { count: number; totalMs: number; maxMs: number };
titleReadyMs: number | null;
jsHeapMB: number;
}
+/** Human readable output. Goes to stderr in --json mode so stdout stays parseable. */
+function log(...parts: unknown[]): void {
+ (JSON_OUTPUT ? console.error : console.log)(...parts);
+}
+
interface FrameReport {
throttle: number;
frames: number;
@@ -102,7 +119,7 @@ async function coldLoad(page: Page): Promise {
});
const shifts = await page.evaluate(() => (window as unknown as { __shifts?: string[] }).__shifts ?? []);
- if (shifts.length > 0) console.log('LAYOUT SHIFTS', shifts);
+ if (shifts.length > 0) log('LAYOUT SHIFTS', shifts);
return {
bytesKB: Math.round(bytes / 1024),
requests,
@@ -110,7 +127,11 @@ async function coldLoad(page: Page): Promise {
fcpMs: inPage.fcp === null ? null : Math.round(inPage.fcp),
lcpMs: inPage.lcp === null ? null : Math.round(inPage.lcp),
clsScore: Math.round(inPage.cls * 1000) / 1000,
- longTasks: { count: inPage.long.length, totalMs: Math.round(inPage.long.reduce((a, b) => a + b, 0)) },
+ longTasks: {
+ count: inPage.long.length,
+ totalMs: Math.round(inPage.long.reduce((a, b) => a + b, 0)),
+ maxMs: Math.round(Math.max(0, ...inPage.long)),
+ },
titleReadyMs,
jsHeapMB: Math.round((inPage.heap / 1024 / 1024) * 10) / 10,
};
@@ -119,12 +140,7 @@ async function coldLoad(page: Page): Promise {
async function sampleFrames(page: Page, throttle: number): Promise {
const cdp = await page.createCDPSession();
await cdp.send('Emulation.setCPUThrottlingRate', { rate: throttle });
- await page.setRequestInterception(true);
- page.on('request', (req) => {
- const host = new URL(req.url()).host;
- if (STELLAR_HOSTS.includes(host)) void req.abort();
- else void req.continue();
- });
+ await blockStellar(page);
await page.goto(URL_UNDER_TEST, { waitUntil: 'load' });
await page.waitForSelector('.title-screen', { timeout: 30_000 });
@@ -187,27 +203,31 @@ async function sampleFrames(page: Page, throttle: number): Promise
};
}
-const browser = await puppeteer.launch({
- executablePath: CHROME,
- headless: true,
- args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required', '--window-size=1280,800'],
- defaultViewport: { width: 1280, height: 800, deviceScaleFactor: 1 },
-});
+const browser = await launchBrowser();
+let violations: Violation[] = [];
try {
const page = await browser.newPage();
const load = await coldLoad(page);
- console.log('COLD LOAD');
- console.log(JSON.stringify(load, null, 2));
+ log('COLD LOAD');
+ log(JSON.stringify(load, null, 2));
- const results: FrameReport[] = [];
- for (const throttle of [1, 4]) {
+ const frames: FrameReport[] = [];
+ for (const throttle of new Set([1, PROFILES[PROFILE].throttle])) {
const fresh = await browser.newPage();
- results.push(await sampleFrames(fresh, throttle));
+ frames.push(await sampleFrames(fresh, throttle));
await fresh.close();
}
- console.log('FRAMES (practice run, Stellar blocked, 12 s of play)');
- console.log(JSON.stringify(results, null, 2));
+ log('FRAMES (practice run, Stellar blocked, 12 s of play)');
+ log(JSON.stringify(frames, null, 2));
+
+ violations = checkBudgets({ load, frames }, PROFILE);
+ if (JSON_OUTPUT) {
+ console.log(JSON.stringify({ url: URL_UNDER_TEST, profile: PROFILE, budgets: { ...BUDGETS, ...PROFILES[PROFILE] }, load, frames, violations }, null, 2));
+ }
+ if (violations.length === 0) log('PERF BUDGETS: all within budget');
+ else for (const v of violations) console.error(`PERF BUDGET EXCEEDED: ${v.message}`);
} finally {
await browser.close();
}
+process.exit(violations.length === 0 ? 0 : 1);
diff --git a/client/src/input/keyboard.ts b/client/src/input/keyboard.ts
index b7dd195..cb4240f 100644
--- a/client/src/input/keyboard.ts
+++ b/client/src/input/keyboard.ts
@@ -75,11 +75,24 @@ function isTypingTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest('form') !== null;
}
+/**
+ * A control the player reached with Tab keeps Enter and Space for itself, so
+ * activating it does what its label says instead of the game's own Enter. A
+ * control that was only clicked is not focus-visible, so a player who clicked
+ * Mute and went on playing still gets Enter as confirm.
+ */
+function isKeyboardFocusedControl(target: EventTarget | null): boolean {
+ return target instanceof Element && target.closest('button, a[href], summary') !== null && target.matches(':focus-visible');
+}
+
+const ACTIVATION_CODES: readonly string[] = ['Enter', 'Space'];
+
export function onAction(handler: (action: Action) => void): void {
emit = handler;
window.addEventListener('keydown', (event) => {
// Typing in a field (the delver's name) must never be read as a game key.
if (isTypingTarget(event.target)) return;
+ if (ACTIVATION_CODES.includes(event.code) && isKeyboardFocusedControl(event.target)) return;
const action = KEY_MAP[event.code];
if (!action) return;
event.preventDefault();
diff --git a/client/src/main.ts b/client/src/main.ts
index 5db063f..7e525bb 100644
--- a/client/src/main.ts
+++ b/client/src/main.ts
@@ -1,3 +1,5 @@
+import { isScreenPreview, mountScreenPreview } from './preview/screens';
+
function isPublicStatsPath(path: string): boolean {
return path.replace(/\/+$/, '') === '/stats';
}
@@ -9,14 +11,15 @@ const previewMode = isLocal ? new URLSearchParams(window.location.search).get('p
const previewDescent = previewMode === 'descent';
const previewResult = previewMode === 'result';
const previewBoard = previewMode === 'board';
+const previewScreen = isScreenPreview(previewMode) ? previewMode : null;
// The public page is also a real document at /stats/. A dev server that was
// started before that document existed answers /stats with the game shell.
// Mount the metrics here so the All players link still opens them.
if (previewDescent) {
- const host = document.createElement('div');
- host.style.cssText = 'position:fixed;inset:0;background:#05060b';
- document.body.replaceChildren(host);
+ // Mounted in the real shell's overlay, as the game does.
+ const host = document.getElementById('overlay') ?? document.body;
+ host.hidden = false;
void Promise.all([import('./render/descent')]).then(([scene]) => {
scene.mountDescent(host, {
art: 'cinder.webp',
@@ -33,10 +36,9 @@ if (previewDescent) {
} else if (previewResult) {
// http://localhost:5173/?preview=result&hero=1&loss=1
const params = new URLSearchParams(window.location.search);
- const host = document.createElement('div');
- host.id = 'overlay';
- host.className = 'overlay';
- document.body.replaceChildren(host);
+ // Mounted in the real shell, so landmarks and styles match the game.
+ const host = document.getElementById('overlay') ?? document.body;
+ host.hidden = false;
void Promise.all([import('./render/result')]).then(([scene]) => {
const lost = params.get('loss') === '1';
scene.mountResult(host, {
@@ -62,6 +64,9 @@ if (previewDescent) {
});
}, 4500);
});
+} else if (previewScreen) {
+ // http://localhost:5173/?preview=vault or ?preview=leaderboard
+ mountScreenPreview(previewScreen);
} else if (previewBoard) {
// http://localhost:5173/?preview=board shows the three floors with no chain call.
void Promise.all([import('./render/canvas'), import('./game/state'), import('./game/floor'), import('./game/enemies')]).then(
diff --git a/client/src/preview/screens.ts b/client/src/preview/screens.ts
new file mode 100644
index 0000000..aec698a
--- /dev/null
+++ b/client/src/preview/screens.ts
@@ -0,0 +1,70 @@
+/**
+ * Sample screens for design review and the accessibility check. Local machine
+ * only (see main.ts): they need no wallet, no network and no run in progress.
+ *
+ * ?preview=vault a vault with two earned delvers
+ * ?preview=leaderboard a leaderboard with the player in second place
+ */
+import { leaderboardCard, vaultCard, type LeaderboardRow } from '../render/chrome';
+import type { Hero } from '../stellar/contract';
+
+const SAMPLE_PLAYER = 'GCDEEPMARKSAMPLEPLAYERADDRESSAAAAAAAAAAAAAAAAAAAAAAAAAAB';
+
+const SAMPLE_HEROES: Hero[] = [
+ {
+ id: 1,
+ owner: SAMPLE_PLAYER,
+ look: { name: 'Brannoch', emblem: 1, cloak: 2 },
+ base: 1,
+ weapon: 0,
+ charm: 1,
+ clears: 2,
+ minted_ledger: 1,
+ proof_score: 6,
+ proof_hp_left: 3,
+ proof_actions: 112,
+ },
+ {
+ id: 2,
+ owner: SAMPLE_PLAYER,
+ look: { name: 'Isolde', emblem: 3, cloak: 0 },
+ base: 0,
+ weapon: 1,
+ charm: 0,
+ clears: 1,
+ minted_ledger: 2,
+ proof_score: 4,
+ proof_hp_left: 1,
+ proof_actions: 140,
+ },
+];
+
+const SAMPLE_ROWS: LeaderboardRow[] = [
+ { address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', name: 'Corvin', emblem: 0, score: 7, floor: 3, hpLeft: 4, actions: 98 },
+ { address: SAMPLE_PLAYER, name: 'Brannoch', emblem: 1, score: 6, floor: 3, hpLeft: 3, actions: 112 },
+ { address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', name: 'Isolde', emblem: 3, score: 4, floor: 2, hpLeft: 0, actions: 131 },
+];
+
+export type ScreenPreview = 'vault' | 'leaderboard';
+
+export function isScreenPreview(value: string | null): value is ScreenPreview {
+ return value === 'vault' || value === 'leaderboard';
+}
+
+/** Mounts a sample card into the real shell's overlay so landmarks and styles match the game. */
+export function mountScreenPreview(kind: ScreenPreview): void {
+ const overlay = document.getElementById('overlay');
+ if (!overlay) return;
+ overlay.hidden = false;
+ overlay.replaceChildren(
+ kind === 'vault'
+ ? vaultCard({ vaultLoading: false, vaultHeroes: SAMPLE_HEROES, onVeteran: () => undefined })
+ : leaderboardCard({
+ boardTab: 'fresh',
+ leaderboardLoading: false,
+ leaderboardRows: SAMPLE_ROWS,
+ playerAddress: SAMPLE_PLAYER,
+ justVerified: false,
+ })
+ );
+}
diff --git a/client/src/render/bossIntro.ts b/client/src/render/bossIntro.ts
index 44d77bd..0c0d6f1 100644
--- a/client/src/render/bossIntro.ts
+++ b/client/src/render/bossIntro.ts
@@ -6,16 +6,14 @@
* reload mid fight, since the caller only invokes it from the `descended`
* event for the boss floor.
*/
+import { prefersReducedMotion } from './preferences';
+
const BOSS_ART = 'boss-intro.webp';
const DURATION_MS = 2400;
const SKIP_AFTER_MS = 800;
const EXIT_MS = 400;
const REDUCED_HOLD_MS = 1200;
-function reduced(): boolean {
- return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
-}
-
/** Warms the image cache so the cinematic never waits on the network. Call when floor 2 clears. */
export function preloadBossArt(): void {
const img = new Image();
@@ -82,7 +80,7 @@ export function playBossIntro(host: HTMLElement, name: string, onSting: () => vo
schedule(finish, EXIT_MS);
}
- if (reduced()) {
+ if (prefersReducedMotion()) {
scene.classList.add('is-reduced');
onSting();
scene.classList.add('is-revealed');
diff --git a/client/src/render/chrome.ts b/client/src/render/chrome.ts
index 407a663..126306f 100644
--- a/client/src/render/chrome.ts
+++ b/client/src/render/chrome.ts
@@ -77,6 +77,10 @@ export interface ChromeView {
onSeal: () => void;
}
+/** The slice of the view each card reads, so a card can be mounted on its own for previews and checks. */
+export type VaultView = Pick;
+export type LeaderboardView = Pick;
+
const TX_HASH = /^[a-f0-9]{64}$/;
function relicSummary(relics: RelicBalances | null): string {
@@ -128,6 +132,9 @@ function setBumpText(id: string, value: string): void {
node.classList.add('bump');
}
+/** The bar turns red at or below this share of max health. A low state is also spoken, so colour is not the only cue. */
+const LOW_HEALTH_RATIO = 0.34;
+
function paintHp(hp: number, max: number): void {
const ratio = max > 0 ? Math.max(0, Math.min(1, hp / max)) : 0;
const fill = document.getElementById('hp-fill');
@@ -136,12 +143,29 @@ function paintHp(hp: number, max: number): void {
const pct = `${(ratio * 100).toFixed(1)}%`;
if (fill) fill.style.width = pct;
if (ghost) ghost.style.width = pct;
- if (bar) bar.classList.toggle('is-low', ratio <= 0.34);
+ if (bar) {
+ bar.classList.toggle('is-low', ratio <= LOW_HEALTH_RATIO);
+ bar.setAttribute('aria-label', `Health ${hp} of ${max}${ratio <= LOW_HEALTH_RATIO ? ', low' : ''}`);
+ }
setText('hp-text', `${hp} / ${max}`);
}
let overlayKey = '';
+/** Screens a player leaves with Back or Escape. Returning to the title from one of these restores focus. */
+const PANEL_KEYS: readonly string[] = ['creator', 'vault', 'board', 'stats', 'result'];
+
+/**
+ * Puts focus on a panel's heading when it opens, so a keyboard or screen
+ * reader player lands on the new screen and Tab reaches its first control.
+ */
+function focusPanelHeading(overlay: HTMLElement): void {
+ const heading = overlay.querySelector('.panel-title');
+ if (!heading) return;
+ heading.tabIndex = -1;
+ heading.focus({ preventScroll: true });
+}
+
function renderOverlay(view: ChromeView): void {
const overlay = document.getElementById('overlay');
if (!overlay) return;
@@ -151,6 +175,7 @@ function renderOverlay(view: ChromeView): void {
if (key === 'title') updateTitle(titleInfo(view));
return;
}
+ const previousKey = overlayKey;
overlayKey = key;
overlay.replaceChildren();
@@ -169,18 +194,21 @@ function renderOverlay(view: ChromeView): void {
if (view.showVault) {
overlay.hidden = false;
overlay.append(vaultCard(view));
+ focusPanelHeading(overlay);
return;
}
if (view.showLeaderboard) {
overlay.hidden = false;
overlay.append(leaderboardCard(view));
+ focusPanelHeading(overlay);
return;
}
if (view.showStats) {
overlay.hidden = false;
overlay.append(statsCard(view));
+ focusPanelHeading(overlay);
return;
}
@@ -189,6 +217,9 @@ function renderOverlay(view: ChromeView): void {
const info = titleInfo(view);
if (overlay.querySelector('.title-v2')) updateTitle(info);
else mountTitle(overlay, info);
+ if (PANEL_KEYS.some((panel) => previousKey.startsWith(panel))) {
+ overlay.querySelector('.menu-item.is-primary')?.focus({ preventScroll: true });
+ }
return;
}
@@ -259,7 +290,7 @@ function titleInfo(view: ChromeView): TitleInfo {
return { local, chain, vault: held ? `Relics in your vault: ${held}` : '' };
}
-function leaderboardCard(view: ChromeView): HTMLElement {
+export function leaderboardCard(view: LeaderboardView): HTMLElement {
const card = el('div', 'panel panel-scroll');
card.append(el('h2', 'panel-title', 'Leaderboard'));
card.append(
@@ -316,7 +347,7 @@ function leaderboardCard(view: ChromeView): HTMLElement {
return card;
}
-function vaultCard(view: ChromeView): HTMLElement {
+export function vaultCard(view: VaultView): HTMLElement {
const wrap = el('div', 'vault-view');
wrap.append(el('div', 'vault-backdrop'));
@@ -349,7 +380,7 @@ function vaultCard(view: ChromeView): HTMLElement {
return wrap;
}
-function heroCard(hero: Hero, view: ChromeView): HTMLElement {
+function heroCard(hero: Hero, view: VaultView): HTMLElement {
const item = el('article', 'vault-card');
const baseId = HERO_ORDER[hero.base] ?? 'lantern';
const portrait = el('img', 'vault-portrait');
@@ -548,7 +579,7 @@ function choiceCard(run: RunState, onDescendHover: (hovering: boolean) => void):
const icon = el('img', 'choice-relic-icon');
icon.src = `/art/relic-${kind}.webp`;
icon.alt = '';
- chip.append(icon, el('span', '', `×${run.relicsByKind[kind]}`));
+ chip.append(icon, el('span', 'sr-only', `${kind} relics `), el('span', '', `×${run.relicsByKind[kind]}`));
relicRow.append(chip);
}
stake.append(relicRow);
diff --git a/client/src/render/descent.ts b/client/src/render/descent.ts
index 721938b..422a4e6 100644
--- a/client/src/render/descent.ts
+++ b/client/src/render/descent.ts
@@ -6,6 +6,7 @@
* dark. Restraint is the point: nothing here tries to look like a loading bar.
*/
import { emblemIcon } from './emblems';
+import { prefersReducedMotion } from './preferences';
export interface DescentOptions {
/** Portrait file under /art, for the medallion. */
@@ -48,10 +49,6 @@ export function setDescentStage(stage: DescentStage): void {
setStageRef?.(stage);
}
-function reducedMotion(): boolean {
- return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
-}
-
function el(tag: K, className: string, text?: string): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
@@ -91,7 +88,7 @@ interface Ember {
export function mountDescent(host: HTMLElement, options: DescentOptions): void {
host.replaceChildren();
- const calm = reducedMotion();
+ const calm = prefersReducedMotion();
const root = el('div', 'descent');
const art = el('div', 'descent-art');
diff --git a/client/src/render/effects.ts b/client/src/render/effects.ts
index c48d63a..0233bb2 100644
--- a/client/src/render/effects.ts
+++ b/client/src/render/effects.ts
@@ -1,5 +1,6 @@
import { now as clockNow } from './clock';
import type { RelicKind } from '../game/floor';
+import { prefersReducedMotion } from './preferences';
interface DamagePopup {
x: number;
@@ -24,7 +25,9 @@ export function spawnPopup(x: number, y: number, text: string, color: string): v
popups.push({ x, y, text, color, start: clockNow() });
}
+/** Screen shake is skipped when the player asks for reduced motion. */
export function triggerShake(duration = 150, magnitude = 6): void {
+ if (prefersReducedMotion()) return;
shake = { start: clockNow(), duration, magnitude };
}
@@ -152,7 +155,8 @@ export function drawLowHealth(ctx: CanvasRenderingContext2D, ratio: number): voi
const w = ctx.canvas.width;
const h = ctx.canvas.height;
const urgency = 1 - ratio / 0.34;
- const beat = Math.pow(Math.max(0, Math.sin(clockNow() / 1000 * (3.2 + urgency * 2))), 6);
+ // A steady tint instead of a heartbeat when the player asks for reduced motion.
+ const beat = prefersReducedMotion() ? 1 : Math.pow(Math.max(0, Math.sin(clockNow() / 1000 * (3.2 + urgency * 2))), 6);
const alpha = (0.1 + urgency * 0.28) * (0.45 + 0.55 * beat);
const g = ctx.createRadialGradient(w / 2, h / 2, h * 0.32, w / 2, h / 2, h * 0.8);
g.addColorStop(0, 'rgba(255, 60, 50, 0)');
diff --git a/client/src/render/preferences.ts b/client/src/render/preferences.ts
new file mode 100644
index 0000000..bfba85d
--- /dev/null
+++ b/client/src/render/preferences.ts
@@ -0,0 +1,9 @@
+/** Player preferences read from the browser, in one place so every scene agrees. */
+
+// One live query for the page. `matches` follows the setting without another lookup,
+// which matters because the render loop asks every frame.
+const reducedMotionQuery = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
+
+export function prefersReducedMotion(): boolean {
+ return reducedMotionQuery?.matches ?? false;
+}
diff --git a/client/src/render/result.ts b/client/src/render/result.ts
index 42ecc5c..46755dc 100644
--- a/client/src/render/result.ts
+++ b/client/src/render/result.ts
@@ -9,6 +9,7 @@
* once per run and only calls `updateVerification` afterwards.
*/
import { emblemIcon } from './emblems';
+import { prefersReducedMotion } from './preferences';
export interface ResultSummary {
won: boolean;
@@ -46,12 +47,8 @@ function el(tag: K, className: string, te
return node;
}
-function reduced(): boolean {
- return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
-}
-
function countUp(node: HTMLElement, to: number, suffix = '', delay = 0): void {
- if (reduced() || to <= 0) {
+ if (prefersReducedMotion() || to <= 0) {
node.textContent = `${to}${suffix}`;
return;
}
@@ -130,7 +127,7 @@ const NUDGE_KEYFRAMES: Keyframe[] = [
/** Strikes the seal: sound and the three motions all start on this one call. */
function strikeSeal(card: HTMLElement, seal: HTMLElement, dust: HTMLElement, onSeal?: () => void): void {
onSeal?.();
- if (reduced()) {
+ if (prefersReducedMotion()) {
seal.style.opacity = '1';
seal.style.transform = 'rotate(-4deg)';
dust.style.opacity = '0';
@@ -152,10 +149,8 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
host.replaceChildren();
const card = el('div', summary.won ? 'panel result result-win' : 'panel result result-loss');
- card.append(
- el('p', 'result-eyebrow', summary.won ? 'The depths let you go' : 'The depths keep what you carried'),
- el('h2', 'result-title', summary.won ? 'Extracted' : 'Lost in the dark')
- );
+ const title = el('h2', 'result-title', summary.won ? 'Extracted' : 'Lost in the dark');
+ card.append(el('p', 'result-eyebrow', summary.won ? 'The depths let you go' : 'The depths keep what you carried'), title);
const stats = el('div', 'result-stats');
stats.append(
@@ -248,6 +243,9 @@ export function mountResult(host: HTMLElement, summary: ResultSummary, onSeal?:
card.append(actions);
host.append(card);
+ // Focus lands on the outcome, so a screen reader reads it and Tab reaches the buttons below.
+ title.tabIndex = -1;
+ title.focus({ preventScroll: true });
let last = '';
let sealed = false;
diff --git a/client/src/render/title.ts b/client/src/render/title.ts
index 7062ce9..47295db 100644
--- a/client/src/render/title.ts
+++ b/client/src/render/title.ts
@@ -6,6 +6,7 @@
* Stellar updates text in place and never replays the entrance.
*/
import { startEmbers } from './embers';
+import { prefersReducedMotion } from './preferences';
export interface TitleInfo {
local: string;
@@ -43,7 +44,7 @@ export function updateTitle(info: TitleInfo): void {
export function mountTitle(host: HTMLElement, info: TitleInfo): void {
host.replaceChildren();
- const calm = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const calm = prefersReducedMotion();
const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
const root = el('div', 'title-screen title-v2');
@@ -61,7 +62,6 @@ export function mountTitle(host: HTMLElement, info: TitleInfo): void {
stage.append(art, light, fog, embers, shade);
const content = el('div', 'title-content');
- const heading = el('h1', 'sr-only', 'Deepmark');
const logo = el('img', 'title-logo');
logo.src = '/art/logo-lockup.webp';
logo.alt = '';
@@ -82,7 +82,7 @@ export function mountTitle(host: HTMLElement, info: TitleInfo): void {
const hint = el('p', 'title-hint', 'Enter forges a delver. V the vault. L the leaderboard. M your record. N sound.');
const players = el('a', 'quiet-link', 'All players');
players.href = '/stats';
- content.append(heading, logo, tagline, menu, infoLocal, infoChain, infoVault, hint, players);
+ content.append(logo, tagline, menu, infoLocal, infoChain, infoVault, hint, players);
root.append(stage, content);
host.append(root);
diff --git a/client/src/styles.css b/client/src/styles.css
index 0d7a2fe..85ccacc 100644
--- a/client/src/styles.css
+++ b/client/src/styles.css
@@ -3,6 +3,7 @@
--bg: #07080e;
--bg-raise: rgba(12, 16, 26, 0.82);
--line: rgba(232, 224, 208, 0.16);
+ --line-control: rgba(232, 224, 208, 0.55);
--text: #f4efe6;
--muted: #a3988c;
--cyan: #7ef0e0;
@@ -50,6 +51,7 @@ button, input { font: inherit; }
.brand {
margin: 0;
+ font-weight: 400;
font-family: Palatino, "Iowan Old Style", Georgia, serif;
font-size: 1.05rem;
letter-spacing: 0.14em;
@@ -397,7 +399,6 @@ body:has(#overlay:not([hidden])) .touch {
display: flex;
align-items: center;
gap: 12px;
- padding: 4px 0;
}
.volume-label {
@@ -409,9 +410,12 @@ body:has(#overlay:not([hidden])) .touch {
.volume-input {
flex: 1;
+ min-height: 44px;
accent-color: var(--cyan);
}
+.volume-input:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; }
+
.board-head, .board-row {
display: grid;
grid-template-columns: 24px 1fr 40px 40px 30px 46px;
@@ -578,7 +582,7 @@ body:has(#overlay:not([hidden])) .touch {
min-height: 44px;
padding: 0 14px;
border-radius: 12px;
- border: 1px solid var(--line);
+ border: 1px solid var(--line-control);
background: rgba(8, 10, 16, 0.7);
color: var(--text);
}
@@ -600,7 +604,7 @@ body:has(#overlay:not([hidden])) .touch {
display: grid;
place-items: center;
border-radius: 12px;
- border: 1px solid var(--line);
+ border: 1px solid var(--line-control);
background: rgba(8, 10, 16, 0.55);
color: var(--text);
cursor: pointer;
@@ -741,7 +745,7 @@ body:has(#overlay:not([hidden])) .touch {
* { scroll-behavior: auto !important; }
}
-.btn-mini { min-height: 32px; padding: 0 12px; font-size: 0.78rem; }
+.btn-mini { min-height: 44px; padding: 0 12px; font-size: 0.78rem; }
.topbar { align-items: center; }
/* The preview stays in view while choosing, and the action row stays reachable. */
@@ -897,7 +901,7 @@ body:has(#overlay:not([hidden])) .touch {
list-style: none;
}
-.descent-step { display: flex; align-items: center; gap: 7px; color: rgba(163, 152, 140, 0.55); font-size: 0.7rem; letter-spacing: 0.16em; text-transform: uppercase; }
+.descent-step { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 0.7rem; letter-spacing: 0.16em; text-transform: uppercase; }
.descent-step-dot { width: 7px; height: 7px; border-radius: 50%; border: 1px solid currentColor; }
.descent-step.is-active { color: var(--cyan); }
.descent-step.is-active .descent-step-dot { background: var(--cyan); box-shadow: 0 0 10px var(--cyan); animation: descent-pulse 1.4s ease-in-out infinite; }
@@ -939,6 +943,7 @@ body:has(#overlay:not([hidden])) .touch {
@media (prefers-reduced-motion: reduce) {
.descent, .descent-art, .descent-light { animation: none; }
.descent-tip { transition: none; }
+ .descent-step.is-active .descent-step-dot { animation: none; }
.descent.is-diving .descent-art { animation: none; }
}
@@ -1188,6 +1193,8 @@ body:has(#overlay:not([hidden])) .touch {
.result [hidden] { display: none !important; }
+.panel-title:focus, .result-title:focus { outline: none; }
+
.sr-only {
position: absolute;
width: 1px;
diff --git a/contracts/deepmark-contract/Cargo.toml b/contracts/deepmark-contract/Cargo.toml
index 03d3c4c..80f2269 100644
--- a/contracts/deepmark-contract/Cargo.toml
+++ b/contracts/deepmark-contract/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
publish = false
description = "records Deepmark run results and a leaderboard on Soroban"
-license = "MIT OR Apache-2.0"
+license = "Apache-2.0"
[lib]
crate-type = ["cdylib", "rlib"]
diff --git a/docs/accessibility.md b/docs/accessibility.md
new file mode 100644
index 0000000..c83ef0f
--- /dev/null
+++ b/docs/accessibility.md
@@ -0,0 +1,134 @@
+# Accessibility
+
+What Deepmark does for keyboard, screen reader, low vision, reduced motion and touch players, how it is checked, and what it does not cover yet.
+
+## How it is checked
+
+```bash
+cd client
+bun run build
+bun run preview --port 4173 &
+bun run contrast # contrast ratios from the stylesheet tokens
+bun run a11y # axe, target sizes, reduced motion and a keyboard only run
+```
+
+`.github/workflows/a11y.yml` runs both on every change to `client/` and uploads the JSON report. The job fails on any serious or critical axe violation, any control under 44 by 44 CSS pixels, any animation still looping with reduced motion on, any contrast pair under its threshold, or a broken keyboard flow.
+
+`bun run a11y` drives a real headless Chrome through eleven screens in three passes (desktop, a 390 by 844 touch phone, and desktop with `prefers-reduced-motion: reduce`), then a keyboard only pass:
+
+| Screen | Source |
+|---|---|
+| Title, creator, vault (offline), leaderboard (offline), record, board in play | The real game with Stellar blocked, so it runs offline in practice mode |
+| Vault with two delvers, leaderboard with ranked rows | `?preview=vault` and `?preview=leaderboard`, local only, sample data |
+| Descent scene, cleared result, lost result | `?preview=descent` and `?preview=result`, local only |
+
+axe-core 4.14.0 runs the WCAG 2.0, 2.1 and 2.2 A and AA rules plus the best practice rules. Serious and critical findings fail the run. Moderate and minor ones are printed and do not.
+
+Two things the automation cannot judge: axe cannot compute contrast for text over painted art, which is why the contrast table below is computed from the stylesheet tokens instead, and nothing here replaces a pass with a real screen reader. See the limits at the end.
+
+## Keyboard walkthrough of a full run
+
+Every step below is also what the keyboard only pass in `bun run a11y` exercises up to the first move.
+
+| Step | Keys | What happens |
+|---|---|---|
+| Start | Tab | First stop is Sound on or off, then the menu: Forge a delver, Vault, Leaderboard, Your record, then the All players link |
+| Shortcuts | L, V, M, N | Leaderboard, vault, your record, sound. Work from the title and from any screen that is not a text field |
+| Open a screen | Enter on a focused menu item | Does what the label says. Focus moves to the screen's heading, so Tab reaches its first control |
+| Leave a screen | Escape or the Back button | Returns to the title with focus on Forge a delver |
+| Forge | Enter on Forge a delver | The creator opens with focus in the name field |
+| Create | Tab, Space or Enter | Another name, six emblems, the cloak colours, two delvers, weapons, charms, then Begin descent and Back. Each choice reports pressed or not pressed |
+| Descend | Enter in the name field, or Enter on Begin descent | The descent scene plays and announces its steps. Escape in the creator goes back instead |
+| Move | Arrow keys or W, A, S, D | One tile per press. Walking onto a relic takes it. Walking into a guard opens the fight prompt |
+| Fight | Enter attacks, Escape retreats | Space also attacks. Each exchange is written to the run log and announced |
+| Clear a floor | Enter descends, Escape extracts | The prompt shows hit points, relics held and the guardians ahead |
+| Sound | N | Mutes and unmutes at any time |
+| Result | Tab | Focus starts on the outcome heading, which is read aloud. Next: View the transaction, Open the vault when a delver was earned, Save image, Copy image and Copy challenge link after a verified win, Try again if verification failed, Title screen |
+| Finish | Enter on Title screen, or Enter with nothing focused | Back to the title |
+
+Enter and Space on a control that was reached with Tab activate that control. A control that was only clicked does not keep them, so a player who clicked Sound and went on playing still gets Enter as attack.
+
+## Structure and announcements
+
+| Part | Semantics |
+|---|---|
+| Page | One `h1`, Deepmark. Each screen is an `h2`. Landmarks: header, main, a Run status region, a Touch controls navigation, a Main menu navigation |
+| Board | The canvas has an accessible name that says how to move and where events are announced |
+| Floor label | `role="status"`, so a change of floor or screen is read |
+| Run log | `role="log"`. Every fight start, hit, relic taken, floor cleared, retreat and defeat is written there as a sentence |
+| Health | The bar is an image with a spoken value, for example `Health 3 of 5, low`. The low state is spoken, not only coloured |
+| Relics held | Each chip carries the relic kind as text for screen readers and as a distinct icon |
+| Hints | The first run hint card is `role="status"` |
+| Verification | The result ribbon's story line is `role="status"`, so pending, sealed and failed states are announced |
+| Dialog focus | Vault, leaderboard, record and result move focus to their heading on open. The creator focuses the name field |
+
+## Contrast
+
+Ratios are computed by `client/scripts/contrast.ts` from the tokens in `client/src/styles.css`, so this table cannot drift from the design. Translucent panels are composited over what they really sit on: the opaque overlay for panels, and the board under the dimming gradient for the combat and floor prompts, assuming the lightest possible board pixel. Text needs 4.5, and the edges of controls and the focus ring need 3 (WCAG 1.4.3 and 1.4.11).
+
+| Use | Foreground | Surface | Ratio | Needs | Result |
+|---|---|---|---|---|---|
+| Body text on the page | `--text` | `--bg` | 17.46 | 4.5 | pass |
+| Body text on a panel | `--text` | `--bg-raise` | 16.77 | 4.5 | pass |
+| Muted text on the page | `--muted` | `--bg` | 7.07 | 4.5 | pass |
+| Muted text on a panel | `--muted` | `--bg-raise` | 6.79 | 4.5 | pass |
+| Cyan accent on the page | `--cyan` | `--bg` | 14.69 | 4.5 | pass |
+| Cyan accent on a panel | `--cyan` | `--bg-raise` | 14.11 | 4.5 | pass |
+| Primary button label | `--cyan` | `--cyan-deep` | 8.54 | 4.5 | pass |
+| Gold accent on the page | `--gold` | `--bg` | 11.82 | 4.5 | pass |
+| Gold accent on a panel | `--gold` | `--bg-raise` | 11.35 | 4.5 | pass |
+| Danger text on the page | `--danger` | `--bg` | 7.14 | 4.5 | pass |
+| Danger text on a panel | `--danger` | `--bg-raise` | 6.86 | 4.5 | pass |
+| Body text on a prompt over the board | `--text` | `--bg-raise` | 12.66 | 4.5 | pass |
+| Muted text on a prompt over the board | `--muted` | `--bg-raise` | 5.13 | 4.5 | pass |
+| Cyan accent on a prompt over the board | `--cyan` | `--bg-raise` | 10.65 | 4.5 | pass |
+| Danger text on a prompt over the board | `--danger` | `--bg-raise` | 5.18 | 4.5 | pass |
+| Safe text on the page | `--safe` | `--bg` | 12.74 | 4.5 | pass |
+| Boss name | `#d8c8ff` | `--bg` | 12.98 | 4.5 | pass |
+| Cleared title | `#f7e9c0` | `--bg` | 16.54 | 4.5 | pass |
+| Lost title | `#e9b9b3` | `--bg` | 11.48 | 4.5 | pass |
+| Focus ring on the page | `--cyan` | `--bg` | 14.69 | 3 | pass |
+| Focus ring on a panel | `--cyan` | `--bg-raise` | 14.11 | 3 | pass |
+| Control edge (input and button border) | `--line-control` | `--bg-raise` | 5.00 | 3 | pass |
+
+The control edge uses its own token, `--line-control`. The decorative `--line` is used for panel borders and dividers, which carry no meaning. The first accessibility pass raised input and chip borders from `--line` (about 1.4) to `--line-control`, and changed the not yet reached descent steps from a 55 percent tint of the muted colour (2.8) to the muted colour.
+
+There are no exceptions to list. Text drawn on the canvas (damage popups) is short lived, duplicated in the run log, and not counted.
+
+## Motion
+
+With `prefers-reduced-motion: reduce`:
+
+| Area | Behaviour |
+|---|---|
+| Title | No parallax, no embers. The lantern light still flickers in opacity only |
+| Descent, boss intro, result | The scenes hold still. The seal appears in place and the counts show their final values |
+| Screen shake on hits | Off |
+| Low health | A steady red tint instead of a heartbeat |
+| Looping CSS animations | None. The check fails if any keeps running |
+| Hit flash on a struck actor | Kept. It is 120 ms, once, and is the only visual cue that a blow landed besides the log |
+
+One shared `prefersReducedMotion()` in `client/src/render/preferences.ts` answers for every scene.
+
+## Not colour alone
+
+| Meaning | Colour | Second cue |
+|---|---|---|
+| Health | Red when low | The numbers, a spoken value, and the word low in the spoken label |
+| Relic rarity | Tint of the relic | A different silhouette for each: a clawed amber egg, a cut crystal, a stone cradling a gold core, and the kind as text |
+| Floor and wall | Light and dark stone | The Open and Blocked legend in the run status bar, and a seam between them |
+| Selected item in the creator | Cyan edge | `aria-pressed`, and an outline offset from the edge |
+| Your row on the leaderboard | Glow | The text (you) after the name |
+| Veteran rank | Filled pips | The words Veteran rank and the number |
+| Practice run | Gold label | The word Practice in the floor label and the run status |
+
+## Touch targets
+
+Every button, link and field is at least 44 by 44 CSS pixels on desktop and on a 390 by 844 phone, and the check fails otherwise. The sound toggle and the volume sliders were below that and were raised.
+
+## Limits
+
+- **The board is a canvas.** A screen reader hears the run log and the health value, not the layout. Where a guard or a relic stands is not exposed, so the game is not fully playable without sight today. A textual description of the nearest tiles is the next step.
+- **No assistive technology pass.** The checks above are automated, plus a scripted keyboard run. Nobody has yet driven the game with VoiceOver, NVDA or TalkBack, and the announcements are untested in a real reader.
+- **No text scaling or high contrast mode test.** The layout is responsive to the viewport but has not been checked at 200 percent text size or in forced colours.
+- **Colour blindness** is covered by the second cues above. It has not been simulated.
diff --git a/docs/architecture/nft-standard.md b/docs/architecture/nft-standard.md
new file mode 100644
index 0000000..b43ec77
--- /dev/null
+++ b/docs/architecture/nft-standard.md
@@ -0,0 +1,58 @@
+# Delvers and the Stellar NFT standard
+
+Research note, written on 2026-10-10. It compares the delver entry points of the game contract with the current Stellar standard for non-fungible tokens and says what would be needed to comply. It does not claim explorer support, and the last section says what was and was not checked.
+
+## What was consulted
+
+| Source | Version and date |
+|---|---|
+| SEP-0050, Non-Fungible Tokens | Status Draft, version 0.1.0, created and updated 2025-03-10. The file was last changed in `stellar/stellar-protocol` on 2025-06-17 (commit `d4d17e6`) |
+| OpenZeppelin `stellar-contracts`, `non_fungible` module, `NonFungibleToken` trait | Release `v0.7.2`, 2026-06-09 (commit `a9c4216`). The newest release, `v0.8.0-rc.3` of 2026-06-16, has a byte for byte identical `non_fungible/mod.rs` |
+| Deployed game contract interface | Read from testnet with `stellar contract info interface` for the contract that was live when this note was written (`CDL7OO7MGCNMHZBF5QPJCMBLLY3ORR44TJ54ON44M25YUX6UZ7L46UZW`, since replaced, see the README for the current id) |
+
+SEP-0050 is a draft authored by OpenZeppelin, and the OpenZeppelin module is its reference implementation, so the two are compared together. The standard also notes a classic alternative, SEP-0039 plus a Stellar Asset Contract, and the reasons it is a poor fit (one asset per token, or fungible tokens inside one asset). Delvers carry a look, a kit and an on-chain proof, which that route cannot hold.
+
+## Where the delver interface matches and where it differs
+
+| Standard (`NonFungibleToken`, token id `u32`) | Game contract today | Verdict |
+|---|---|---|
+| `balance(account) -> u32` | `balance(owner) -> u32` | Matches |
+| `owner_of(token_id) -> Address`, fails with `NonExistentToken` (200) | `owner_of(id) -> Option` | Differs. Returns `None` for an unknown id instead of failing |
+| `transfer(from, to, token_id)`, fails with `IncorrectOwner` (201) or `NonExistentToken` (200) | `transfer(from, to, id) -> Result<(), Error>`, fails with `NotHeroOwner` (11) or `HeroNotFound` (10) | Same call shape, different errors |
+| `transfer_from(spender, from, to, token_id)` | none | Missing |
+| `approve`, `approve_for_all`, `get_approved`, `is_approved_for_all` | none | Missing |
+| `name`, `symbol`, `token_uri(token_id)` | none | Missing. A delver's metadata is readable through `hero(id)`, which returns the look, kit and the proof of the first clear, but not through the standard calls |
+| Event `transfer`, topics `["transfer", from, to]`, data `token_id` | `hero_transfer`, topic `from`, data `to` and `hero_id` | Differs. An indexer that listens for the standard event does not see delver transfers |
+| Event `mint`, topics `["mint", to]`, data `token_id` | `hero_earned`, topic `player`, data `hero_id` and `name` | Differs |
+| No cap on tokens an account can hold | `MAX_HEROES_PER_OWNER` is 64, and `transfer` refuses a recipient already at the cap with `InvalidHero` (1) | Deviation. It keeps each owner's index bounded, and it lets someone fill another player's slots, see `docs/threat-model.md` |
+| Optional extensions: enumerable, burnable, consecutive, royalties | `heroes_of(owner)` and `hero_count()` play the part of enumeration. Nothing burns | Not required |
+
+The token id type, `u32`, is the same, and ids are sequential from zero, as in the OpenZeppelin sequential extension.
+
+So the shape follows the standard for ownership and transfer, and does not comply with it. An explorer or indexer that discovers tokens through the standard calls and events would find no name, no symbol, no URI, and no events it knows.
+
+## Two ways to comply
+
+**A. Implement the trait in the game contract.** Add the missing calls and events, make `owner_of` fail on an unknown id, and add `name`, `symbol` and `token_uri`. The contract stays the single registry, so a delver is still minted only inside `finish_run`, after the replay. The cost is one dependency and one design decision:
+
+- `stellar-tokens` from `v0.7.2` requires `soroban-sdk` 26.1.0, and the contract is on 25.1.0. `v0.7.0` pins 25.3.0, which satisfies the current requirement. Its trait would need the same line by line check as above before relying on it.
+- `token_uri` has to return something an explorer can fetch. A static client cannot serve one JSON file per delver. Either a small metadata endpoint is added, which is new infrastructure for a game that has none, or the contract builds a `data:` URI from the stored delver, which needs a size check and a check against an explorer.
+
+**B. A separate collection contract that the game mints through.** The game contract would call a token contract that is a standard NFT. That keeps the game contract small, but the token contract needs an owner or minter role to authorise the game contract, which brings back the admin key the design deliberately does not have, and a second contract to audit.
+
+**Recommendation: A**, done together with the release deployment tracked in the mainnet readiness issue. The contract now has an admin and an upgrade path, so adding the interface no longer forces a redeploy, as long as the storage layout stays readable. A redeploy would still need a fresh relic issuer because a Stellar Asset Contract admin cannot be reassigned. Delvers earned on an earlier deployment do not migrate.
+
+Until then the README says what is true: delvers have an ownership and transfer interface shaped like the standard, and they are not standard tokens.
+
+## What was and was not checked
+
+Checked, from public sources on 2026-10-10:
+
+- The standard's call list, error codes and events against the game contract's source, and the deployed contract's interface against both.
+- The deployed contract exposes `balance`, `owner_of`, `transfer`, `heroes_of`, `hero` and `hero_count` for delvers, and none of `name`, `symbol`, `token_uri`, `transfer_from` or the approval calls.
+
+Not checked, and why:
+
+- **Explorers.** No explorer was loaded with a delver. Players never need one, because the game creates and holds their account for them, so this only matters for anyone who wants to inspect a delver from outside. Whether `stellar.expert` or another explorer renders a Soroban contract that follows the standard is not documented in the sources above, and it is not claimed here.
+
+This stays open on the issue. The acceptance criterion about a link showing a delver in an explorer is therefore not met, and the honest statement for now is: not supported, because the contract does not expose the metadata calls and events an explorer looks for, and nothing has been tested against one.
diff --git a/docs/performance.md b/docs/performance.md
index ff42571..03c59cb 100644
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -8,9 +8,12 @@ Measured on the production build, in a real headless Chrome driven over CDP, so
cd client
bun run build
bun run preview --port 4173 &
-bun run perf # optionally: bun run perf http://127.0.0.1:4173/
+bun run perf # optionally: bun run perf http://localhost:4173/
+bun run perf --json > perf.json
```
+The harness exits with code 1 when a budget below is exceeded. With `--json` the report goes to stdout and the readable log to stderr, so the file stays parseable. Set `CHROME_PATH` to use a Chrome other than the default macOS install.
+
- **Cold load:** cache disabled, one navigation. Reports bytes by type, first contentful paint, largest contentful paint, cumulative layout shift, long tasks, and the time until the title is usable.
- **Frames:** a practice run with the Stellar hosts blocked, so it is independent of the network. The script walks into the guard, fights and wanders for 12 seconds while sampling every frame, once at normal CPU and once with the CPU throttled 4 times.
- **Environment:** an Apple silicon laptop, Chrome 1280 by 800 at device pixel ratio 1, local preview server. Network latency is not simulated, so absolute load times on a real connection are higher. Use the byte counts for that.
@@ -40,14 +43,22 @@ bun run perf # optionally: bun run perf http://127.0.0.1:4173/
## Budgets to keep
-| Item | Budget |
-|---|---|
-| Cold load transfer | under 1.2 MB |
-| JavaScript before the title | under 60 KB gzip |
-| Largest contentful paint (local) | under 200 ms |
-| Cumulative layout shift | 0.05 or less |
-| Frame time, 99th percentile, 4x CPU throttle | under 20 ms |
-| Long tasks during load | none over 50 ms |
+The numbers live in `client/scripts/budgets.ts`, which the harness imports, so the code and this table cannot disagree.
+
+| Item | Budget | Enforced in CI |
+|---|---|---|
+| Cold load transfer | under 1.2 MB | yes |
+| Cumulative layout shift | 0.05 or less | yes |
+| Long tasks during load | none over 50 ms (200 ms in the CI profile) | yes |
+| Frame time, 99th percentile, 4x CPU throttle | under 20 ms (measured at 1x in the CI profile) | yes |
+| JavaScript before the title | under 60 KB gzip | no, read from the build output |
+| Largest contentful paint (local) | under 200 ms | no, too noisy on shared runners |
+
+## Continuous integration
+
+The harness has two profiles, chosen with `--profile=laptop` (the default) or `--profile=ci`. Bytes and layout shift are the same in both. Timing is not: the first run on a GitHub runner measured a 49.9 ms frame at 4x throttle and a 126 ms task during load, for a build that holds 16.8 ms and no long task on the laptop, because the runner has no GPU and a CPU several times slower than the reference machine. The `ci` profile therefore samples frames at 1x, which is already a slow machine, allows a 200 ms task during load, and judges the 95th percentile frame time instead of the 99th, because a shared runner produced 4 frames over 33 ms in a run of 705 while the 95th stayed at 16.8 ms. The budget is 20 ms in both profiles. Both sets of numbers are in `client/scripts/budgets.ts`.
+
+`.github/workflows/perf.yml` builds the client, serves the production preview, runs the harness in a pinned Chrome and fails the check when a budget is exceeded. The failure names the metric, the measured value and the limit. The full report is uploaded as the `perf-report` artifact and summarised on the run page, so two runs can be compared. A pull request that adds a 2 MB image fails with `Cold load transfer is 3091 KB, budget is 1200 KB`.
## Known limits
diff --git a/docs/submission-checklist.md b/docs/submission-checklist.md
new file mode 100644
index 0000000..cbb856e
--- /dev/null
+++ b/docs/submission-checklist.md
@@ -0,0 +1,60 @@
+# Submission checklist
+
+Status as of 2026-10-10. "Engineering" items are done in the repository and can be checked there. "Owner" items need the project owner's accounts, devices or a decision, and are left open on purpose. A line marked done says how it was checked.
+
+## Confirm first
+
+| Item | Owner | Status |
+|---|---|---|
+| Confirm the submission deadline. `CLAUDE.md` gives 2026-10-05 16:00 Costa Rica time, and this checklist was written on 2026-10-10 | Owner | Open |
+
+## Repository and public text
+
+| Item | Owner | Status |
+|---|---|---|
+| README that gets a stranger playing in a minute, with contract and asset ids from the code | Engineering | Done, `README.md` |
+| Threat model with the attacks considered, what stops each, and what remains | Engineering | Done, `docs/threat-model.md` |
+| Every README claim traceable to code, a test or an id on the explorer | Engineering | Done for this version. Re-read after any later edit |
+| No em dash in public text | Engineering | Done for the working tree: the only occurrence is the rule quoted in `CLAUDE.md`. Git history contains older ones (9 outside `.claude/` on 2026-10-10) and has not been rewritten |
+| No AI sounding filler in public text | Owner | Open. Final read of README and docs by a person |
+| Licence file | Engineering | Done. Apache 2.0 in `LICENSE`, and the contract crate declares `Apache-2.0` |
+| `.claude/` toolkit files in the tree | Owner | Open. 169 tracked files of third party skills. Decide whether they belong in the public repository. History also holds a merge titled `add-toolkit-claude` |
+| Dependency licence audit | Owner | Open |
+| Live link in the README | Owner | Open. The game has no public deployment yet, and the repository is private |
+
+## Secrets and visibility
+
+| Item | Owner | Status |
+|---|---|---|
+| Secrets scan of the full history | Engineering | Done 2026-10-10 with gitleaks 8.30.1: 59 commits, no leaks. A second pattern search for Stellar secret keys (`S` followed by 55 base32 characters) over all revisions found none. Run both again at the freeze |
+| Secrets scan again at the T-1 freeze, on the final history | Owner | Open |
+| Visibility flip, after the freeze audit | Owner | Open. The repository is private |
+
+## Contract
+
+| Item | Owner | Status |
+|---|---|---|
+| `cargo test` | Engineering | Done 2026-10-11: 71 tests and 600 golden vectors pass |
+| Golden vectors current against the client | Engineering | Checked by the client workflow on every change |
+| Testnet deployment, ids and transactions published | Engineering | Done, in the README and `docs/deployments.md` |
+| Issuer account locked | Engineering | Done on the 2026-10-11 testnet deployment: one signer with weight 0, and a transaction signed with its key is rejected |
+| Admin renounced or moved to a multisig | Owner | Open. The testnet admin is a single key and can upgrade the contract. Decide before mainnet and before the final submission |
+| Mainnet or an explicit statement that there is none | Owner | Open. Needs a funded deployer and presence for the transactions |
+| Standard NFT interface or an explicit list of deviations | Engineering | Done as a list of deviations, `docs/architecture/nft-standard.md` |
+| A delver visible in an explorer | Engineering | Open. The contract does not yet expose the metadata calls and events an explorer looks for |
+
+## Client quality
+
+| Item | Owner | Status |
+|---|---|---|
+| Typecheck and build | Engineering | Checked by the client workflow |
+| Performance budgets enforced | Engineering | Done, `.github/workflows/perf.yml` and `docs/performance.md` |
+| Accessibility checks and the manual keyboard walkthrough | Engineering | Done, `.github/workflows/a11y.yml` and `docs/accessibility.md`. No screen reader pass yet |
+| Clean browser verification on a laptop | Owner | Open. A fresh profile, from the title to a verified result |
+| Clean browser verification on a phone, iOS Safari and a mid range Android | Owner | Open. Needs the devices |
+
+## Media
+
+| Item | Owner | Status |
+|---|---|---|
+| Trailer, 30 and 90 second cuts, and README media | Owner | Open. Tracked in its own issue |
diff --git a/docs/threat-model.md b/docs/threat-model.md
new file mode 100644
index 0000000..9c0bf01
--- /dev/null
+++ b/docs/threat-model.md
@@ -0,0 +1,88 @@
+# Threat model
+
+The attacks considered against the Deepmark contract, what stops each one, and what is left. Every "stops" entry names the code path and the test that covers it, so it can be checked: `contracts/deepmark-contract/src/lib.rs` for the entrypoints and `src/test.rs` for the tests. The suite is run with `cargo test` and was green on 2026-10-10: 63 unit and entrypoint tests plus the 600 cross-language vectors.
+
+## What is protected
+
+- A **result**: the floor reached, relics, score and HP of a run, and its place on a leaderboard.
+- A **relic**: a unit of `DMCOMMON`, `DMRARE` or `DMCORE`. Its only claim to value is that a verified run produced it.
+- A **delver**: a character that exists because a fresh one cleared all three floors.
+- A **player's access**: nobody else can start, finish or move things for an address.
+
+There is no money in the contract. It does have an admin who can upgrade it, which is the largest trust assumption and is covered under "The admin" below. Apart from the admin, the worst realistic outcome of any attack below is a false leaderboard row or a relic that was not earned. That sets how much defence is proportionate.
+
+## Attacks
+
+### Forged result
+
+*Claim a floor, score or relic count the player did not earn.*
+
+**Stops it.** `finish_run` takes only the action log. It never accepts a claimed result. It replays the log with `sim::replay` against the seed and delver that `start_run` stored, and derives everything from that replay (`lib.rs`, `finish_run`). A log that is not a legal sequence of the game is rejected: an unknown action byte, a log that does not end exactly on the action that finishes the run, padding after the end, or more than 2,048 actions. Tests: `a_claim_the_game_cannot_produce_is_rejected`, `unknown_actions_incomplete_logs_and_padding_are_rejected`, `oversized_logs_are_rejected`. The Rust replay is a port of the TypeScript game, and `tests/golden.rs` checks 600 runs played by the real client byte for byte, so the two cannot drift without `cargo test` failing. The README of the contract records a mutation check that those vectors catch RNG drift.
+
+**Remains.** A forged log either fails or is a legitimate run. A solver can play a perfect legal run, since the game is deterministic and the seed is public in the `run_started` event. That cannot be prevented in a deterministic game. Ties are broken by HP left and then fewest actions. The Rust port is only as correct as its tests, and there has been no third party audit.
+
+### Replay of a log or a run
+
+*Submit the same log twice, or reuse another run's log.*
+
+**Stops it.** Finishing removes the open run (`finish_run` removes the `Run` entry from temporary storage), so a second submission fails with `RunNotStarted`. Tests: `the_same_log_cannot_be_claimed_twice`, `finishing_without_starting_is_rejected`. A log taken from someone else's run is replayed against the caller's own seed, which is derived from the ledger, the run counter and the caller's address (`derive_seed`), so it produces whatever that log produces under that seed, or fails. Test: `seeds_differ_between_players_and_ledgers`. `finish_run` also needs the caller's signature, and a Soroban signature covers the call's arguments, so a relayer cannot swap in another log.
+
+**Remains.** Nothing specific. A replayed log that happens to be a valid run under the new seed is a legitimate run.
+
+### Seed rerolling
+
+*Discard unfavourable seeds until one gives an easy run.*
+
+**Stops it, partly.** The client cannot choose the seed. It is derived on chain from the ledger sequence, the ledger time, the player's run counter and the address (`derive_seed`). Starting again costs a transaction and a cooldown of 6 ledgers, about 30 seconds (`START_COOLDOWN_LEDGERS`), and starting while a run is open counts that run as abandoned on the profile (`forfeits`). Test: `starting_again_too_soon_is_rejected_and_later_it_forfeits_the_open_run`.
+
+**Remains.** A patient player can keep starting runs and only finish the ones with a good seed. The cost is time and fees, and the abandoned counter is visible, but the limit is economic, not absolute. The finish must come at least `actions / 16` ledgers after the start (`TooFast`, `ACTIONS_PER_LEDGER`), so a bot cannot submit a plan instantly, but it can plan during that wait. Test: `a_run_cannot_be_submitted_faster_than_it_could_be_played`.
+
+### Lockout
+
+*Stop another player from playing, finishing or keeping what they earned.*
+
+**Stops it.** Both writing entrypoints call `require_auth` on the player, so nobody can start, finish or give away for an address that did not sign. Test: `nobody_can_act_for_an_address_that_did_not_sign`. A failed replay returns an error before the open run is removed, and the transaction reverts, so a bad or partial log never consumes or blocks a run. An open run that is never finished expires after two days (`RUN_TTL`), and a player can start again after the cooldown, which forfeits it. A veteran that was given away in the middle of a run cannot be finished by the old owner, who can start a new run. Test: `a_veteran_given_away_mid_run_cannot_finish_for_the_old_owner`.
+
+**Remains.**
+- **Delver slot griefing.** An account can hold at most 64 delvers (`MAX_HEROES_PER_OWNER`), and `transfer` needs only the sender's signature, so someone who owns 64 delvers could send them to a victim and fill that victim's slots. After that a clear by the victim still counts and still mints relics, but `earn_or_advance` returns no new delver. The attacker needs 64 verified full clears first, which is a high price. It is a known gap, and the fix is to let a recipient refuse or to remove the cap.
+- **A lost log.** If a player closes the tab before the log is sent, the run is lost and later counted as abandoned. Persisting the unsent log and resuming is tracked in the issue tracker.
+- **The browser keypair.** The player's key is kept in that browser's `localStorage`. Clearing storage loses the identity and its records, and script running on the page could read it. A passkey account is the planned replacement, and no custodial key is held by any server.
+
+### Minting without play
+
+*Obtain relics or a delver without a verified run.*
+
+**Stops it.** The only code that mints relics is `mint_relics`, called from `finish_run` with the amounts the replay derived and only for an extraction. The only code that creates a delver is `earn_or_advance`, also inside `finish_run`, and only for a fresh delver that extracted from floor 3. The relics are Stellar Asset Contracts whose admin is the game contract, set by `scripts/deploy-testnet.sh`. Tests: `a_banked_relic_is_minted_as_a_stellar_asset`, `a_run_without_relics_mints_nothing`, `a_run_that_does_not_clear_all_three_floors_earns_nothing`. A mint failure, for example a missing trustline, is isolated with `try_mint`, so the run is still recorded. Test: `a_failed_mint_never_loses_the_verified_run`.
+
+**Remains.**
+- **The issuer is locked, the admin is not.** The relic issuer `GAD752QFRTNEMQW2SELGHQ5VAMIV3O7H4XRLJN2K2TQFGGXKNFG7CQUK` has a single signer with weight 0 and thresholds at 0, checked on Horizon on 2026-10-11, and a transaction signed with its own key is rejected with `TxBadAuth`. So the only minter is the game contract. While the contract admin exists, the admin can upgrade the contract to code that mints without a replay. See "The admin".
+- **A missed mint is not retried.** If `try_mint` fails, the run reports `minted: false` and the relics are not issued later.
+- **Delvers move freely.** A delver can be given to anyone, which is intended. It means a delver does not prove the current holder played it, only that its first clear was verified.
+
+### Leaderboard flooding
+
+*Fill the board, grow storage, or push real players out.*
+
+**Stops it.** Each sorted list holds the best 100 entries with one row per player, and only a run that beats that player's own best is inserted (`update_top`). A worse run does not replace a better one. Tests: `the_leaderboard_ranks_by_the_verified_result_and_lists_each_player_once`, `a_worse_run_does_not_replace_a_better_leaderboard_entry`, `the_sorted_top_is_bounded_but_players_are_not`. Fresh delvers and veterans have separate lists. The list of all players is paged at 50 per call (`players_are_paged_without_a_cap_on_how_many_exist`), so reading it has a bounded cost.
+
+**Remains.**
+- **Many accounts.** A person with many funded accounts can occupy rows, each with a real verified run. On testnet, Friendbot funds accounts for free, so the cost is only the work of playing. The board proves that a run was verified. It does not prove that a distinct person played it.
+- **Index growth.** The player index is not capped. Each entry costs its creator a funded account and the fees of a start, and the storage lifetime is extended in the same write.
+- **Exact ties.** Two runs with the same ranking key (extraction, score, floor, HP left, actions) are ordered by who got there first, because `update_top` inserts a new entry only above a strictly lower key.
+
+### The admin
+
+*Replace the contract code with code that skips the replay.*
+
+**Stops it.** `upgrade`, `set_admin` and `renounce_admin` all require the admin's signature, and handing the role over needs the signatures of both the current and the new admin, so a mistyped address cannot take it. Every change emits an event. Tests in `src/admin.rs`: `nobody_without_the_admin_signature_can_change_anything`, `the_admin_can_upgrade_and_the_swap_is_announced`, `handing_over_the_role_needs_both_signatures`, `a_new_admin_that_did_not_sign_cannot_be_installed`, `renouncing_freezes_the_contract_for_good`. After `renounce_admin` the contract rejects `upgrade` and `set_admin` with `Frozen`, which was also checked on a throwaway testnet deployment.
+
+**Remains.**
+- **While the role exists, players trust the admin.** The admin can upgrade to code that mints relics without a replay, rewrites the leaderboard or moves delvers. Replay verification protects players from forged logs, not from the admin. The testnet admin is `GCZCKB4FUIS5BGEMJOC3EQWKLGH662BOY3HFLH3WWPCGQJXOILDXP7GZ`, a single key.
+- **The way out is planned, not done.** Before any mainnet release the role is renounced, or moved with `set_admin` to a multisig account so no single key can change the code.
+- **Storage is not versioned.** An upgrade has to keep every stored key and struct readable. `version()` tells a client which release is running.
+
+## What is not covered
+
+- No third party audit. The review that exists is the test suite above, a mutation check on the vectors, and live testnet attempts to replay a log, forge a floor 3 run and restart too soon, all rejected, as recorded in the contract README.
+- Testnet only. A mainnet deployment needs the checklist in the internal repository, a locked issuer, and a decision on how players are funded.
+- The client is not a trust boundary. Everything it computes is advisory, and the contract replays it again.