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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
95
sdk/eslint.config.js
Normal file
95
sdk/eslint.config.js
Normal 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
2981
sdk/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
sdk/package.json
Normal file
36
sdk/package.json
Normal 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
731
sdk/src/components.js
Normal 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
675
sdk/src/components.test.js
Normal 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
275
sdk/src/contract.js
Normal 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
64
sdk/src/embed.js
Normal 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
48
sdk/src/global.js
Normal 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
489
sdk/src/react.js
vendored
Normal 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
627
sdk/src/runtime.js
Normal 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
202
sdk/src/runtime.test.js
Normal 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
29
sdk/tsconfig.json
Normal 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
51
sdk/vite.config.js
Normal 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
11
sdk/vitest.config.js
Normal 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,
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue