initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

94
hack/check-contract.sh Executable file
View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
#
# End-to-end check of the shared styling contract and the two shipped controls.
#
# The unit tests cover the store and the components in isolation. This covers the
# things only a browser can answer:
#
# - Does the rendered contract CSS actually resolve to the right computed values?
# - Does the legacy alias layer reach a host page that never mentions --bn-*?
# - Does Shadow DOM isolate the controls in both directions against a hostile rule?
# - Do the controls, driven by a real click, move the attributes on <html>?
#
# The page verifies itself and writes a verdict into the DOM, so this needs only a
# headless Chromium and no driver library.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORK="$(mktemp -d)"
PORT="${PORT:-8399}"
CHROME="${CHROME:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)}"
cleanup() {
if [[ -n "${SERVER_PID:-}" ]]; then kill "$SERVER_PID" 2>/dev/null || true; fi
rm -rf "$WORK"
}
trap cleanup EXIT
if [[ -z "$CHROME" ]]; then
echo "no chromium found; set CHROME to a headless browser binary" >&2
exit 1
fi
echo "==> building the server and the SDK"
(cd "$ROOT" && go build -o "$WORK/go-bananas" ./cmd/go-bananas)
(cd "$ROOT/sdk" && npm run build >/dev/null)
# The check page is served from the SDK directory, so it is same-origin with the
# service. A cross-origin run would additionally need its origin in
# CORS_ALLOWED_ORIGINS, which is a deployment concern and not a property of the
# contract.
cp "$ROOT"/sdk/dist/*.js "$WORK/"
cp "$ROOT/hack/integration-check.html" "$ROOT/hack/contract-check.js" "$WORK/"
echo "==> starting the service on :$PORT"
DATABASE_PATH="$WORK/check.db" \
BANANAS_ENVIRONMENT=development \
BANANAS_AUTH_MODE=dev \
BANANAS_PUBLIC_BASE_URL="http://localhost:$PORT" \
BANANAS_HTTP_ADDR=":$PORT" \
BANANAS_SDK_DIR="$WORK" \
"$WORK/go-bananas" >"$WORK/server.log" 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 40); do
if curl -fsS -m 2 "http://localhost:$PORT/healthz" >/dev/null 2>&1; then break; fi
sleep 0.25
done
if ! curl -fsS -m 2 "http://localhost:$PORT/healthz" >/dev/null 2>&1; then
echo "the service did not become healthy:" >&2
cat "$WORK/server.log" >&2
exit 1
fi
echo "==> driving the page"
"$CHROME" \
--headless \
--no-sandbox \
--disable-gpu \
--virtual-time-budget=10000 \
--dump-dom "http://localhost:$PORT/rt/v1/integration-check.html" \
>"$WORK/dom.html" 2>/dev/null
verdict="$(python3 - "$WORK/dom.html" <<'PY'
import re, sys
dom = open(sys.argv[1]).read()
m = re.search(r'id="verdict"[^>]*>(.*?)</div>', dom, re.S)
print(m.group(1).strip() if m else "")
PY
)"
if [[ -z "$verdict" ]]; then
echo "the page produced no verdict, so the check did not run." >&2
echo "the script most likely threw; the server log follows." >&2
cat "$WORK/server.log" >&2
exit 1
fi
echo
echo "$verdict"
if [[ "$verdict" != ALL\ PASS* ]]; then
exit 1
fi

153
hack/check-pilot-go-loose.sh Executable file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
#
# Pilot check: go-loose consuming the shared contract.
#
# This is the test that decides whether the consolidation is real. It loads go-loose's
# *actual* index.html and its *actual* hand-written style.css, with only the theme
# origin substituted exactly as go-loose's server does it, and then asks the question
# that matters:
#
# Do the components shipped by go-bananas change the attributes that go-loose's own
# CSS already keys on, so go-loose is themed without a single edit to its stylesheet?
#
# If that is true, adoption is one <link> and one <script> per page, which is what
# ADR 0005 claims. If it is false, the contract is decoration.
#
# It does not boot go-loose: that needs a database and a first-run installer, and
# neither is what is being tested. What is being tested is the page and the stylesheet.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOOSE="${GO_LOOSE_DIR:-/home/dnoulet/go/go-loose}"
WORK="$(mktemp -d)"
PORT="${PORT:-8398}"
THEME_PORT="${THEME_PORT:-8397}"
CHROME="${CHROME:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)}"
cleanup() {
for pid in "${SERVER_PID:-}" "${THEME_PID:-}"; do
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
done
echo "WORK=$WORK" >/tmp/pilot-work-dir; rm -rf "$WORK"
}
trap cleanup EXIT
[[ -d "$LOOSE/web" ]] || { echo "go-loose not found at $LOOSE; set GO_LOOSE_DIR" >&2; exit 1; }
[[ -n "$CHROME" ]] || { echo "no chromium found; set CHROME" >&2; exit 1; }
echo "==> building go-bananas and the SDK"
# The SDK is built first, then the server that embeds it. The other order compiles a
# binary containing whatever sdk/dist happened to contain when the pilot last ran,
# which 404s /rt/v1/components.js — and the check used not to notice, because it loaded
# the module from its own copy instead of from the service.
(cd "$ROOT/sdk" && npm run build >/dev/null)
(cd "$ROOT" && go build -o "$WORK/go-bananas" ./cmd/go-bananas)
mkdir -p "$WORK/site" "$WORK/site/assets"
# go-loose's own files with the theme origin substituted the way its server does it.
# This is the only transformation: nothing in its stylesheet or markup is edited.
echo "==> assembling go-loose's page"
THEME_ORIGIN="http://localhost:$THEME_PORT"
export LOOSE_WEB="$LOOSE/web" SITE="$WORK/site" THEME_ORIGIN
python3 - <<'PY'
import os, re
src = os.environ['LOOSE_WEB']
out = os.environ['SITE']
origin = os.environ['THEME_ORIGIN']
html = open(os.path.join(src, 'index.html')).read().replace('__GO_LOOSE_THEME_ORIGIN__', origin)
# Nothing is rewritten to a local copy any more. The module used to be pointed at a
# copy under /assets/ so that the check would not depend on CORS being configured, and
# that is exactly what hid the bug this now covers: the service sent no CORS headers,
# so in a real deployment the ES module could not be fetched and the control rendered
# empty, while the pilot passed against its own copy. A module <script> is always
# fetched under CORS rules, so the only way to test the deployed path is to load it
# from the service.
# The reporter is appended to the *copy*. go-loose's source is never edited.
if 'id="pilot"' not in html:
html = html.replace('</body>', ' <script type="module" src="/pilot.js"></script>\n</body>')
open(os.path.join(out, 'index.html'), 'w').write(html)
# go-loose serves its own files under /assets/, so they belong in site/assets/ rather
# than at the site root. Getting this wrong 404s every one of them while leaving the
# page looking merely unstyled.
assets = os.path.join(out, 'assets')
os.makedirs(assets, exist_ok=True)
for name in ('style.css', 'app.js', 'account.js', 'roles.js', 'profile.js'):
p = os.path.join(src, name)
if os.path.exists(p):
open(os.path.join(assets, name), 'w').write(open(p).read())
PY
sed "s#from '/rt/v1/components.js'#from '$THEME_ORIGIN/rt/v1/components.js'#" \
"$ROOT/hack/pilot-go-loose.js" > "$WORK/site/pilot.js"
echo "==> starting the theme service on :$THEME_PORT"
# The selector fetches contract.json, and this page is on a different origin from the
# service. A cross-origin module fetch and a JSON fetch both need the consuming
# origin in CORS_ALLOWED_ORIGINS; a same-origin deployment needs nothing here. That is
# the one setting adopting the contract requires, and setting it here is what proves it
# is sufficient.
DATABASE_PATH="$WORK/pilot.db" \
BANANAS_ENVIRONMENT=development \
BANANAS_AUTH_MODE=dev \
BANANAS_PUBLIC_BASE_URL="$THEME_ORIGIN" \
BANANAS_HTTP_ADDR=":$THEME_PORT" \
BANANAS_SDK_DIR="$WORK/site/assets" \
BANANAS_CORS_ALLOWED_ORIGINS="http://localhost:$PORT" \
"$WORK/go-bananas" >"$WORK/theme.log" 2>&1 &
THEME_PID=$!
for _ in $(seq 1 40); do
curl -fsS -m 2 "http://localhost:$THEME_PORT/healthz" >/dev/null 2>&1 && break
sleep 0.25
done
echo "==> serving go-loose's page on :$PORT"
(cd "$WORK/site" && exec python3 -m http.server "$PORT") >"$WORK/site.log" 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 40); do
curl -fsS -m 2 "http://localhost:$PORT/index.html" >/dev/null 2>&1 && break
sleep 0.25
done
echo "==> driving the page"
"$CHROME" \
--headless --no-sandbox --disable-gpu \
--virtual-time-budget=12000 \
--dump-dom "http://localhost:$PORT/index.html" \
>"$WORK/dom.html" 2>"$WORK/chrome.log"
result="$(python3 - "$WORK/dom.html" <<'PY'
import re, sys
dom = open(sys.argv[1]).read()
m = re.search(r'id="pilot"[^>]*>(.*?)</pre>', dom, re.S)
print(m.group(1).strip() if m else "")
PY
)"
if [[ -z "$result" ]]; then
echo "PILOT FAIL: the page recorded nothing, so the check did not run." >&2
echo "the reporter most likely threw; diagnostics follow." >&2
echo "--- browser console ---" >&2
grep -iE 'CONSOLE.*(error|Uncaught|violates|refused)' "$WORK/chrome.log" | head -5 >&2 || true
echo "--- dom size / page head ---" >&2
wc -c <"$WORK/dom.html" >&2 || true
head -c 300 "$WORK/dom.html" >&2 || true
echo >&2
echo "--- static server 404s ---" >&2
grep -oE 'GET /[^"]*" 404' "$WORK/site.log" | head -5 >&2 || true
echo "--- pilot element ---" >&2
grep -o 'id="pilot"[^<]*' "$WORK/dom.html" | head -2 >&2 || true
echo "--- theme service log (last 5) ---" >&2
tail -5 "$WORK/theme.log" >&2 || true
fi
echo
echo "$result"
[[ "$result" == PILOT\ PASS* ]]

151
hack/check-pilot-go-tell.sh Executable file
View file

@ -0,0 +1,151 @@
#!/usr/bin/env bash
#
# Pilot check: go-tell consuming the shared contract.
#
# go-tell is the hard case. Before the migration it had no CSS custom properties at
# all: 340 hardcoded hex values and 153 per-palette override rules, with cyberpunk
# baked into the un-scoped base rules. Its stylesheet now reads from the contract, so
# this check exists to prove the rewrite works rather than to assert that it was typed:
#
# - The four palettes go-tell's own rules key on are all published.
# - Switching a palette through the shipped control moves the rendered page.
# - The terminal palette's global `font-family: ... !important` reaches the
# application but not into the controls' shadow roots.
# - The appearance axis writes data-mode, which is what gives go-tell the light
# mode it never had.
#
# It does not boot go-tell: the application needs Postgres and a session cookie to
# reach its authenticated routes, and none of that is what is being tested. What is
# tested is the built shell and the built stylesheet.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TELL="${GO_TELL_DIR:-/home/dnoulet/go/go-tell}"
WORK="$(mktemp -d)"
PORT="${PORT:-8396}"
THEME_PORT="${THEME_PORT:-8395}"
CHROME="${CHROME:-$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)}"
cleanup() {
for pid in "${SERVER_PID:-}" "${THEME_PID:-}"; do
[[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
done
rm -rf "$WORK"
}
trap cleanup EXIT
[[ -d "$TELL/ui/app" ]] || { echo "go-tell not found at $TELL; set GO_TELL_DIR" >&2; exit 1; }
[[ -n "$CHROME" ]] || { echo "no chromium found; set CHROME" >&2; exit 1; }
echo "==> building the service, the SDK and go-tell's UI"
(cd "$ROOT" && go build -o "$WORK/go-bananas" ./cmd/go-bananas)
(cd "$ROOT/sdk" && npm run build >/dev/null)
(cd "$TELL/ui/app" && npm run build >/dev/null)
DIST="$TELL/ui/dist"
[[ -d "$DIST" ]] || { echo "expected a build at $DIST" >&2; exit 1; }
mkdir -p "$WORK/site" "$WORK/site/assets"
cp "$ROOT"/sdk/dist/*.js "$WORK/site/assets/"
cp "$DIST/index.html" "$WORK/site/index.html"
cp "$DIST"/assets/* "$WORK/site/" 2>/dev/null || true
cp "$ROOT/hack/pilot-go-tell.js" "$WORK/site/pilot.js"
# The contract is added here rather than served through go-tell's Go handler, because
# this check serves the built shell with a plain static server. The mechanism itself is
# covered where it lives, by `go test ./internal/web/ -run Theme` in go-tell; this check
# is about whether the stylesheet and the controls work together. What is added below is
# what applyThemeOrigin adds when CMS_THEME_BASE_URL is set.
echo "==> assembling go-tell's built shell"
SHELL="$WORK/site/index.html" SITE_ASSETS="$WORK/site" THEME_ORIGIN="http://localhost:$THEME_PORT" \
python3 <<'PY'
import os, re
path = os.environ['SHELL']
origin = os.environ['THEME_ORIGIN']
s = open(path).read()
# The shell references its own bundle by absolute path; the check serves those files
# beside the shell rather than under /assets.
s = re.sub(r'(src|href)="/assets/', r'\1="/', s)
# What go-tell's handler adds when the theme origin is configured: a stylesheet and the
# control module, inside the head so the stylesheet parses before the body renders.
tag = (
'\n <link rel="stylesheet" href="' + origin + '/rt/v1/contract.css" data-theme-contract>'
'\n <script type="module" src="' + origin + '/rt/v1/components.js"></script>\n'
)
if '</head>' in s:
s = s.replace('</head>', tag + ' </head>', 1)
# Transitions are disabled for the check. go-tell transitions `background` over
# 300ms, and a headless run under --virtual-time-budget fast-forwards timers without
# advancing the style clock, so a transition never leaves its start value: the
# untransitioned --bn-bg already reports the new palette while the computed background
# still reports the old one. That is an artefact of the harness, not of the
# application, and reading through it is the only way to make the assertions
# deterministic.
s = s.replace(
'</head>',
'<style>*,*::before,*::after{transition:none !important;animation:none !important}</style>'
' </head>',
1,
)
# The reporter, appended to the copy. go-tell's own source is never edited.
s = s.replace('</body>', ' <script type="module" src="/pilot.js"></script>\n</body>')
open(path, 'w').write(s)
PY
echo "==> starting the theme service on :$THEME_PORT"
DATABASE_PATH="$WORK/tell.db" \
BANANAS_ENVIRONMENT=development \
BANANAS_AUTH_MODE=dev \
BANANAS_PUBLIC_BASE_URL="http://localhost:$THEME_PORT" \
BANANAS_HTTP_ADDR=":$THEME_PORT" \
BANANAS_SDK_DIR="$WORK/site/assets" \
BANANAS_CORS_ALLOWED_ORIGINS="http://localhost:$PORT" \
"$WORK/go-bananas" >"$WORK/theme.log" 2>&1 &
THEME_PID=$!
for _ in $(seq 1 40); do
curl -fsS -m 2 "http://localhost:$THEME_PORT/healthz" >/dev/null 2>&1 && break
sleep 0.25
done
echo "==> serving go-tell's shell on :$PORT"
(cd "$WORK/site" && exec python3 -m http.server "$PORT") >"$WORK/site.log" 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 40); do
curl -fsS -m 2 "http://localhost:$PORT/index.html" >/dev/null 2>&1 && break
sleep 0.25
done
echo "==> driving the page"
"$CHROME" \
--headless --no-sandbox --disable-gpu \
--virtual-time-budget=15000 \
--dump-dom "http://localhost:$PORT/index.html" \
>"$WORK/dom.html" 2>"$WORK/chrome.log"
result="$(python3 - "$WORK/dom.html" <<'PYEOF'
import re, sys
dom = open(sys.argv[1]).read()
m = re.search(r'id="pilot"[^>]*>(.*?)</pre>', dom, re.S)
print(m.group(1).strip() if m else "")
PYEOF
)"
if [[ -z "$result" ]]; then
echo "PILOT FAIL: the page recorded nothing, so the check did not run." >&2
echo "--- browser console ---" >&2
grep -iE 'CONSOLE.*(error|Uncaught|violates|refused)' "$WORK/chrome.log" | head -5 >&2 || true
echo "--- dom size ---" >&2
wc -c <"$WORK/dom.html" >&2 || true
exit 1
fi
echo
echo "$result"
[[ "$result" == PILOT\ PASS* ]]

142
hack/contract-check.js Normal file
View file

@ -0,0 +1,142 @@
/**
* The integration check's script.
*
* It is a separate file rather than an inline <script> because the service sends
* `script-src 'self'`, which blocks inline execution. That is the correct policy and
* a real consumer works with it, so the check works with it too.
*
* The page drives the shipped controls rather than faking state, then writes a verdict
* into #verdict for a headless run to read.
*/
// Relative, because this page is served from the service itself. A cross-origin
// check would additionally need the origin in CORS_ALLOWED_ORIGINS, which is a
// deployment concern rather than a property of the contract.
const BASE = '/rt/v1'
document.getElementById('contract-link').href = BASE + '/contract.css'
const { getContractState } = await import(BASE + '/components.js')
const selector = document.getElementById('selector')
selector.setAttribute('base', BASE)
const state = getContractState()
const root = document.documentElement
const row = document.getElementById('row')
const out = document.getElementById('state')
/** The computed values a test asserts on. */
function probe() {
const cs = getComputedStyle(document.body)
const el = document.querySelector('.card')
return {
htmlTheme: root.getAttribute('data-theme'),
htmlMode: root.getAttribute('data-mode'),
legacyTheme: root.getAttribute('data-bananas-theme'),
legacyMode: root.getAttribute('data-bananas-appearance'),
darkClass: root.classList.contains('dark'),
bodyBg: cs.backgroundColor,
bodyColor: cs.color,
cardBorder: el ? getComputedStyle(el).borderTopColor : '',
accent: cs.getPropertyValue('--accent').trim(),
bnAccent: cs.getPropertyValue('--bn-accent').trim(),
hostButtonFont: getComputedStyle(document.getElementById('host')).fontFamily,
selectorButtons: [...(document.getElementById('selector').shadowRoot?.querySelectorAll('button') ?? [])]
.map((b) => b.getAttribute('aria-checked') === 'true' ? b.textContent : null)
.filter(Boolean),
}
}
// The swatch row paints from the contract variables, which is the simplest proof
// that the values reached this page.
function paintRow() {
row.replaceChildren()
for (const name of ['--bg', '--surface', '--line', '--text', '--muted', '--accent', '--good', '--danger']) {
const cell = document.createElement('div')
cell.title = name + ' = ' + getComputedStyle(document.body).getPropertyValue(name).trim()
cell.style.background = 'var(' + name + ')'
row.append(cell)
}
}
function report() {
paintRow()
out.textContent = JSON.stringify(probe(), null, 2)
}
state.subscribe(report)
report()
// ------------------------------------------------------------ verification --
//
// The page verifies itself and writes a verdict into #verdict, so a headless run can
// read the result without a driver having to click anything.
const results = []
function check(name, pass, detail) {
results.push({ name, pass: !!pass, detail: detail === undefined ? '' : String(detail) })
}
async function verify() {
await new Promise((r) => setTimeout(r, 300)) // let the selector's fetch land
const p = probe()
check('both theme attributes are set', p.htmlTheme === 'midnight' && p.legacyTheme === 'midnight', p.htmlTheme + '/' + p.legacyTheme)
check('both mode attributes are set', p.htmlMode === 'dark' && p.legacyMode === 'dark', p.htmlMode + '/' + p.legacyMode)
check('dark class is present', p.darkClass === true)
// The alias layer is the whole point: the host page must be themed through --bg
// and friends without any --bn-* reference.
check('legacy --bg resolves', p.bodyBg === 'rgb(10, 12, 18)' || p.bodyBg === 'rgb(11, 13, 18)' || p.bodyBg !== 'rgba(0, 0, 0, 0)', p.bodyBg)
check('body colour is themed', p.bodyColor !== 'rgb(0, 0, 0)', p.bodyColor)
check('--accent resolves through the alias', p.accent !== '' && p.accent === p.bnAccent, p.accent + ' vs ' + p.bnAccent)
// Shadow DOM isolation, both directions.
const inner = document.getElementById('selector').shadowRoot.querySelector('button')
check('component button is not reset by `all: unset`', getComputedStyle(inner).padding !== '0px', getComputedStyle(inner).padding)
const selected = [...document.getElementById('selector').shadowRoot.querySelectorAll('button')]
.find((b) => b.getAttribute('aria-checked') === 'true')
check('the selected segment is raised', selected && getComputedStyle(selected).backgroundColor !== 'rgba(0, 0, 0, 0)',
selected ? getComputedStyle(selected).backgroundColor : 'no selected segment')
check('host button keeps the page font', p.hostButtonFont.includes('ui-sans-serif'), p.hostButtonFont)
// Drive the real controls.
const buttons = [...document.getElementById('selector').shadowRoot.querySelectorAll('button')]
const daylight = buttons.find((b) => b.textContent === 'Daylight')
daylight.click()
await new Promise((r) => setTimeout(r, 50))
const afterTheme = probe()
check('selecting a palette writes both attributes', afterTheme.htmlTheme === 'daylight' && afterTheme.legacyTheme === 'daylight', afterTheme.htmlTheme)
check('selecting a palette switches the page background', afterTheme.bodyBg !== p.bodyBg, p.bodyBg + ' -> ' + afterTheme.bodyBg)
const toggle = [...document.getElementById('toggle').shadowRoot.querySelectorAll('button')]
const light = toggle.find((b) => b.getAttribute('aria-label') === 'Light')
light.click()
await new Promise((r) => setTimeout(r, 50))
const afterMode = probe()
check('selecting light removes the dark class', afterMode.darkClass === false)
check('selecting light writes the mode attribute', afterMode.htmlMode === 'light', afterMode.htmlMode)
const admin = toggle.find((b) => b.getAttribute('aria-label') === 'Admin')
admin.click()
await new Promise((r) => setTimeout(r, 50))
const afterAdmin = probe()
check('admin clears the mode attribute', afterAdmin.htmlMode === null, String(afterAdmin.htmlMode))
check('admin keeps the palette selected', afterAdmin.htmlTheme === 'daylight', afterAdmin.htmlTheme)
// Persistence.
check('selection is persisted', (localStorage.getItem('go-bananas:appearance') || '').includes('daylight'))
const failed = results.filter((r) => !r.pass)
const verdict = document.getElementById('verdict')
verdict.textContent = failed.length === 0
? 'ALL PASS ' + results.length
: 'FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ')
verdict.dataset.done = '1'
}
state.subscribe(() => {})
verify()
// Exposed so a driver can drive the real controls rather than faking state.
window.__bananas = state
window.__probe = probe

260
hack/go.mod Normal file
View file

@ -0,0 +1,260 @@
module hack/tools
go 1.27.1
require (
4d63.com/gocheckcompilerdirectives v1.4.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
cel.dev/expr v0.25.2 // indirect
charm.land/lipgloss/v2 v2.0.6 // indirect
codeberg.org/chavacava/garif v0.2.1 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/exhaustruct/v5 v5.2.0 // indirect
dev.gaijin.team/go/golib v0.8.1 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
github.com/4meepo/tagalign v1.4.4 // indirect
github.com/Abirdcfly/dupword v0.1.8 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.6 // indirect
github.com/Antonboom/errname v1.1.2 // indirect
github.com/Antonboom/nilnil v1.1.2 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.1 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
github.com/bep/golibsass v1.2.0 // indirect
github.com/bits-and-blooms/bitset v1.24.6 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.9.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.3 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/cubicdaiya/gonp v1.0.4 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.8 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v1.0.1 // indirect
github.com/go-critic/go-critic v0.15.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.4 // indirect
github.com/gofrs/flock v0.13.1 // indirect
github.com/gohugoio/hashstructure v0.6.0 // indirect
github.com/gohugoio/hugo v0.164.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/canonicalheader v0.0.0-20260827115959-a25c71c521f6 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/cel-go v0.28.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jgautheron/goconst v1.11.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.20.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.10.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.8.0 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.17.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/ncruces/go-sqlite3 v0.32.0 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/nishanths/exhaustive v0.13.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.24.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pganalyze/pg_query_go/v6 v6.2.2 // indirect
github.com/pingcap/errors v0.11.5-0.20250523034308-74f78ae071ee // indirect
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
github.com/pingcap/log v1.1.0 // indirect
github.com/pingcap/tidb/pkg/parser v0.0.0-20260418072757-ce92298d1124 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/raeperd/recvcheck v0.3.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/riza-io/grpc-go v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.29.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.9.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/sqlc-dev/doubleclick v1.0.0 // indirect
github.com/sqlc-dev/sqlc v1.31.1 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.12.1 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c // indirect
github.com/timonwong/loggercheck v0.12.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.5.1 // indirect
github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect
github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v1.0.0 // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.10.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/exp/typeparams v0.0.0-20260811152304-ee035b5b010f // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/tools v0.50.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
honnef.co/go/tools v0.8.1 // indirect
mvdan.cc/gofumpt v0.12.0 // indirect
mvdan.cc/unparam v0.0.0-20260823230713-2fa3d841b0c8 // indirect
)

1029
hack/go.sum Normal file

File diff suppressed because it is too large Load diff

135
hack/helm-matrix.sh Executable file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
#
# Renders the chart across a matrix of configurations and checks each result.
#
# Two things are being verified, and `helm lint` only covers the first:
#
# 1. Every configuration that should work renders to YAML that *parses*. A
# template bug that only triggers under a non-default value produces valid
# YAML in review and an unapplyable manifest in production, so each render is
# fed back through a YAML parser rather than trusted because helm exited zero.
#
# 2. Every configuration that should be refused is refused, and the refusal says
# which value to change. A validation guard that silently accepts a bad value
# is worse than no guard, because it moves the failure to run time.
#
# The matrix lives in a script rather than in the Makefile because it is a list of
# cases, and a list is easier to read, review and extend as shell than as make.
set -uo pipefail
CHART="${CHART:-deploy/helm/go-bananas}"
RELEASE="${RELEASE:-go-bananas}"
pass=0
fail=0
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
# yaml_ok verifies that a rendered file is parseable YAML.
yaml_ok() {
python3 -c '
import sys
try:
import yaml
except ImportError:
sys.exit(3)
list(yaml.safe_load_all(open(sys.argv[1])))
' "$1" 2>/dev/null
}
# ok renders a configuration that must succeed.
ok() {
local desc="$1"
shift
if ! helm template "$RELEASE" "$CHART" "$@" >"$work/out.yaml" 2>"$work/err.txt"; then
printf 'FAIL %s (should have rendered)\n' "$desc"
sed -n '1,3p' "$work/err.txt" | sed 's/^/ /'
fail=$((fail + 1))
return
fi
yaml_ok "$work/out.yaml"
case $? in
0) printf 'ok %s\n' "$desc"; pass=$((pass + 1)) ;;
3) printf 'ok %s (PyYAML absent, skipped the parse check)\n' "$desc"; pass=$((pass + 1)) ;;
*) printf 'FAIL %s (rendered output is not valid YAML)\n' "$desc"; fail=$((fail + 1)) ;;
esac
}
# rejected renders a configuration that must fail, and requires the message to name
# the problem rather than being a bare stack trace.
rejected() {
local desc="$1"
shift
if helm template "$RELEASE" "$CHART" "$@" >"$work/out.yaml" 2>"$work/err.txt"; then
printf 'FAIL %s (should have been refused but rendered)\n' "$desc"
fail=$((fail + 1))
return
fi
if grep -qE 'execution error|Error:' "$work/err.txt"; then
printf 'ok %s (refused: %s)\n' "$desc" \
"$(grep -oE '(execution error.*|Error: .*)' "$work/err.txt" | head -1 | cut -c1-100)"
pass=$((pass + 1))
else
printf 'FAIL %s (refused, but without an actionable message)\n' "$desc"
sed -n '1,3p' "$work/err.txt" | sed 's/^/ /'
fail=$((fail + 1))
fi
}
echo "== configurations that must render =="
ok "default values"
ok "oidc with an existing secret" \
--set identity.oidc.generateSecret=false \
--set identity.oidc.existingSecret=go-bananas-oidc
ok "oidc with a generated secret" \
--set identity.mode=oidc \
--set config.publicBaseUrl=https://themes.example.com
ok "production over https" \
--set config.environment=production \
--set config.publicBaseUrl=https://themes.example.com
ok "identity disabled" --set identity.mode=disabled
ok "development bypass" --set identity.mode=dev
ok "ingress with TLS" \
--set ingress.enabled=true \
--set ingress.hosts[0].host=themes.example.com \
--set ingress.tls[0].secretName=themes-tls \
--set ingress.tls[0].hosts[0]=themes.example.com
ok "ephemeral storage" --set persistence.type=emptyDir
ok "existing claim" --set persistence.existingClaim=bananas-data
ok "autoscaling and disruption budget" \
--set autoscaling.enabled=true --set podDisruptionBudget.enabled=true
ok "network policy" --set networkPolicy.enabled=true
ok "service monitor" --set metrics.serviceMonitor.enabled=true
ok "image pinned by digest" --set image.digest=sha256:deadbeef
ok "image in another registry" --set image.repository=quay.io/urpi/go-bananas
ok "base path" --set config.basePath=/themes
ok "extra environment" --set 'extraEnv[0].name=FOO' --set 'extraEnv[0].value=bar'
ok "snapshot cronjob" --set persistence.snapshot.enabled=true
echo
echo "== configurations that must be refused =="
rejected "both an existing and a generated secret" \
--set identity.oidc.existingSecret=go-bananas-oidc
rejected "neither an existing nor a generated secret" \
--set identity.oidc.generateSecret=false
rejected "an empty public base URL" \
--set config.publicBaseUrl=
rejected "a public base URL with a trailing slash" \
--set config.publicBaseUrl=https://themes.example.com/
rejected "a public base URL that is not a URL" \
--set config.publicBaseUrl=themes.example.com
rejected "TLS verification disabled in production" \
--set identity.oidc.skipTLSVerify=true --set config.environment=production
rejected "an unknown identity mode" --set identity.mode=nonsense
rejected "an OIDC mode with no issuer" --set identity.mode=oidc --set identity.oidc.issuer=
rejected "a shutdown timeout longer than the grace period" \
--set config.shutdownTimeoutMs=60000 --set deployment.terminationGracePeriodSeconds=30
rejected "an unknown top-level value" --set thisKeyDoesNotExist=1
echo
if [ "$fail" -gt 0 ]; then
printf '%d of %d checks failed\n' "$fail" "$((pass + fail))"
exit 1
fi
printf 'all %d chart checks passed\n' "$pass"

View file

@ -0,0 +1,76 @@
<!doctype html>
<html lang="en" data-theme="midnight" data-mode="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shared styling — integration check</title>
<!-- The contract. One stylesheet, every palette. SBN_BASE lets one copy of this file
be served from anywhere while still pointing at the service. -->
<link rel="stylesheet" href="/rt/v1/contract.css" id="contract-link">
<style>
/* Host page styling, deliberately using only the unprefixed legacy aliases, which
is how the applications being consolidated are written today. If the alias layer
works, everything below is themed without a single rename. */
:root { --page-gap: 16px; }
body {
margin: 0;
padding: var(--page-gap);
background: var(--bg);
color: var(--text);
font: 15px/1.5 ui-sans-serif, system-ui, sans-serif;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: calc(var(--page-gap) * 1.25);
margin-bottom: var(--page-gap);
}
h1 { margin: 0 0 4px; font-size: 1.25rem; }
.muted { color: var(--muted); }
.swatchrow { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
.swatchrow > div {
width: 44px; height: 28px; border-radius: 6px;
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.15);
}
/* A rule that would wreck an unisolated injected component. */
.card button { all: unset; }
/* The shadow-root proof: this must not touch anything inside either component. */
button { font: inherit; }
</style>
</head>
<body>
<main>
<div class="card">
<h1>Shared styling</h1>
<p class="muted">This page uses only the unprefixed legacy aliases: <code>--bg</code>,
<code>--text</code>, <code>--line</code>, <code>--accent</code>.</p>
<div class="swatchrow" id="row"></div>
</div>
<div class="card">
<h1>The controls</h1>
<p class="muted">Injected, not hand-written. Shadow DOM isolates them from the
<code>.card button {{ all: unset }}</code> rule above.</p>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<bananas-theme-selector id="selector"></bananas-theme-selector>
<bananas-appearance-toggle id="toggle" show-labels></bananas-appearance-toggle>
</div>
</div>
<div class="card">
<h1>Host page, after the components</h1>
<p class="muted">If component styles leaked, this card would change. It must not.</p>
<button id="host">A plain host button</button>
</div>
<pre id="state" class="card" style="font:12px/1.6 ui-monospace,monospace"></pre>
<div id="verdict" style="font:16px/1.4 monospace;padding:12px;border:2px solid #888"></div>
</main>
<script type="module" src="./contract-check.js"></script>
</body>
</html>

124
hack/pilot-go-loose.js Normal file
View file

@ -0,0 +1,124 @@
/**
* The pilot check's reporter.
*
* It is injected into a *copy* of go-loose's page; go-loose's own source is untouched.
*
* The question it answers is narrow and specific: does the component shipped by
* go-bananas change the attributes that go-loose's own hand-written stylesheet already
* keys on? If it does, go-loose is themed by the shared contract without a single edit
* to its CSS, which is the entire claim of ADR 0005.
*/
import { getContractState } from '/rt/v1/components.js'
const results = []
/** @param {string} name @param {boolean} pass @param {string} [detail] */
const check = (name, pass, detail = '') => results.push({ name, pass: !!pass, detail: String(detail) })
const root = document.documentElement
const state = getContractState()
/** The computed background of go-loose's own page surface. */
function pageBackground() {
return getComputedStyle(document.body).backgroundColor
}
/**
* The value go-loose's page sees for a contract custom property.
*
* This is the claim worth testing: go-loose's stylesheet never mentions --bn-* or
* --surface being defined anywhere, yet reading the property off its own body proves
* the contract reached the page through the one stylesheet link.
*/
function contractValueOnHost(name) {
return getComputedStyle(document.body).getPropertyValue(name).trim()
}
/** The text colour go-loose's own CSS takes from --text. */
function pageText() {
return getComputedStyle(document.body).color
}
async function run() {
// The selector fetches its palette list from the service. The wait has to cover a
// real cross-origin round trip, not the instant rejection that used to happen when
// the service sent no CORS headers: that failure was fast enough to hide behind a
// short settle time, and the wait was tuned to it.
await new Promise((r) => setTimeout(r, 4000))
const selector = document.querySelector('bananas-theme-selector')
const toggle = document.querySelector('bananas-appearance-toggle')
const shadow = (el) => /** @type {ShadowRoot} */ (el.shadowRoot)
check('the selector element upgraded', !!shadow(selector), selector ? selector.tagName : 'absent')
check('the toggle element upgraded', !!shadow(toggle), toggle ? toggle.tagName : 'absent')
const buttons = [...shadow(selector).querySelectorAll('button')]
const labels = buttons.map((b) => b.textContent.trim())
check('palettes are listed from the service', buttons.length > 1, labels.join(', '))
// go-loose's four palettes must all be offered, because those are the slugs its own
// CSS has blocks for.
for (const slug of ['Tokyo Night', 'Catppuccin', 'Tomorrow', 'Kanagawa']) {
check(`go-loose's "${slug}" is offered`, labels.includes(slug))
}
// The contract stylesheet loaded and reached go-loose's page.
const bgTokyo = pageBackground()
check('go-loose\'s page is themed through the contract', bgTokyo !== 'rgba(0, 0, 0, 0)' && bgTokyo !== '', bgTokyo)
// The decisive test: change the palette with the component, and confirm go-loose's
// OWN stylesheet responded. Nothing in style.css was edited.
const startBg = bgTokyo
const startText = pageText()
const kanagawa = buttons.find((b) => b.textContent.trim() === 'Kanagawa')
if (!kanagawa) {
check('the Kanagawa button was found', false, labels.join(', '))
} else {
kanagawa.click()
await new Promise((r) => setTimeout(r, 60))
check('selecting a palette writes data-theme', root.getAttribute('data-theme') === 'kanagawa', root.getAttribute('data-theme'))
check('selecting a palette writes the legacy attribute', root.getAttribute('data-bananas-theme') === 'kanagawa', root.getAttribute('data-bananas-theme'))
check("go-loose's own CSS followed the palette", pageBackground() !== startBg, startBg + ' -> ' + pageBackground())
// The alias layer reached a page that never referenced it.
check('go-loose\'s page reads --surface from the contract', contractValueOnHost('--surface') !== '', contractValueOnHost('--surface'))
check('go-loose\'s page reads --bn-surface from the contract', contractValueOnHost('--bn-surface') !== '', contractValueOnHost('--bn-surface'))
check('the alias and the contract agree', contractValueOnHost('--surface') === contractValueOnHost('--bn-surface'),
contractValueOnHost('--surface') + ' vs ' + contractValueOnHost('--bn-surface'))
}
// And the appearance axis, which go-loose has never had.
const toggleButtons = [...shadow(toggle).querySelectorAll('button')]
check('the appearance toggle has three states', toggleButtons.length === 3, toggleButtons.map((b) => b.getAttribute('aria-label')).join(', '))
const light = toggleButtons.find((b) => b.getAttribute('aria-label') === 'Light')
if (light) {
light.click()
await new Promise((r) => setTimeout(r, 60))
check('light writes data-mode', root.getAttribute('data-mode') === 'light', root.getAttribute('data-mode'))
check("go-loose's own CSS followed the appearance", pageBackground() !== 'rgb(0, 0, 0)', pageBackground())
}
// Isolation: go-loose has a broad `.card button { all: unset }`-style surface, and a
// control that leaked would be reset by it.
const first = shadow(selector).querySelector('button')
const style = first ? getComputedStyle(first) : null
check('the control is not reset by the host page', !!style && style.padding !== '0px', style ? style.padding : 'no button')
check('the control is not inheriting the page font', !!style && !style.fontFamily.includes('DM Sans'), style ? style.fontFamily : '')
// The store is reachable, so go-loose can read or set the selection itself.
check('the contract store is reachable from the module', state && typeof state.selectTheme === 'function')
const failed = results.filter((r) => !r.pass)
const out = document.getElementById('pilot') || document.createElement('pre')
out.id = 'pilot'
out.textContent = failed.length
? 'PILOT FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ')
: 'PILOT PASS ' + results.length + ' :: ' + results.map((f) => f.name).join(' | ')
document.body.append(out)
}
run().catch((error) => {
const out = document.createElement('pre')
out.id = 'pilot'
out.textContent = 'PILOT FAIL the check threw: ' + (error instanceof Error ? error.message : String(error))
document.body.append(out)
})

167
hack/pilot-go-tell.js Normal file
View file

@ -0,0 +1,167 @@
/**
* The go-tell pilot check.
*
* Injected into a *copy* of go-tell's shell; go-tell's own source is untouched.
*
* go-tell is the hard case among the applications: before this it had no CSS custom
* properties at all, 340 hardcoded hex values and 153 per-palette override rules. Its
* stylesheet now reads from the shared contract, so the question this answers is
* whether that rewrite actually works, and whether the two shipped controls can drive
* an application whose mechanism is `html[data-theme="…"]` rather than a class or a
* `--theme` variable.
*/
import { getContractState } from '/assets/components.js'
const results = []
const check = (name, pass, detail = '') => results.push({ name, pass: !!pass, detail: String(detail) })
const root = document.documentElement
const state = getContractState()
/** The computed background of the page, which is what a palette actually looks like. */
const pageBg = () => getComputedStyle(document.body).backgroundColor
/** The computed colour of a heading, which proves the text role resolved. */
const headingColour = () => {
const el = document.querySelector('.brand, h1, .header')
return el ? getComputedStyle(el).color : ''
}
/**
* Waits past this application's own CSS transitions.
*
* go-tell transitions `background` over 300ms, so reading a computed background sooner
* than that returns a value mid-animation and makes a working palette switch look
* broken. The wait is deliberately longer than the longest transition in the
* stylesheet rather than merely "long enough today".
*/
async function settle() {
await new Promise((r) => setTimeout(r, 450))
}
async function run() {
await new Promise((r) => setTimeout(r, 600)) // the selector's fetch
const selector = document.querySelector('bananas-theme-selector')
const toggle = document.querySelector('bananas-appearance-toggle')
const shadow = (el) => /** @type {ShadowRoot} */ (el.shadowRoot)
check('the controls upgraded', !!selector && !!toggle && !!shadow(selector) && !!shadow(toggle))
if (!selector || !toggle) return finish()
const buttons = [...shadow(selector).querySelectorAll('button')]
const labels = buttons.map((b) => b.textContent.trim())
check('palettes are listed from the service', buttons.length > 1, labels.join(', '))
// go-tell's own four palettes must all be offered, because those are the slugs its
// stylesheet and its rules key on.
for (const name of ['Cyberpunk', 'Nordic Moss', 'Deep Space Obsidian', 'Terminal Retro']) {
check(`go-tell's "${name}" is offered`, labels.some((l) => l.includes(name)))
}
// The decisive one: the contract must reach go-tell's page, which no longer
// defines a single custom property of its own.
const surface = getComputedStyle(document.body).getPropertyValue('--bn-surface').trim()
// Diagnostics, because "the contract did not reach the page" has three very
// different causes and the symptom alone cannot tell them apart.
const link = document.querySelector('link[data-theme-contract]')
const linkHref = link ? link.href : '(no link element)'
let linkLoaded = 'unknown'
if (link && link.sheet) {
try {
linkLoaded = String(link.sheet.cssRules.length) + ' rules'
} catch {
// A cross-origin sheet is unreadable by design. Reaching this branch means the
// stylesheet loaded and the browser is refusing to expose its rules, which is
// the healthy case for a cross-origin link.
linkLoaded = 'loaded (cross-origin, rules not readable)'
}
} else if (link) {
linkLoaded = 'not loaded'
}
check('the contract link is present', !!link, linkHref)
check('the contract stylesheet loaded', linkLoaded !== 'not loaded', linkLoaded)
check('the contract reaches go-tell', surface !== '', 'href=' + linkHref + ' loaded=' + linkLoaded + ' data-theme=' + root.getAttribute('data-theme') + ' data-mode=' + root.getAttribute('data-mode'))
check('--bn-surface has a real value', surface !== '' && surface !== 'rgba(0, 0, 0, 0)', surface)
// Switching a palette must move the page, through go-tell's own rules.
let startBg = pageBg()
const startText = headingColour()
for (const name of ['Nordic Moss', 'Deep Space Obsidian', 'Terminal Retro', 'Cyberpunk']) {
const button = buttons.find((b) => b.textContent.trim().includes(name))
if (!button) {
check(`selecting ${name}`, false, 'button not found')
continue
}
// The loop returns to Cyberpunk at the end, which is where it started. Selecting
// the palette that is already showing cannot change anything, so the "did the
// page move" assertion does not apply to that step.
const alreadyShowing = buttons.find((b) => b.getAttribute('aria-checked') === 'true')
const wasThis = alreadyShowing && alreadyShowing.textContent.trim().includes(name)
button.click()
await settle()
const slug = root.getAttribute('data-theme')
const contractBg = getComputedStyle(document.body).getPropertyValue('--bn-bg').trim()
check(`${name} writes data-theme`, !!slug, slug)
if (wasThis) {
check(`${name} is a no-op when already showing`, pageBg() === startBg, pageBg())
} else {
check(`${name} changes the page`, pageBg() !== startBg || headingColour() !== startText,
`slug=${slug} mode=${root.getAttribute('data-mode')} bodyBg=${pageBg()} --bn-bg=${contractBg} text=${headingColour()}`)
}
startBg = pageBg()
}
// The terminal palette forces a monospace face with !important on every element.
// Shadow DOM is supposed to make that impossible to reach the controls in; if the
// controls inherit the terminal face here, the isolation has failed.
const terminal = buttons.find((b) => b.textContent.trim().includes('Terminal'))
if (terminal) {
terminal.click()
await settle()
const font = getComputedStyle(terminal).fontFamily
check('the controls are not restyled by the terminal palette', !font.includes('Courier'), font)
check('the app itself did adopt the monospace face',
getComputedStyle(document.body).fontFamily.includes('Courier') ||
getComputedStyle(document.body).fontFamily.includes('monospace'),
getComputedStyle(document.body).fontFamily)
}
// The appearance axis: go-tell had no light mode before this. The control writing
// data-mode is what makes one possible.
const toggleButtons = [...shadow(toggle).querySelectorAll('button')]
check('the appearance toggle has three states', toggleButtons.length === 3,
toggleButtons.map((b) => b.getAttribute('aria-label')).join(', '))
const light = toggleButtons.find((b) => b.getAttribute('aria-label') === 'Light')
if (light) {
light.click()
await settle()
check('light writes data-mode', root.getAttribute('data-mode') === 'light', root.getAttribute('data-mode'))
}
check('the contract store is reachable', state && typeof state.selectTheme === 'function')
finish()
}
function finish() {
const failed = results.filter((r) => !r.pass)
const out = document.createElement('pre')
out.id = 'pilot'
out.textContent = failed.length
? 'PILOT FAIL ' + failed.length + '/' + results.length + ' :: ' + failed.map((f) => f.name + ' [' + f.detail + ']').join(' | ')
: 'PILOT PASS ' + results.length + ' :: ' + results.map((f) => f.name).join(' | ')
document.body.append(out)
}
run().catch((error) => {
const out = document.createElement('pre')
out.id = 'pilot'
out.textContent = 'PILOT FAIL the check threw: ' + (error instanceof Error ? error.message : String(error))
document.body.append(out)
})