Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions .github/skills/game-theme-preview/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
---
name: game-theme-preview
description: 'Audit and fix WCAG color contrast in Bloom game themes. Generates a scrollable HTML preview showing every element of every theme in its real colors with low-contrast pairs flagged, then drives an AI-curated, human-approved fix loop. Use when adding or editing a game theme in gamesThemes.less, when a game element (dashed target, draggable, button, checkbox) is hard to see, or when a tester wants to eyeball all themes at once.'
argument-hint: 'optional: a specific theme name or a color pair to check'
user-invocable: true
---

# Game Theme Preview & Contrast Fix

## Outcome
Every part of every Bloom game theme is verifiable at a glance - each element rendered in
its resolved colors with a WCAG ratio under it - and every low-contrast spot gets an
**AI-curated, palette-aware fix** that the human approves element-by-element in the page
itself. The agent then applies the approved colors and re-verifies. Also: a one-shot CLI
to check the contrast of any two colors.

## When To Use
- You changed colors in `src/content/templates/template books/Games/gamesThemes.less`.
- Someone reports a game element (dashed drop target, draggable, button, checkbox) is
hard to see against the background.
- A tester wants to scroll through all themes and confirm they look good.
- You need the WCAG contrast ratio between two colors.

## The canonical loop (do it this way)
This is the agreed process. The generator measures, validates, and visualizes; **the
agent chooses the colors** (fitting each theme's palette and name); **the human approves**
each change; the agent applies and re-verifies.

1. **Measure.** Generate the preview from the *source* LESS and publish it:
```
py ".github/skills/game-theme-preview/generate_preview.py" <out.html>
```
(`<out.html>` in the session scratchpad). Publish with the **Artifact** tool; the nav
chips show each theme's issue count.
2. **Curate (agent judgment, not just the algorithm).** For each flagged element, dump the
theme's resolved palette and failing checks (call `build()` per theme), then pick a
color that a designer eyeballing the *whole* theme would choose:
- reuse a color the theme already uses (text, header, an existing outline),
- honor the theme's name/identity (keep *coral-reef* coral, *cherry-blossom* pink),
- prefer one upstream change that fixes several checks (e.g. deepening
`--game-primary-color` slightly), and note when one variable feeds another,
- keep intentional translucency (darken / raise alpha rather than going opaque).
Write the picks to a curated JSON file (scratchpad - it's a per-review input, not a
repo asset):
`theme -> "Component title|check label" -> [ {label, why, changes:[[var,value],...]} ]`
3. **Validate + visualize.** Re-run with the curated file as the 3rd argument:
```
py generate_preview.py out.html "<path>/gamesThemes.less" curated.json
```
Each curated pick renders first as a blue **Recommended** option with its rationale, in
context, and is held to the **same whole-element validation** as a generated one -
invalid picks are dropped, never shipped. Mechanical alternatives appear below as
fallbacks. Republish to the same Artifact URL.
4. **Human approves.** The user ticks the options they want and clicks **Copy approved
changes**; they paste back an apply-ready block.
5. **Apply + re-verify.** Edit the named `.bloom-page.game-theme-*` block(s) in
`gamesThemes.less` (one variable per line, with a short comment + the issue id).
Re-run the generator against the edited file and confirm the affected theme's nav chip
is clean and **no other theme regressed**.

For a single quick check, skip the page entirely: `py contrast.py "#ffde00" "#feefb1"`.

## Tools in this folder
- `contrast.py` - reusable WCAG library. Named colors, 3/6/8-digit hex, alpha compositing
over a background. CLI: `py contrast.py <fg> <bg>`.
- `generate_preview.py` - parses `gamesThemes.less`, resolves the full `--game-*` cascade
per theme, measures contrast for every meaningful element pair, generates validated fix
options, folds in the curated recommendations, and writes a self-contained HTML page.
Args: `[out.html] [gamesThemes.less] [curated.json]`.

## Background: how theming works
- Themes live in `gamesThemes.less`. Each `.bloom-page.game-theme-NAME` block sets
`--game-primary-color` / `--game-secondary-color` plus targeted overrides.
- The `.apply-game-theme()` mixin pushes those down into ~25 specific `--game-*`
variables (text, header, draggable, target outline, buttons, checkboxes, etc.), and
some variables derive from others (e.g. the selected-checkbox fill follows the
correct-answer fill) - so one change can fix several elements.
- Elements are styled in `Games.less` (and per-game `.less` files) purely via those
variables. The dashed **drop target** outline is a CSS border, not an SVG:
`[data-target-of] { border: dashed ... var(--game-draggable-target-outline-color) }`.
- Key trap: by default `--game-draggable-target-outline-color` follows the draggable
background, which follows the primary color. If a theme's primary color is close to its
page background, the dashed target nearly vanishes; give that theme its own
`--game-draggable-target-outline-color`.

## Contrast thresholds (WCAG 2.1 Level AA)
Bloom targets **Level AA**, so that is what the generator enforces:
- **4.5:1** - normal text (text-on-fill pairs).
- **3.0:1** - large/bold text and non-text UI components / graphics (the dashed target
outline, draggable-vs-page, button-vs-page, checkbox outline, etc.).
The generator picks the right threshold per pair and flags anything under it. A result
that only just clears its threshold is shown amber, not green, so a barely-passing value
is never dressed up as high contrast. (AAA would require 7:1 / 4.5:1; if that's ever the
goal, raise the thresholds in `build()`.)

## How the generated fallback options are computed
The curated picks come first, but the tool also derives its own options as a backstop, and
they use the same rules (so understand them):
- An element usually has more than one contrast relationship at once (a draggable must
stand out from the page **and** hold readable text on its fill). Each candidate is
validated against **all** of that element's checks - a fix can't pass one relationship
while silently breaking another (no black text on a newly-black fill).
- It picks the right "knob": the foreground, the background fill, or - for a "visible"
check - the fill *or* the outline. It never recolors the shared page background.
- When a text-bearing element must stand out from a same-tone page, a single-color nudge
muddies the fill and starves its text, so it also offers a coordinated "darker/lighter
fill + opposite-tone text" fix.
- Every option lists the **before→after** ratio of each relationship it touches.

## What Good Looks Like
- Every theme's nav chip shows a checkmark (zero low-contrast pairs), or any remaining
amber/red pair is a deliberate, explained design choice.
- The dashed drop target is clearly visible on every theme's page background.
- Fix colors are drawn from the theme's own palette / identity, not just the nearest
passing color - and the rationale is recorded (in `why`, and in the LESS comment).
- Applied edits carry a short comment and the issue id; a re-run shows no regressions.

## Notes / Limitations
- The generator reads the **source** `gamesThemes.less`, not the compiled
`output/.../gamesThemes.css`, so it reflects your edits before a build runs.
- It does not resolve `color-mix()` (only used outside the per-theme blocks today). If a
theme starts using `color-mix()`, extend `resolve()` in `generate_preview.py`.
- The artifact runs in a sandboxed iframe, so the async clipboard API is often blocked;
the Copy button falls back to `execCommand` and always shows a selectable text box.
- SVG control-button glyphs are fixed-color image files and are not theme-driven, so they
aren't measured here.

## Example Prompts
- `/game-theme-preview Regenerate the theme preview so I can check my new theme.`
- `/game-theme-preview Is #ffde00 readable on #feefb1?`
- `/game-theme-preview The drop targets are invisible in garden-path - curate a fix and let me approve it.`
83 changes: 83 additions & 0 deletions .github/skills/game-theme-preview/contrast.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""WCAG contrast utilities for Bloom game theme colors.

Reusable helper for measuring the contrast ratio between two colors as defined by
WCAG 2.x. Handles named colors (white/black), 3/6/8-digit hex, and alpha
compositing (an 8-digit hex is composited over a supplied background before the
ratio is computed, which is what a viewer actually perceives).

Thresholds worth remembering:
* 4.5:1 - normal text (WCAG 1.4.3 AA)
* 3.0:1 - large/bold text AND non-text UI components / graphics (WCAG 1.4.11)
"""

_NAMED = {"white": "ffffff", "black": "000000", "transparent": "00000000"}


def parse(color, bg=None):
"""Parse a CSS color to an (r, g, b) tuple in 0..255.

If the color carries alpha (8-digit hex) and `bg` (an r,g,b tuple) is given,
the color is alpha-composited over `bg` so the result reflects what is seen.
"""
c = color.strip().lstrip("#").lower()
c = _NAMED.get(c, c)
if len(c) == 3:
c = "".join(ch * 2 for ch in c)
if len(c) == 8:
r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
a = int(c[6:8], 16) / 255
if bg is not None:
br, bgc, bb = bg
r = round(r * a + br * (1 - a))
g = round(g * a + bgc * (1 - a))
b = round(b * a + bb * (1 - a))
return (r, g, b)
if len(c) != 6:
raise ValueError(f"Cannot parse color: {color!r}")
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))


def _lin(v):
v = v / 255
return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4


def luminance(rgb):
"""Relative luminance of an (r, g, b) tuple per WCAG."""
r, g, b = rgb
return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)


def contrast(fg, bg):
"""WCAG contrast ratio (1..21) between two colors.

`fg` and `bg` may be color strings or pre-parsed (r,g,b) tuples. A string
foreground is composited over the (parsed) background when it has alpha.
"""
bg_rgb = bg if isinstance(bg, tuple) else parse(bg)
fg_rgb = fg if isinstance(fg, tuple) else parse(fg, bg_rgb)
hi, lo = luminance(fg_rgb), luminance(bg_rgb)
if hi < lo:
hi, lo = lo, hi
return (hi + 0.05) / (lo + 0.05)


def rating(ratio, threshold=3.0):
"""Short human label for a ratio against a pass threshold (default UI 3:1)."""
if ratio >= 4.5:
return "GOOD"
if ratio >= threshold:
return "OK"
if ratio >= 2.0:
return "WEAK"
return "FAIL"


if __name__ == "__main__":
import sys

if len(sys.argv) == 3:
r = contrast(sys.argv[1], sys.argv[2])
print(f"{sys.argv[1]} on {sys.argv[2]}: {r:.2f}:1 ({rating(r)})")
else:
print("usage: py contrast.py <foreground> <background>")
Loading