Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5186ab583b |
@@ -1,97 +0,0 @@
|
||||
name: Agent PR Gate
|
||||
'on':
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
syntax_status: ${{ steps.syntax.outcome }}
|
||||
tests_status: ${{ steps.tests.outcome }}
|
||||
criteria_status: ${{ steps.criteria.outcome }}
|
||||
risk_level: ${{ steps.risk.outputs.level }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install CI dependencies
|
||||
run: |
|
||||
python3 -m pip install --quiet pyyaml pytest
|
||||
|
||||
- id: risk
|
||||
name: Classify PR risk
|
||||
run: |
|
||||
BASE_REF="${GITHUB_BASE_REF:-main}"
|
||||
git fetch origin "$BASE_REF" --depth 1
|
||||
git diff --name-only "origin/$BASE_REF"...HEAD > /tmp/changed_files.txt
|
||||
python3 scripts/agent_pr_gate.py classify-risk --files-file /tmp/changed_files.txt > /tmp/risk.json
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
with open('/tmp/risk.json', 'r', encoding='utf-8') as fh:
|
||||
data = json.load(fh)
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh:
|
||||
fh.write('level=' + data['risk'] + '\n')
|
||||
PY
|
||||
|
||||
- id: syntax
|
||||
name: Syntax and parse checks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
find . \( -name '*.yml' -o -name '*.yaml' \) | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | while read f; do python3 -m json.tool "$f" > /dev/null || exit 1; done
|
||||
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||
find . -name '*.sh' | xargs -r bash -n
|
||||
|
||||
- id: tests
|
||||
name: Test suite
|
||||
continue-on-error: true
|
||||
run: |
|
||||
pytest -q --ignore=uni-wizard/v2/tests/test_author_whitelist.py
|
||||
|
||||
- id: criteria
|
||||
name: PR criteria verification
|
||||
continue-on-error: true
|
||||
run: |
|
||||
python3 scripts/agent_pr_gate.py validate-pr --event-path "$GITHUB_EVENT_PATH"
|
||||
|
||||
- name: Fail gate if any required check failed
|
||||
if: steps.syntax.outcome != 'success' || steps.tests.outcome != 'success' || steps.criteria.outcome != 'success'
|
||||
run: exit 1
|
||||
|
||||
report:
|
||||
needs: gate
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Post PR gate report
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/agent_pr_gate.py comment \
|
||||
--event-path "$GITHUB_EVENT_PATH" \
|
||||
--token "$GITEA_TOKEN" \
|
||||
--syntax "${{ needs.gate.outputs.syntax_status }}" \
|
||||
--tests "${{ needs.gate.outputs.tests_status }}" \
|
||||
--criteria "${{ needs.gate.outputs.criteria_status }}" \
|
||||
--risk "${{ needs.gate.outputs.risk_level }}"
|
||||
|
||||
- name: Auto-merge low-risk clean PRs
|
||||
if: needs.gate.result == 'success' && needs.gate.outputs.risk_level == 'low'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
python3 scripts/agent_pr_gate.py merge \
|
||||
--event-path "$GITHUB_EVENT_PATH" \
|
||||
--token "$GITEA_TOKEN"
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Smoke Test
|
||||
'on':
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -11,13 +11,10 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install parse dependencies
|
||||
run: |
|
||||
python3 -m pip install --quiet pyyaml
|
||||
- name: Parse check
|
||||
run: |
|
||||
find . \( -name '*.yml' -o -name '*.yaml' \) | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | while read f; do python3 -m json.tool "$f" > /dev/null || exit 1; done
|
||||
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
|
||||
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||
find . -name '*.sh' | xargs -r bash -n
|
||||
echo "PASS: All files parse"
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
LOW_RISK_PREFIXES = (
|
||||
'docs/', 'reports/', 'notes/', 'tickets/', 'research/', 'briefings/',
|
||||
'twitter-archive/notes/', 'tests/'
|
||||
)
|
||||
LOW_RISK_SUFFIXES = {'.md', '.txt', '.jsonl'}
|
||||
MEDIUM_RISK_PREFIXES = ('.gitea/workflows/',)
|
||||
HIGH_RISK_PREFIXES = (
|
||||
'scripts/', 'deploy/', 'infrastructure/', 'metrics/', 'heartbeat/',
|
||||
'wizards/', 'evennia/', 'uniwizard/', 'uni-wizard/', 'timmy-local/',
|
||||
'evolution/'
|
||||
)
|
||||
HIGH_RISK_SUFFIXES = {'.py', '.sh', '.ini', '.service'}
|
||||
|
||||
|
||||
def read_changed_files(path):
|
||||
return [line.strip() for line in Path(path).read_text(encoding='utf-8').splitlines() if line.strip()]
|
||||
|
||||
|
||||
def classify_risk(files):
|
||||
if not files:
|
||||
return 'high'
|
||||
level = 'low'
|
||||
for file_path in files:
|
||||
path = file_path.strip()
|
||||
suffix = Path(path).suffix.lower()
|
||||
if path.startswith(LOW_RISK_PREFIXES):
|
||||
continue
|
||||
if path.startswith(HIGH_RISK_PREFIXES) or suffix in HIGH_RISK_SUFFIXES:
|
||||
return 'high'
|
||||
if path.startswith(MEDIUM_RISK_PREFIXES):
|
||||
level = 'medium'
|
||||
continue
|
||||
if path.startswith(LOW_RISK_PREFIXES) or suffix in LOW_RISK_SUFFIXES:
|
||||
continue
|
||||
level = 'high'
|
||||
return level
|
||||
|
||||
|
||||
def validate_pr_body(title, body):
|
||||
details = []
|
||||
combined = f"{title}\n{body}".strip()
|
||||
if not re.search(r'#\d+', combined):
|
||||
details.append('PR body/title must include an issue reference like #562.')
|
||||
if not re.search(r'(^|\n)\s*(verification|tests?)\s*:', body, re.IGNORECASE):
|
||||
details.append('PR body must include a Verification: section.')
|
||||
return (len(details) == 0, details)
|
||||
|
||||
|
||||
def build_comment_body(syntax_status, tests_status, criteria_status, risk_level):
|
||||
statuses = {
|
||||
'syntax': syntax_status,
|
||||
'tests': tests_status,
|
||||
'criteria': criteria_status,
|
||||
}
|
||||
all_clean = all(value == 'success' for value in statuses.values())
|
||||
action = 'auto-merge' if all_clean and risk_level == 'low' else 'human review'
|
||||
lines = [
|
||||
'## Agent PR Gate',
|
||||
'',
|
||||
'| Check | Status |',
|
||||
'|-------|--------|',
|
||||
f"| Syntax / parse | {syntax_status} |",
|
||||
f"| Test suite | {tests_status} |",
|
||||
f"| PR criteria | {criteria_status} |",
|
||||
f"| Risk level | {risk_level} |",
|
||||
'',
|
||||
]
|
||||
failed = [name for name, value in statuses.items() if value != 'success']
|
||||
if failed:
|
||||
lines.append('### Failure details')
|
||||
for name in failed:
|
||||
lines.append(f'- {name} reported failure. Inspect the workflow logs for that step.')
|
||||
else:
|
||||
lines.append('All automated checks passed.')
|
||||
lines.extend([
|
||||
'',
|
||||
f'Recommendation: {action}.',
|
||||
'Low-risk documentation/test-only PRs may be auto-merged. Operational changes stay in human review.',
|
||||
])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _read_event(event_path):
|
||||
data = json.loads(Path(event_path).read_text(encoding='utf-8'))
|
||||
pr = data.get('pull_request') or {}
|
||||
repo = (data.get('repository') or {}).get('full_name') or os.environ.get('GITHUB_REPOSITORY')
|
||||
pr_number = pr.get('number') or data.get('number')
|
||||
title = pr.get('title') or ''
|
||||
body = pr.get('body') or ''
|
||||
return repo, pr_number, title, body
|
||||
|
||||
|
||||
def _request_json(method, url, token, payload=None):
|
||||
data = None if payload is None else json.dumps(payload).encode('utf-8')
|
||||
headers = {'Authorization': f'token {token}', 'Content-Type': 'application/json'}
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
|
||||
def post_comment(repo, pr_number, token, body):
|
||||
url = f'{API_BASE}/repos/{repo}/issues/{pr_number}/comments'
|
||||
return _request_json('POST', url, token, {'body': body})
|
||||
|
||||
|
||||
def merge_pr(repo, pr_number, token):
|
||||
url = f'{API_BASE}/repos/{repo}/pulls/{pr_number}/merge'
|
||||
return _request_json('POST', url, token, {'Do': 'merge'})
|
||||
|
||||
|
||||
def cmd_classify_risk(args):
|
||||
files = list(args.files or [])
|
||||
if args.files_file:
|
||||
files.extend(read_changed_files(args.files_file))
|
||||
print(json.dumps({'risk': classify_risk(files), 'files': files}, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate_pr(args):
|
||||
_, _, title, body = _read_event(args.event_path)
|
||||
ok, details = validate_pr_body(title, body)
|
||||
if ok:
|
||||
print('PR body validation passed.')
|
||||
return 0
|
||||
for detail in details:
|
||||
print(detail)
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_comment(args):
|
||||
repo, pr_number, _, _ = _read_event(args.event_path)
|
||||
body = build_comment_body(args.syntax, args.tests, args.criteria, args.risk)
|
||||
post_comment(repo, pr_number, args.token, body)
|
||||
print(f'Commented on PR #{pr_number} in {repo}.')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_merge(args):
|
||||
repo, pr_number, _, _ = _read_event(args.event_path)
|
||||
merge_pr(repo, pr_number, args.token)
|
||||
print(f'Merged PR #{pr_number} in {repo}.')
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(description='Agent PR CI helpers for timmy-home.')
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
|
||||
classify = sub.add_parser('classify-risk')
|
||||
classify.add_argument('--files-file')
|
||||
classify.add_argument('files', nargs='*')
|
||||
classify.set_defaults(func=cmd_classify_risk)
|
||||
|
||||
validate = sub.add_parser('validate-pr')
|
||||
validate.add_argument('--event-path', required=True)
|
||||
validate.set_defaults(func=cmd_validate_pr)
|
||||
|
||||
comment = sub.add_parser('comment')
|
||||
comment.add_argument('--event-path', required=True)
|
||||
comment.add_argument('--token', required=True)
|
||||
comment.add_argument('--syntax', required=True)
|
||||
comment.add_argument('--tests', required=True)
|
||||
comment.add_argument('--criteria', required=True)
|
||||
comment.add_argument('--risk', required=True)
|
||||
comment.set_defaults(func=cmd_comment)
|
||||
|
||||
merge = sub.add_parser('merge')
|
||||
merge.add_argument('--event-path', required=True)
|
||||
merge.add_argument('--token', required=True)
|
||||
merge.set_defaults(func=cmd_merge)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
35
tests/docs/test_the_playground_genome.py
Normal file
35
tests/docs/test_the_playground_genome.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _content() -> str:
|
||||
return Path("the-playground-GENOME.md").read_text()
|
||||
|
||||
|
||||
def test_the_playground_genome_exists() -> None:
|
||||
assert Path("the-playground-GENOME.md").exists()
|
||||
|
||||
|
||||
def test_the_playground_genome_has_required_sections() -> None:
|
||||
content = _content()
|
||||
assert "# GENOME.md — the-playground" in content
|
||||
assert "## Project Overview" in content
|
||||
assert "## Architecture" in content
|
||||
assert "```mermaid" in content
|
||||
assert "## Entry Points" in content
|
||||
assert "## Data Flow" in content
|
||||
assert "## Key Abstractions" in content
|
||||
assert "## API Surface" in content
|
||||
assert "## Test Coverage Gaps" in content
|
||||
assert "## Security Considerations" in content
|
||||
assert "## Dependencies" in content
|
||||
assert "## Deployment" in content
|
||||
assert "## Technical Debt" in content
|
||||
|
||||
|
||||
def test_the_playground_genome_captures_repo_specific_findings() -> None:
|
||||
content = _content()
|
||||
assert "IndexedDB" in content
|
||||
assert "AudioContext" in content
|
||||
assert "smoke-test.html" in content
|
||||
assert "no tests ran" in content
|
||||
assert "innerHTML" in content
|
||||
@@ -1,68 +0,0 @@
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'scripts'))
|
||||
|
||||
import agent_pr_gate # noqa: E402
|
||||
|
||||
|
||||
class TestAgentPrGate(unittest.TestCase):
|
||||
def test_classify_risk_low_for_docs_and_tests_only(self):
|
||||
level = agent_pr_gate.classify_risk([
|
||||
'docs/runbook.md',
|
||||
'reports/daily-summary.md',
|
||||
'tests/test_agent_pr_gate.py',
|
||||
])
|
||||
self.assertEqual(level, 'low')
|
||||
|
||||
def test_classify_risk_high_for_operational_paths(self):
|
||||
level = agent_pr_gate.classify_risk([
|
||||
'scripts/failover_monitor.py',
|
||||
'deploy/playbook.yml',
|
||||
])
|
||||
self.assertEqual(level, 'high')
|
||||
|
||||
def test_validate_pr_body_requires_issue_ref_and_verification(self):
|
||||
ok, details = agent_pr_gate.validate_pr_body(
|
||||
'feat: add thing',
|
||||
'What changed only\n\nNo verification section here.'
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn('issue reference', ' '.join(details).lower())
|
||||
self.assertIn('verification', ' '.join(details).lower())
|
||||
|
||||
def test_validate_pr_body_accepts_issue_ref_and_verification(self):
|
||||
ok, details = agent_pr_gate.validate_pr_body(
|
||||
'feat: add thing (#562)',
|
||||
'Refs #562\n\nVerification:\n- pytest -q\n'
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(details, [])
|
||||
|
||||
def test_build_comment_body_reports_failures_and_human_review(self):
|
||||
body = agent_pr_gate.build_comment_body(
|
||||
syntax_status='success',
|
||||
tests_status='failure',
|
||||
criteria_status='success',
|
||||
risk_level='high',
|
||||
)
|
||||
self.assertIn('tests', body.lower())
|
||||
self.assertIn('failure', body.lower())
|
||||
self.assertIn('human review', body.lower())
|
||||
|
||||
def test_changed_files_file_loader_ignores_blanks(self):
|
||||
with tempfile.NamedTemporaryFile('w+', delete=False) as handle:
|
||||
handle.write('docs/one.md\n\nreports/two.md\n')
|
||||
path = handle.name
|
||||
try:
|
||||
files = agent_pr_gate.read_changed_files(path)
|
||||
finally:
|
||||
pathlib.Path(path).unlink(missing_ok=True)
|
||||
self.assertEqual(files, ['docs/one.md', 'reports/two.md'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,24 +0,0 @@
|
||||
import pathlib
|
||||
import unittest
|
||||
import yaml
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
WORKFLOW = ROOT / '.gitea' / 'workflows' / 'agent-pr-gate.yml'
|
||||
|
||||
|
||||
class TestAgentPrWorkflow(unittest.TestCase):
|
||||
def test_workflow_exists(self):
|
||||
self.assertTrue(WORKFLOW.exists(), 'agent-pr-gate workflow should exist')
|
||||
|
||||
def test_workflow_has_pr_gate_and_reporting_jobs(self):
|
||||
data = yaml.safe_load(WORKFLOW.read_text(encoding='utf-8'))
|
||||
self.assertIn('pull_request', data.get('on', {}))
|
||||
jobs = data.get('jobs', {})
|
||||
self.assertIn('gate', jobs)
|
||||
self.assertIn('report', jobs)
|
||||
report_steps = jobs['report']['steps']
|
||||
self.assertTrue(any('Auto-merge low-risk clean PRs' in (step.get('name') or '') for step in report_steps))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
539
the-playground-GENOME.md
Normal file
539
the-playground-GENOME.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# GENOME.md — the-playground
|
||||
|
||||
Generated: 2026-04-15 00:19:15 EDT
|
||||
Repo: Timmy_Foundation/the-playground
|
||||
Issue: timmy-home #671
|
||||
|
||||
## Project Overview
|
||||
|
||||
The Sovereign Playground is a browser-only creative sandbox: a dark, local-first art toy with an entrance ritual, a canvas in the center, a sound panel on the left, a gallery on the right, and a footer action bar for save/download/clear/fullscreen.
|
||||
|
||||
The current codebase is much smaller than the README vision. The README describes a platform with Sound Studio, Visual Forge, Gallery, Games Floor, Video Forge, and a long roadmap of immersive experiences. The code on `main` today implements a solid prototype shell with:
|
||||
- a cinematic entrance screen
|
||||
- two actual canvas modes: `free-draw` and `ambient`
|
||||
- a basic Web Audio engine for notes/chords/scales
|
||||
- a basic Canvas 2D visual engine
|
||||
- an IndexedDB-backed gallery
|
||||
- a manual browser smoke harness
|
||||
|
||||
Quick measured facts from the fresh main clone I analyzed:
|
||||
- 10 JavaScript source files
|
||||
- 1 CSS design system file
|
||||
- 2 HTML entry pages (`index.html`, `smoke-test.html`)
|
||||
- 0 package manifests
|
||||
- 0 build steps
|
||||
- `python3 -m pytest -q` -> `no tests ran in 0.02s`
|
||||
- browser smoke harness shows 18 passing checks, but the summary is broken and still says `0 passed, 0 failed`
|
||||
|
||||
This repo is best understood as a browser-native prototype platform shell with one strong design language and three real cores:
|
||||
1. orchestration in `src/playground.js`
|
||||
2. browser engines (`PlaygroundAudio`, `PlaygroundVisual`, `PlaygroundGallery`)
|
||||
3. thin shared globals (`PlaygroundUtils`, `PlaygroundState`, `PlaygroundEvents`, `ModeManager`)
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
HTML[index.html] --> CSS[src/styles/design-system.css]
|
||||
HTML --> U[src/utils/utils.js]
|
||||
HTML --> S[src/utils/state.js]
|
||||
HTML --> E[src/utils/events.js]
|
||||
HTML --> A[src/engine/audio-engine.js]
|
||||
HTML --> V[src/engine/visual-engine.js]
|
||||
HTML --> G[src/gallery/gallery.js]
|
||||
HTML --> SP[src/panels/sound/sound-panel.js]
|
||||
HTML --> GP[src/panels/gallery/gallery-panel.js]
|
||||
HTML --> M[src/modes/mode-manager.js]
|
||||
HTML --> P[src/playground.js]
|
||||
|
||||
P --> A
|
||||
P --> V
|
||||
P --> G
|
||||
P --> SP
|
||||
P --> GP
|
||||
P --> M
|
||||
P --> S
|
||||
P --> E
|
||||
P --> U
|
||||
|
||||
User[User interactions] --> P
|
||||
P --> Canvas[Canvas 2D]
|
||||
P --> Audio[AudioContext]
|
||||
P --> DB[IndexedDB playground-gallery]
|
||||
DB --> GP
|
||||
SP --> A
|
||||
M --> Canvas
|
||||
Smoke[smoke-test.html] --> U
|
||||
Smoke --> S
|
||||
Smoke --> E
|
||||
Smoke --> A
|
||||
Smoke --> V
|
||||
Smoke --> G
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### `index.html`
|
||||
The real application shell.
|
||||
- loads `src/styles/design-system.css`
|
||||
- renders the entrance curtain, header, panels, canvas, action bar, and toast container
|
||||
- loads 10 classic `<script>` files in a strict dependency order
|
||||
- has no framework, bundler, or module loader
|
||||
|
||||
Script order is the runtime contract:
|
||||
1. `src/utils/utils.js`
|
||||
2. `src/utils/state.js`
|
||||
3. `src/utils/events.js`
|
||||
4. `src/engine/audio-engine.js`
|
||||
5. `src/engine/visual-engine.js`
|
||||
6. `src/gallery/gallery.js`
|
||||
7. `src/panels/sound/sound-panel.js`
|
||||
8. `src/panels/gallery/gallery-panel.js`
|
||||
9. `src/modes/mode-manager.js`
|
||||
10. `src/playground.js`
|
||||
|
||||
Because everything is loaded as globals, this order matters. `src/playground.js` assumes the prior globals already exist.
|
||||
|
||||
### `src/playground.js`
|
||||
The orchestration nucleus.
|
||||
Responsibilities:
|
||||
- entrance particle animation
|
||||
- enter transition
|
||||
- engine construction and initialization
|
||||
- canvas sizing
|
||||
- gallery boot
|
||||
- sound panel boot
|
||||
- ambient particle loop
|
||||
- mode registration
|
||||
- save/download/clear/fullscreen button wiring
|
||||
- panel toggle wiring
|
||||
- keyboard shortcut wiring
|
||||
|
||||
If you want to know what the product actually does today, this is the file.
|
||||
|
||||
### `smoke-test.html`
|
||||
The only real automated harness shipped in the target repo.
|
||||
- dynamically loads a subset of source files
|
||||
- performs 18 browser assertions around utils/state/events/audio/visual/gallery
|
||||
- writes green/red lines into the DOM
|
||||
- currently has a broken summary counter
|
||||
|
||||
### Engine modules
|
||||
- `src/engine/audio-engine.js`
|
||||
- Web Audio wrapper for notes, chords, scales, note playback, and chord playback
|
||||
- `src/engine/visual-engine.js`
|
||||
- Canvas wrapper for resize, clear, line/circle drawing, seeded palette generation, and placeholder noise
|
||||
- `src/gallery/gallery.js`
|
||||
- IndexedDB persistence layer
|
||||
|
||||
### Panel / mode modules
|
||||
- `src/panels/sound/sound-panel.js`
|
||||
- renders sound controls and quick-play chord UI
|
||||
- `src/panels/gallery/gallery-panel.js`
|
||||
- renders gallery thumbnails and empty state
|
||||
- `src/modes/mode-manager.js`
|
||||
- registry/switcher for canvas modes
|
||||
|
||||
## Data Flow
|
||||
|
||||
### App boot flow
|
||||
1. Browser opens `index.html`.
|
||||
2. CSS design system establishes the entire visual identity.
|
||||
3. Utility/state/event globals load.
|
||||
4. Audio, visual, gallery, panel, and mode globals load.
|
||||
5. `src/playground.js` runs immediately in an IIFE.
|
||||
6. The entrance screen appears with animated gold particles.
|
||||
7. User clicks `Enter` or presses any key.
|
||||
8. `enterPlayground()`:
|
||||
- fades the entrance out
|
||||
- creates and initializes `PlaygroundAudio`
|
||||
- reveals the playground
|
||||
- calls `initPlayground()`
|
||||
- plays a welcome chord
|
||||
|
||||
### Main interaction flow
|
||||
1. `initPlayground()` creates `PlaygroundVisual(canvas)`.
|
||||
2. Canvas is resized to the container.
|
||||
3. `PlaygroundGallery` opens IndexedDB and initializes the gallery panel.
|
||||
4. `SoundPanel.init(audioEngine)` renders the left control surface.
|
||||
5. `ModeManager.register()` adds two modes:
|
||||
- `free-draw`
|
||||
- `ambient`
|
||||
6. `ModeManager.renderSelector()` creates mode buttons.
|
||||
7. `ModeManager.switch('ambient')` makes the experience feel alive on load.
|
||||
|
||||
### Draw mode flow
|
||||
1. User switches to `Draw`.
|
||||
2. `free-draw.init()` binds mouse and touch listeners.
|
||||
3. Pointer movement draws lines on the canvas via `visualEngine.drawLine()`.
|
||||
4. X-position is mapped to frequency with `PlaygroundUtils.map()`.
|
||||
5. `audioEngine.play()` emits short sine notes while drawing.
|
||||
6. The first interaction hides the “Click anywhere to begin” prompt.
|
||||
|
||||
### Save/export flow
|
||||
1. User clicks `Save`.
|
||||
2. Canvas is converted to PNG via `canvas.toBlob()`.
|
||||
3. `FileReader` converts the blob to a data URL.
|
||||
4. `galleryEngine.save()` writes an object into IndexedDB with:
|
||||
- `id`
|
||||
- `created`
|
||||
- `modified`
|
||||
- `type`
|
||||
- `name`
|
||||
- `data`
|
||||
- `mimeType`
|
||||
- `thumbnail`
|
||||
- `metadata.mode`
|
||||
5. `gallery:item-saved` fires on the event bus.
|
||||
6. `GalleryPanel` rerenders.
|
||||
|
||||
### Gallery render flow
|
||||
1. `GalleryPanel.render()` calls `gallery.getAll()`.
|
||||
2. Results are sorted newest-first by ISO timestamp.
|
||||
3. Gallery HTML is rebuilt via `innerHTML`.
|
||||
4. Clicking a thumb currently only shows a toast with the item id prefix.
|
||||
- there is no real open/view/edit flow yet
|
||||
|
||||
### Download flow
|
||||
1. User clicks `Download`.
|
||||
2. Canvas blob is created.
|
||||
3. `PlaygroundUtils.downloadBlob()` synthesizes an `<a download>` link.
|
||||
4. Browser downloads a PNG snapshot.
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
### `PlaygroundUtils`
|
||||
A tiny global helpers object.
|
||||
Important methods:
|
||||
- `uuid()` -> `crypto.randomUUID()`
|
||||
- `clamp()`
|
||||
- `lerp()`
|
||||
- `map()`
|
||||
- `toast()`
|
||||
- `downloadBlob()`
|
||||
|
||||
It is intentionally small, but it is depended on by multiple subsystems.
|
||||
|
||||
### `PlaygroundState`
|
||||
A global mutable state container with sections for:
|
||||
- `canvas`
|
||||
- `audio`
|
||||
- `gallery`
|
||||
- `ui`
|
||||
- `recording`
|
||||
|
||||
It behaves more like a convenience registry than a true source-of-truth store. Real durable gallery data lives in IndexedDB, not here.
|
||||
|
||||
### `PlaygroundEvents`
|
||||
A minimal event bus:
|
||||
- `on(event, fn)`
|
||||
- `emit(event, data)`
|
||||
- `off(event, fn)`
|
||||
|
||||
This is the main loose-coupling seam across modules.
|
||||
|
||||
### `PlaygroundAudio`
|
||||
A lightweight music engine over `AudioContext`.
|
||||
Capabilities:
|
||||
- note-name to frequency conversion
|
||||
- chord construction
|
||||
- scale construction
|
||||
- one-shot oscillator playback
|
||||
- chord playback
|
||||
- analyser wiring for future visualization/reactivity
|
||||
|
||||
### `PlaygroundVisual`
|
||||
A minimal canvas wrapper.
|
||||
Capabilities:
|
||||
- resize canvas and bind context into `PlaygroundState`
|
||||
- clear canvas
|
||||
- draw lines and circles
|
||||
- deterministic palette generation from a seed
|
||||
- placeholder pseudo-noise function (`perlin2d`, not real Perlin)
|
||||
|
||||
### `PlaygroundGallery`
|
||||
A thin IndexedDB repository.
|
||||
Contract:
|
||||
- DB name: `playground-gallery`
|
||||
- store: `items`
|
||||
- indexes: `type`, `collection`, `created`
|
||||
- CRUD methods:
|
||||
- `init()`
|
||||
- `save(item)`
|
||||
- `getById(id)`
|
||||
- `getAll()`
|
||||
- `deleteItem(id)`
|
||||
|
||||
### `ModeManager`
|
||||
A registry + switcher for canvas modes.
|
||||
It holds:
|
||||
- `modes`
|
||||
- `current`
|
||||
- `register()`
|
||||
- `switch()`
|
||||
- `renderSelector()`
|
||||
|
||||
This is the intended extension point for future experiences.
|
||||
|
||||
### `SoundPanel` and `GalleryPanel`
|
||||
These are rendering adapters that convert state/engine methods into DOM UI.
|
||||
They keep the app readable by not putting every DOM template inside `src/playground.js`.
|
||||
|
||||
## API Surface
|
||||
|
||||
This repo has no network API. Its API surface is an in-browser global surface.
|
||||
|
||||
### Browser globals exposed by load order
|
||||
- `PlaygroundUtils`
|
||||
- `PlaygroundState`
|
||||
- `PlaygroundEvents`
|
||||
- `PlaygroundAudio`
|
||||
- `PlaygroundVisual`
|
||||
- `PlaygroundGallery`
|
||||
- `SoundPanel`
|
||||
- `GalleryPanel`
|
||||
- `ModeManager`
|
||||
|
||||
### Event bus contract
|
||||
Observed event names:
|
||||
- `audio:note-played`
|
||||
- `audio:chord-played`
|
||||
- `gallery:item-saved`
|
||||
- `gallery:item-deleted`
|
||||
- `canvas:mode-changed`
|
||||
- `playground:ready`
|
||||
|
||||
### IndexedDB object contract
|
||||
Saved gallery items can contain:
|
||||
- `id`
|
||||
- `created`
|
||||
- `modified`
|
||||
- `type`
|
||||
- `name`
|
||||
- `data`
|
||||
- `mimeType`
|
||||
- `thumbnail`
|
||||
- `metadata`
|
||||
|
||||
### UI control contract
|
||||
Important DOM ids and commands:
|
||||
- `btn-save`
|
||||
- `btn-download`
|
||||
- `btn-clear`
|
||||
- `btn-fullscreen`
|
||||
- `mode-selector`
|
||||
- `sound-content`
|
||||
- `gallery-content`
|
||||
- `playground-canvas`
|
||||
|
||||
Keyboard shortcuts implemented today:
|
||||
- `Ctrl+S` -> Save
|
||||
- `Ctrl+D` -> Download
|
||||
- `F11` -> Fullscreen
|
||||
- `Escape` -> exit fullscreen
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
### Current state
|
||||
What I verified on a fresh clone of `main`:
|
||||
- `find src -name '*.js' -print0 | xargs -0 -n1 node --check` -> passes
|
||||
- `python3 -m pytest -q` -> `no tests ran in 0.02s`
|
||||
- `smoke-test.html` runs 18 browser assertions successfully
|
||||
- but `smoke-test.html` reports `0 passed, 0 failed` in the summary even while showing 18 green checks
|
||||
|
||||
This means the repo has a manual browser smoke harness, but no real automated CI-grade test suite.
|
||||
|
||||
### What is covered by `smoke-test.html`
|
||||
- UUID/clamp/lerp helpers
|
||||
- default state and snapshot
|
||||
- event bus firing
|
||||
- AudioContext construction and music theory helpers
|
||||
- canvas visual primitives and deterministic palette generation
|
||||
- IndexedDB save/getAll/getById/delete flow
|
||||
|
||||
### What is not covered and should be
|
||||
1. `src/playground.js` orchestration
|
||||
- entrance flow
|
||||
- mode registration
|
||||
- action bar wiring
|
||||
- keyboard shortcuts
|
||||
- panel toggles
|
||||
|
||||
2. `ModeManager`
|
||||
- teardown/init switching order
|
||||
- active button state
|
||||
- event emission correctness
|
||||
|
||||
3. `SoundPanel`
|
||||
- BPM slider updates state
|
||||
- quality button activation
|
||||
- chord button actually invokes audio engine
|
||||
- volume slider is rendered but currently unwired
|
||||
|
||||
4. `GalleryPanel`
|
||||
- empty/non-empty rendering
|
||||
- item-count text updates
|
||||
- click behavior
|
||||
- escaping/sanitization of item fields before `innerHTML`
|
||||
|
||||
5. cross-module browser integration
|
||||
- draw mode pointer lifecycle
|
||||
- touch behavior
|
||||
- fullscreen and download wiring
|
||||
- prompt fade-out on first interaction
|
||||
|
||||
### Generated missing tests for critical paths
|
||||
|
||||
#### A. Mode switching contract test
|
||||
A Node+VM or browser test should verify teardown/init ordering and active button state.
|
||||
|
||||
```python
|
||||
# pseudo-test idea
|
||||
# load utils/state/events/mode-manager
|
||||
# register two fake modes with counters
|
||||
# switch twice
|
||||
# assert first teardown ran before second init
|
||||
# assert PlaygroundState.canvas.mode updated
|
||||
```
|
||||
|
||||
#### B. Smoke summary correctness test
|
||||
The current smoke harness is lying about pass/fail totals.
|
||||
|
||||
```python
|
||||
# browser-level assertion
|
||||
# after smoke-test.html finishes,
|
||||
# count the green result rows and compare them to the h2 summary
|
||||
```
|
||||
|
||||
#### C. GalleryPanel XSS regression test
|
||||
`GalleryPanel.render()` builds markup with `innerHTML` from gallery item data.
|
||||
That should be locked down with a test before the panel grows more capable.
|
||||
|
||||
```python
|
||||
# save item with name containing HTML-like content
|
||||
# render gallery
|
||||
# assert rendered text is escaped / inert
|
||||
# assert no unexpected nodes/scripts are created
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Strengths
|
||||
- zero network/API attack surface in the app itself
|
||||
- no dependency tree or third-party script loaders
|
||||
- local-first persistence using IndexedDB instead of remote storage
|
||||
- deterministic, transparent runtime based on classic script tags
|
||||
- reduced-motion CSS support already present
|
||||
|
||||
### Risks and caveats
|
||||
1. `innerHTML` is used in multiple modules.
|
||||
- `ModeManager.renderSelector()` builds buttons with `innerHTML`
|
||||
- `SoundPanel.render()` builds control markup with `innerHTML`
|
||||
- `GalleryPanel.render()` builds gallery thumbnails with `innerHTML`
|
||||
- The first two are fed by trusted in-repo data.
|
||||
- `GalleryPanel.render()` is the risky one because it interpolates gallery item data (`item.name`, `item.thumbnail`) coming back from IndexedDB.
|
||||
|
||||
2. Browser capability assumptions are strong.
|
||||
- `crypto.randomUUID()`
|
||||
- `AudioContext`
|
||||
- `indexedDB`
|
||||
- `canvas.toBlob()`
|
||||
- Fullscreen API
|
||||
- FileReader
|
||||
- all are required for the best path
|
||||
|
||||
3. No storage limits or cleanup policy.
|
||||
- IndexedDB can grow without quotas or cleanup UX inside the app
|
||||
- saved images are stored as data URLs, which can become heavy over time
|
||||
|
||||
4. No CSP/integrity story because the repo assumes direct static hosting or file-open execution.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Browser/runtime dependencies
|
||||
- Canvas 2D API
|
||||
- Web Audio API / `AudioContext`
|
||||
- IndexedDB
|
||||
- Fullscreen API
|
||||
- Blob / `toBlob`
|
||||
- FileReader
|
||||
- `crypto.randomUUID()`
|
||||
- standard DOM APIs
|
||||
|
||||
### Project/tooling dependencies
|
||||
- none declared
|
||||
- no `package.json`
|
||||
- no `requirements.txt`
|
||||
- no build tooling
|
||||
- no CI workflow files on `main`
|
||||
|
||||
### Verification tools used during analysis
|
||||
- `node --check` for JS syntax verification
|
||||
- browser execution of `smoke-test.html`
|
||||
- `pytest` baseline probe, which confirmed there is no Python test suite in this target repo
|
||||
|
||||
## Deployment
|
||||
|
||||
The deployment model is intentionally trivial.
|
||||
|
||||
How to run it today:
|
||||
- open `index.html` in a browser
|
||||
- or serve the repo as static files from any plain web server
|
||||
|
||||
There is no backend, no API contract, no environment variables, and no deployment automation in the target repo.
|
||||
|
||||
Practical verification flow:
|
||||
1. `find src -name '*.js' -print0 | xargs -0 -n1 node --check`
|
||||
2. open `smoke-test.html`
|
||||
3. open `index.html`
|
||||
4. click `Enter`
|
||||
5. verify:
|
||||
- entrance transition
|
||||
- ambient mode active by default
|
||||
- sound panel playable
|
||||
- save creates a gallery item in IndexedDB
|
||||
- download exports a PNG
|
||||
|
||||
## Technical Debt
|
||||
|
||||
### Highest-priority debt
|
||||
1. README vision vs code reality gap
|
||||
- the README describes a much larger platform than the current implementation
|
||||
- mainline code today is a polished shell plus two real modes
|
||||
|
||||
2. No real automated test suite
|
||||
- `python3 -m pytest -q` returns `no tests ran`
|
||||
- the only harness is `smoke-test.html`
|
||||
- the smoke harness summary is already broken
|
||||
|
||||
3. `GalleryPanel.render()` trusts item data too much
|
||||
- direct `innerHTML` interpolation of stored item fields is a future XSS footgun
|
||||
|
||||
4. Global load-order coupling
|
||||
- every major module assumes previous globals are already loaded
|
||||
- there is no module isolation or dependency enforcement beyond script order
|
||||
|
||||
5. Volume slider is fake right now
|
||||
- `vol-slider` exists in `SoundPanel.render()`
|
||||
- there is no listener wiring it to `audioEngine.masterGain`
|
||||
|
||||
### Meaningful product debt
|
||||
- gallery items do not really open; click only toasts an id prefix
|
||||
- no import/restore/export package flows
|
||||
- no video forge
|
||||
- no games floor
|
||||
- no persistence integration between `PlaygroundState.gallery` and IndexedDB
|
||||
- `mode-label` in the footer exists but is never updated
|
||||
- `canvas-overlay` exists but is unused
|
||||
- `perlin2d()` is explicitly a placeholder, not real Perlin noise
|
||||
- skip-link CSS exists, but no skip link appears in `index.html`
|
||||
|
||||
## Bottom Line
|
||||
|
||||
The Playground is a clean sovereign-web prototype: one HTML shell, one design system, a handful of browser engines, and a strong aesthetic identity. It already proves the interaction model.
|
||||
|
||||
What it does not yet have is the verification, hardening, and feature depth implied by its own vision. The core challenge now is not invention. It is contraction into truth:
|
||||
- make the shipped surface match the promise
|
||||
- turn `smoke-test.html` into real automated coverage
|
||||
- harden `innerHTML` paths
|
||||
- finish the panel/mode/gallery interactions that are still only half-born
|
||||
Reference in New Issue
Block a user