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

95
sdk/eslint.config.js Normal file
View file

@ -0,0 +1,95 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
/**
* ESLint configuration for the runtime SDK.
*
* The SDK is written in JavaScript with JSDoc types rather than TypeScript,
* because it is served as source to be read as well as to be executed. That
* choice drives the rule set:
*
* - The rules designed for a `.ts` boundary (`explicit-module-boundary-types`)
* are redundant here: a JSDoc `@returns` annotation is the same information,
* and the linter cannot see it as an annotation.
* - The rules that catch real defects — floating promises, misused promises,
* unreachable switch cases, unused variables — stay, and are errors.
* - `no-console` is an error rather than a warning, because this module runs in
* someone else's page and must not write to their console on a path they did
* not ask for. The two exceptions below are deliberate diagnostics that only
* fire when the theme itself has already failed.
*
* The SDK is loaded by third-party pages, so anything it gets wrong is somebody
* else's incident. The strictness here is proportionate to that.
*/
export default tseslint.config(
{ ignores: ['dist', 'node_modules', '*.config.js'] },
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
globals: { ...globals.browser, ...globals.es2022 },
},
plugins: { '@typescript-eslint': tseslint.plugin },
rules: {
// JSDoc already declares every boundary type in a JavaScript project.
'@typescript-eslint/explicit-module-boundary-types': 'off',
// A fetch stub that returns a Response directly has nothing to await.
'@typescript-eslint/require-await': 'off',
// `||` is deliberate where an empty string should fall through, which is
// not what `??` does.
'@typescript-eslint/prefer-nullish-coalescing': 'off',
// --- correctness -----------------------------------------------------
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
// Disabled in this package. The DOM type definitions describe a fully
// featured modern browser, but the runtime guards below exist for Safari
// below 14 (no `MediaQueryList.addEventListener`) and for embedded
// webviews with no `matchMedia` at all. Those guards are load-bearing for
// the consumers this package exists to serve, and the rule reports them as
// dead code.
'@typescript-eslint/no-unnecessary-condition': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' },
],
// --- host safety -----------------------------------------------------
'no-console': ['error', { allow: ['warn', 'error'] }],
eqeqeq: ['error', 'always', { null: 'ignore' }],
'prefer-const': 'error',
'no-var': 'error',
'object-shorthand': 'error',
// An unsubscribe function that does nothing is the correct return value
// when there is nothing to unsubscribe from.
'@typescript-eslint/no-empty-function': [
'error',
{ allow: ['arrowFunctions'] },
],
},
},
{
// Tests build doubles that are deliberately loosely typed, and a test's
// console output is how a failure is diagnosed.
files: ['**/*.test.js'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/unbound-method': 'off',
'no-console': 'off',
},
},
)

2981
sdk/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
sdk/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "@go-bananas/sdk",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Runtime theme delivery SDKs served by Go Bananas!!. Built, not published: consumers load these from the service origin.",
"license": "SEE LICENSE IN ../LICENSE",
"scripts": {
"build": "vite build",
"dev": "vite build --watch --mode development",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@types/react": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"eslint": "10.12.0",
"globals": "17.13.0",
"jsdom": "30.1.2",
"react": "19.3.0",
"react-dom": "19.3.0",
"typescript": "6.0.3",
"typescript-eslint": "8.71.0",
"vite": "8.3.2",
"vitest": "5.0.3"
},
"engines": {
"node": ">=22"
}
}

731
sdk/src/components.js Normal file
View file

@ -0,0 +1,731 @@
/**
* The two shipped controls: a theme selector and an appearance toggle.
*
* They are custom elements rather than framework components on purpose. The
* applications this service feeds are a mix of vanilla JS, React and Tailwind, and a
* React component cannot be used by the first while a plain custom element works in
* all three with no build step and no import map.
*
* Both render into a shadow root, which is what makes them safe to inject into
* somebody else's page:
*
* - The page's CSS cannot reach inside, so a global `button { all: unset }` or a
* Tailwind preflight cannot restyle them.
* - Their CSS cannot leak out, so they cannot restyle the page. This is the same
* property that makes a live preview of a theme safe: the preview's styles stay in
* the preview.
* - Theming still works, because a shadow root inherits the custom properties that
* cross the boundary. `--bn-bg` set on <html> is visible inside, so the controls
* follow the palette they are switching between without a single extra rule.
*
* Styling hooks: `::part(button)`, `::part(list)` and so on, plus the `--bn-control-*`
* custom properties below. Nothing else is reachable, which is the point.
*
* ```html
* <link rel="stylesheet" href="https://themes.example.com/rt/v1/contract.css">
* <bananas-theme-selector base="https://themes.example.com/rt/v1"></bananas-theme-selector>
* <bananas-appearance-toggle></bananas-appearance-toggle>
* ```
*
* @module go-bananas/components
*/
import {
ADMIN_MODE,
ContractState,
DEFAULT_STORAGE_KEY,
getContractState,
LEGACY_MODE_ATTRIBUTE,
LEGACY_THEME_ATTRIBUTE,
MODE_ATTRIBUTE,
MODES,
readMode,
resetContractState,
THEME_ATTRIBUTE,
} from './contract.js'
/**
* The base the selector falls back to.
*
* It is only a fallback: a page that has already linked the contract stylesheet has
* said where the service is, and the component reads it from there rather than being
* configured a second time. Two places to set the same value is two places to get
* wrong.
*/
const DEFAULT_BASE = '/rt/v1'
/** The marker attribute on the contract stylesheet the selector looks for. */
const CONTRACT_LINK_MARKER = 'data-theme-contract'
/**
* Finds the runtime base from the contract stylesheet already on the page.
*
* @returns {string|null} The directory, or null when no link was found.
*/
function baseFromContractLink() {
if (typeof document === 'undefined') return null
// The marker attribute is preferred, because it is unambiguous. A link whose href
// ends in contract.css is accepted as a fallback so a page that predates the marker
// still works.
let link = document.querySelector(`link[${CONTRACT_LINK_MARKER}]`)
if (!link) {
for (const candidate of document.querySelectorAll('link[rel~="stylesheet"][href]')) {
const href = candidate.getAttribute('href') || ''
if (href.endsWith('contract.css')) {
link = candidate
break
}
}
}
if (!link) return null
const href = link.getAttribute('href')
if (!href) return null
// Strip the stylesheet filename, leaving the runtime directory.
const withoutFile = href.replace(/\/[^/]*$/, '')
return withoutFile === href ? null : withoutFile
}
/**
* Constructed contract stylesheets, cached per contract URL.
*
* A stylesheet in the document does not select into a shadow root, so the selector
* cannot borrow the one the page already linked. It has to fetch the contract itself
* and adopt it. Constructed sheets are shareable, so one fetch and one parse serve
* every control on the page.
*
* @type {Map<string, Promise<CSSStyleSheet|null>>}
*/
const contractSheets = new Map()
/**
* Fetches the contract and parses it into a stylesheet the controls can adopt.
*
* Returns null rather than throwing when the contract cannot be adopted. The palette
* names still come from `/contract.json`, so a control with no stylesheet is a
* working text-only selector rather than a broken one, and that is the right trade:
* the preview is the thing that degrades, not the ability to pick a theme.
*
* @param {string} base
* @returns {Promise<CSSStyleSheet|null>}
*/
async function adoptContractSheet(base) {
const url = `${base}/contract.css`
// The capability is probed by name rather than by reading the method off the
// prototype: holding the function would detach it from the constructor it belongs
// to. `in` asks the same question without a value to misuse.
const supported =
typeof CSSStyleSheet !== 'undefined' &&
'replaceSync' in CSSStyleSheet.prototype &&
typeof fetch === 'function'
if (!supported) return null
let pending = contractSheets.get(url)
if (!pending) {
pending = (async () => {
try {
const response = await fetch(url, { credentials: 'omit' })
if (!response.ok) throw new Error(`contract.css responded ${response.status}`)
const css = await response.text()
const sheet = new CSSStyleSheet()
// The contract is generated, not authored, so there is nothing to await a
// progressive render for. replaceSync also keeps adoption off the microtask
// queue, which is what stops the preview flashing unstyled.
sheet.replaceSync(css)
return sheet
} catch {
return null
}
})()
contractSheets.set(url, pending)
}
const sheet = await pending
if (!sheet) {
// Only failures are evicted, so a control mounted later can retry a contract that
// was momentarily unavailable. Concurrent controls still share the one attempt,
// because they were all awaiting the same promise.
contractSheets.delete(url)
return null
}
return sheet
}
/**
* The shadow stylesheet for both controls.
*
* Colours come from the contract custom properties with a literal fallback for each,
* so the controls are legible even on a page that has not loaded contract.css. The
* fallbacks are the light-mode values: a control that has to guess should guess
* legibly rather than guess dark text on a dark background.
*/
const STYLES = `
:host {
/* Overridable by the host application without reaching into the shadow root. */
--bn-control-radius: 8px;
--bn-control-gap: 2px;
--bn-control-padding: 6px 10px;
--bn-control-font: 13px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif;
display: inline-block;
font: var(--bn-control-font);
color: var(--bn-text, #0f172a);
/* The control is a control, so it does not inherit the page's text colour. */
-webkit-font-smoothing: antialiased;
}
:host([hidden]) { display: none; }
/* A segmented control: one bordered group with an inset selected segment. Built
from a plain flex row rather than a list so there is no marker semantics to
suppress, and so it reads the same in every engine. */
.group {
display: inline-flex;
gap: var(--bn-control-gap);
padding: 2px;
background: var(--bn-surface-sunken, #eef2f6);
border: 1px solid var(--bn-border, #e2e8f0);
border-radius: var(--bn-control-radius);
}
button {
appearance: none;
font: inherit;
margin: 0;
cursor: pointer;
padding: var(--bn-control-padding);
color: var(--bn-muted, #475569);
background: transparent;
border: 0;
border-radius: calc(var(--bn-control-radius) - 2px);
white-space: nowrap;
transition: background-color 120ms ease, color 120ms ease;
}
button:hover { color: var(--bn-text, #0f172a); }
button:focus-visible {
outline: 2px solid var(--bn-focus, #2563eb);
outline-offset: 1px;
}
button[aria-pressed='true'],
button[aria-checked='true'] {
background: var(--bn-surface, #fff);
color: var(--bn-text, #0f172a);
/* A border rather than a shadow: it survives a forced-colours mode where a
shadow is not painted. */
box-shadow: inset 0 0 0 1px var(--bn-border, #e2e8f0);
}
button[disabled] { cursor: not-allowed; opacity: 0.5; }
label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* The palette preview: a miniature of the application, not a row of colour chips.
Every colour is read from the contract, so what the preview shows is what the
theme does -- there is no hardcoded palette here to fall out of step with the
server. A chip of the accent colour was tried first and told you almost nothing:
two themes sharing an accent looked identical, which is the case the preview
exists to resolve. */
.preview {
display: block;
flex: none;
width: 28px;
height: 18px;
overflow: hidden;
border-radius: 3px;
background: var(--bn-bg, #fff);
box-shadow: inset 0 0 0 1px var(--bn-border, #e2e8f0);
}
/* A title bar, then a body with two rules of text and an accent control: enough
surface that the palette's surface, border and text all show at once. */
.preview-bar {
display: block;
height: 5px;
background: var(--bn-surface, #f8fafc);
border-bottom: 1px solid var(--bn-border, #e2e8f0);
}
.preview-body {
display: flex;
align-items: center;
gap: 2px;
padding: 2px;
}
.preview-rule {
display: block;
height: 2px;
flex: 1;
border-radius: 1px;
background: var(--bn-text, #0f172a);
/* Faded rather than a second colour: the miniature has to work on a palette whose
muted role is very close to its text role. */
opacity: 0.4;
}
.preview-accent {
display: block;
flex: none;
width: 9px;
height: 4px;
border-radius: 1px;
background: var(--bn-accent, #4f46e5);
}
.spinner { opacity: 0.6; }
`
/**
* A control's shared behaviour: shadow root, styles, subscription, attributes.
*
* @abstract
*/
class BananasControl extends HTMLElement {
constructor() {
super()
this._root = this.attachShadow({ mode: 'open' })
const style = document.createElement('style')
style.textContent = STYLES
this._root.append(style)
this._unsub = null
}
/** @returns {ShadowRoot} */
get shadowRoot_() {
return this._root
}
connectedCallback() {
this._unsub = getContractState().subscribe((state) => this._render(state))
}
disconnectedCallback() {
if (this._unsub) this._unsub()
this._unsub = null
}
/**
* @param {string} name
* @param {string} [fallback]
* @returns {string}
*/
_attr(name, fallback = '') {
const value = this.getAttribute(name)
return typeof value === 'string' ? value : fallback
}
/**
* @param {import('./contract.js').Selection} _state
* @returns {void}
*/
_render(_state) {
throw new Error('not implemented')
}
/**
* Fires a bubbling composed event so a host framework can react without having to
* reach into the shadow root.
*
* @param {string} type
* @param {Record<string, unknown>} detail
* @returns {void}
*/
_emit(type, detail) {
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
}
}
/**
* Narrows the contract payload's theme list to what the control can render.
*
* @param {unknown} raw
* @returns {Array<{slug: string, name: string, defaultMode: string}>}
*/
function normaliseThemes(raw) {
if (!Array.isArray(raw)) return []
const out = []
for (const entry of raw) {
if (typeof entry !== 'object' || entry === null) continue
/** @type {unknown} */
const asUnknown = entry
const item = /** @type {Record<string, unknown>} */ (asUnknown)
if (typeof item.slug !== 'string' || item.slug === '') continue
out.push({
slug: item.slug,
name: typeof item.name === 'string' ? item.name : item.slug,
defaultMode: item.defaultMode === 'light' ? 'light' : 'dark',
})
}
return out
}
/**
* Builds the miniature that previews a palette.
*
* The palette is carried on the element as an attribute rather than applied from the
* component, because that is the same mechanism the application uses. The preview is
* then styled by the real contract rules, which is the only way it can be honest: if
* the server changes how a palette is built, the preview changes with it instead of
* disagreeing with the page it is previewing.
*
* @param {{slug: string, defaultMode: string}} theme
* @returns {HTMLSpanElement}
*/
function previewFor(theme) {
const preview = document.createElement('span')
preview.className = 'preview'
preview.setAttribute('part', 'preview')
// The palette name is the button's text and therefore already announced. Announcing
// the picture as well would read the same theme twice.
preview.setAttribute('aria-hidden', 'true')
preview.setAttribute(THEME_ATTRIBUTE, theme.slug)
preview.setAttribute(MODE_ATTRIBUTE, theme.defaultMode)
const bar = document.createElement('span')
bar.className = 'preview-bar'
const body = document.createElement('span')
body.className = 'preview-body'
const rule = document.createElement('span')
rule.className = 'preview-rule'
const accent = document.createElement('span')
accent.className = 'preview-accent'
body.append(rule, accent)
preview.append(bar, body)
return preview
}
/**
* `<bananas-theme-selector>` — picks the palette.
*
* It reads the palette list from `/contract.json` on the service, so a host
* application publishes a theme and it appears here with no code change on either side.
*
* Each palette is previewed with a miniature of the application styled by the contract.
* The contract itself is fetched separately and adopted into the shadow root, because a
* stylesheet in the document does not select into a shadow root. When it cannot be
* fetched the selector still works, listing palette names only.
*
* Attributes:
* base service runtime base; defaults to /rt/v1
* label accessible label; defaults to "Theme"
* compact omit the "No theme" entry when present
* no-preview list palette names without the miniature
*
* Properties (for framework interop):
* value the selected slug, or ''
*
* Events: `bananas:theme` with `detail: { slug, mode }`.
*/
export class BananasThemeSelector extends BananasControl {
/** @type {Array<{slug: string, name: string, defaultMode: string}>} */
_themes = []
/** @type {'idle'|'loading'|'ready'|'error'} */
_status = 'idle'
/** @type {CSSStyleSheet|null} */
_sheet = null
/** @override */
connectedCallback() {
// The DOM is built before subscribing. Subscription renders immediately, so
// subscribing first would render into a group that does not exist yet and the
// control would come up empty until something else changed the store.
this._root.append(
(this._label = document.createElement('label')),
(this._group = document.createElement('div')),
)
this._label.className = 'sr'
this._label.textContent = this._attr('label', 'Theme')
this._group.className = 'group'
this._group.setAttribute('part', 'group')
this._group.setAttribute('role', 'radiogroup')
this._group.setAttribute('aria-label', this._label.textContent)
super.connectedCallback()
// Not awaited: connectedCallback cannot be async, and the control renders a
// loading state until the store resolves. The method handles its own failures.
void this._load()
}
/** @returns {string} */
get value() {
return getContractState().state.theme || ''
}
set value(slug) {
getContractState().selectTheme(slug || null)
}
/** @returns {Promise<void>} */
async _load() {
if (this._status === 'loading' || this._status === 'ready') return
this._status = 'loading'
// The explicit attribute wins; otherwise the contract stylesheet on the page says
// where the service is; otherwise the same-origin default.
const configured = this.getAttribute('base')
const base = (configured || baseFromContractLink() || DEFAULT_BASE).replace(/\/$/, '')
// The contract is adopted alongside the palette list rather than before it. Waiting
// for it would delay the control for a decoration, and the palette names are what
// the control is actually for.
void this._adoptContract(base)
try {
const response = await fetch(`${base}/contract.json`, { credentials: 'omit' })
if (!response.ok) throw new Error(`contract.json responded ${response.status}`)
// json() is untyped, so the payload is narrowed here rather than trusted: a
// contract document from a mismatched build must not throw inside the render.
// The value is landed in an `unknown`-typed variable first. `json()` returns
// `any`, and assigning an `any` straight into a typed position is exactly the
// hole no-unsafe-assignment exists to close.
/** @type {unknown} */
const body = await response.json()
const payload = /** @type {{themes?: unknown}} */ (body)
this._themes = normaliseThemes(payload.themes)
this._status = 'ready'
this._render(getContractState().state)
} catch (error) {
this._status = 'error'
this._render(getContractState().state)
this._emit('bananas:error', { error: error instanceof Error ? error.message : String(error) })
}
}
/**
* Adopts the contract into the shadow root, then repaints with the previews on.
*
* @param {string} base
* @returns {Promise<void>}
*/
async _adoptContract(base) {
const sheet = await adoptContractSheet(base)
// A null sheet is not a failure here: the control renders without previews, which
// is a lesser outcome, not a broken one.
if (sheet) {
this._sheet = sheet
// Read defensively: the property is absent in some DOM implementations, and
// spreading undefined throws where appending a sheet is otherwise harmless.
const adopted = this._root.adoptedStyleSheets
this._root.adoptedStyleSheets = adopted ? [...adopted, sheet] : [sheet]
}
if (this._status === 'ready') this._render(getContractState().state)
}
/**
* @override
* @param {import('./contract.js').Selection} state
* @returns {void}
*/
_render(state) {
const group = this._group
if (!group) return
group.replaceChildren()
if (this._status === 'error') {
const note = document.createElement('button')
note.type = 'button'
note.className = 'spinner'
note.textContent = 'No themes'
note.disabled = true
group.append(note)
return
}
if (this._status !== 'ready') {
const note = document.createElement('button')
note.type = 'button'
note.className = 'spinner'
note.textContent = 'Loading'
note.disabled = true
group.append(note)
return
}
if (!this.hasAttribute('compact')) {
group.append(this._button(null, 'None', state.theme === null))
}
for (const theme of this._themes) {
group.append(this._button(theme, theme.name, state.theme === theme.slug))
}
}
/**
* @param {{slug: string, name: string, defaultMode: string}|null} theme
* @param {string} label
* @param {boolean} selected
* @returns {HTMLButtonElement}
*/
_button(theme, label, selected) {
const button = document.createElement('button')
button.type = 'button'
button.setAttribute('part', 'button')
button.setAttribute('role', 'radio')
button.setAttribute('aria-checked', selected ? 'true' : 'false')
button.title = theme ? `${label} (${theme.defaultMode})` : 'Use no theme'
if (selected) button.setAttribute('aria-current', 'true')
button.addEventListener('click', () => {
const slug = theme ? theme.slug : null
getContractState().selectTheme(slug, theme ? theme.defaultMode : null)
this._emit('bananas:theme', { slug, mode: theme ? theme.defaultMode : ADMIN_MODE })
})
// A preview needs the contract to mean anything, and the "None" entry has no
// palette to preview, so only real palettes get one.
if (theme && this._sheet && !this.hasAttribute('no-preview')) {
button.prepend(previewFor(theme))
}
button.append(document.createTextNode(label))
return button
}
}
/**
* `<bananas-appearance-toggle>` — picks between admin chrome, light and dark.
*
* Three states, not two, because "light or dark" is the wrong question for an
* administration screen: it has to be able to fall back to the application's own
* chrome while a palette is being previewed.
*
* Attributes:
* label accessible label; defaults to "Appearance"
* show-labels render the text labels rather than icons only
*
* Events: `bananas:appearance` with `detail: { mode }`.
*/
export class BananasAppearanceToggle extends BananasControl {
/** @override */
connectedCallback() {
this._root.append((this._group = document.createElement('div')))
this._group.className = 'group'
this._group.setAttribute('part', 'group')
this._group.setAttribute('role', 'radiogroup')
this._group.setAttribute('aria-label', this._attr('label', 'Appearance'))
super.connectedCallback()
}
/** @returns {'admin'|'light'|'dark'} */
get value() {
return getContractState().state.mode
}
set value(mode) {
if (MODES.includes(mode)) getContractState().selectMode(mode)
}
/**
* @override
* @param {import('./contract.js').Selection} state
* @returns {void}
*/
_render(state) {
const group = this._group
if (!group) return
group.replaceChildren()
const withLabels = this.hasAttribute('show-labels')
/** @type {Array<{mode: 'admin'|'light'|'dark', icon: string, label: string}>} */
const entries = [
{ mode: ADMIN_MODE, icon: '◧', label: 'Admin' },
{ mode: 'light', icon: '☀', label: 'Light' },
{ mode: 'dark', icon: '☾', label: 'Dark' },
]
for (const entry of entries) {
const button = document.createElement('button')
button.type = 'button'
button.setAttribute('part', 'button')
button.setAttribute('role', 'radio')
// The label is always present for assistive technology; only the visible form
// changes. An icon-only button with no accessible name is unusable with a screen
// reader, which is why the text is not dropped but hidden.
button.setAttribute('aria-label', entry.label)
button.setAttribute('aria-checked', state.mode === entry.mode ? 'true' : 'false')
button.title = entry.label
const icon = document.createElement('span')
icon.setAttribute('aria-hidden', 'true')
icon.textContent = entry.icon
button.append(icon)
if (withLabels) {
const text = document.createElement('span')
text.textContent = ` ${entry.label}`
button.append(text)
}
button.addEventListener('click', () => {
getContractState().selectMode(entry.mode)
this._emit('bananas:appearance', { mode: entry.mode })
})
group.append(button)
}
}
}
/**
* Defines both elements. Safe to call more than once.
*
* The registration is guarded on the constructor's own name rather than
* `customElements.get`, because two copies of this module in one page (two bundles,
* or an import map plus a direct import) is a realistic scenario and the second
* registration would otherwise throw and take the page down with it.
*
* @returns {void}
*/
export function defineComponents() {
if (typeof customElements === 'undefined') return
for (const [name, ctor] of /** @type {const} */ ([
['bananas-theme-selector', BananasThemeSelector],
['bananas-appearance-toggle', BananasAppearanceToggle],
])) {
if (!customElements.get(name)) customElements.define(name, ctor)
}
}
if (typeof customElements !== 'undefined') defineComponents()
// Expose getContractState globally so consumers loading via script tag can access it.
// This is needed because dynamic import() may not find the module loaded via script tag.
if (typeof window !== 'undefined') {
window.__BANANAS_GET_CONTRACT_STATE__ = getContractState
}
// The contract API is re-exported so that loading this one module is enough. A
// consumer that wants to build its own control, or to read the current selection, has
// no reason to know that the store lives in a different file — and requiring a second
// import is exactly the kind of friction that gets a consumer writing their own
// storage code instead.
export {
ADMIN_MODE,
ContractState,
DEFAULT_STORAGE_KEY,
LEGACY_MODE_ATTRIBUTE,
LEGACY_THEME_ATTRIBUTE,
MODE_ATTRIBUTE,
THEME_ATTRIBUTE,
getContractState,
readMode,
resetContractState,
}

675
sdk/src/components.test.js Normal file
View file

@ -0,0 +1,675 @@
/**
* Tests for the contract store and the two controls.
*
* The store is tested directly because both controls are thin views over it. The
* controls are tested for the two properties that matter and are easy to lose: the
* attributes land on <html>, and the shadow root actually isolates style in both
* directions.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ADMIN_MODE,
ContractState,
DEFAULT_STORAGE_KEY,
LEGACY_MODE_ATTRIBUTE,
LEGACY_THEME_ATTRIBUTE,
MODE_ATTRIBUTE,
THEME_ATTRIBUTE,
getContractState,
resetContractState,
} from './contract.js'
import { BananasAppearanceToggle, BananasThemeSelector, defineComponents } from './components.js'
/** Clears the document and storage between tests. */
function reset() {
resetContractState()
localStorage.clear()
for (const attr of [THEME_ATTRIBUTE, MODE_ATTRIBUTE, LEGACY_THEME_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) {
document.documentElement.removeAttribute(attr)
}
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('style')
}
/**
* The shadow root of a custom element, typed.
*
* `shadowRoot` is `ShadowRoot | null` in the DOM types even though these elements
* always open one, so the cast lives in one place instead of at twenty call sites.
*
* @param {Element} el
* @returns {ShadowRoot}
*/
function shadowOf(el) {
const root = /** @type {ShadowRoot | null} */ (el.shadowRoot)
if (!root) throw new Error('element has no shadow root')
return root
}
/** Builds a state wired to the live document. */
function liveState() {
return new ContractState({ storageKey: 'test:contract' })
}
/**
* Fetches a contract payload for the selector.
*
* @param {Array<{slug: string, name: string, defaultMode: string}>} themes
* @returns {void}
*/
function stubContract(themes) {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ themes }),
})),
)
}
/**
* A contract stylesheet with the rules a preview needs.
*
* Bare attribute selectors, because that is what the control adopts: a document
* stylesheet reaches the shadow root only through an adopted sheet, where `:root`
* matches nothing.
*/
const CONTRACT_CSS = `
[data-theme="midnight"] { --bn-bg: #101014; --bn-surface: #1a1a20; --bn-border: #2e2e38; --bn-text: #cfd0e0; --bn-accent: #6f7dff; }
[data-theme="midnight"][data-mode="dark"] { --bn-bg: #0b0b0e; --bn-accent: #8a94ff; }
[data-theme="daylight"] { --bn-bg: #fdfdfd; --bn-surface: #f2f2f4; --bn-border: #d8d8de; --bn-text: #17171a; --bn-accent: #0a5ad6; }
[data-theme="daylight"][data-mode="light"] { --bn-bg: #ffffff; --bn-accent: #0a5ad6; }
`
/**
* Serves both halves of the runtime the selector reads: the palette list and the
* contract it previews from.
*
* Each call gets its own base URL. The component caches a parsed contract per URL for
* the life of the page, which is right in a browser and wrong in a test file: without
* a fresh URL every test would inherit whichever contract the first one parsed, and a
* test stubbing a 404 would still get the sheet an earlier test warmed.
*
* @param {Array<{slug: string, name: string, defaultMode: string}>} themes
* @param {{css?: string, failCss?: boolean}} [options]
* @returns {{base: string, calls: () => Array<unknown[]>}} The base to mount on, and
* the recorded requests.
*/
function stubRuntime(themes, options = {}) {
const css = options.css === undefined ? CONTRACT_CSS : options.css
/** @type {Array<unknown[]>} */
const calls = []
const base = `/rt/v1-test-${++runtimeSeq}`
vi.stubGlobal(
'fetch',
vi.fn(async (/** @type {string} */ url) => {
calls.push([url])
if (url.endsWith('contract.css')) {
if (options.failCss) return { ok: false, status: 404, text: async () => '' }
return { ok: true, status: 200, text: async () => css }
}
return { ok: true, status: 200, json: async () => ({ themes }) }
}),
)
return { base, calls: () => calls }
}
/** Counts bases so no two tests share a contract cache entry. */
let runtimeSeq = 0
/**
* Mounts a selector and waits for it to settle.
*
* The control fires two unawaited promises -- the palette list and the contract -- and
* which one finishes last decides whether previews are on the first paint. Waiting for
* both is the only way to assert the finished state rather than a race.
*
* @param {Record<string, string>} [attributes]
* @returns {Promise<BananasThemeSelector>}
*/
async function mountSelector(attributes = {}) {
const el = /** @type {BananasThemeSelector} */ (document.createElement('bananas-theme-selector'))
for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value)
document.body.append(el)
await vi.waitFor(() => {
expect(shadowOf(el).querySelectorAll('button').length).toBeGreaterThan(0)
})
return el
}
describe('ContractState', () => {
beforeEach(reset)
it('writes the palette to both the short and the prefixed attribute', () => {
const state = liveState()
state.selectTheme('midnight')
// Both, because which convention a host's CSS reads is not knowable from here.
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight')
expect(document.documentElement.getAttribute(LEGACY_THEME_ATTRIBUTE)).toBe('midnight')
})
it('writes the appearance to both attributes and sets the dark class', () => {
const state = liveState()
state.selectMode('dark')
expect(document.documentElement.getAttribute(MODE_ATTRIBUTE)).toBe('dark')
expect(document.documentElement.getAttribute(LEGACY_MODE_ATTRIBUTE)).toBe('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
// colorScheme is what makes native form controls and scrollbars follow.
expect(document.documentElement.style.colorScheme).toBe('dark')
})
it('removes both appearance attributes for admin chrome', () => {
const state = liveState()
state.selectMode('dark')
state.selectMode(ADMIN_MODE)
expect(document.documentElement.hasAttribute(MODE_ATTRIBUTE)).toBe(false)
expect(document.documentElement.hasAttribute(LEGACY_MODE_ATTRIBUTE)).toBe(false)
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
it('restores the theme default appearance when a palette is chosen', () => {
const state = liveState()
state.selectMode('light')
state.selectTheme('midnight', 'dark')
expect(state.state.theme).toBe('midnight')
expect(state.state.mode).toBe('dark')
})
it('drops the palette and returns to admin chrome when there is none', () => {
const state = liveState()
state.selectTheme('midnight', 'dark')
state.selectTheme(null)
expect(state.state.theme).toBeNull()
expect(state.state.mode).toBe(ADMIN_MODE)
expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false)
})
it('persists and restores across instances', () => {
liveState().selectTheme('midnight', 'dark')
// A second store over the same key is what a page reload produces.
const restored = new ContractState({ storageKey: 'test:contract' })
expect(restored.state.theme).toBe('midnight')
expect(restored.state.mode).toBe('dark')
})
it('ignores a corrupt stored value instead of failing', () => {
localStorage.setItem('test:contract', '{not json')
expect(() => new ContractState({ storageKey: 'test:contract' })).not.toThrow()
})
it('survives localStorage throwing, as it does in private browsing', () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('QuotaExceededError')
})
const state = liveState()
expect(() => state.selectTheme('midnight')).not.toThrow()
// The selection still applies to this page view even though it cannot persist.
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight')
setItem.mockRestore()
})
it('notifies subscribers and reports who wrote', () => {
const state = liveState()
/** @type {import('./contract.js').Selection[]} */
const seen = []
state.subscribe((s) => seen.push(s))
state.selectMode('dark')
// Once on subscribe, once on the change.
expect(seen).toHaveLength(2)
expect(seen[1]?.mode).toBe('dark')
expect(seen[1]?.source).toBe('toggle')
})
it('hands out copies so a subscriber cannot mutate the store', () => {
const state = liveState()
/** @type {import('./contract.js').Selection|undefined} */
let captured
state.subscribe((s) => {
captured = s
})
if (!captured) throw new Error('subscribe did not deliver a value')
captured.theme = 'tampered'
expect(state.state.theme).not.toBe('tampered')
})
it('does not apply anything when there is no document', () => {
// A build step or a test environment can import this without a DOM.
const state = new ContractState({ storageKey: 'test:contract', root: null })
expect(() => state.selectTheme('midnight')).not.toThrow()
})
it('shares one store between callers', () => {
const a = getContractState({ storageKey: DEFAULT_STORAGE_KEY })
const b = getContractState()
expect(a).toBe(b)
})
})
describe('components', () => {
beforeEach(() => {
reset()
defineComponents()
})
it('registers both elements without throwing on a second call', () => {
expect(() => defineComponents()).not.toThrow()
expect(customElements.get('bananas-theme-selector')).toBe(BananasThemeSelector)
expect(customElements.get('bananas-appearance-toggle')).toBe(BananasAppearanceToggle)
})
it('renders the appearance toggle with three states', () => {
const el = document.createElement('bananas-appearance-toggle')
document.body.append(el)
const buttons = shadowOf(el).querySelectorAll('button')
expect(buttons).toHaveLength(3)
// Each is named for assistive technology even though only the icon is visible.
expect([...buttons].map((b) => b.getAttribute('aria-label'))).toEqual(['Admin', 'Light', 'Dark'])
})
it('marks the current appearance as checked', () => {
const el = document.createElement('bananas-appearance-toggle')
document.body.append(el)
getContractState().selectMode('dark')
const checked = [...shadowOf(el).querySelectorAll('button')].find(
(b) => b.getAttribute('aria-checked') === 'true',
)
expect(checked?.getAttribute('aria-label')).toBe('Dark')
})
it('changes the appearance when a button is pressed', () => {
const el = document.createElement('bananas-appearance-toggle')
document.body.append(el)
const dark = [...shadowOf(el).querySelectorAll('button')].find(
(b) => b.getAttribute('aria-label') === 'Dark',
)
dark?.click()
expect(getContractState().state.mode).toBe('dark')
expect(document.documentElement.getAttribute(MODE_ATTRIBUTE)).toBe('dark')
})
it('emits an appearance event that crosses the shadow boundary', () => {
const el = document.createElement('bananas-appearance-toggle')
document.body.append(el)
/** @type {Array<{mode: string}>} */
const seen = []
// Listening on document, not on the element: the event has to be composed for a
// host framework to see it without a shadow-root reference.
document.addEventListener('bananas:appearance', (event) => {
seen.push(/** @type {CustomEvent<{mode: string}>} */ (event).detail)
})
const light = [...shadowOf(el).querySelectorAll('button')].find(
(b) => b.getAttribute('aria-label') === 'Light',
)
light?.click()
expect(seen).toEqual([{ mode: 'light' }])
})
it('loads palettes from the service and renders one button each', async () => {
stubContract([
{ slug: 'midnight', name: 'Midnight', defaultMode: 'dark' },
{ slug: 'daylight', name: 'Daylight', defaultMode: 'light' },
])
const el = document.createElement('bananas-theme-selector')
document.body.append(el)
await vi.waitFor(() => {
expect(shadowOf(el).querySelectorAll('button').length).toBe(3)
})
const labels = [...shadowOf(el).querySelectorAll('button')].map((b) => b.textContent)
expect(labels).toContain('Midnight')
expect(labels).toContain('None')
})
it('surfaces a service failure without throwing', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })),
)
const el = document.createElement('bananas-theme-selector')
document.body.append(el)
await vi.waitFor(() => {
const button = shadowOf(el).querySelector('button')
expect(button?.textContent).toBe('No themes')
})
})
it('keeps its styles out of the page and the page out of it', () => {
const el = document.createElement('bananas-appearance-toggle')
document.body.append(el)
// The shadow root exists, which is the mechanism: page CSS cannot select into it
// and this stylesheet has no way to reach a page rule.
const shadow = shadowOf(el)
expect(shadow.querySelector('style')).toBeTruthy()
// Nothing was written to the document head, so nothing can leak out.
const leaked = [...document.head.querySelectorAll('style')].filter((s) =>
(s.textContent || '').includes('--bn-control-radius'),
)
expect(leaked).toHaveLength(0)
})
it('inherits the contract custom properties through the shadow boundary', () => {
// This is what makes a control follow the palette it is switching: custom
// properties cross the shadow boundary, so --bn-text set on <html> reaches in.
document.documentElement.style.setProperty('--bn-text', 'rgb(1, 2, 3)')
const el = document.createElement('bananas-appearance-toggle')
document.body.append(el)
const sheet = shadowOf(el).querySelector('style')
expect(sheet?.textContent).toContain('var(--bn-text')
document.documentElement.style.removeProperty('--bn-text')
})
it('removes its subscription when disconnected', () => {
const el = document.createElement('bananas-appearance-toggle')
document.body.append(el)
el.remove()
// A store change after removal must not reach a detached element.
expect(() => getContractState().selectMode('dark')).not.toThrow()
})
it('reflects the value property both ways', () => {
const selector = /** @type {BananasThemeSelector} */ (
document.createElement('bananas-theme-selector')
)
const toggle = /** @type {BananasAppearanceToggle} */ (
document.createElement('bananas-appearance-toggle')
)
document.body.append(selector, toggle)
selector.value = 'midnight'
expect(getContractState().state.theme).toBe('midnight')
toggle.value = 'light'
expect(getContractState().state.mode).toBe('light')
})
})
describe('BananasThemeSelector previews', () => {
const themes = [
{ slug: 'midnight', name: 'Midnight', defaultMode: 'dark' },
{ slug: 'daylight', name: 'Daylight', defaultMode: 'light' },
]
beforeEach(reset)
it('gives every palette a miniature that carries the palette it previews', async () => {
const rt = stubRuntime(themes)
const el = await mountSelector({ base: rt.base })
await vi.waitFor(() => {
expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2)
})
const previews = [...shadowOf(el).querySelectorAll('.preview')]
// The palette is on the element as an attribute, so the contract styles the
// miniature rather than the component hardcoding a colour per theme.
expect(previews.map((p) => p.getAttribute(THEME_ATTRIBUTE))).toEqual(['midnight', 'daylight'])
// Each palette previews in its own default appearance, because the default is the
// appearance the theme is actually chosen for.
expect(previews.map((p) => p.getAttribute(MODE_ATTRIBUTE))).toEqual(['dark', 'light'])
})
it('builds the miniature out of the contract tokens rather than fixed colours', async () => {
const rt = stubRuntime(themes)
const el = await mountSelector({ base: rt.base })
const preview = await vi.waitFor(() => {
const found = shadowOf(el).querySelector('.preview')
expect(found).toBeTruthy()
return /** @type {Element} */ (found)
})
// A title bar, a text rule and an accent control: enough of the application for
// surface, border, text and accent to all be visible at once.
expect(preview.querySelectorAll('.preview-bar')).toHaveLength(1)
expect(preview.querySelectorAll('.preview-rule')).toHaveLength(1)
expect(preview.querySelectorAll('.preview-accent')).toHaveLength(1)
// Every colour is a contract token with a fallback, so no palette value is baked
// into the component and none can fall out of step with the server.
const css = shadowOf(el).querySelector('style')?.textContent || ''
for (const token of ['--bn-bg', '--bn-surface', '--bn-border', '--bn-text', '--bn-accent']) {
expect(css).toContain(`var(${token},`)
}
})
it('hides the miniature from assistive technology', async () => {
const rt = stubRuntime(themes)
const el = await mountSelector({ base: rt.base })
await vi.waitFor(() => {
expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2)
})
// The palette name is the button's text, so announcing the picture too would read
// the same theme twice.
for (const preview of shadowOf(el).querySelectorAll('.preview')) {
expect(preview.getAttribute('aria-hidden')).toBe('true')
}
})
it('leaves the name readable and the miniature beside it', async () => {
const rt = stubRuntime(themes)
const el = await mountSelector({ base: rt.base })
const button = await vi.waitFor(() => {
const found = [...shadowOf(el).querySelectorAll('button')].find(
(b) => b.textContent === 'Midnight' && b.querySelector('.preview'),
)
expect(found).toBeTruthy()
return /** @type {Element} */ (found)
})
// textContent ignores the picture, so the accessible name is still the palette.
expect(button.textContent).toBe('Midnight')
expect(button.querySelector('.preview')).toBeTruthy()
// The miniature comes first, which is the order the two read in visually.
expect(button.firstElementChild?.className).toBe('preview')
})
it('adopts the contract into the shadow root', async () => {
const rt = stubRuntime(themes)
const el = await mountSelector({ base: rt.base })
await vi.waitFor(() => {
expect(shadowOf(el).adoptedStyleSheets.length).toBe(1)
})
// The reason the control fetches the contract at all: a stylesheet in the document
// does not select into a shadow root, so linking it would leave every preview
// rendering from its fallbacks.
const sheet = shadowOf(el).adoptedStyleSheets[0]
if (!sheet) throw new Error('no stylesheet was adopted')
expect(sheet.cssRules.length).toBeGreaterThan(0)
// The contract's own selectors are what style the miniature, so the adopted sheet
// has to carry them rather than just the component's styles.
const selectors = [...sheet.cssRules]
.filter((rule) => rule instanceof CSSStyleRule)
.map((rule) => rule.selectorText)
.join('\n')
expect(selectors).toContain('[data-theme="midnight"]')
})
it('fetches the contract once for every control on the page', async () => {
const rt = stubRuntime(themes)
await mountSelector({ base: rt.base })
await mountSelector({ base: rt.base })
const cssCalls = rt.calls().filter((call) => String(call[0]).endsWith('contract.css'))
// Constructed stylesheets are shareable, so a second control costs no second
// request.
expect(cssCalls).toHaveLength(1)
})
it('lists the palettes without a miniature when the contract is unavailable', async () => {
const rt = stubRuntime(themes, { failCss: true })
const el = await mountSelector({ base: rt.base })
await vi.waitFor(() => {
expect(shadowOf(el).querySelectorAll('button').length).toBe(3)
})
// Let the failed contract request settle, so the assertions below are about the
// outcome of the failure rather than about not having got there yet.
await vi.waitFor(() => {
expect(rt.calls().filter((call) => String(call[0]).endsWith('contract.css')).length).toBe(1)
})
await new Promise((resolve) => setTimeout(resolve, 0))
// Degrading to names, not to a broken control: picking a theme still has to work.
expect(shadowOf(el).querySelectorAll('.preview')).toHaveLength(0)
// Read tolerantly: the property is absent in some DOM implementations, and the
// claim under test is that nothing was adopted, not that an empty list came back.
expect(shadowOf(el).adoptedStyleSheets?.length ?? 0).toBe(0)
const labels = [...shadowOf(el).querySelectorAll('button')].map((b) => b.textContent)
expect(labels).toContain('Midnight')
expect(labels).toContain('Daylight')
})
it('does not preview the None entry, which has no palette', async () => {
const rt = stubRuntime(themes)
const el = await mountSelector({ base: rt.base })
await vi.waitFor(() => {
expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2)
})
const none = [...shadowOf(el).querySelectorAll('button')].find((b) => b.textContent === 'None')
expect(none?.querySelector('.preview')).toBeNull()
})
it('omits every miniature when asked', async () => {
const rt = stubRuntime(themes)
const el = await mountSelector({ base: rt.base, 'no-preview': '' })
// Wait for the contract to land before asserting that no miniature was drawn.
// Without that, "no miniature" would also hold simply because none had been drawn
// yet, and the assertion would prove nothing about the attribute.
await vi.waitFor(() => {
expect(shadowOf(el).adoptedStyleSheets.length).toBe(1)
})
// The escape hatch for a toolbar too narrow to hold them.
expect(shadowOf(el).querySelectorAll('.preview')).toHaveLength(0)
})
it('keeps the miniature in step with the selection', async () => {
const rt = stubRuntime(themes)
const el = await mountSelector({ base: rt.base })
/** @returns {Promise<HTMLButtonElement>} */
const midnightButton = () =>
vi.waitFor(() => {
const found = [...shadowOf(el).querySelectorAll('button')].find(
(b) => b.textContent === 'Midnight',
)
if (!(found instanceof HTMLButtonElement)) throw new Error('no Midnight button')
return found
})
expect((await midnightButton()).getAttribute('aria-checked')).toBe('false')
;(await midnightButton()).click()
// The group is rebuilt on every state change, so the button that was clicked is
// replaced; the check has to be made against the one now on screen.
await vi.waitFor(async () => {
expect((await midnightButton()).getAttribute('aria-checked')).toBe('true')
})
// The previews survive the re-render, so the palette that was previewed is still
// the one on screen after selecting it.
expect(shadowOf(el).querySelectorAll('.preview').length).toBe(2)
})
})
describe('base discovery', () => {
beforeEach(() => {
reset()
defineComponents()
})
afterEach(() => {
for (const link of document.querySelectorAll('link')) link.remove()
})
it('finds the runtime base from the contract stylesheet', () => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.setAttribute('data-theme-contract', '')
link.href = 'https://themes.example.com/rt/v1/contract.css'
document.head.append(link)
stubContract([{ slug: 'midnight', name: 'Midnight', defaultMode: 'dark' }])
const el = document.createElement('bananas-theme-selector')
document.body.append(el)
return vi.waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'https://themes.example.com/rt/v1/contract.json',
expect.anything(),
)
})
})
it('accepts a link without the marker when the href is recognisable', () => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://cdn.example.com/rt/v1/contract.css'
document.head.append(link)
stubContract([])
const el = document.createElement('bananas-theme-selector')
document.body.append(el)
return vi.waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'https://cdn.example.com/rt/v1/contract.json',
expect.anything(),
)
})
})
it('lets an explicit base attribute win over the stylesheet', () => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.setAttribute('data-theme-contract', '')
link.href = 'https://from-link.example.com/rt/v1/contract.css'
document.head.append(link)
stubContract([])
const el = document.createElement('bananas-theme-selector')
el.setAttribute('base', 'https://explicit.example.com/rt/v1')
document.body.append(el)
return vi.waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'https://explicit.example.com/rt/v1/contract.json',
expect.anything(),
)
})
})
it('falls back to the same-origin default with no link', () => {
stubContract([])
const el = document.createElement('bananas-theme-selector')
document.body.append(el)
return vi.waitFor(() => {
expect(fetch).toHaveBeenCalledWith('/rt/v1/contract.json', expect.anything())
})
})
})

275
sdk/src/contract.js Normal file
View file

@ -0,0 +1,275 @@
/**
* The contract state shared by the shipped components.
*
* This module is the single place that knows how a palette and an appearance turn
* into attributes on the document element. Both components read and write it, and a
* host application can use it directly to build its own controls.
*
* The attributes are configurable rather than hard-coded, because a host page may
* already have committed to a different pair — two generations of applications do use
* different ones — and because a component that cannot be told where to write cannot
* be adopted by either.
*
* @module go-bananas/contract
*/
/** The attribute a palette is selected with. */
export const THEME_ATTRIBUTE = 'data-theme'
/** The attribute an appearance is selected with. */
export const MODE_ATTRIBUTE = 'data-mode'
/** The prefixed attributes this service emitted before the contract existed. */
export const LEGACY_THEME_ATTRIBUTE = 'data-bananas-theme'
export const LEGACY_MODE_ATTRIBUTE = 'data-bananas-appearance'
/**
* The appearance that means "no theme": the application's own chrome.
*
* It is a third state rather than a fourth mode, because it is a different question
* from light or dark. An administration screen has to look like the administration
* screen even while a palette is being previewed.
*/
export const ADMIN_MODE = 'admin'
/** Every appearance value the shipped components use. */
export const MODES = /** @type {const} */ ([ADMIN_MODE, 'light', 'dark'])
/** The default storage key. Namespaced by origin so two apps on one host do not collide. */
export const DEFAULT_STORAGE_KEY = 'go-bananas:appearance'
/**
* @typedef {object} Selection
* @property {string|null} theme The selected palette slug, or null for none.
* @property {'admin'|'light'|'dark'} mode The selected appearance.
* @property {boolean} system Follow the operating system preference for mode.
* @property {string|null} source The component that last wrote, for change events.
*/
/**
* A tiny observable holding the current selection.
*
* The alternative is a custom event on `document` plus a global variable, which is
* what a single component needs. With two components that have to agree, a shared
* store with subscribers is less code and fewer edge cases.
*/
export class ContractState {
/** @type {Selection} */
#state
/** @type {Set<(state: Selection) => void>} */
#subscribers = new Set()
/** @type {string} */
#storageKey
/**
* @param {object} [options]
* @param {string} [options.storageKey]
* @param {Element|null} [options.root] The element carrying the attributes.
* Defaults to the document element, so a caller with no DOM can pass null.
*/
constructor(options = {}) {
this.#storageKey = options.storageKey || DEFAULT_STORAGE_KEY
const root =
options.root !== undefined
? options.root
: typeof document === 'undefined'
? null
: document.documentElement
this.#state = {
theme: root?.getAttribute(THEME_ATTRIBUTE) || root?.getAttribute(LEGACY_THEME_ATTRIBUTE) || null,
mode: readMode(root),
system: true,
source: null,
}
this.#readStored()
// Normalised on construction rather than on the first change. A host page that
// sets only `data-theme` would otherwise keep the two conventions out of step
// until the user touched a control, and a consumer whose CSS reads the prefixed
// attribute would render unthemed in the meantime. Writing both on load is one
// attribute write and removes the whole class of "why is nothing themed".
this.#applyToDocument()
}
/** @returns {Selection} A copy, so a subscriber cannot mutate the store. */
get state() {
return { ...this.#state }
}
/** @returns {string} */
get storageKey() {
return this.#storageKey
}
/**
* Applies a partial change, writes it to the document, persists it, and notifies.
*
* @param {Partial<Selection>} patch
* @param {string|null} [source]
* @returns {Selection}
*/
set(patch, source = null) {
const next = { ...this.#state, ...patch, source }
this.#state = next
this.#applyToDocument()
this.#persist()
for (const fn of this.#subscribers) fn(this.state)
return this.state
}
/**
* @param {(state: Selection) => void} fn
* @returns {() => void} An unsubscribe function.
*/
subscribe(fn) {
this.#subscribers.add(fn)
fn(this.state)
return () => this.#subscribers.delete(fn)
}
/**
* Selects a palette.
*
* Selecting the palette a theme's own default came from also restores that default
* appearance, because "I want this theme" and "as its designer intended" are the same
* request far more often than not.
*
* @param {string|null} slug
* @param {string|null} [defaultMode]
* @returns {Selection}
*/
selectTheme(slug, defaultMode = null) {
if (!slug) return this.set({ theme: null, mode: ADMIN_MODE }, 'selector')
return this.set(
defaultMode ? { theme: slug, mode: /** @type {'light'|'dark'} */ (defaultMode) } : { theme: slug },
'selector',
)
}
/**
* @param {'admin'|'light'|'dark'} mode
* @returns {Selection}
*/
selectMode(mode) {
return this.set({ mode, system: false }, 'toggle')
}
/**
* Follows the operating system appearance, ignoring any explicit choice.
*
* @returns {Selection}
*/
followSystem() {
return this.set({ system: true }, 'toggle')
}
/** @returns {void} */
#applyToDocument() {
if (typeof document === 'undefined') return
const root = document.documentElement
for (const attr of [THEME_ATTRIBUTE, LEGACY_THEME_ATTRIBUTE]) {
if (this.#state.theme) root.setAttribute(attr, this.#state.theme)
else root.removeAttribute(attr)
}
// `admin` means no appearance, so both attributes come off. Anything else sets
// both conventions, because which one a consumer's CSS reads is not knowable from
// here and setting both is free.
const mode = this.#state.mode
if (mode === ADMIN_MODE || !mode) {
for (const attr of [MODE_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) root.removeAttribute(attr)
} else {
for (const attr of [MODE_ATTRIBUTE, LEGACY_MODE_ATTRIBUTE]) root.setAttribute(attr, mode)
}
root.classList.toggle('dark', mode === 'dark')
root.style.colorScheme = mode === 'dark' ? 'dark' : 'light'
}
/** @returns {void} */
#readStored() {
if (typeof localStorage === 'undefined') return
try {
const raw = localStorage.getItem(this.#storageKey)
if (!raw) return
// Landed in `unknown` first: JSON.parse returns `any`, and assigning an `any`
// into a typed position is the hole no-unsafe-assignment exists to close.
/** @type {unknown} */
const body = JSON.parse(raw)
const parsed = /** @type {{theme?: unknown, mode?: unknown, system?: unknown}} */ (body)
if (typeof parsed !== 'object' || parsed === null) return
/** @type {Partial<Selection>} */
const patch = {}
if (typeof parsed.theme === 'string' || parsed.theme === null) patch.theme = parsed.theme
if (typeof parsed.mode === 'string' && MODES.includes(/** @type {'admin'} */ (parsed.mode))) {
patch.mode = /** @type {'admin'|'light'|'dark'} */ (parsed.mode)
}
if (typeof parsed.system === 'boolean') patch.system = parsed.system
this.#state = { ...this.#state, ...patch }
this.#applyToDocument()
} catch {
// A corrupt or unreadable value is not worth failing a page load over. The
// attributes already on the element are the fallback.
}
}
/** @returns {void} */
#persist() {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(
this.#storageKey,
JSON.stringify({
theme: this.#state.theme,
mode: this.#state.mode,
system: this.#state.system,
}),
)
} catch {
// Private browsing and a full quota both raise here. The selection still works
// for this page view, which is the important part.
}
}
}
/**
* Reads the appearance already on an element, tolerating either attribute or neither.
*
* @param {Element|null|undefined} el
* @returns {'admin'|'light'|'dark'}
*/
export function readMode(el) {
if (!el) return ADMIN_MODE
const value = el.getAttribute(MODE_ATTRIBUTE) || el.getAttribute(LEGACY_MODE_ATTRIBUTE)
if (value === 'light' || value === 'dark') return value
if (el.classList.contains('dark')) return 'dark'
return ADMIN_MODE
}
/** @type {ContractState|null} */
let shared = null
/**
* The process-wide store, created on first use.
*
* Both components default to this so that a page with two of them agrees without the
* host having to wire them together.
*
* @param {object} [options]
* @param {string} [options.storageKey]
* @returns {ContractState}
*/
export function getContractState(options = {}) {
if (!shared) shared = new ContractState(options)
return shared
}
/** @returns {void} Resets the shared store. For tests. */
export function resetContractState() {
shared = null
}

64
sdk/src/embed.js Normal file
View file

@ -0,0 +1,64 @@
/**
* go-bananas one-line embed.
*
* The smallest possible integration: a single script tag that applies a theme
* before the first paint. There is no module, no import map and no configuration,
* which makes this the right snippet for a quick trial or for a page that cannot
* be modified beyond adding one tag.
*
* ```html
* <script async src="https://themes.example.com/rt/v1/embed.js"
* data-theme="midnight"></script>
* ```
*
* The data attributes configure it:
*
* data-theme slug to apply
* data-appearance light | dark | system
* data-variant full | tokens | minified
* data-storage "false" to skip localStorage
* data-auto-dark "true" to follow the operating system
*
* @module go-bananas/embed
*/
import { applyTheme, watchSystemAppearance } from './runtime.js'
/** Attribute that marks this script so a second copy is a no-op. */
const MARKER = 'data-bananas-embedded'
// Reading the currentScript synchronously is the only way to discover the
// element's data attributes: the script is async, so document.currentScript is
// already null by the time this module body runs.
// document.currentScript is null by the time this module body runs, because the
// script is async. The element is found by selector instead, which works whether
// the tag was loaded by the parser or injected.
const script = /** @type {HTMLScriptElement|null} */ (
document.querySelector('script[src*="/rt/v1/embed.js"]')
)
/** Already embedded on this page. */
if (script?.hasAttribute(MARKER)) {
// A second copy would race the first over the same link element.
} else if (script) {
script.setAttribute(MARKER, 'true')
const slug = script.dataset.theme || 'default'
const appearance = /** @type {'light'|'dark'|'system'} */ (
script.dataset.appearance || 'system'
)
const autoDark = script.dataset.autoDark === 'true' || appearance === 'system'
const variant = script.dataset.variant || 'full'
const storage = script.dataset.storage !== 'false'
// Following the operating system has to be set up before the first apply, so
// that a user whose OS is already dark never sees a light flash.
if (autoDark) watchSystemAppearance()
applyTheme(slug, { appearance, variant, storage }).catch((error) => {
// A theme that fails to load must never take the host page down with it.
console.warn('Go Bananas!!: could not apply theme', slug, error)
})
}
export { applyTheme }

48
sdk/src/global.js Normal file
View file

@ -0,0 +1,48 @@
/**
* go-bananas runtime — global build.
*
* The ESM build is the right choice for modern applications, but a plain
* <script src> tag is the only option for a server-rendered page, a legacy
* application, or anything that cannot use an import map. This build attaches the
* same API to `window.Bananas`.
*
* ```html
* <script src="https://themes.example.com/rt/v1/global.js"></script>
* <script>Bananas.applyTheme('midnight')</script>
* ```
*
* @module go-bananas/global
*/
import * as runtime from './runtime.js'
import * as components from './components.js'
const api = {
...runtime,
// Registering on load means a consumer that only adds a <script> tag does not also
// have to remember to call something.
...components,
/**
* Convenience wrapper: apply a theme and follow the operating system.
*
* @param {string} slug
* @param {import('./runtime.js').BananasOptions} [options]
* @returns {Promise<any>}
*/
auto(slug, options) {
runtime.watchSystemAppearance()
return runtime.applyTheme(slug, options)
},
}
// The host object is cast rather than indexed dynamically: assigning a new
// property to globalThis is the entire point of this build, and TypeScript has no
// way to express "extend the global scope from a module".
const target = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (globalThis))
target.Bananas = api
// The bare name is also exported, because it is what a developer reaches for first.
target.GoBananas = api
export default api
export * from './runtime.js'

489
sdk/src/react.js vendored Normal file
View file

@ -0,0 +1,489 @@
/**
* go-bananas runtime — React bindings.
*
* This module is served from the go-bananas origin and loaded through an import
* map, so a consuming application adds no dependency and no build step:
*
* ```html
* <script type="importmap">
* { "imports": {
* "react": "https://esm.sh/react@19",
* "react-dom/client": "https://esm.sh/react-dom@19/client",
* "go-bananas/runtime": "https://themes.example.com/rt/v1/react.js"
* } }
* </script>
* ```
*
* React itself is imported as a bare specifier so the host application's copy is
* used. Importing a second copy of React would break hooks, because each copy has
* its own dispatcher state.
*
* @module go-bananas/runtime-react
*/
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {
APPEARANCE_ATTRIBUTE,
THEME_ATTRIBUTE,
applyTheme,
assetUrl,
clearTheme,
getAppearance,
getSlug,
getVariables,
loadManifest,
loadTheme,
setAppearance,
subscribe,
version,
} from './runtime.js'
export {
APPEARANCE_ATTRIBUTE,
THEME_ATTRIBUTE,
applyTheme,
assetUrl,
clearTheme,
getAppearance,
getSlug,
getVariables,
loadManifest,
loadTheme,
setAppearance,
version,
}
/**
* @typedef {'light'|'dark'|'system'} Appearance
*/
/**
* @typedef {import('./runtime.js').Manifest} Manifest
* @typedef {import('./runtime.js').ResolvedTheme} ResolvedTheme
*/
/**
* @typedef {object} BananasContextValue
* @property {string|null} slug
* @property {Appearance} appearance
* @property {'light'|'dark'} resolvedAppearance
* @property {boolean} prefersDark
* @property {ResolvedTheme|null} theme Resolved token payload, once loaded.
* @property {Manifest|null} manifest Available themes, once loaded.
* @property {'idle'|'loading'|'ready'|'error'} status
* @property {Error|null} error
* @property {(slug: string) => Promise<void>} setTheme
* @property {(appearance: Appearance) => void} setAppearance
*/
/** @type {import('react').Context<BananasContextValue|null>} */
const BananasContext = createContext(/** @type {BananasContextValue|null} */ (null))
BananasContext.displayName = 'Bananas'
/**
* Subscribes to the vanilla runtime's state from React.
*
* useSyncExternalStore is used rather than a useEffect subscription so that a
* theme change made outside React — a theme switcher in another part of the
* application, or a browser back button — cannot tear the tree.
*
* @returns {{slug: string|null, appearance: Appearance, prefersDark: boolean}}
*/
function useExternalState() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
/** Reads the runtime snapshot outside of React's render cycle. */
function getSnapshot() {
return {
slug: getSlug(),
appearance: getAppearance(),
prefersDark:
typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches,
}
}
/** Server snapshot: a stable object so hydration does not mismatch. */
function getServerSnapshot() {
return { slug: null, appearance: /** @type {Appearance} */ ('system'), prefersDark: false }
}
/**
* @typedef {object} BananasProviderProps
* @property {string} [slug] Theme to apply. Controlled when supplied.
* @property {Appearance} [appearance='system']
* @property {string} [baseUrl] Runtime base URL.
* @property {boolean} [autoDark] Follow the operating system preference.
* @property {boolean} [storage=true] Persist the user's choice.
* @property {string} [variant] Render variant.
* @property {boolean} [loadManifest] Fetch the theme list on mount.
* @property {boolean} [suspense] Suspend until the theme is ready.
* @property {(error: Error) => void} [onError]
* @property {import('react').ReactNode} children
*/
/**
* Applies a theme and shares its state with the tree.
*
* @param {BananasProviderProps} props
* @returns {import('react').ReactElement}
*/
export function BananasProvider(props) {
const {
slug,
appearance = 'system',
baseUrl,
autoDark = false,
storage = true,
variant = 'full',
loadManifest: shouldLoadManifest = false,
onError,
children,
} = props
const [theme, setTheme] = useState(/** @type {ResolvedTheme|null} */ (null))
const [manifest, setManifest] = useState(/** @type {Manifest|null} */ (null))
const [status, setStatus] = useState(/** @type {'idle'|'loading'|'ready'|'error'} */ ('idle'))
const [error, setError] = useState(/** @type {Error|null} */ (null))
const external = useExternalState()
// Configuration is applied once, through a module-level singleton inside the
// vanilla runtime. The effect runs on mount only; changing baseUrl later
// requires a reload, which is documented rather than silently supported.
useEffect(() => {
const controller = new AbortController()
let cancelled = false
async function start() {
setStatus('loading')
try {
if (baseUrl) {
// The runtime reads its base from the module URL by default. An explicit
// override is published on the global, which is what `resolveBaseUrl`
// checks before falling back to the module URL.
/** @type {{ __BANANAS_BASE__?: string }} */ (globalThis).__BANANAS_BASE__ = baseUrl
}
const target = autoDark ? 'system' : appearance
if (slug) {
const loaded = await applyTheme(slug, { appearance: target, storage, variant })
if (!cancelled) {
setTheme(loaded)
setStatus('ready')
}
}
if (shouldLoadManifest) {
const loadedManifest = await loadManifest()
if (!cancelled) setManifest(loadedManifest)
}
if (!slug && shouldLoadManifest) setStatus('ready')
} catch (caught) {
if (cancelled) return
const failure =
caught instanceof Error ? caught : new Error(String(caught))
setError(failure)
setStatus('error')
onError?.(failure)
}
}
void start()
return () => {
cancelled = true
controller.abort()
}
// The effect intentionally depends on nothing that can change per render.
}, [])
const changeTheme = useCallback(
/**
* Changes the applied theme.
*
* @param {string} next
* @returns {Promise<void>}
*/
async (next) => {
setStatus('loading')
try {
const loaded = await applyTheme(next, { appearance: external.appearance })
setTheme(loaded)
setError(null)
setStatus('ready')
} catch (caught) {
const failure = caught instanceof Error ? caught : new Error(String(caught))
setError(failure)
setStatus('error')
onError?.(failure)
}
},
[external.appearance, onError],
)
const changeAppearance = useCallback(
/**
* Changes the colour scheme.
*
* @param {Appearance} next
* @returns {void}
*/
(next) => {
setAppearance(next)
},
[],
)
const value = useMemo(
() => ({
slug: external.slug,
appearance: external.appearance,
resolvedAppearance:
external.appearance === 'system'
? external.prefersDark
? 'dark'
: 'light'
: external.appearance,
prefersDark: external.prefersDark,
theme,
manifest,
status,
error,
setTheme: changeTheme,
setAppearance: changeAppearance,
}),
[external, theme, manifest, status, error, changeTheme, changeAppearance],
)
return createElement(BananasContext.Provider, { value }, children)
}
/**
* Reads the theme context.
*
* @returns {BananasContextValue}
* @throws when used outside a BananasProvider, because a silent default would
* let a component render with the wrong theme and never report the mistake.
*/
export function useBananas() {
const value = useContext(BananasContext)
if (!value) {
throw new Error(
'go-bananas: useBananas must be used inside a <BananasProvider>. ' +
'Wrap your application in one, or import { BananasProvider } from this module.',
)
}
return value
}
/**
* Returns the resolved value of one design token.
*
* @param {string} name Token name, with or without the `--bn-` prefix.
* @returns {string|undefined}
*/
export function useThemeVariable(name) {
const { theme } = useBananas()
return useMemo(() => {
if (!theme) return undefined
const key = name.startsWith('--') ? name : `--bn-${name}`
return theme.variables[key]
}, [theme, name])
}
/**
* Returns every resolved token as a record.
*
* @returns {Record<string,string>}
*/
export function useThemeVariables() {
const { theme } = useBananas()
// An empty record rather than undefined: a consumer spreading or indexing the
// result should not have to guard for the pre-load window.
return useMemo(() => theme?.variables ?? {}, [theme])
}
/**
* Returns the list of available themes.
*
* @returns {{themes: any[], loading: boolean, error: Error|null}}
*/
export function useAvailableThemes() {
const { manifest } = useBananas()
const [local, setLocal] = useState(/** @type {Manifest|null} */ (null))
const [error, setError] = useState(/** @type {Error|null} */ (null))
useEffect(() => {
let cancelled = false
if (manifest === null) {
loadManifest()
.then((loaded) => {
if (!cancelled) setLocal(loaded)
})
.catch((caught) => {
if (!cancelled) setError(caught instanceof Error ? caught : new Error(String(caught)))
})
}
return () => {
cancelled = true
}
}, [manifest])
const source = manifest ?? local
return {
themes: source?.themes ?? [],
loading: !source && !error,
error,
}
}
export { BananasContext }
/* -------------------------------------------------------------------------- */
/* Palette and appearance controls */
/* -------------------------------------------------------------------------- */
/**
* The element interface the palette control presents. Declared here rather than
* cast to `any`, because a wrapper that cannot see the element's own API is how a
* ref-based binding silently stops working.
*
* @typedef {object} ThemeSelectorElement
* @property {string} value
* @property {string|null} base
* @property {(type: string, listener: (event: any) => void) => void} addEventListener
* @property {(type: string, listener: (event: any) => void) => void} removeEventListener
*/
/**
* The element interface the appearance control presents.
*
* @typedef {object} AppearanceToggleElement
* @property {'admin'|'light'|'dark'} value
* @property {(type: string, listener: (event: any) => void) => void} addEventListener
* @property {(type: string, listener: (event: any) => void) => void} removeEventListener
*/
/**
* @typedef {object} ThemeChangeDetail
* @property {string|null} slug
*/
/**
* @typedef {object} AppearanceChangeDetail
* @property {'admin'|'light'|'dark'} mode
*/
/**
* @typedef {object} ThemeSelectorProps
* @property {string} [base] Service runtime base. Defaults to the contract
* stylesheet already on the page.
* @property {string} [label] Accessible label.
* @property {boolean} [compact] Omit the "no theme" entry.
* @property {string} [value] The selected slug. Controlled when supplied.
* @property {(slug: string) => void} [onChange]
*/
/**
* The palette control, as a React component.
*
* A thin wrapper around the custom element rather than a React reimplementation:
* the element owns its rendering, its Shadow DOM and its accessibility tree, so a
* React version would be a second implementation that drifts.
*
* It exists because using the bare element from JSX is workable but awkward. The
* element's `value` is a property rather than a React-controlled one, so a caller
* that wants to drive it from state needs a ref and an effect. This accepts ordinary
* props instead.
*
* @param {ThemeSelectorProps & Record<string, unknown>} props
* @returns {any}
*/
export function ThemeSelector({ base, label, compact, value, onChange, ...rest }) {
const ref = useRef(/** @type {ThemeSelectorElement|null} */ (null))
useEffect(() => {
const el = ref.current
if (value === undefined || !el) return
if (el.value !== value) el.value = value
}, [value])
useEffect(() => {
const el = ref.current
if (!el || !onChange) return undefined
// The event is composed and crosses the shadow boundary, so listening on the
// element rather than on document is enough.
/** @param {Event} raw */
const handler = (raw) => {
const event = /** @type {CustomEvent<ThemeChangeDetail>} */ (raw)
onChange(event.detail?.slug ?? '')
}
el.addEventListener('bananas:theme', handler)
return () => el.removeEventListener('bananas:theme', handler)
}, [onChange])
return createElement('bananas-theme-selector', {
...rest,
ref,
base,
label,
compact: compact ? '' : undefined,
})
}
/**
* @typedef {object} AppearanceToggleProps
* @property {string} [label] Accessible label.
* @property {boolean} [showLabels] Render text labels rather than icons only.
* @property {'admin'|'light'|'dark'} [value] Controlled when supplied.
* @property {(mode: 'admin'|'light'|'dark') => void} [onChange]
*/
/**
* The appearance control, as a React component. Wraps the custom element for the
* same reason as {@link ThemeSelector}.
*
* @param {AppearanceToggleProps & Record<string, unknown>} props
* @returns {any}
*/
export function AppearanceToggle({ label, showLabels, value, onChange, ...rest }) {
const ref = useRef(/** @type {AppearanceToggleElement|null} */ (null))
useEffect(() => {
const el = ref.current
if (value === undefined || !el) return
if (el.value !== value) el.value = value
}, [value])
useEffect(() => {
const el = ref.current
if (!el || !onChange) return undefined
/** @param {Event} raw */
const handler = (raw) => {
const event = /** @type {CustomEvent<AppearanceChangeDetail>} */ (raw)
onChange(event.detail?.mode ?? 'admin')
}
el.addEventListener('bananas:appearance', handler)
return () => el.removeEventListener('bananas:appearance', handler)
}, [onChange])
return createElement('bananas-appearance-toggle', {
...rest,
ref,
label,
showLabels: showLabels ? '' : undefined,
})
}

627
sdk/src/runtime.js Normal file
View file

@ -0,0 +1,627 @@
/**
* go-bananas runtime — framework-free.
*
* Applies a theme to the page at run time. There is nothing to install: load
* this module with a URL and the theme is applied by fetching CSS from the
* service. That is the entire point of the runtime delivery model — a theme
* becomes a deployment-time concern rather than a dependency.
*
* @module go-bananas/runtime
*/
/**
* @typedef {object} ThemeRef
* @property {string} slug
* @property {string} name
* @property {string} [description]
* @property {number} version
* @property {Appearance} appearance
* @property {Appearance} defaultAppearance
* @property {string} updatedAt
*/
/**
* @typedef {object} ManifestTheme
* @property {string} slug
* @property {string} name
* @property {string} [description]
* @property {Appearance} defaultAppearance
* @property {number} version
* @property {string[]} [tags]
* @property {string} css
* @property {string} tokens
*/
/**
* @typedef {object} Manifest
* @property {string} apiVersion
* @property {string} [default]
* @property {string} baseUrl
* @property {ManifestTheme[]} themes
*/
/**
* The payload the runtime JSON endpoint returns.
*
* Typed here rather than as `any` so that a consumer importing these typedefs
* gets the real shape, and so that a change to the payload is a type error rather
* than a surprise at run time.
*
* @typedef {object} ResolvedTheme
* @property {ThemeRef} theme
* @property {Record<string, unknown>} tokens
* @property {Record<string, string>} variables
* @property {Record<string, unknown>} [meta]
*/
/** Attribute that scopes a theme to a subtree. */
export const THEME_ATTRIBUTE = 'data-bananas-theme'
/** Attribute that selects the colour scheme within a themed subtree. */
export const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance'
/** Contract version this module understands. */
export const API_VERSION = '1'
/** Local storage key used to remember the user's appearance choice. */
const STORAGE_KEY = 'go-bananas:appearance'
/** Local storage key used to remember the applied slug. */
const SLUG_KEY = 'go-bananas:theme'
/**
* @typedef {'light'|'dark'|'system'} Appearance
*/
/**
* @typedef {object} BananasOptions
* @property {string} [baseUrl] Runtime base URL. Defaults to the origin the
* module was loaded from, which is what makes a same-origin deployment work
* with no configuration at all.
* @property {string} [slug] Theme to apply on load.
* @property {Appearance} [appearance='system'] Initial colour scheme.
* @property {Element} [target] Element to scope to. Defaults to
* `document.documentElement`.
* @property {boolean} [storage=true] Persist the appearance choice.
* @property {string} [variant] Render variant, e.g. `full` or `tokens`.
* @property {(err: Error) => void} [onError] Error callback.
*/
/** @type {BananasOptions} */
let options = {
appearance: 'system',
storage: true,
variant: 'full',
}
/**
* Current state, exposed so consumers can render a theme picker.
*
* The shape is annotated explicitly because an object literal infers `null` for
* an initialised-to-null field and `string` rather than a literal union for an
* appearance, which then makes every later assignment a type error under strict
* checking. Declaring the types once here keeps the rest of the module clean.
*
* @type {{
* slug: string|null,
* appearance: Appearance,
* cache: Map<string, {theme: ResolvedTheme, appearance: Appearance}>,
* manifest: Manifest | null,
* listeners: Set<(state: {slug: string|null, appearance: Appearance, dark: boolean}) => void>,
* mediaQuery: MediaQueryList|null,
* dark: boolean,
* }}
*/
const state = {
slug: null,
appearance: 'system',
cache: new Map(),
manifest: null,
listeners: new Set(),
mediaQuery: null,
dark: false,
}
/* -------------------------------------------------------------------------- */
/* URL construction */
/* -------------------------------------------------------------------------- */
/**
* Returns the runtime base URL, inferred from where this module was loaded.
*
* Inferring beats configuring here because the overwhelmingly common deployment
* serves the SDK and the runtime API from the same origin, so the correct answer
* is knowable without being told.
*
* @returns {string}
*/
export function resolveBaseUrl() {
if (options.baseUrl) return stripTrailingSlash(options.baseUrl)
const configured = /** @type {{ __BANANAS_BASE__?: string }} */ (globalThis).__BANANAS_BASE__
if (typeof configured === 'string' && configured) {
return stripTrailingSlash(configured)
}
if (typeof document !== 'undefined') {
// currentScript is typed as an SVG-or-HTML script element; only an HTML one
// has a src, so the narrowing is explicit rather than a cast on the value.
const script = document.currentScript
if (script instanceof HTMLScriptElement && script.src) {
return new URL('.', script.src).toString()
}
}
if (typeof location !== 'undefined') {
return `${stripTrailingSlash(location.origin)}${basePath()}`
}
throw new Error('go-bananas: cannot determine the runtime base URL; pass options.baseUrl')
}
/**
* Reads the deployment's base path from the document, for installations served
* under a sub-path such as `/themes`.
*
* @returns {string}
*/
function basePath() {
if (typeof document !== 'undefined') {
const configured = document.documentElement.getAttribute('data-bananas-base-path')
if (configured) return configured.replace(/\/$/, '')
}
return ''
}
/**
* Strips trailing slashes from a URL.
*
* @param {string} value
* @returns {string}
*/
function stripTrailingSlash(value) {
return value.replace(/\/+$/, '')
}
/**
* Builds the URL of a runtime asset.
*
* @param {string} path e.g. `theme.css`
* @param {Record<string, string|number|undefined|null>} [query]
* @returns {string}
*/
export function assetUrl(path, query = {}) {
const url = new URL(`${resolveBaseUrl()}/${path}`)
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
url.searchParams.set(key, String(value))
}
return url.toString()
}
/* -------------------------------------------------------------------------- */
/* Appearance */
/* -------------------------------------------------------------------------- */
/**
* Reports whether the operating system currently prefers a dark colour scheme.
*
* @returns {boolean}
*/
export function prefersDark() {
// matchMedia is absent in some embedded webviews, and the theme attribute
// cannot be resolved without it.
if (typeof window === 'undefined' || !window.matchMedia) return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
/**
* Resolves an appearance choice against the operating system preference.
*
* @param {Appearance} appearance
* @returns {'light'|'dark'}
*/
export function resolveAppearance(appearance) {
if (appearance === 'light' || appearance === 'dark') return appearance
return prefersDark() ? 'dark' : 'light'
}
/**
* Subscribes to operating-system appearance changes.
*
* The returned function unsubscribes. When the user has chosen `system`, a change
* in the operating system preference re-applies the theme; when they have chosen
* an explicit scheme, nothing happens, which is the behaviour a user who picked
* "light" on a dark laptop expects.
*
* @param {(dark: boolean) => void} [onChange]
* @returns {() => void}
*/
export function watchSystemAppearance(onChange) {
// There is nothing to watch without matchMedia. Returning a no-op unsubscribe
// keeps the caller's cleanup symmetric.
if (typeof window === 'undefined' || !window.matchMedia) return () => undefined
const query = window.matchMedia('(prefers-color-scheme: dark)')
/** @param {MediaQueryListEvent} event */
const listener = (event) => {
state.dark = event.matches
if (state.appearance === 'system') {
applyToDocument()
notify()
}
onChange?.(event.matches)
}
// addEventListener is the modern API; addListener is kept for Safari < 14.
if (query.addEventListener) query.addEventListener('change', listener)
else query.addListener?.(listener)
state.mediaQuery = query
state.dark = query.matches
return () => {
if (query.removeEventListener) query.removeEventListener('change', listener)
else query.removeListener?.(listener)
state.mediaQuery = null
}
}
/* -------------------------------------------------------------------------- */
/* Theme application */
/* -------------------------------------------------------------------------- */
/**
* Loads and applies a theme.
*
* The stylesheet is injected as a `<link>` rather than fetched and evaluated, so
* the browser's own cache, preload scanner and CDN revalidation all apply. That
* is both faster and safer than injecting a constructed `<style>` element.
*
* @param {string} slug
* @param {{appearance?: Appearance, target?: Element, variant?: string, storage?: boolean}} [overrides]
* @returns {Promise<ResolvedTheme>} the resolved theme payload
*/
export async function applyTheme(slug, overrides = {}) {
const opts = { ...options, ...overrides }
const target = opts.target ?? document.documentElement
const appearance = overrides.appearance ?? options.appearance ?? 'system'
state.slug = slug
state.appearance = appearance
if (opts.storage !== false && overrides.appearance) {
persist(SLUG_KEY, slug)
persist(STORAGE_KEY, appearance)
}
const href = assetUrl('theme.css', {
slug,
appearance: appearance === 'system' ? '' : appearance,
variant: opts.variant ?? 'full',
})
injectStylesheet(target, slug, href)
applyToDocument()
const theme = await loadTheme(slug, appearance)
notify()
return theme
}
/**
* Sets the colour scheme without changing the theme.
*
* @param {Appearance} appearance
*/
/** @param {Appearance} appearance */
export function setAppearance(appearance) {
state.appearance = appearance
if (options.storage !== false) persist(STORAGE_KEY, appearance)
applyToDocument()
notify()
}
/**
* Returns the current appearance choice.
*
* @returns {Appearance}
*/
export function getAppearance() {
return state.appearance
}
/**
* Returns the slug of the applied theme, if any.
*
* @returns {string|null}
*/
export function getSlug() {
return state.slug
}
/**
* Removes the applied theme, restoring the page to whatever the document
* shipped with.
*/
export function clearTheme() {
const target = options.target ?? document.documentElement
target.removeAttribute(THEME_ATTRIBUTE)
target.removeAttribute(APPEARANCE_ATTRIBUTE)
const existing = document.querySelector(`link[data-bananas-slug="${cssEscape(state.slug ?? '')}"]`)
existing?.parentNode?.removeChild(existing)
state.slug = null
state.cache.clear()
if (options.storage !== false) {
try {
localStorage.removeItem(SLUG_KEY)
} catch {
/* storage may be unavailable in a private window; that is not an error */
}
}
notify()
}
/**
* Loads the manifest of available themes.
*
* @returns {Promise<Manifest>}
*/
export async function loadManifest() {
// The cache check is deliberately truthiness-based rather than a null check: an
// empty manifest would be a server misconfiguration, and refetching on every
// call would turn that into a request loop on the consumer's page.
const cached = state.manifest
if (cached) {
return cached
}
const response = await fetch(assetUrl('manifest.json'), {
// The manifest is public and cacheable, but a fetch from a browser should
// still revalidate so a newly published theme picker updates.
headers: { accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`go-bananas: manifest request failed with ${response.status}`)
}
// The cast is explicit rather than implicit: the payload shape is documented by
// the Manifest typedef, and `response.json()` is unavoidably `any` in the DOM
// type definitions.
// `response.json()` is typed `any` in the DOM definitions. This is the
// boundary where untyped network data enters the module, and it is cast to the
// documented shape on the next line.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const manifest = /** @type {Manifest} */ (await response.json())
state.manifest = manifest
return manifest
}
/**
* Fetches a theme's resolved tokens as JSON.
*
* @param {string} slug
* @param {Appearance} [appearance]
* @returns {Promise<ResolvedTheme>}
*/
export async function loadTheme(slug, appearance = state.appearance) {
const resolved = resolveAppearance(appearance)
const key = `${slug}|${resolved}`
const cached = state.cache.get(key)
if (cached) return cached.theme
const response = await fetch(
assetUrl('theme.json', { slug, appearance: resolved }),
{ headers: { accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(`go-bananas: theme "${slug}" request failed with ${response.status}`)
}
// The other network boundary, cast to the documented shape immediately.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const theme = /** @type {ResolvedTheme} */ (await response.json())
state.cache.set(key, { theme, appearance: resolved })
return theme
}
/**
* Returns the resolved custom properties for the applied theme.
*
* Useful for consuming tokens in JavaScript, for example to tint a canvas or a
* chart palette that cannot read CSS itself.
*
* @param {string} [name] A single variable name, without the leading `--`.
* @returns {string|Record<string, string>}
*/
export function getVariables(name) {
const theme = state.cache.get(`${state.slug}|${resolveAppearance(state.appearance)}`)?.theme
const variables = theme?.variables ?? {}
if (name) {
const key = name.startsWith('--') ? name : `--bn-${name}`
const value = variables[key]
if (value === undefined) {
throw new Error(`go-bananas: no such design token "${name}"`)
}
return value
}
return { ...variables }
}
/**
* Subscribes to theme state changes.
*
* @param {(state: {slug: string|null, appearance: Appearance, dark: boolean}) => void} listener
* @returns {() => void} unsubscribe
*/
/** @param {(state: {slug: string|null, appearance: Appearance, dark: boolean}) => void} listener */
export function subscribe(listener) {
state.listeners.add(listener)
return () => state.listeners.delete(listener)
}
/** Notifies subscribers of a state change. */
function notify() {
const snapshot = { slug: state.slug, appearance: state.appearance, dark: state.dark }
for (const listener of state.listeners) {
try {
listener(snapshot)
} catch (error) {
// One faulty listener must not stop the others from being told.
console.error('go-bananas: theme listener threw', error)
}
}
}
/**
* Initialises the runtime.
*
* Restores the persisted choice when one exists, otherwise applies the configured
* slug. Errors are reported through `onError` and otherwise swallowed: a theme
* failing to load must leave the application usable with its own styles.
*
* @param {BananasOptions} [init]
* @returns {Promise<void>}
*/
export async function init(init = {}) {
options = { ...options, ...init }
watchSystemAppearance()
const persisted = readPersisted()
const slug = options.slug ?? persisted.slug
const appearance = /** @type {Appearance} */ (
options.appearance ?? persisted.appearance ?? 'system'
)
if (!slug) {
return
}
try {
await applyTheme(slug, { appearance })
} catch (caught) {
const failure = caught instanceof Error ? caught : new Error(String(caught))
options.onError?.(failure)
console.error('go-bananas: failed to apply theme', failure)
}
}
/* -------------------------------------------------------------------------- */
/* DOM plumbing */
/* -------------------------------------------------------------------------- */
/**
* Injects or updates the theme stylesheet link.
*
* The link carries the slug in a data attribute so that switching themes replaces
* the existing element rather than accumulating a second one, and so that
* `clearTheme` can find it again.
*
* @param {Element} target
* @param {string} slug
* @param {string} href
* @returns {void}
*/
function injectStylesheet(target, slug, href) {
// Every go-bananas link on the page except the one for the current slug is
// removed. Leaving the previous theme's link in place would keep both
// stylesheets applying: their scoped selectors would cascade by document order,
// so the "old" theme would silently win for any element that still carries its
// theme attribute.
for (const stale of document.querySelectorAll('link[data-bananas-slug]')) {
if (stale.getAttribute('data-bananas-slug') !== slug) {
stale.parentNode?.removeChild(stale)
}
}
let link = /** @type {HTMLLinkElement|null} */ (
document.querySelector(`link[data-bananas-slug="${cssEscape(slug)}"]`)
)
if (!link) {
link = document.createElement('link')
link.rel = 'stylesheet'
link.setAttribute('data-bananas-slug', slug)
// The stylesheet applies before paint once the attribute is set, so a slow
// network does not produce a flash of unstyled, wrongly-coloured content.
document.head.appendChild(link)
}
if (link.href !== href) link.href = href
target.setAttribute(THEME_ATTRIBUTE, slug)
}
/** Writes the theme and appearance attributes onto the target element. */
function applyToDocument() {
const target = options.target ?? document.documentElement
if (!state.slug) return
target.setAttribute(THEME_ATTRIBUTE, state.slug)
target.setAttribute(APPEARANCE_ATTRIBUTE, resolveAppearance(state.appearance))
}
/**
* Escapes a string for use inside a CSS attribute selector.
*
* @param {string} value
* @returns {string}
*/
function cssEscape(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value)
}
return value.replace(/["\\]/g, '\\$&')
}
/**
* Reads the persisted slug and appearance.
*
* @returns {{slug?: string, appearance?: Appearance}}
*/
function readPersisted() {
if (options.storage === false) return {}
try {
const appearance = localStorage.getItem(STORAGE_KEY)
return {
slug: localStorage.getItem(SLUG_KEY) ?? undefined,
appearance: /** @type {Appearance|undefined} */ (appearance || undefined),
}
} catch {
return {}
}
}
/**
* Persists a value, ignoring storage failures.
*
* @param {string} key
* @param {string} value
*/
function persist(key, value) {
try {
localStorage.setItem(key, value)
} catch {
/* private browsing, disabled storage, or a full quota */
}
}
/**
* Reads a runtime configuration value the deployment may have placed on the
* document element, for example `<html data-bananas-base-path="/themes">`.
*
* @param {string} name
* @returns {string|null}
*/
export function readConfig(name) {
if (typeof document === 'undefined') return null
return document.documentElement.getAttribute(`data-bananas-${name}`)
}
/**
* Returns the version of this module's contract.
*
* @returns {string}
*/
export function version() {
return API_VERSION
}

202
sdk/src/runtime.test.js Normal file
View file

@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
APPEARANCE_ATTRIBUTE,
THEME_ATTRIBUTE,
applyTheme,
assetUrl,
clearTheme,
getAppearance,
getSlug,
getVariables,
loadManifest,
loadTheme,
init,
prefersDark,
resolveAppearance,
setAppearance,
subscribe,
version,
} from './runtime.js'
/**
* The runtime is a singleton over `document`, so each test gets a fresh document
* and a fresh module registry. Without the registry reset the module-level cache
* from one test would leak into the next.
*/
beforeEach(async () => {
document.documentElement.removeAttribute(THEME_ATTRIBUTE)
document.documentElement.removeAttribute(APPEARANCE_ATTRIBUTE)
document.head.innerHTML = ''
localStorage.clear()
vi.stubGlobal(
'fetch',
vi.fn(async (url) => {
const target = String(url)
/** @param {unknown} body */
const json = (body) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
})
if (target.includes('manifest.json')) {
return json({
apiVersion: '1',
baseUrl: 'http://localhost/rt/v1',
themes: [{ slug: 'midnight', name: 'Midnight', version: 1 }],
})
}
if (target.includes('theme.json')) {
return json({
theme: { slug: 'midnight', version: 3, appearance: 'dark' },
variables: { '--bn-color-semantic-primary': '#a78bfa' },
})
}
return new Response('not found', { status: 404 })
}),
)
})
describe('assetUrl', () => {
it('builds a URL from the configured base', async () => {
// An explicit baseUrl is the documented override for a cross-origin
// deployment; the inferred default cannot be asserted under jsdom because
// there is no currentScript to infer it from.
await init({ baseUrl: 'https://themes.example.com/rt/v1' })
const url = new URL(assetUrl('theme.css', { slug: 'midnight' }))
expect(url.origin).toBe('https://themes.example.com')
expect(url.pathname).toBe('/rt/v1/theme.css')
expect(url.searchParams.get('slug')).toBe('midnight')
})
it('falls back to the page origin when no base can be inferred', async () => {
// The runtime keeps its configuration in a module-level singleton, so this
// test needs a fresh module instance rather than relying on ordering.
vi.resetModules()
const fresh = await import('./runtime.js')
const url = new URL(fresh.assetUrl('theme.css'))
expect(url.pathname).toBe('/theme.css')
})
it('omits empty query parameters', () => {
const url = new URL(assetUrl('theme.css', { slug: 'x', appearance: '', variant: undefined }))
expect(url.searchParams.has('appearance')).toBe(false)
expect(url.searchParams.has('variant')).toBe(false)
})
})
describe('applyTheme', () => {
it('injects a stylesheet link and scopes the document', async () => {
await applyTheme('midnight', { appearance: 'dark' })
const link = document.querySelector(`link[data-bananas-slug="midnight"]`)
expect(link).not.toBeNull()
expect(link?.getAttribute('rel')).toBe('stylesheet')
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('midnight')
expect(document.documentElement.getAttribute(APPEARANCE_ATTRIBUTE)).toBe('dark')
})
it('replaces the link rather than accumulating them', async () => {
await applyTheme('midnight')
await applyTheme('daylight')
const links = document.querySelectorAll('link[data-bananas-slug]')
expect(links).toHaveLength(1)
expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe('daylight')
})
it('loads the theme payload', async () => {
const theme = await applyTheme('midnight', { appearance: 'dark' })
expect(theme.theme.slug).toBe('midnight')
})
it('serves the cached payload on a second call', async () => {
await loadTheme('midnight', 'dark')
const before = vi.mocked(fetch).mock.calls.length
await loadTheme('midnight', 'dark')
const after = vi.mocked(fetch).mock.calls.length
expect(after).toBe(before)
})
})
describe('clearTheme', () => {
it('removes the attributes and the link element', async () => {
await applyTheme('midnight')
clearTheme()
expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false)
expect(document.querySelector('link[data-bananas-slug]')).toBeNull()
expect(getSlug()).toBeNull()
})
})
describe('appearance', () => {
it('persists an explicit choice', () => {
setAppearance('dark')
expect(getAppearance()).toBe('dark')
expect(localStorage.getItem('go-bananas:appearance')).toBe('dark')
})
it('resolves system against the media query', () => {
expect(resolveAppearance('system')).toBe(prefersDark() ? 'dark' : 'light')
expect(resolveAppearance('light')).toBe('light')
expect(resolveAppearance('dark')).toBe('dark')
})
})
describe('getVariables', () => {
it('returns a single token with and without the prefix', async () => {
await applyTheme('midnight', { appearance: 'dark' })
expect(getVariables('color-semantic-primary')).toBe('#a78bfa')
expect(getVariables('--bn-color-semantic-primary')).toBe('#a78bfa')
})
it('throws for an unknown token rather than returning undefined', async () => {
await applyTheme('midnight', { appearance: 'dark' })
expect(() => getVariables('nope')).toThrow(/no such design token/i)
})
})
describe('loadManifest', () => {
it('fetches once and caches', async () => {
await loadManifest()
const before = vi.mocked(fetch).mock.calls.length
await loadManifest()
expect(vi.mocked(fetch).mock.calls.length).toBe(before)
})
})
describe('subscribe', () => {
it('notifies on appearance change and stops after unsubscribe', () => {
const listener = vi.fn()
const unsubscribe = subscribe(listener)
setAppearance('dark')
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
setAppearance('light')
expect(listener).toHaveBeenCalledTimes(1)
})
it('keeps notifying other listeners when one throws', () => {
const bad = vi.fn(() => {
throw new Error('boom')
})
const good = vi.fn()
subscribe(bad)
subscribe(good)
setAppearance('dark')
expect(bad).toHaveBeenCalled()
expect(good).toHaveBeenCalled()
})
})
describe('version', () => {
it('reports the contract version', () => {
expect(version()).toBe('1')
})
})

29
sdk/tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": []
},
"include": [
"src/**/*.js",
"vite.config.js",
"vitest.config.js"
]
}

51
sdk/vite.config.js Normal file
View file

@ -0,0 +1,51 @@
import { defineConfig } from 'vite'
/**
* The SDK is built as a set of ES modules and one global bundle.
*
* Two build shapes are produced because they serve different consumers:
*
* - `runtime.js`, `react.js`, `global.js`, `embed.js`, `components.js` are ES
* modules with a
* stable, unhashed filename. A stable name matters because a consumer's
* import map has to name a specific URL, and a content hash in that URL would
* make the map wrong after every deploy.
* - The admin application and any hashed assets use the default hashed naming,
* because nothing references them by name.
*
* React is marked external so the module imports the bare specifier "react". The
* host application resolves it through its own import map, which is what
* guarantees there is exactly one React instance in the page: two copies would
* each have their own hook dispatcher, and a hook called from one while another
* renders would fail at run time in a way that is very hard to diagnose.
*/
export default defineConfig({
plugins: [],
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'es2022',
sourcemap: true,
minify: true,
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
input: {
runtime: 'src/runtime.js',
react: 'src/react.js',
global: 'src/global.js',
embed: 'src/embed.js',
components: 'src/components.js',
},
// Without this, a multi-entry library build treats each entry's exports as
// unused by the other entries and tree-shakes the public API away, leaving
// an empty module behind. 'strict' keeps every declared export.
preserveEntrySignatures: 'strict',
output: {
// Stable, predictable names: see the comment above.
entryFileNames: '[name].js',
chunkFileNames: 'chunk-[name].js',
assetFileNames: '[name][extname]',
},
},
},
})

11
sdk/vitest.config.js Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// The runtime manipulates document, localStorage and matchMedia, so the
// browser-like environment is required rather than merely convenient.
environment: 'jsdom',
include: ['src/**/*.test.js'],
globals: false,
},
})