From 015804843634d496c8b128ba72e9259cb06c590b Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:19:34 +0200 Subject: [PATCH 01/11] =?UTF-8?q?docs(plan):=20Site=20Plan=20C=20=E2=80=94?= =?UTF-8?q?=20merged=20Try-it=20+=20sport=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-08-28-siteC-tryit-sports.md | 1754 +++++++++++++++++ 1 file changed, 1754 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-siteC-tryit-sports.md diff --git a/docs/superpowers/plans/2026-08-28-siteC-tryit-sports.md b/docs/superpowers/plans/2026-08-28-siteC-tryit-sports.md new file mode 100644 index 0000000..dc89cea --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-siteC-tryit-sports.md @@ -0,0 +1,1754 @@ +# Plan C — "Try it" Merge + Sport Pages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute this plan. Work one task at a time, dispatch a subagent per task, and stop at each review checkpoint. Do not batch tasks. + +## Goal + +Ship Plan C of 4 of the site redesign. Two parts: + +**Part 1 — one "Try it" page.** The existing renderer page +(`site/src/pages/playground/renderer.astro`) is already the merged +editor + validate + render + canvas + examples-dropdown surface. Move it to the +new canonical path `site/src/pages/use/try-it.astro` and restructure **only its +layout**: editor on the left, a tab strip **[Validation] [Diagram]** on the +right; on the Diagram tab the editor collapses to a thin strip so the +canvas gets **full width** (plus a fullscreen button) and scales responsively +(replacing today's fixed two-column split). Integrate the version-aware +validator (0.2.0): a schema-version badge ("validated against X.Y.Z" from +`Result.schema.validatedAgainst`) and a "newer version available" hint driven by +`https://opencoachingformat.org/schema/versions.json`. Bump the pinned validator +to `0.2.0`. The old pages `playground.astro` and `playground/renderer.astro` +**redirect** to `/use/try-it/`; the Use hub points its cards there. + +**Part 2 — sport pages under Learn.** A build step generates +`site/src/generated/sports.json` from the repo's `sports/*.json` (same +read-repo-at-build → write-`src/generated/` pattern as `build-adoc.mjs`). One +overview page `learn/sports/index.astro` (5 cards, status badges) and one shared +template `learn/sports/[sport].astro` using `getStaticPaths` to emit +`/learn/sports/{basketball,soccer,handball,hockey,futsal}/`. Basketball (status +`defined`) shows the full vocabulary (action types, variants, outcomes, +rulesets); the four provisional sports show a "reserved" status, their +provisional action types, and a prominent **Contribute** block. Finally, the +homepage sport cards' `href` moves from `/learn/` to `/learn/sports/`. + +Plan C does **not** change any schema/validator code — it consumes the already +shipped 0.2.0 validator and the existing `sports/*.json` files. + +## Architecture + +**Verified facts (2026-08-28):** + +- `site/src/pages/playground/renderer.astro` (722 lines) is already the merged + "Try it" surface: an examples ` + + +
+
+ + + +
+ +
+ +
Click Validate to check the document.
+
+ + +
+ + ``` + + Keep the `.controls` block (example select + Validate/Render/Reset/Feedback + buttons) and the `` block exactly as they are, + in the same positions. + + **Change 2c — the ` + ``` + +3. Build and assert the overview renders all five cards with the right badges and + links. Run: + ``` + cd site && npm run build + node -e "const fs=require('fs');const p='dist/learn/sports/index.html';if(!fs.existsSync(p)){console.error('MISSING '+p);process.exit(1)}const h=fs.readFileSync(p,'utf8');const need=['

Sports

','href=\"/learn/sports/basketball/\"','href=\"/learn/sports/soccer/\"','href=\"/learn/sports/handball/\"','href=\"/learn/sports/hockey/\"','href=\"/learn/sports/futsal/\"','>Full<','>Reserved<'];const miss=need.filter(s=>!h.includes(s));if(miss.length){console.error('MISSING on overview:',miss.join(', '));process.exit(1)}const reservedCount=(h.match(/>Reserved ({ + params: { sport: sport.sport }, + props: { sport }, + })); + } + + const { sport } = Astro.props; + const variantEntries = Object.entries(sport.variants ?? {}); + --- + +
+
+

{sport.label}

+ + {sport.reserved ? 'Reserved' : 'Full'} + +
+

+ {sport.reserved + ? 'The shared OCF structure — court, entities, frames — already fits this sport. Its action vocabulary is provisional and open for contribution.' + : 'This sport is fully specified: the action vocabulary, outcomes, and supported rulesets below are stable.'} +

+
+ +
+

Action types

+ {sport.action_types.length > 0 ? ( +
    + {sport.action_types.map((a) =>
  • {a}
  • )} +
+ ) : ( +

No action types defined yet.

+ )} +
+ + {!sport.reserved && ( + +
+

Variants

+ {variantEntries.length > 0 ? ( +
+ {variantEntries.map(([action, variants]) => ( +
+
{action}
+
+
    + {variants.map((v) =>
  • {v}
  • )} +
+
+
+ ))} +
+ ) : ( +

No variants defined.

+ )} +
+ +
+

Outcomes

+ {sport.outcomes.length > 0 ? ( +
    + {sport.outcomes.map((o) =>
  • {o}
  • )} +
+ ) : ( +

No outcomes defined.

+ )} +
+ +
+

Rulesets

+ {sport.rulesets.length > 0 ? ( +
    + {sport.rulesets.map((r) =>
  • {r}
  • )} +
+ ) : ( +

No rulesets defined.

+ )} +
+
+ )} + + {sport.reserved && ( +
+

Contribute

+

+ {sport.label} is reserved: the format is ready, but its action + vocabulary needs the people who know the game. If you coach or play + {` ${sport.label.toLowerCase()}`}, you can help define the actions, + variants, outcomes, and rulesets that make an OCF {sport.label.toLowerCase()} + play precise. +

+
    +
  • + Sport experts wanted. Propose the action vocabulary + for {sport.label} through the RFC process — no code required to start + the conversation. +
  • +
  • + Open an RFC or discussion. See the + {' '} + RFC process + {' '}in the specification repository. +
  • +
  • + Browse the repository. The + {' '} + opencoachingformat/spec + {' '}repo holds the sport registry files under sports/. +
  • +
+
+ )} + +

← All sports

+ + + + ``` + +3. Build and assert every sport route resolves and the branches render correctly. + Basketball must show Variants/Outcomes/Rulesets and no Contribute block; + reserved sports must show a Contribute block and "sport experts wanted", and + must **not** render a Variants section. Run: + ``` + cd site && npm run build + node -e " + const fs=require('fs'); + const routes=['basketball','soccer','handball','hockey','futsal']; + for(const s of routes){const p='dist/learn/sports/'+s+'/index.html';if(!fs.existsSync(p)){console.error('MISSING '+p);process.exit(1)}} + const b=fs.readFileSync('dist/learn/sports/basketball/index.html','utf8'); + for(const s of ['

Basketball','>Full<','Action types','Variants','Outcomes','Rulesets']){if(!b.includes(s)){console.error('basketball missing:',s);process.exit(1)}} + if(b.includes('Contribute')){console.error('basketball should not have Contribute block');process.exit(1)} + for(const chip of ['fiba','nba','ncaa','nfhs']){if(!b.includes('>'+chip+'<')){console.error('basketball missing ruleset chip',chip);process.exit(1)}} + for(const s of ['soccer','handball','hockey','futsal']){ + const h=fs.readFileSync('dist/learn/sports/'+s+'/index.html','utf8'); + if(!h.includes('>Reserved<')){console.error(s+' missing Reserved badge');process.exit(1)} + if(!h.includes('Contribute')){console.error(s+' missing Contribute block');process.exit(1)} + if(!h.includes('Sport experts wanted')){console.error(s+' missing experts-wanted copy');process.exit(1)} + if(h.includes('>Variants<')){console.error(s+' should not render Variants section');process.exit(1)} + if(!h.includes('Action types')){console.error(s+' missing Action types');process.exit(1)} + } + console.log('sport template OK: 5 routes, basketball full vocab, reserved sports show Contribute'); + " + ``` + Expect success. + +4. Commit: `feat(site): shared per-sport template via getStaticPaths`. + +--- + +## Task 9: Point the homepage sport cards at `/learn/sports/` + +**Files:** +- `site/src/pages/index.astro` (change the sport-cards `href` only) + +Steps: + +1. Confirm the current placeholder href: + ``` + cd site && node -e "const fs=require('fs');const c=fs.readFileSync('src/pages/index.astro','utf8');if(!c.includes('href=\"/learn/\"')){console.error('expected placeholder href=\"/learn/\" on homepage sport cards');process.exit(1)}console.log('pre-edit: homepage sport cards point at /learn/')" + ``` + Expect success. + +2. In `site/src/pages/index.astro`, the sport cards are rendered in the + "One format, every sport" section: + ```astro +
+ {sports.map((sport) => ( + + ))} +
+ ``` + Change **only** the sport-card `href` from `/learn/` to `/learn/sports/`: + ```astro +
+ {sports.map((sport) => ( + + ))} +
+ ``` + Leave the "Where to go next" cards (Learn/Use/Build) unchanged — the + Learn card there still points at `/learn/`. + +3. Build and assert the sport cards now link to `/learn/sports/` while the "where + next" Learn card still links to `/learn/`. Run: + ``` + cd site && npm run build + node -e "const fs=require('fs');const h=fs.readFileSync('dist/index.html','utf8');if(!h.includes('href=\"/learn/sports/\"')){console.error('homepage sport cards not repointed to /learn/sports/');process.exit(1)}if(!h.includes('href=\"/learn/\"')){console.error('where-next Learn card should still link /learn/');process.exit(1)}const sportsLinks=(h.match(/href=\"\/learn\/sports\/\"/g)||[]).length;if(sportsLinks<5){console.error('expected 5 sport-card links to /learn/sports/, saw',sportsLinks);process.exit(1)}console.log('homepage sport cards repointed OK')" + ``` + Expect success. + +4. Commit: `feat(site): homepage sport cards link to /learn/sports/`. + +--- + +## Task 10: Update the a11y workflow (route list + unit-test step) + +**Files:** +- `.github/workflows/a11y-check.yml` (edit the axe route list and the unit-test step) + +> Editing `.github/workflows/*.yml` triggers a security hook, so do **not** use +> the Edit tool. Patch with the `python3` heredoc below and validate as YAML. + +Steps: + +1. Failing check first — confirm the target changes are not yet present. Run: + ``` + python3 - <<'PY' + import sys + text = open('.github/workflows/a11y-check.yml').read() + problems = [] + if 'localhost:4321/use/try-it/' in text: problems.append('/use/try-it/ already present') + if 'localhost:4321/learn/sports/' not in text: pass + else: problems.append('/learn/sports/ already present') + if 'test/schema-versions.test.mjs' in text: problems.append('schema-versions test already in step') + if 'localhost:4321/playground/renderer/' not in text: problems.append('renderer route unexpectedly absent') + if problems: + print('PRE-EDIT unexpected state:', '; '.join(problems)); sys.exit(1) + print('pre-edit: renderer route present, new routes/tests absent as expected') + PY + ``` + Expect success. + +2. Patch the workflow: (a) add the two new unit-test files to the "Diagram + pipeline unit tests" step, (b) replace the `/playground/renderer/` axe route + with `/use/try-it/` and add the sport routes. Run: + ``` + python3 - <<'PY' + path = '.github/workflows/a11y-check.yml' + text = open(path).read() + + # (a) extend the unit-test step with the two new test files. + old_test = 'run: node --test test/diagram.test.mjs test/replace-plantuml.test.mjs' + new_test = 'run: node --test test/diagram.test.mjs test/replace-plantuml.test.mjs test/schema-versions.test.mjs test/build-sports.test.mjs' + assert old_test in text, 'unit-test step anchor not found; inspect the workflow' + text = text.replace(old_test, new_test, 1) + + # (b) replace the renderer route line with the try-it + sport routes. + old_route = ' http://localhost:4321/playground/renderer/ \\\n' + assert old_route in text, 'renderer axe route anchor not found; inspect the workflow' + new_routes = ( + ' http://localhost:4321/use/try-it/ \\\n' + ' http://localhost:4321/learn/sports/ \\\n' + ' http://localhost:4321/learn/sports/basketball/ \\\n' + ' http://localhost:4321/learn/sports/soccer/ \\\n' + ' http://localhost:4321/learn/sports/handball/ \\\n' + ' http://localhost:4321/learn/sports/hockey/ \\\n' + ' http://localhost:4321/learn/sports/futsal/ \\\n' + ) + text = text.replace(old_route, new_routes, 1) + + open(path, 'w').write(text) + print('patched a11y-check.yml: new tests + try-it/sport axe routes, renderer route removed') + PY + ``` + +3. Validate the file is well-formed YAML and the changes landed. Run: + ``` + python3 -c "import yaml; d=yaml.safe_load(open('.github/workflows/a11y-check.yml')); assert d['name']=='Accessibility Check'; print('YAML valid; workflow name:', d['name'])" + python3 - <<'PY' + import sys + text = open('.github/workflows/a11y-check.yml').read() + need = [ + 'localhost:4321/use/try-it/', + 'localhost:4321/learn/sports/', + 'localhost:4321/learn/sports/basketball/', + 'localhost:4321/learn/sports/soccer/', + 'localhost:4321/learn/sports/handball/', + 'localhost:4321/learn/sports/hockey/', + 'localhost:4321/learn/sports/futsal/', + 'test/schema-versions.test.mjs', + 'test/build-sports.test.mjs', + ] + missing = [n for n in need if n not in text] + if missing: + print('MISSING after patch:', ', '.join(missing)); sys.exit(1) + if 'localhost:4321/playground/renderer/' in text: + print('renderer route still present; it should have been replaced'); sys.exit(1) + print('a11y workflow OK: try-it + sport routes present, renderer route removed, new tests wired') + PY + ``` + Expect both to succeed. + +4. Commit: `ci(a11y): axe /use/try-it/ + sport routes; run schema-versions/build-sports tests`. + +--- + +## Task 11: Full-site verification and open a PR against main (do not merge) + +**Files:** +- (none changed — verification and PR only) + +Steps: + +1. Run the full unit-test suite (all `node --test` files plus the hand-rolled + playground runner) to confirm nothing regressed: + ``` + cd site && node --test test/diagram.test.mjs test/replace-plantuml.test.mjs test/schema-versions.test.mjs test/build-sports.test.mjs + node src/lib/renderer-playground.test.mjs + ``` + Expect all to pass. + +2. Run a clean full build and assert every route Plan C touches resolves, the old + playground URLs forward, and no page still links the retired URLs. This is the + end-to-end gate. Run: + ``` + cd site && rm -rf dist && npm run build + node -e " + const fs=require('fs'); + const routes=[ + 'index.html','learn/index.html','use/index.html','build/index.html', + 'use/try-it/index.html', + 'learn/sports/index.html', + 'learn/sports/basketball/index.html','learn/sports/soccer/index.html', + 'learn/sports/handball/index.html','learn/sports/hockey/index.html','learn/sports/futsal/index.html', + 'playground/index.html','playground/renderer/index.html', + 'docs/index.html','docs/spec/index.html','docs/schema/index.html', + 'docs/features/index.html','docs/rulesets/index.html', + 'examples/index.html','errors/index.html','ecosystem/index.html' + ]; + const miss=routes.filter(r=>!fs.existsSync('dist/'+r)); + if(miss.length){console.error('MISSING built routes:',miss.join(', '));process.exit(1)} + // old playground URLs forward to /use/try-it/ + for(const r of ['playground/index.html','playground/renderer/index.html']){ + const h=fs.readFileSync('dist/'+r,'utf8'); + if(!/use\/try-it/.test(h)){console.error(r+' does not forward to /use/try-it/');process.exit(1)} + } + // the Use hub and homepage no longer link the retired playground URLs + const use=fs.readFileSync('dist/use/index.html','utf8'); + if(use.includes('href=\"/playground/\"')||use.includes('href=\"/playground/renderer/\"')){console.error('Use hub still links old playground URLs');process.exit(1)} + const home=fs.readFileSync('dist/index.html','utf8'); + if(!home.includes('href=\"/learn/sports/\"')){console.error('homepage sport cards not repointed');process.exit(1)} + console.log('full-site OK: '+routes.length+' routes built, redirects forward, links updated'); + " + ``` + Expect success. + +3. CI note: `.github/workflows/a11y-check.yml` runs automatically on this PR + because it touches `site/src/pages/**`, `site/src/lib/**`, `site/test/**`, + `site/scripts/**`, and `site/package.json`. It runs the unit tests (now + including `schema-versions` and `build-sports`), builds the site, and axes the + route list (now `/use/try-it/` and the five sport routes). No manual CI trigger + is needed; confirm the checks go green on the PR before handoff. + +4. Push the branch and open a PR **based on `main`** (NOT stacked on Plan A/B — + the prior stacked-PR incident means the base MUST be `main`). Do **not** merge; + the maintainer merges. Run: + ``` + gh pr create --repo opencoachingformat/spec --base main \ + --title "Plan C: merged 'Try it' page + sport pages" \ + --body "Plan C of the site redesign (see docs/superpowers/specs/2026-08-28-site-redesign-design.md and docs/superpowers/plans/2026-08-28-siteC-tryit-sports.md). + + Part 1 — merged Try it page: + - Moved playground/renderer.astro to /use/try-it/ and restructured the layout: editor left, [Validation] [Diagram] tabs right; the Diagram tab collapses the editor to a thin strip so the canvas is full width, with a fullscreen button. Rendering logic (WebGL canvas, renderer-playground.mjs imports, __OCF_RENDERER_PLAYGROUND_VARS__ bridge) is preserved unchanged. + - Version-aware validator transparency: a 'validated against X.Y.Z' schema badge (from Result.schema.validatedAgainst) and a 'newer schema available' hint driven by /schema/versions.json. New site/src/lib/schema-versions.mjs (isNewerVersion + fetchLatestSchemaVersion), unit-tested with injected fetch. + - Bumped the ocf-validator pin from 0.1.1 to 0.2.0. + - /playground and /playground/renderer now redirect to /use/try-it/; the Use hub links /use/try-it/. + + Part 2 — sport pages under Learn: + - New build step scripts/build-sports.mjs generates src/generated/sports.json from sports/*.json (unit-tested pure transform). + - Overview /learn/sports/ (5 cards, status badges) and one shared template /learn/sports/[sport].astro via getStaticPaths: basketball shows the full vocabulary; the four provisional sports show a Reserved status and a Contribute block. + - Homepage sport cards repointed from /learn/ to /learn/sports/. + + CI: a11y axe route list swaps /playground/renderer/ for /use/try-it/ and adds the sport routes; the unit-test step runs the new schema-versions and build-sports tests. + + Do not merge — leaving for the maintainer to review and merge. + + 🤖 Generated with [Claude Code](https://claude.com/claude-code)" + ``` + Do **not** merge the PR. Report the PR URL and leave it open for the maintainer. From 1c4201a209cdd3fd131aa4786b2a7253805dea72 Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:20:28 +0200 Subject: [PATCH 02/11] feat(site): schema-versions helper (isNewerVersion + fetchLatestSchemaVersion) --- site/src/lib/schema-versions.mjs | 50 ++++++++++++++++++++ site/test/schema-versions.test.mjs | 75 ++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 site/src/lib/schema-versions.mjs create mode 100644 site/test/schema-versions.test.mjs diff --git a/site/src/lib/schema-versions.mjs b/site/src/lib/schema-versions.mjs new file mode 100644 index 0000000..1d13645 --- /dev/null +++ b/site/src/lib/schema-versions.mjs @@ -0,0 +1,50 @@ +// Testable helpers for the "Try it" schema-version transparency: +// - isNewerVersion(a, b): true iff semver a > b (numeric core compare). +// - fetchLatestSchemaVersion(fetchImpl): reads `latest` from +// https://opencoachingformat.org/schema/versions.json; fail-soft (null). +// No side effects; fetch is injected so this file is unit-testable offline. + +export const SCHEMA_VERSIONS_URL = + "https://opencoachingformat.org/schema/versions.json"; + +// Parse "v1.4.0-rc.1" -> [1, 4, 0]; the pre-release "-…" suffix is dropped so +// comparisons run on the numeric core. Returns null if no numeric core. +function parseCore(version) { + if (typeof version !== "string") return null; + const core = version.trim().replace(/^v/i, "").split("-")[0].split("+")[0]; + if (core === "") return null; + const parts = core.split("."); + const nums = []; + for (const p of parts) { + if (!/^\d+$/.test(p)) return null; + nums.push(Number(p)); + } + return nums.length > 0 ? nums : null; +} + +export function isNewerVersion(candidate, baseline) { + const a = parseCore(candidate); + const b = parseCore(baseline); + if (a === null || b === null) return false; + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const ai = a[i] ?? 0; + const bi = b[i] ?? 0; + if (ai > bi) return true; + if (ai < bi) return false; + } + return false; +} + +export async function fetchLatestSchemaVersion(fetchImpl) { + const doFetch = fetchImpl ?? fetch; + try { + const res = await doFetch(SCHEMA_VERSIONS_URL); + if (!res || !res.ok) return null; + const data = await res.json(); + const latest = data && data.latest; + return typeof latest === "string" && latest !== "" ? latest : null; + } catch { + return null; + } +} diff --git a/site/test/schema-versions.test.mjs b/site/test/schema-versions.test.mjs new file mode 100644 index 0000000..6f16d87 --- /dev/null +++ b/site/test/schema-versions.test.mjs @@ -0,0 +1,75 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isNewerVersion, fetchLatestSchemaVersion } from "../src/lib/schema-versions.mjs"; + +test("isNewerVersion: strictly greater major/minor/patch", () => { + assert.equal(isNewerVersion("1.4.0", "1.3.0"), true); + assert.equal(isNewerVersion("2.0.0", "1.9.9"), true); + assert.equal(isNewerVersion("1.3.1", "1.3.0"), true); +}); + +test("isNewerVersion: equal versions are not newer", () => { + assert.equal(isNewerVersion("1.4.0", "1.4.0"), false); +}); + +test("isNewerVersion: older is not newer", () => { + assert.equal(isNewerVersion("1.3.0", "1.4.0"), false); + assert.equal(isNewerVersion("1.3.0", "1.3.1"), false); +}); + +test("isNewerVersion: tolerates a leading v", () => { + assert.equal(isNewerVersion("v1.4.0", "1.3.0"), true); + assert.equal(isNewerVersion("1.4.0", "v1.4.0"), false); +}); + +test("isNewerVersion: compares numeric core, ignoring pre-release suffix", () => { + assert.equal(isNewerVersion("1.4.0-rc.1", "1.4.0"), false); + assert.equal(isNewerVersion("1.5.0-rc.1", "1.4.0"), true); +}); + +test("isNewerVersion: shorter version treated as zero-padded", () => { + assert.equal(isNewerVersion("1.4", "1.4.0"), false); + assert.equal(isNewerVersion("1.4.1", "1.4"), true); +}); + +test("isNewerVersion: non-parseable input returns false (no false alarm)", () => { + assert.equal(isNewerVersion("", "1.0.0"), false); + assert.equal(isNewerVersion("1.0.0", ""), false); + assert.equal(isNewerVersion(null, "1.0.0"), false); + assert.equal(isNewerVersion("abc", "1.0.0"), false); +}); + +test("fetchLatestSchemaVersion: returns the latest field from versions.json", async () => { + const calls = []; + const fakeFetch = async (url) => { + calls.push(url); + return { + ok: true, + status: 200, + json: async () => ({ + latest: "1.4.0", + major: "v1", + versions: ["1.0.0", "1.4.0"], + schema_url: "https://opencoachingformat.org/schema/v1.json", + }), + }; + }; + const latest = await fetchLatestSchemaVersion(fakeFetch); + assert.equal(latest, "1.4.0"); + assert.match(calls[0], /\/schema\/versions\.json$/); +}); + +test("fetchLatestSchemaVersion: non-OK response returns null (fail soft)", async () => { + const fakeFetch = async () => ({ ok: false, status: 500, json: async () => ({}) }); + assert.equal(await fetchLatestSchemaVersion(fakeFetch), null); +}); + +test("fetchLatestSchemaVersion: fetch rejection returns null (fail soft)", async () => { + const fakeFetch = async () => { throw new Error("network down"); }; + assert.equal(await fetchLatestSchemaVersion(fakeFetch), null); +}); + +test("fetchLatestSchemaVersion: missing latest field returns null", async () => { + const fakeFetch = async () => ({ ok: true, status: 200, json: async () => ({ versions: [] }) }); + assert.equal(await fetchLatestSchemaVersion(fakeFetch), null); +}); From 4ae79fc0c7ff594891adc5daf79e7c548a129d82 Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:21:30 +0200 Subject: [PATCH 03/11] chore(site): pin ocf-validator to 0.2.0 + error-codes commit (18 codes) Bumps the browser validator to the version-aware 0.2.0 and moves the error-codes commit pin to the same release so SCHEMA_MAJOR_UNSUPPORTED and VALIDATOR_MAYBE_OUTDATED appear on /errors. --- site/src/lib/validator-version.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/lib/validator-version.mjs b/site/src/lib/validator-version.mjs index 11c9315..1d05cdf 100644 --- a/site/src/lib/validator-version.mjs +++ b/site/src/lib/validator-version.mjs @@ -14,13 +14,13 @@ // This is a plain .mjs (not .ts) so it can be imported both by Astro pages // (via Vite) and directly by scripts/build-adoc.mjs, which runs under plain // `node` with no TypeScript loader configured. -export const OCF_VALIDATOR_VERSION = '0.1.1'; +export const OCF_VALIDATOR_VERSION = '0.2.0'; const JSDELIVR_NPM_BASE = `https://cdn.jsdelivr.net/npm/@opencoachingformat/validator@${OCF_VALIDATOR_VERSION}`; export const OCF_VALIDATOR_BROWSER_URL = `${JSDELIVR_NPM_BASE}/dist/browser/browser.js`; -export const OCF_VALIDATOR_COMMIT = '45a11869cfe170269d53a91890063b9ccc95f5fd'; +export const OCF_VALIDATOR_COMMIT = '07361d99f2433f2c715486e0894da5594160a218'; const JSDELIVR_GH_BASE = `https://cdn.jsdelivr.net/gh/opencoachingformat/ocf-validator@${OCF_VALIDATOR_COMMIT}`; From d13dbf5d6a723cc966ea090dad5586590f8c1f41 Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:24:40 +0200 Subject: [PATCH 04/11] feat(site): move renderer to /use/try-it/ with tabbed Validation/Diagram layout --- site/src/pages/use/try-it.astro | 874 ++++++++++++++++++++++++++++++++ 1 file changed, 874 insertions(+) create mode 100644 site/src/pages/use/try-it.astro diff --git a/site/src/pages/use/try-it.astro b/site/src/pages/use/try-it.astro new file mode 100644 index 0000000..b7fccf0 --- /dev/null +++ b/site/src/pages/use/try-it.astro @@ -0,0 +1,874 @@ +--- +import Base from '../../layouts/Base.astro'; +import examples from '../../generated/examples.json'; +import { OCF_VALIDATOR_VERSION, OCF_VALIDATOR_BROWSER_URL } from '../../lib/validator-version.mjs'; +import { OCF_RENDERER_VERSION } from '../../lib/renderer-version.mjs'; +import { buildDiscussionUrl } from '../../lib/discussions.mjs'; +import rendererModuleUrl from '../../generated/renderer/index.js?url'; + +const CATEGORY_CONFIG = { + renderer: 'renderer-feedback', +}; + +const seedExample = examples.find((e) => e.slug === 'quick-mode'); +const seedJson = seedExample ? seedExample.json : '{}'; +--- + + +
+ +
+ Experimental renderer — this preview and its API are not final and may change. +
+ +

Try it

+

+ Edit an OCF document, validate it, and render the result on a WebGL canvas. + Validation uses ocf-validator + (@opencoachingformat/validator@{OCF_VALIDATOR_VERSION}). Rendering uses + ocf-renderer + (@opencoachingformat/renderer@{OCF_RENDERER_VERSION}). Everything runs locally in your browser. +

+ +
+ + + + + + +
+ +
+
+ + +
+ +
+
+ + + +
+ +
+ +
Click Validate to check the document.
+
+ + +
+
+ + +
+ + + + + + From b17e219ecedcddda0230cb1abb67c8d33f1cfa89 Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:25:56 +0200 Subject: [PATCH 05/11] feat(site): try-it tabs + fullscreen + schema-version badge --- site/src/pages/use/try-it.astro | 87 ++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/site/src/pages/use/try-it.astro b/site/src/pages/use/try-it.astro index b7fccf0..528fb24 100644 --- a/site/src/pages/use/try-it.astro +++ b/site/src/pages/use/try-it.astro @@ -4,6 +4,7 @@ import examples from '../../generated/examples.json'; import { OCF_VALIDATOR_VERSION, OCF_VALIDATOR_BROWSER_URL } from '../../lib/validator-version.mjs'; import { OCF_RENDERER_VERSION } from '../../lib/renderer-version.mjs'; import { buildDiscussionUrl } from '../../lib/discussions.mjs'; +import { SCHEMA_VERSIONS_URL } from '../../lib/schema-versions.mjs'; import rendererModuleUrl from '../../generated/renderer/index.js?url'; const CATEGORY_CONFIG = { @@ -588,18 +589,20 @@ const seedJson = seedExample ? seedExample.json : '{}'; seedJson, examples: examples.map((e) => ({ slug: e.slug, json: e.json })), categoryConfig: CATEGORY_CONFIG, + schemaVersionsUrl: SCHEMA_VERSIONS_URL, }}> - window.__OCF_RENDERER_PLAYGROUND_VARS__ = { validatorUrl, rendererUrl, seedJson, examples, categoryConfig }; + window.__OCF_RENDERER_PLAYGROUND_VARS__ = { validatorUrl, rendererUrl, seedJson, examples, categoryConfig, schemaVersionsUrl }; + + + + + + + Moved to Try it — Open Coaching Format + + +

The Playground has moved to Try it.

+ + diff --git a/site/src/pages/playground/renderer.astro b/site/src/pages/playground/renderer.astro index 0e3c032..ba50e08 100644 --- a/site/src/pages/playground/renderer.astro +++ b/site/src/pages/playground/renderer.astro @@ -1,722 +1,15 @@ --- -import Base from '../../layouts/Base.astro'; -import examples from '../../generated/examples.json'; -import { OCF_VALIDATOR_VERSION, OCF_VALIDATOR_BROWSER_URL } from '../../lib/validator-version.mjs'; -import { OCF_RENDERER_VERSION } from '../../lib/renderer-version.mjs'; -import { buildDiscussionUrl } from '../../lib/discussions.mjs'; -import rendererModuleUrl from '../../generated/renderer/index.js?url'; - -const CATEGORY_CONFIG = { - renderer: 'renderer-feedback', -}; - -const seedExample = examples.find((e) => e.slug === 'quick-mode'); -const seedJson = seedExample ? seedExample.json : '{}'; +const target = '/use/try-it/'; --- - - -
- -
- Experimental renderer — this preview and its API are not final and may change. -
- -

Renderer Playground

-

- Edit an OCF document, validate it, and render the result on a WebGL canvas. - Validation uses ocf-validator - (@opencoachingformat/validator@{OCF_VALIDATOR_VERSION}). Rendering uses - ocf-renderer - (@opencoachingformat/renderer@{OCF_RENDERER_VERSION}). Everything runs locally in your browser. -

- -
- - - - - - -
- -
-
- - -
Click Validate to check the document.
-
- -
- -
- - -
- - -
-
- - -
- - - - - - + + + + + + + Moved to Try it — Open Coaching Format + + +

The Renderer has moved to Try it.

+ + diff --git a/site/src/pages/use/index.astro b/site/src/pages/use/index.astro index 65713e3..1900634 100644 --- a/site/src/pages/use/index.astro +++ b/site/src/pages/use/index.astro @@ -17,14 +17,9 @@ import Card from '../../components/Card.astro';
- Date: Fri, 28 Aug 2026 22:28:58 +0200 Subject: [PATCH 07/11] feat(site): generate src/generated/sports.json from sports/*.json --- site/package.json | 9 +++-- site/scripts/build-sports.mjs | 59 +++++++++++++++++++++++++++ site/test/build-sports.test.mjs | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 site/scripts/build-sports.mjs create mode 100644 site/test/build-sports.test.mjs diff --git a/site/package.json b/site/package.json index f069c09..3cabbe7 100644 --- a/site/package.json +++ b/site/package.json @@ -6,11 +6,14 @@ "scripts": { "build:adoc": "node scripts/build-adoc.mjs", "build:renderer": "node scripts/build-renderer.mjs", - "build": "npm run build:adoc && npm run build:renderer && astro build", - "dev": "npm run build:adoc && npm run build:renderer && astro dev", + "build:sports": "node scripts/build-sports.mjs", + "build": "npm run build:adoc && npm run build:renderer && npm run build:sports && astro build", + "dev": "npm run build:adoc && npm run build:renderer && npm run build:sports && astro dev", "test:renderer-build": "node scripts/build-renderer.test.mjs", "test:renderer-playground": "node src/lib/renderer-playground.test.mjs", - "test:diagram": "node --test test/diagram.test.mjs" + "test:diagram": "node --test test/diagram.test.mjs", + "test:schema-versions": "node --test test/schema-versions.test.mjs", + "test:sports": "node --test test/build-sports.test.mjs" }, "dependencies": { "@astrojs/sitemap": "^3.7.3", diff --git a/site/scripts/build-sports.mjs b/site/scripts/build-sports.mjs new file mode 100644 index 0000000..d39a3be --- /dev/null +++ b/site/scripts/build-sports.mjs @@ -0,0 +1,59 @@ +import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +function titleCase(slug) { + return slug.charAt(0).toUpperCase() + slug.slice(1); +} + +// Pure transform: [{ filename, data }] -> normalized, sorted sport entries. +// Basketball is pinned first; the remaining sports are alphabetical. +export function buildSportsIndex(files) { + const entries = files.map(({ data }) => ({ + sport: data.sport, + label: titleCase(data.sport), + version: data.version ?? '', + status: data.status ?? 'provisional', + statusLabel: data.status === 'defined' ? 'Full' : 'Reserved', + reserved: data.status !== 'defined', + action_types: Array.isArray(data.action_types) ? data.action_types : [], + variants: + data.variants && typeof data.variants === 'object' && !Array.isArray(data.variants) + ? data.variants + : {}, + outcomes: Array.isArray(data.outcomes) ? data.outcomes : [], + rulesets: Array.isArray(data.rulesets) ? data.rulesets : [], + })); + entries.sort((a, b) => { + if (a.sport === 'basketball') return -1; + if (b.sport === 'basketball') return 1; + return a.sport.localeCompare(b.sport); + }); + return entries; +} + +// Only run the filesystem side effects when executed directly, not on import +// (keeps the module unit-testable). +const isMain = + process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); + +if (isMain) { + const siteRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + const repoRoot = path.dirname(siteRoot); + const outDir = path.join(siteRoot, 'src', 'generated'); + const sportsDir = path.join(repoRoot, 'sports'); + + mkdirSync(outDir, { recursive: true }); + + const files = readdirSync(sportsDir) + .filter((name) => name.endsWith('.json')) + .sort() + .map((filename) => ({ + filename, + data: JSON.parse(readFileSync(path.join(sportsDir, filename), 'utf-8')), + })); + + const index = buildSportsIndex(files); + writeFileSync(path.join(outDir, 'sports.json'), JSON.stringify(index, null, 2), 'utf-8'); + console.log(`Generated site/src/generated/sports.json (${index.length} sports)`); +} diff --git a/site/test/build-sports.test.mjs b/site/test/build-sports.test.mjs new file mode 100644 index 0000000..aa50e91 --- /dev/null +++ b/site/test/build-sports.test.mjs @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildSportsIndex } from "../scripts/build-sports.mjs"; + +const basketball = { + filename: "basketball-v1.json", + data: { + sport: "basketball", + version: "1.0.0", + status: "defined", + action_types: ["move", "pass", "shoot"], + variants: { pass: ["chest", "bounce"] }, + outcomes: ["make", "miss"], + rulesets: ["fiba", "nba"], + }, +}; +const soccer = { + filename: "soccer-v0.0.1.json", + data: { + sport: "soccer", + version: "0.0.1", + status: "provisional", + action_types: ["move", "pass", "shoot"], + variants: {}, + outcomes: [], + rulesets: [], + }, +}; + +test("buildSportsIndex: maps a defined sport to Full/not-reserved", () => { + const [b] = buildSportsIndex([basketball]); + assert.equal(b.sport, "basketball"); + assert.equal(b.label, "Basketball"); + assert.equal(b.status, "defined"); + assert.equal(b.statusLabel, "Full"); + assert.equal(b.reserved, false); + assert.deepEqual(b.action_types, ["move", "pass", "shoot"]); + assert.deepEqual(b.rulesets, ["fiba", "nba"]); + assert.deepEqual(b.variants, { pass: ["chest", "bounce"] }); +}); + +test("buildSportsIndex: maps a provisional sport to Reserved/reserved", () => { + const [s] = buildSportsIndex([soccer]); + assert.equal(s.label, "Soccer"); + assert.equal(s.statusLabel, "Reserved"); + assert.equal(s.reserved, true); + assert.deepEqual(s.variants, {}); + assert.deepEqual(s.outcomes, []); + assert.deepEqual(s.rulesets, []); +}); + +test("buildSportsIndex: basketball is sorted first, then the rest alphabetically", () => { + const idx = buildSportsIndex([soccer, basketball]); + assert.deepEqual(idx.map((s) => s.sport), ["basketball", "soccer"]); +}); + +test("buildSportsIndex: sorts non-basketball sports alphabetically", () => { + const mk = (sport) => ({ + filename: `${sport}-v0.0.1.json`, + data: { sport, version: "0.0.1", status: "provisional", action_types: [], variants: {}, outcomes: [], rulesets: [] }, + }); + const idx = buildSportsIndex([mk("hockey"), mk("futsal"), mk("handball")]); + assert.deepEqual(idx.map((s) => s.sport), ["futsal", "handball", "hockey"]); +}); + +test("buildSportsIndex: defaults missing arrays to empty and objects to {}", () => { + const [x] = buildSportsIndex([{ filename: "x-v1.json", data: { sport: "x", version: "1", status: "defined" } }]); + assert.deepEqual(x.action_types, []); + assert.deepEqual(x.outcomes, []); + assert.deepEqual(x.rulesets, []); + assert.deepEqual(x.variants, {}); +}); From eaa6e9de7c0205f20051b866bdb2a9963a1a1713 Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:29:40 +0200 Subject: [PATCH 08/11] feat(site): sports overview page under Learn --- site/src/pages/learn/sports/index.astro | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 site/src/pages/learn/sports/index.astro diff --git a/site/src/pages/learn/sports/index.astro b/site/src/pages/learn/sports/index.astro new file mode 100644 index 0000000..3c5a8f4 --- /dev/null +++ b/site/src/pages/learn/sports/index.astro @@ -0,0 +1,61 @@ +--- +import Base from '../../../layouts/Base.astro'; +import Card from '../../../components/Card.astro'; +import sports from '../../../generated/sports.json'; + +const descriptions = { + basketball: + 'Full vocabulary today: action types, variants, outcomes, and supported rulesets.', + soccer: + 'Reserved. A provisional action list is stubbed and waiting for sport experts to shape it.', + handball: + 'Reserved. Court, entities, and frames carry over; the action list is open for contribution.', + hockey: + 'Reserved. The shared structure applies; a hockey-specific vocabulary is yet to be defined.', + futsal: + 'Reserved. Same document shape, awaiting a futsal vocabulary from the community.', +}; +--- + +
+

Sports

+

+ OCF captures the shared shape of a team-sport play once, then lets each + sport add its own vocabulary. Basketball is fully specified; the other + sports are reserved and open for the people who know each game best. +

+
+ +
+
+ {sports.map((sport) => ( + + ))} +
+
+ + + From b2b117963e000b3e3772ee6232b516681fc7c493 Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:30:26 +0200 Subject: [PATCH 09/11] feat(site): shared per-sport template via getStaticPaths --- site/src/pages/learn/sports/[sport].astro | 207 ++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 site/src/pages/learn/sports/[sport].astro diff --git a/site/src/pages/learn/sports/[sport].astro b/site/src/pages/learn/sports/[sport].astro new file mode 100644 index 0000000..01b8dde --- /dev/null +++ b/site/src/pages/learn/sports/[sport].astro @@ -0,0 +1,207 @@ +--- +import Base from '../../../layouts/Base.astro'; +import sports from '../../../generated/sports.json'; + +export function getStaticPaths() { + return sports.map((sport) => ({ + params: { sport: sport.sport }, + props: { sport }, + })); +} + +const { sport } = Astro.props; +const variantEntries = Object.entries(sport.variants ?? {}); +--- + +
+
+

{sport.label}

+ + {sport.reserved ? 'Reserved' : 'Full'} + +
+

+ {sport.reserved + ? 'The shared OCF structure — court, entities, frames — already fits this sport. Its action vocabulary is provisional and open for contribution.' + : 'This sport is fully specified: the action vocabulary, outcomes, and supported rulesets below are stable.'} +

+
+ +
+

Action types

+ {sport.action_types.length > 0 ? ( +
    + {sport.action_types.map((a) =>
  • {a}
  • )} +
+ ) : ( +

No action types defined yet.

+ )} +
+ + {!sport.reserved && ( + +
+

Variants

+ {variantEntries.length > 0 ? ( +
+ {variantEntries.map(([action, variants]) => ( +
+
{action}
+
+
    + {variants.map((v) =>
  • {v}
  • )} +
+
+
+ ))} +
+ ) : ( +

No variants defined.

+ )} +
+ +
+

Outcomes

+ {sport.outcomes.length > 0 ? ( +
    + {sport.outcomes.map((o) =>
  • {o}
  • )} +
+ ) : ( +

No outcomes defined.

+ )} +
+ +
+

Rulesets

+ {sport.rulesets.length > 0 ? ( +
    + {sport.rulesets.map((r) =>
  • {r}
  • )} +
+ ) : ( +

No rulesets defined.

+ )} +
+
+ )} + + {sport.reserved && ( +
+

Contribute

+

+ {sport.label} is reserved: the format is ready, but its action + vocabulary needs the people who know the game. If you coach or play + {` ${sport.label.toLowerCase()}`}, you can help define the actions, + variants, outcomes, and rulesets that make an OCF {sport.label.toLowerCase()} + play precise. +

+
    +
  • + Sport experts wanted. Propose the action vocabulary + for {sport.label} through the RFC process — no code required to start + the conversation. +
  • +
  • + Open an RFC or discussion. See the + {' '} + RFC process + {' '}in the specification repository. +
  • +
  • + Browse the repository. The + {' '} + opencoachingformat/spec + {' '}repo holds the sport registry files under sports/. +
  • +
+
+ )} + +

← All sports

+ + + From 9dc00daf01a2ca4dd23d69648965ed86916d2f71 Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:30:52 +0200 Subject: [PATCH 10/11] feat(site): homepage sport cards link to /learn/sports/ --- site/src/pages/index.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 496097e..2ceb955 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -114,7 +114,7 @@ const jsonLd = { ))} From 9b413e6b51803f961f6211fdad8e9616673b201e Mon Sep 17 00:00:00 2001 From: Oliver-Marcus Eder Date: Fri, 28 Aug 2026 22:32:16 +0200 Subject: [PATCH 11/11] ci(site): axe /use/try-it/ + sport routes, add Plan C unit tests --- .github/workflows/a11y-check.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/a11y-check.yml b/.github/workflows/a11y-check.yml index 1887f62..d135ec6 100644 --- a/.github/workflows/a11y-check.yml +++ b/.github/workflows/a11y-check.yml @@ -32,7 +32,7 @@ jobs: - name: Diagram pipeline unit tests working-directory: site - run: node --test test/diagram.test.mjs test/replace-plantuml.test.mjs + run: node --test test/diagram.test.mjs test/replace-plantuml.test.mjs test/schema-versions.test.mjs test/build-sports.test.mjs - name: Build site working-directory: site @@ -77,6 +77,12 @@ jobs: http://localhost:4321/docs/rulesets/ \ http://localhost:4321/examples/ \ http://localhost:4321/ecosystem/ \ - http://localhost:4321/playground/renderer/ \ + http://localhost:4321/use/try-it/ \ + http://localhost:4321/learn/sports/ \ + http://localhost:4321/learn/sports/basketball/ \ + http://localhost:4321/learn/sports/soccer/ \ + http://localhost:4321/learn/sports/handball/ \ + http://localhost:4321/learn/sports/hockey/ \ + http://localhost:4321/learn/sports/futsal/ \ --chromedriver-path "$CHROMEDRIVER_PATH" \ --exit