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
+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()