Scaffold: Vite + React 19 + Tailwind 3, PIG's token layer, the demo contract

Foundation for a gallery of RL-environment demos. Three decisions worth
recording here rather than in a commit nobody reads:

The word lists are built, not copied. `envs/wordle_five/words/build_words.py`
intersects Wordnik (MIT, 11,846 five-letter words) with SCOWL's common-American
tier to produce 4,603 answers. The intersection is the point: the list is
derived from two permissive sources by a stated rule rather than copied from
anyone's editorial selection, and both inputs are committed so a rebuild is
byte-identical.

The design tokens are PIG's, inlined as literals. PIG writes its accent onto
the root at runtime because a user picks it; this site has no such choice, so
the runtime theme layer would be a moving part buying nothing. Board tiles get
their own named tokens with measured contrast ratios, because the board is the
one place where colour carries meaning.

pnpm 11 no longer reads the "pnpm" field in package.json. Settings live in
pnpm-workspace.yaml, and an unapproved build script makes `pnpm install` exit 1
rather than warn — so this would have failed CI on a clean checkout, not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 15:28:57 -07:00
commit 5a9ff8dda9
19 changed files with 207563 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
outputs/
.venv/
__pycache__/
*.pyc
.DS_Store
.pattern-cache
+202
View File
@@ -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.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+189
View File
@@ -0,0 +1,189 @@
"""The game itself: pure, seeded, and with no dependency on verifiers.
This module is the reference implementation. `src/demos/wordle/engine.ts` is a
port of it, and CI proves the two agree by scoring every (guess, answer) pair
in the answer list through both and comparing a SHA-256 of the result. If you
change anything here, that hash changes and the TypeScript must change with it.
"""
from __future__ import annotations
import hashlib
import json
import random
from collections import Counter
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
WORD_LENGTH = 5
MAX_GUESSES = 6
GREEN, YELLOW, GREY = "G", "Y", "X"
_WORDS = Path(__file__).parent.parent / "words"
@lru_cache(maxsize=1)
def answers() -> tuple[str, ...]:
return tuple(json.loads((_WORDS / "answers.json").read_text()))
@lru_cache(maxsize=1)
def guesses() -> frozenset[str]:
return frozenset(json.loads((_WORDS / "guesses.json").read_text()))
def score_guess(guess: str, answer: str) -> str:
"""Green/yellow/grey feedback, as two passes.
The two passes are not a stylistic choice. A letter may be marked non-grey
at most as many times as it occurs in the answer, and greens have first
claim on that allocation — so every green in the whole word must be
resolved before any yellow is assigned. Doing it in one pass marks the
first `SASSY` S yellow when `BASIS` has already spent both its S's on the
greens that follow, which is the single most common bug in Wordle
implementations. It is the bug that put a correction video on the most
watched explanation of this game ever made.
"""
guess, answer = guess.lower(), answer.lower()
if len(guess) != len(answer):
raise ValueError(f"length mismatch: {guess!r} vs {answer!r}")
n = len(answer)
pattern = [GREY] * n
remaining: Counter[str] = Counter()
# Pass 1 — greens claim their letters out of the pool.
for i in range(n):
if guess[i] == answer[i]:
pattern[i] = GREEN
else:
remaining[answer[i]] += 1
# Pass 2 — yellows take only what pass 1 left, left to right.
for i in range(n):
if pattern[i] is GREEN or pattern[i] == GREEN:
continue
if remaining[guess[i]] > 0:
pattern[i] = YELLOW
remaining[guess[i]] -= 1
return "".join(pattern)
def is_consistent(candidate: str, guess: str, pattern: str) -> bool:
"""Would `candidate` have produced `pattern` for `guess`?
This is the whole of constraint filtering, and it is also how the
`consistency` reward decides whether a guess contradicts what the player
was already told: a guess is consistent iff it is still a viable answer
given every previous piece of feedback.
"""
return score_guess(guess, candidate) == pattern
def hard_mode_violation(guess: str, prev_guess: str, prev_pattern: str) -> str | None:
"""NYT hard-mode legality, or None if the guess is legal.
Three details are routinely got wrong and are deliberate here: greens are
positional and locked; yellows are counted, not merely present, so a guess
must carry at least as many copies as were revealed; and grey letters are
NOT banned — hard mode places no constraint at all on known-absent letters.
"""
guess, prev_guess = guess.lower(), prev_guess.lower()
for i, tile in enumerate(prev_pattern):
if tile == GREEN and guess[i] != prev_guess[i]:
return f"{prev_guess[i].upper()} must stay in position {i + 1}"
need = Counter(prev_guess[i] for i, t in enumerate(prev_pattern) if t in (GREEN, YELLOW))
have = Counter(guess)
for letter, count in need.items():
if have[letter] < count:
plural = "" if count == 1 else f" {count} copies of"
return f"guess must contain{plural} {letter.upper()}"
return None
def answer_for_seed(seed: int) -> str:
"""The hidden word for a seed.
Seeded from a dedicated Random rather than the module-global one, because
TextArena seeds the process-global RNG and anything sharing it becomes
order-dependent under concurrency.
"""
pool = answers()
return pool[random.Random(seed).randrange(len(pool))]
@dataclass
class Game:
"""One episode. Deterministic in `seed`, and never raises on bad input."""
seed: int
answer: str = ""
max_guesses: int = MAX_GUESSES
hard_mode: bool = False
history: list[tuple[str, str]] = field(default_factory=list)
rejected: int = 0
def __post_init__(self) -> None:
if not self.answer:
self.answer = answer_for_seed(self.seed)
@property
def solved(self) -> bool:
return bool(self.history) and self.history[-1][1] == GREEN * WORD_LENGTH
@property
def over(self) -> bool:
return self.solved or len(self.history) >= self.max_guesses
def rejection_reason(self, word: str) -> str | None:
"""Why this guess would not be accepted, or None if it is playable."""
word = word.lower().strip()
if len(word) != WORD_LENGTH:
return f"'{word}' is not {WORD_LENGTH} letters"
if word not in guesses():
return f"'{word}' is not in the word list"
if any(word == prev for prev, _ in self.history):
return f"'{word}' has already been guessed"
if self.hard_mode and self.history:
prev, pattern = self.history[-1]
violation = hard_mode_violation(word, prev, pattern)
if violation:
return violation
return None
def play(self, word: str) -> tuple[str | None, str | None]:
"""Play a guess. Returns (pattern, rejection) — exactly one is None.
A rejected guess costs a turn of the model's patience but not a row of
the board, which is how the real game behaves and what keeps a
malformed reply from silently ending the episode.
"""
if self.over:
return None, "the game is already over"
reason = self.rejection_reason(word)
if reason:
self.rejected += 1
return None, reason
word = word.lower().strip()
pattern = score_guess(word, self.answer)
self.history.append((word, pattern))
return pattern, None
def conformance_digest() -> str:
"""SHA-256 over every (guess, answer) pair in the answer list.
5,3xx,xxx patterns hashed in a fixed order. This is the cross-language
gate: the TypeScript port computes the same digest over the same pairs, and
CI fails if they differ. It is strictly stronger than a hand-picked vector
file, which only ever catches the cases somebody thought of.
"""
pool = answers()
digest = hashlib.sha256()
for answer in pool:
digest.update("".join(score_guess(g, answer) for g in pool).encode())
return digest.hexdigest()
File diff suppressed because one or more lines are too long
+59
View File
@@ -0,0 +1,59 @@
"""Build the guess and answer lists from two permissively-licensed sources.
Deterministic and offline: the inputs are committed beside this script, so a
rebuild on any machine reproduces the shipped JSON byte for byte. See
PROVENANCE.md for the licences and why the lists are constructed this way
rather than copied from the original game.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
HERE = Path(__file__).parent
# Words we never make the hidden answer. Guesses stay permissive — the original
# game accepts these too — but no boardroom demo should ever reveal one as the
# solution. Deliberately short and hand-curated: a large imported blocklist
# would drag in its own licence and its own false positives.
BLOCKED = {
"bitch", "boner", "chink", "cocks", "coons", "crack", "cunts", "dagos",
"dicks", "dildo", "dykes", "fagot", "farts", "gooks", "gypsy", "harem",
"hussy", "incel", "junky", "kikes", "lynch", "micks", "nazis", "negro",
"nonce", "pussy", "queer", "raped", "rapes", "retch", "sperm", "spick",
"spics", "tards", "titty", "twats", "wench", "whore", "wogs",
}
FIVE = re.compile(r"^[a-z]{5}$")
def five_letter(path: Path) -> set[str]:
"""Every five-letter lowercase ASCII word in a whitespace/quote-delimited file."""
raw = path.read_text(encoding="utf-8", errors="ignore")
return {w for w in raw.replace('"', " ").replace(",", " ").lower().split() if FIVE.match(w)}
def main() -> None:
guesses = five_letter(HERE / "wordnik-20210729.txt")
common = five_letter(HERE / "scowl-wamerican-2020.12.07.txt")
# Answers are the intersection: a word must be in Wordnik (so it is a real
# headword) AND in SCOWL's common-American tier (so a non-specialist has
# plausibly met it). The intersection is what makes this list ours — it is
# derived from two permissive sources by a stated rule, not copied from
# anyone's editorial selection.
answers = sorted((guesses & common) - BLOCKED)
# Every answer must also be guessable, or the game is unwinnable.
assert set(answers) <= guesses, "answer list escaped the guess list"
out = HERE
(out / "guesses.json").write_text(json.dumps(sorted(guesses)) + "\n")
(out / "answers.json").write_text(json.dumps(answers) + "\n")
print(f"guesses={len(guesses)} answers={len(answers)} blocked={len(BLOCKED & (guesses & common))}")
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>PIG Demo — RL environments you can play</title>
<meta name="description" content="Interactive demos of reinforcement-learning environments for executives. Real verifiers environments, real recorded rollouts, and a reward you can change to see the ranking flip." />
<link rel="icon" href="/icons/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href="https://demo.primeintellectgrowth.com/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PIG Demo" />
<meta property="og:title" content="PIG Demo — RL environments you can play" />
<meta property="og:description" content="Real environments, real recorded rollouts, and a reward you can change to see the ranking flip." />
<meta property="og:url" content="https://demo.primeintellectgrowth.com/" />
<meta property="og:image" content="https://demo.primeintellectgrowth.com/og/home.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://demo.primeintellectgrowth.com/og/home.png" />
</head>
<body>
<div id="root"></div>
<!--
The page's whole claim is that it shows you real receipts. Rendering
nothing at all to a visitor whose JavaScript failed would undercut that
more than any missing feature, so the thesis and the verifiable command
are in the HTML itself.
-->
<noscript>
<main style="max-width:42rem;margin:4rem auto;padding:0 1.5rem;font-family:system-ui,sans-serif;line-height:1.6">
<h1 style="font-size:1.5rem;margin-bottom:1rem">PIG Demo</h1>
<p>These demos are interactive and need JavaScript. The substance behind them does not.</p>
<p>Every demo on this site ships a real <a href="https://github.com/PrimeIntellect-ai/verifiers">verifiers</a> environment and a set of recorded rollouts, both in the repository. You can run the environment yourself:</p>
<pre style="background:#f4f4f5;padding:1rem;border-radius:.5rem;overflow-x:auto"><code>git clone https://github.com/karti-ai/PIG-Demo
cd PIG-Demo &amp;&amp; uv sync --all-packages
uv run vf-eval wordle-five -n 8</code></pre>
<p><a href="https://github.com/karti-ai/PIG-Demo">Read the source on GitHub →</a></p>
</main>
</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
{
"name": "pig-demo",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"engines": {
"node": ">=22"
},
"packageManager": "pnpm@11.21.0",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build && node scripts/prerender.mjs",
"preview": "vite preview --port 4173",
"typecheck": "tsc --noEmit",
"test": "node --test src/**/__tests__/*.test.ts",
"check": "node scripts/check-demos.mjs && node scripts/check-receipts.mjs && node scripts/check-licenses.mjs",
"demo:new": "node scripts/new-demo.mjs",
"conformance": "node scripts/conformance.mjs"
},
"dependencies": {
"@fontsource-variable/manrope": "^5.3.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-navigation-menu": "^1.2.16",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.15",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"recharts": "^2.15.4",
"tailwind-merge": "^2.6.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"playwright": "^1.62.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}
+3347
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# pnpm 11 no longer reads settings from the "pnpm" field in package.json — they
# live here. Without this, `pnpm install` exits 1 with ERR_PNPM_IGNORED_BUILDS
# rather than merely warning, which fails CI on a clean checkout.
allowBuilds:
esbuild: true
+1
View File
@@ -0,0 +1 @@
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
+160
View File
@@ -0,0 +1,160 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'Manrope Variable';
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url('@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2') format('woff2-variations');
}
/*
* PIG's token layer, with the accent inlined as literals rather than written
* onto the root at runtime. This site has no user-chosen accent, so a runtime
* theme module would be a moving part that buys nothing here.
*
* Every colour is an HSL channel triple — `H S% L%` — so Tailwind can compose
* it with an opacity modifier (`bg-brand/10`). Writing `hsl(...)` into the
* variable breaks that silently: the class compiles and the opacity is ignored.
*/
:root {
--bg: 240 5% 98%;
--surface: 0 0% 100%;
--surface-2: 240 5% 97%;
--border: 240 6% 90%;
--fg: 240 10% 4%;
--muted: 240 4% 46%;
--accent: 262 83% 55%;
--accent-fg: 263 70% 46%;
--accent-on: 0 0% 100%;
--accent-subtle: 270 100% 97%;
--positive: 160 84% 24%;
--warning: 32 95% 31%;
--danger: 0 72% 45%;
--info: 201 90% 32%;
--shadow: 240 10% 4%;
/*
* Board tiles. Foregrounds are stated explicitly and measured, because the
* board is the one place on the site where colour carries meaning: an unlit
* tile and a grey tile differ only by fill, and a letter that fails contrast
* against its own tile makes the result unreadable exactly when it matters.
*
* Measured against each tile's own fill:
* exact white on 160 84% 24% -> 6.03:1
* present white on 32 95% 31% -> 5.03:1
* absent white on 240 4% 46% -> 4.61:1
*/
--tile-exact: 160 84% 24%;
--tile-exact-fg: 0 0% 100%;
--tile-present: 32 95% 31%;
--tile-present-fg: 0 0% 100%;
--tile-absent: 240 4% 46%;
--tile-absent-fg: 0 0% 100%;
--dur-1: 120ms;
--dur-2: 180ms;
--dur-3: 240ms;
--ease-out: cubic-bezier(0.2, 0, 0, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--app-header-h: calc(3.5rem + var(--safe-top));
}
@media (min-width: 1024px) {
:root { --app-header-h: calc(4rem + var(--safe-top)); }
}
:root[data-theme='dark'] {
--bg: 240 10% 4%;
--surface: 240 8% 7%;
--surface-2: 240 6% 11%;
--border: 240 5% 18%;
--fg: 0 0% 98%;
--muted: 240 5% 60%;
--accent: 258 90% 70%;
--accent-fg: 255 92% 78%;
--accent-on: 264 80% 12%;
--accent-subtle: 260 35% 20%;
--positive: 158 64% 48%;
--warning: 38 92% 58%;
--danger: 0 84% 65%;
--info: 199 89% 58%;
/*
* Dark tiles lighten and take a dark glyph. Keeping the light theme's dark
* fills would have made the board the only near-black object on a near-black
* page, so a solved row read as an empty one.
* exact 240 10% 4% on 158 64% 48% -> 7.31:1
* present 240 10% 4% on 38 92% 58% -> 9.24:1
* absent 0 0% 98% on 240 5% 28% -> 8.94:1
*/
--tile-exact: 158 64% 48%;
--tile-exact-fg: 240 10% 4%;
--tile-present: 38 92% 58%;
--tile-present-fg: 240 10% 4%;
--tile-absent: 240 5% 28%;
--tile-absent-fg: 0 0% 98%;
}
/*
* High contrast. Green/yellow/grey is a colour-only distinction, which is
* exactly why the original game ships a high-contrast mode. Toggled from the
* header and persisted per-viewer; the tiles also carry a glyph in this mode,
* so the result survives being printed, screenshotted or seen by someone with
* deuteranopia.
*/
:root[data-contrast='high'] {
--tile-exact: 209 100% 40%;
--tile-present: 27 100% 42%;
--tile-absent: 240 4% 38%;
--tile-exact-fg: 0 0% 100%;
--tile-present-fg: 0 0% 100%;
--tile-absent-fg: 0 0% 100%;
}
@layer base {
* { @apply border-border; }
body {
@apply bg-bg text-fg font-sans antialiased;
/* Painted explicitly: the viewer's chrome shows through a transparent body
and the page would borrow the host's theme instead of its own. */
text-rendering: optimizeLegibility;
}
/* Tabular figures everywhere a number is compared to another number. */
.nums { font-variant-numeric: tabular-nums; }
:focus-visible { @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-bg; }
}
@layer components {
.card { @apply rounded-xl border border-border bg-surface; }
/* 44px minimum touch target — the board keys and step chips are the two
places this is load-bearing on a phone. */
.tap { @apply min-h-11 min-w-11; }
}
/*
* Reduced motion clamps everything to effectively zero. That is correct, and
* it is also why every state change in this app must ALSO be announced: with
* the flip gone, colour alone carries the result, and colour alone is not a
* result for a screen-reader user.
*/
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+259
View File
@@ -0,0 +1,259 @@
/**
* The demo contract.
*
* Every demo in this repo is one directory under `src/demos/<slug>/` that
* satisfies these interfaces and imports nothing from the shared shell except
* `@/lib/demo-kit`. The shell renders any demo that does; a demo that reaches
* deeper is caught by `scripts/check-demos.mjs`, not by review.
*
* There is no runtime code in this file. It is types only, so a demo can
* depend on the contract without pulling the player, the registry or the
* verifier into its chunk.
*/
/** A vertical the demo lineup is organised by. Purely presentational grouping. */
export type Vertical =
| 'reference'
| 'support'
| 'healthcare'
| 'insurance'
| 'financial-crime'
| 'energy'
| 'logistics'
| 'code'
| 'retail'
| 'telecom'
| 'data'
| 'legal';
/**
* `live` demos are interactive and ship a real environment. `spec` demos are a
* published specification with no interactive surface — a real task, action
* set, grader, counterweight and eval command, not a coming-soon card.
* `check-demos` refuses a `spec` whose specification is incomplete.
*/
export type DemoStatus = 'live' | 'spec';
/**
* The eagerly-loaded half of a demo.
*
* This is imported for EVERY demo on every page load, because the header, the
* gallery and the router are all generated from it. It must therefore be
* serialisable and cheap: no React, no component imports, and `icon` is a
* lucide icon NAME rather than a component, or one demo's icon would pull
* lucide into the entry chunk for all of them.
*/
export interface DemoMeta {
/** URL segment and registry key. Must equal the directory name. */
slug: string;
/** Shown in the header, the gallery card and the page title. */
title: string;
/** One line, exec-facing. Says what the agent DOES, not how it works. */
tagline: string;
vertical: Vertical;
status: DemoStatus;
/** Sort order within a vertical. Ties break on slug. */
order: number;
/** A lucide-react icon name, e.g. 'Grid3x3'. Resolved by the shell. */
icon: string;
/** The job title of the person this demo is aimed at. */
persona: string;
/** Six words on what the reward pays for, and what it takes away. */
rewardLine: string;
/** Absolute path to the social card, e.g. '/og/wordle.png'. */
ogImage: string;
}
/** One weighted term of a reward, as the environment actually computes it. */
export interface RewardComponent {
key: string;
/** Plain English, as an executive would say it. Not the variable name. */
label: string;
/** What it measures, one sentence. Shown in the breakdown and the editor. */
description: string;
/** Shipped weight. The editor varies this; the environment ships this. */
weight: number;
/**
* `objective` is what the task is for. `counterweight` is what stops the
* objective being maximised the crude way, and must be in genuine tension
* with it — a component every good policy also scores 1.0 on is a gate, not
* a counterweight, and must be declared `gate`.
*/
role: 'objective' | 'counterweight' | 'gate';
}
/** The reward, as a whole, plus the source that proves it. */
export interface RewardSpec {
components: RewardComponent[];
/** Unweighted diagnostics. Rendered, never summed. */
metrics?: { key: string; label: string; description: string }[];
/** The Python that computes it, imported with `?raw` and quoted verbatim. */
source: { path: string; code: string; marker?: string };
}
/** One model call inside a step, straight off the recorded trace. */
export interface ModelCall {
promptTokens: number | null;
completionTokens: number | null;
reasoningTokens: number | null;
/** Real elapsed milliseconds, as recorded. Never invented for playback. */
durationMs: number | null;
finishReason: string | null;
}
/**
* One turn of a recorded episode, already adapted to this demo's board type.
*
* `state` is whatever the demo's surface renders. The shell never inspects it.
*/
export interface DemoStep<TState = unknown> {
index: number;
/** The board AFTER this step. Steps are snapshots, not deltas. */
state: TState;
/** The model's visible reply, verbatim. */
reply: string | null;
/** Reasoning content, if the recorded run had thinking enabled. */
reasoning: string | null;
call: ModelCall | null;
/**
* What a screen reader announces when this step lands. REQUIRED, because
* reduced-motion clamps every animation to nothing and colour alone cannot
* carry the result.
*/
announce: string;
/** Rendered under the step chip. Six words at most. */
caption?: string;
}
/** A scored reward on a recorded run. `null` means not scored, never zero. */
export type RewardValues = Record<string, number | null>;
/** Pointer to one recorded run, listed in `public/traces/manifest.json`. */
export interface RunRef {
id: string;
/** Exec-facing arm label, e.g. 'Out of the box'. */
label: string;
/** Where the JSON lives, absolute from the site root. */
path: string;
/**
* `recorded` is a plain rollout. `intervened` had something done to it, and
* `intervention` is then REQUIRED — so a prompt change can never be
* presented as a training result by omission.
*/
kind: 'recorded' | 'intervened' | 'generated';
intervention?: string;
model: string;
/** ISO date. Rendered in the permanent provenance badge. */
capturedAt: string;
seed: number;
}
/** A recorded episode as it sits on disk. */
export interface DemoEpisode {
runId: string;
seed: number;
model: string;
capturedAt: string;
/** Verbatim from the environment. `null` values render 'not scored'. */
rewards: RewardValues;
/** Unweighted diagnostics from the environment. */
metrics?: Record<string, number | null>;
/** True when the run ended without reaching a terminal state. */
truncated?: boolean;
outcome: 'solved' | 'failed' | 'aborted';
/** Raw turns. The demo's adapter turns these into DemoStep[]. */
turns: {
reply: string | null;
reasoning: string | null;
call: ModelCall | null;
info?: Record<string, unknown>;
}[];
}
/** One beat of the exec narrative. The shell renders these in order. */
export interface StoryBeat {
id: string;
title: string;
/** One sentence, asserted as a claim the page then demonstrates. */
claim: string;
/** Which shared surface renders it. */
surface:
| 'hero'
| 'anatomy'
| 'split-play'
| 'scrubber'
| 'reward-editor'
| 'metric'
| 'receipt'
| 'limits'
| 'custom';
}
/** What this demo deliberately does not teach, and which demo answers it. */
export interface Limit {
text: string;
/** Slug of the demo that closes this gap, if one is planned. */
answeredBy?: string;
}
export interface Narrative {
/** The one-paragraph thesis, exec-facing. */
thesis: string;
/** The question in the buyer's head when they land. */
anxiety: string;
beats: StoryBeat[];
limits: Limit[];
}
/** Everything needed to reproduce the recorded runs. Rendered verbatim. */
export interface Provenance {
/** The environment package, e.g. 'wordle_five'. */
envPackage: string;
tasksetId: string;
verifiersVersion: string;
/** The literal command that produced the fixtures. Copy-pasteable. */
command: string;
/** Upstream work this mirrors or builds on, with URLs. */
credits: { label: string; href: string }[];
}
/**
* The lazily-loaded half of a demo: everything that costs bundle size.
*
* `TState` is the demo's own board type. The shell is generic over it and
* never reaches inside.
*/
export interface DemoModule<TState = unknown> {
meta: DemoMeta;
narrative: Narrative;
reward: RewardSpec;
provenance: Provenance;
/** The four boxes: task, legal moves, grader, and the score that moves. */
anatomy: { task: string; actions: string; grader: string; score: string };
/** Turns a recorded episode into renderable steps. Pure. */
adapt: (episode: DemoEpisode) => DemoStep<TState>[];
/** Renders one board state. Used by play, replay AND the gallery card. */
Surface: React.ComponentType<{ state: TState; compact?: boolean }>;
/**
* Optional interactive mode: lets the visitor play rather than watch. A demo
* without one still renders every other surface.
*/
interactive?: {
/** Fresh state for a seed. Must be deterministic in the seed. */
init: (seed: number) => TState;
/** The keyboard/controls component, wired to the demo's own state. */
Controls: React.ComponentType<{
state: TState;
onChange: (next: TState) => void;
seed: number;
}>;
};
/**
* Re-derives the reward from a recorded episode, in the browser, so the page
* can prove the recorded numbers rather than assert them. Returning null
* means 'unverifiable' — a truncated trace — and must never render as zero.
*/
verify?: (episode: DemoEpisode) => RewardValues | null;
/** Extra tabs beside the default ones. */
tabs?: { id: string; label: string; Component: React.ComponentType }[];
}
+69
View File
@@ -0,0 +1,69 @@
import animate from 'tailwindcss-animate';
/**
* Hand-written, never CLI-generated. `shadcn add` rewrites this file and
* strips every comment in it, so if you run the CLI, diff this file
* afterwards and put the comments back.
*
* The palette is PIG's, inlined as literals rather than injected at runtime:
* this site has no user-chosen accent, so a runtime theme layer would be a
* moving part that buys nothing.
*/
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class', '[data-theme="dark"]'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
bg: 'hsl(var(--bg))',
background: 'hsl(var(--bg))',
surface: 'hsl(var(--surface))',
'surface-2': 'hsl(var(--surface-2))',
border: 'hsl(var(--border))',
input: 'hsl(var(--border))',
ring: 'hsl(var(--accent))',
fg: 'hsl(var(--fg))',
foreground: 'hsl(var(--fg))',
muted: 'hsl(var(--muted))',
'muted-foreground': 'hsl(var(--muted))',
// shadcn's `accent` is its SUBTLE hover surface, not the brand. Mapping
// it to the brand paints a full-strength block on every hover state.
accent: 'hsl(var(--accent-subtle))',
'accent-foreground': 'hsl(var(--accent-fg))',
brand: 'hsl(var(--accent))',
'accent-fg': 'hsl(var(--accent-fg))',
'accent-on': 'hsl(var(--accent-on))',
'accent-subtle': 'hsl(var(--accent-subtle))',
primary: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-on))' },
secondary: { DEFAULT: 'hsl(var(--surface-2))', foreground: 'hsl(var(--fg))' },
card: { DEFAULT: 'hsl(var(--surface))', foreground: 'hsl(var(--fg))' },
popover: { DEFAULT: 'hsl(var(--surface))', foreground: 'hsl(var(--fg))' },
destructive: { DEFAULT: 'hsl(var(--danger))', foreground: 'hsl(0 0% 100%)' },
positive: 'hsl(var(--positive))',
warning: 'hsl(var(--warning))',
danger: 'hsl(var(--danger))',
info: 'hsl(var(--info))',
// The board's three tile states. Named by MEANING, not by colour, so
// the high-contrast palette can swap the values without renaming.
tile: {
exact: 'hsl(var(--tile-exact))',
'exact-fg': 'hsl(var(--tile-exact-fg))',
present: 'hsl(var(--tile-present))',
'present-fg': 'hsl(var(--tile-present-fg))',
absent: 'hsl(var(--tile-absent))',
'absent-fg': 'hsl(var(--tile-absent-fg))',
},
},
fontFamily: {
sans: ['Manrope Variable', 'Avenir Next', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
},
borderRadius: { md: '0.5rem', lg: '0.75rem', xl: '1rem' },
transitionDuration: { 1: 'var(--dur-1)', 2: 'var(--dur-2)', 3: 'var(--dur-3)' },
transitionTimingFunction: { enter: 'var(--ease-out)', exit: 'var(--ease-in)' },
maxWidth: { canvas: '80rem' },
},
},
plugins: [animate],
};
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client", "node"],
"paths": { "@/*": ["./src/*"] },
"baseUrl": "."
},
"include": ["src", "scripts", "vite.config.ts"]
}
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react()],
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
server: {
port: Number(process.env.PIG_DEMO_PORT ?? 5273),
// `envs/` is outside the Vite root but inside the repo, and the reward
// receipts import Python from it with `?raw`. Without this the dev server
// refuses the read and the receipt panel renders empty.
fs: { allow: ['..'] },
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom', 'react-router-dom'],
// Named explicitly so the object form of manualChunks does not hoist
// the chart library into the entry chunk. Only one tab imports it.
charts: ['recharts'],
},
},
},
},
});