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:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
outputs/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.pattern-cache
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 && 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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+3347
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
+160
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }[];
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user