@proveanything/smartlinks 2.0.15 → 2.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ /* @proveanything/smartlinks/theme-boot.js — iframe theme + viewer-prefs bootstrap (theme tokens v1)
2
+ *
3
+ * ONLY for iframe / standalone app entries (admin.html, index.html). Container & widget apps do NOT
4
+ * need this — they inherit the host's :root tokens through the DOM. See docs/theme-tokens.md.
5
+ *
6
+ * What it does, and nothing more:
7
+ * 1. BOOT (synchronous, before first paint): reads the initial theme + viewer prefs from the URL
8
+ * (`#slt=<base64url JSON>` plus convenience params ?theme=&contrast=&lang=&fontScale=) and applies
9
+ * them to :root. Put this in <head>, inline, so it runs before the app's stylesheet — no flash.
10
+ * 2. LIVE: applies generic root-state updates the host posts (`smartlinks:root-state`) — viewer /
11
+ * accessibility prefs (dark, contrast, font-scale, lang) and, if the host chooses, editor
12
+ * brand-preview. The app AUTHORS CSS against these root hooks; it never writes a message handler.
13
+ *
14
+ * Root hooks the app styles against:
15
+ * - theme: the `.dark` class (Tailwind/shadcn convention) AND `[data-theme="dark|light"]`
16
+ * - contrast: `[data-contrast="high"]` (also honour @media (prefers-contrast: more) natively)
17
+ * - font size: `--sl-font-scale` (e.g. 1.25) — scale your rem/base type off it
18
+ * - language: `lang` attribute (content/i18n is the app's own job; this just flags it)
19
+ * - brand: the --sl-* theme tokens (see theme.css)
20
+ *
21
+ * Brand THEME changes are expected via iframe reload (rare, operator-driven). Viewer / a11y prefs are
22
+ * applied LIVE here (a reload on an accessibility toggle is itself an accessibility failure).
23
+ */
24
+ (function () {
25
+ if (typeof document === 'undefined') return;
26
+ var root = document.documentElement;
27
+
28
+ function setVar(k, val) {
29
+ root.style.setProperty(k.charAt(0) === '-' ? k : '--sl-' + k, String(val));
30
+ }
31
+ function applyTheme(mode) { // 'dark' | 'light'
32
+ if (!mode) return;
33
+ root.classList.toggle('dark', mode === 'dark');
34
+ root.setAttribute('data-theme', mode);
35
+ }
36
+ function applyState(s) {
37
+ if (!s || typeof s !== 'object') return;
38
+ var v = s.values || s.vars;
39
+ if (v) for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) setVar(k, v[k]);
40
+ if (s.theme) applyTheme(s.theme);
41
+ if (s.contrast != null) {
42
+ if (s.contrast === 'normal' || s.contrast === false) root.removeAttribute('data-contrast');
43
+ else root.setAttribute('data-contrast', String(s.contrast));
44
+ }
45
+ if (s.fontScale != null) setVar('font-scale', s.fontScale);
46
+ if (s.lang) root.setAttribute('lang', String(s.lang));
47
+ var a = s.attrs;
48
+ if (a) for (var name in a) if (Object.prototype.hasOwnProperty.call(a, name)) {
49
+ if (a[name] == null || a[name] === false) root.removeAttribute(name);
50
+ else root.setAttribute(name, a[name] === true ? '' : String(a[name]));
51
+ }
52
+ }
53
+
54
+ // 1) BOOT from the URL — synchronous, before paint.
55
+ try {
56
+ var hash = location.hash || '';
57
+ var m = hash.match(/[#&]slt=([^&]+)/);
58
+ if (m) {
59
+ var b64 = m[1].replace(/-/g, '+').replace(/_/g, '/');
60
+ applyState(JSON.parse(decodeURIComponent(escape(atob(b64)))));
61
+ }
62
+ var q = new URLSearchParams(location.search);
63
+ var boot = {};
64
+ if (q.get('theme')) boot.theme = q.get('theme');
65
+ if (q.get('contrast')) boot.contrast = q.get('contrast');
66
+ if (q.get('lang')) boot.lang = q.get('lang');
67
+ if (q.get('fontScale')) boot.fontScale = q.get('fontScale');
68
+ applyState(boot);
69
+ } catch (e) { /* never block the app on a malformed theme payload */ }
70
+
71
+ // 2) LIVE updates — generic; the app never writes this.
72
+ try {
73
+ window.addEventListener('message', function (e) {
74
+ var d = e && e.data;
75
+ if (d && d.type === 'smartlinks:root-state') applyState(d);
76
+ });
77
+ } catch (e) {}
78
+ })();
package/dist/theme.css ADDED
@@ -0,0 +1,55 @@
1
+ /* @proveanything/smartlinks/theme.css — SmartLinks host theming preset (theme tokens v1)
2
+ *
3
+ * Maps Tailwind 4's design tokens onto the SmartLinks semantic theme tokens (--sl-color-*,
4
+ * --sl-radius-*, --sl-font-*) that the host sets on each app's mount root. After importing this,
5
+ * an app's `bg-primary` / `text-foreground` / `border-border` / `rounded-md` / `font-heading`
6
+ * resolve to whatever brand the host is running — no per-app mapping, no hardcoded palette.
7
+ *
8
+ * app styles.css:
9
+ * @import "tailwindcss";
10
+ * @import "@proveanything/smartlinks/theme.css";
11
+ *
12
+ * Requires Tailwind 4 (@theme). Every value has a neutral fallback, so the app renders correctly
13
+ * even against a host that sets none of the --sl-* tokens. See docs/theme-tokens.md.
14
+ */
15
+
16
+ @theme {
17
+ /* ── Colour (semantic roles) ─────────────────────────────────────────── */
18
+ --color-background: var(--sl-color-bg, #ffffff);
19
+ --color-surface: var(--sl-color-surface, #f7f7f8);
20
+ --color-surface-raised:var(--sl-color-surface-raised, var(--sl-color-surface, #ffffff));
21
+ --color-foreground: var(--sl-color-fg, #18181b);
22
+ --color-muted: var(--sl-color-muted, #71717a);
23
+ --color-border: var(--sl-color-border, #e4e4e7);
24
+ --color-primary: var(--sl-color-accent, #4f46e5);
25
+ --color-on-primary: var(--sl-color-on-accent, #ffffff);
26
+ --color-primary-soft: var(--sl-color-accent-soft, color-mix(in oklab, var(--sl-color-accent, #4f46e5) 12%, transparent));
27
+ --color-success: var(--sl-color-success, #16a34a);
28
+ --color-danger: var(--sl-color-danger, #dc2626);
29
+
30
+ /* ── Shape ───────────────────────────────────────────────────────────── */
31
+ --radius-sm: var(--sl-radius-sm, 4px);
32
+ --radius-md: var(--sl-radius-md, 8px);
33
+ --radius-lg: var(--sl-radius-lg, 16px);
34
+
35
+ /* ── Type ────────────────────────────────────────────────────────────── */
36
+ --font-heading: var(--sl-font-heading, var(--sl-font-body, ui-sans-serif, system-ui, sans-serif));
37
+ --font-body: var(--sl-font-body, ui-sans-serif, system-ui, sans-serif);
38
+
39
+ /* ── Elevation (extended; no shadow unless the host sets one) ─────────── */
40
+ --shadow-sm: var(--sl-shadow-sm, 0 0 #0000);
41
+ --shadow-md: var(--sl-shadow-md, 0 0 #0000);
42
+ }
43
+
44
+ /* shadcn bridge (optional): if the app uses shadcn's --primary/--background/etc. HSL variables,
45
+ * uncomment to drive them from the SmartLinks tokens too. Left commented so it never overrides an
46
+ * app that has deliberately customised its shadcn palette — opt in per app (see migration step 17).
47
+ *
48
+ * :where([data-sl-app]) {
49
+ * --primary: var(--sl-color-accent);
50
+ * --background: var(--sl-color-bg);
51
+ * --foreground: var(--sl-color-fg);
52
+ * --border: var(--sl-color-border);
53
+ * --radius: var(--sl-radius-md);
54
+ * }
55
+ */
@@ -429,6 +429,18 @@ export interface AppManifest {
429
429
  * `@proveanything/smartlinks/baseline.css`. Opt-in and additive. See docs/css-baseline.md.
430
430
  */
431
431
  cssBaseline?: string;
432
+ /**
433
+ * True if this app reads the host theme tokens (`--sl-color-*`, `--sl-radius-*`,
434
+ * `--sl-font-*`) rather than hardcoding a palette — i.e. it follows the host's brand.
435
+ * Lets a host's app browser show "follows your brand" vs "brings its own look", and turns
436
+ * on the doctor's hardcoded-colour warning. Opt-in. See docs/theme-tokens.md.
437
+ */
438
+ respectsHostTheme?: boolean;
439
+ /**
440
+ * Theme-token contract version this app targets, e.g. `"v1"`. Pairs with
441
+ * `@proveanything/smartlinks/theme.css`. See docs/theme-tokens.md.
442
+ */
443
+ themeTokens?: string;
432
444
  /**
433
445
  * Per-app namespaced UMD globals (R4.7+), e.g. `{ widgets: "MyAppWidgets" }`.
434
446
  * UMD-only: ESM bundles expose their exports through the module namespace and
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.0.15 | Generated: 2026-09-22T15:00:20.418Z
3
+ Version: 2.0.17 | Generated: 2026-09-24T09:42:39.649Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -2183,6 +2183,8 @@ interface AppManifest {
2183
2183
  moduleFormat?: 'umd' | 'esm' | 'dual';
2184
2184
  sharedDependencies?: string;
2185
2185
  cssBaseline?: string;
2186
+ respectsHostTheme?: boolean;
2187
+ themeTokens?: string;
2186
2188
  globals?: Record<string, string>;
2187
2189
  seo?: {
2188
2190
  strategy?: 'executor' | string;
@@ -7,7 +7,7 @@
7
7
  > Status: **standard**. New apps MUST follow this contract; existing apps SHOULD migrate.
8
8
  >
9
9
  > SDK: `@proveanything/smartlinks` ≥ **2.0** (R5).
10
- > Admin shell (React only): `@proveanything/smartlinks-utils-ui` ≥ **0.7.6** — required for the admin side if using the React shell; not needed in public widgets.
10
+ > Admin shell (React only): `@proveanything/smartlinks-utils-ui` ≥ **2.0** — required for the admin side if using the React shell; not needed in public widgets.
11
11
 
12
12
  ---
13
13
 
@@ -34,14 +34,24 @@ bundle. Two small additions to your build, both non-secret:
34
34
  // app.manifest.json
35
35
  "build": { "hash": "a1b2c3d4", "at": "2026-09-20T10:00:00Z" }
36
36
  ```
37
- 2. **Ping us on publish** (a `postbuild` step — `SMARTLINKS_APP_ID` is an *identifier, not a
38
- secret*, so it's fine in the repo):
37
+ 2. **Ping us on publish** (a `postbuild` step — the app id is an *identifier, not a secret*, so it's
38
+ fine in the repo). **Prefer the tiny node wrapper** the example app ships
39
+ (`scripts/smartlinks-deploy.mjs` in `smartlinks-app-example` — copy it): its
40
+ no-key path does exactly this ping and, crucially, **resolves the app id from
41
+ `SMARTLINKS_APP_ID` *or* the manifest's `meta.appId`**, so it works even when no env var is set:
42
+ ```js
43
+ // the id-resolution the wrapper uses — env var first, manifest fallback
44
+ const appId = process.env.SMARTLINKS_APP_ID
45
+ || JSON.parse(fs.readFileSync('public/app.manifest.json')).meta.appId
46
+ ```
47
+ ```jsonc
48
+ // package.json — wrapper (recommended)
49
+ "scripts": { "postbuild": "node scripts/smartlinks-deploy.mjs" }
50
+ ```
51
+ A bare `curl` works too, **but only if `$SMARTLINKS_APP_ID` is actually set in the build env** —
52
+ it has no manifest fallback, so an unset var makes the ping a silent no-op (a common miss):
39
53
  ```jsonc
40
- // package.json
41
- "scripts": {
42
- "build": "vite build && … && node scripts/hash-bundles.mjs", // your existing hashing step
43
- "postbuild": "curl -fsS -X POST \"$SMARTLINKS_API/api/v1/apps/$SMARTLINKS_APP_ID/refresh-dev\" -H 'content-type: application/json' -d \"{\\\"hash\\\":\\\"$BUILD_HASH\\\"}\""
44
- }
54
+ "postbuild": "curl -fsS -X POST \"$SMARTLINKS_API/api/v1/apps/$SMARTLINKS_APP_ID/refresh-dev\" -H 'content-type: application/json' -d \"{\\\"hash\\\":\\\"$BUILD_HASH\\\"}\""
45
55
  ```
46
56
 
47
57
  That's it — no deploy key. When the ping arrives, SmartLinks looks up your app's published URL
@@ -51,6 +61,14 @@ and registers the dev release (the hash becomes the version). `POST /apps/{appId
51
61
  authorises nothing on its own — worst case it re-fetches your app's own public bundle — so it needs
52
62
  no secret; it's rate-limited and de-duped per app.
53
63
 
64
+ **Where your app learns its own id:** either `SMARTLINKS_APP_ID` (a build-env var) or the manifest's
65
+ `meta.appId`. **Whichever you use must equal your authoritative platform id** — the one in the
66
+ catalog (the `appModules` handle the CDN + every collection's config bind to). On the keyed
67
+ `/releases` path `meta.appId` is *ignored* in favour of the URL id, but for `refresh-dev` the id you
68
+ ping with **is** the lookup key — so if `meta.appId` is blank or has drifted from your platform id,
69
+ the ping resolves the wrong app (or `NO_DEV_URL`). **Stamp `meta.appId` with your real platform id**
70
+ (don't invent one) and the manifest fallback is reliable.
71
+
54
72
  **One-time setup:** the app must exist in the catalog with its **id** and its **Lovable URL**
55
73
  recorded (that's how we know what to fetch, and the `id` you ping with must match). Ask the platform
56
74
  owner to register the app once; after that, every Publish auto-updates dev.
@@ -0,0 +1,120 @@
1
+ {
2
+ "tokensVersion": "v1",
3
+ "note": "Reference starter themes for the theme-tokens v1 contract (see theme-tokens.md). These seed the Hub lookbook; the AI refines from one of these or generates new value-sets. Each is a plain value map the host expands to --sl-* on the app mount root. All pass WCAG AA for fg/bg and on-accent/accent.",
4
+ "themes": [
5
+ {
6
+ "id": "clean-slate",
7
+ "name": "Clean Slate",
8
+ "description": "Neutral default — safe, modern, brandable. The fallback identity.",
9
+ "values": {
10
+ "color-bg": "#ffffff",
11
+ "color-surface": "#f7f7f8",
12
+ "color-surface-raised": "#ffffff",
13
+ "color-fg": "#18181b",
14
+ "color-muted": "#71717a",
15
+ "color-border": "#e4e4e7",
16
+ "color-accent": "#4f46e5",
17
+ "color-on-accent": "#ffffff",
18
+ "color-accent-soft": "#eef2ff",
19
+ "radius-sm": "4px", "radius-md": "8px", "radius-lg": "16px",
20
+ "font-heading": "'Inter', ui-sans-serif, system-ui, sans-serif",
21
+ "font-body": "'Inter', ui-sans-serif, system-ui, sans-serif"
22
+ }
23
+ },
24
+ {
25
+ "id": "midnight",
26
+ "name": "Midnight",
27
+ "description": "Dark surface, cool accent. For premium / tech brands.",
28
+ "values": {
29
+ "color-bg": "#0b0d12",
30
+ "color-surface": "#151821",
31
+ "color-surface-raised": "#1d212c",
32
+ "color-fg": "#e7e9ee",
33
+ "color-muted": "#9aa1ad",
34
+ "color-border": "#2a2f3a",
35
+ "color-accent": "#6ea8fe",
36
+ "color-on-accent": "#0b0d12",
37
+ "color-accent-soft": "#18233a",
38
+ "radius-sm": "5px", "radius-md": "10px", "radius-lg": "18px",
39
+ "font-heading": "'Space Grotesk', ui-sans-serif, system-ui, sans-serif",
40
+ "font-body": "'Inter', ui-sans-serif, system-ui, sans-serif"
41
+ }
42
+ },
43
+ {
44
+ "id": "warm-editorial",
45
+ "name": "Warm Editorial",
46
+ "description": "Cream paper, serif headings, terracotta accent. Food, lifestyle, craft.",
47
+ "values": {
48
+ "color-bg": "#faf7f2",
49
+ "color-surface": "#ffffff",
50
+ "color-surface-raised": "#ffffff",
51
+ "color-fg": "#1a1a1a",
52
+ "color-muted": "#6b6b6b",
53
+ "color-border": "#e6e0d8",
54
+ "color-accent": "#b4531f",
55
+ "color-on-accent": "#ffffff",
56
+ "color-accent-soft": "#f6e9df",
57
+ "radius-sm": "3px", "radius-md": "6px", "radius-lg": "12px",
58
+ "font-heading": "'Fraunces', Georgia, serif",
59
+ "font-body": "'Inter', ui-sans-serif, system-ui, sans-serif"
60
+ }
61
+ },
62
+ {
63
+ "id": "fresh-mint",
64
+ "name": "Fresh Mint",
65
+ "description": "Airy light, green accent, soft corners. Wellness, sustainability, outdoors.",
66
+ "values": {
67
+ "color-bg": "#f6faf7",
68
+ "color-surface": "#ffffff",
69
+ "color-surface-raised": "#ffffff",
70
+ "color-fg": "#14261c",
71
+ "color-muted": "#5c7367",
72
+ "color-border": "#d9e7de",
73
+ "color-accent": "#0f8a5f",
74
+ "color-on-accent": "#ffffff",
75
+ "color-accent-soft": "#e2f3ea",
76
+ "radius-sm": "8px", "radius-md": "14px", "radius-lg": "24px",
77
+ "font-heading": "'Poppins', ui-sans-serif, system-ui, sans-serif",
78
+ "font-body": "'Inter', ui-sans-serif, system-ui, sans-serif"
79
+ }
80
+ },
81
+ {
82
+ "id": "bold-contrast",
83
+ "name": "Bold Contrast",
84
+ "description": "Mono, sharp corners, black accent. Streetwear, fashion, high-impact.",
85
+ "values": {
86
+ "color-bg": "#ffffff",
87
+ "color-surface": "#ffffff",
88
+ "color-surface-raised": "#ffffff",
89
+ "color-fg": "#0a0a0a",
90
+ "color-muted": "#5c5c5c",
91
+ "color-border": "#0a0a0a",
92
+ "color-accent": "#0a0a0a",
93
+ "color-on-accent": "#ffffff",
94
+ "color-accent-soft": "#f0f0f0",
95
+ "radius-sm": "0px", "radius-md": "0px", "radius-lg": "2px",
96
+ "font-heading": "'Archivo', ui-sans-serif, system-ui, sans-serif",
97
+ "font-body": "'Archivo', ui-sans-serif, system-ui, sans-serif"
98
+ }
99
+ },
100
+ {
101
+ "id": "soft-pastel",
102
+ "name": "Soft Pastel",
103
+ "description": "Gentle lilac, very rounded, friendly. Kids, beauty, playful DTC.",
104
+ "values": {
105
+ "color-bg": "#fbf7ff",
106
+ "color-surface": "#ffffff",
107
+ "color-surface-raised": "#ffffff",
108
+ "color-fg": "#241b33",
109
+ "color-muted": "#7a6f8c",
110
+ "color-border": "#ece2f7",
111
+ "color-accent": "#7c3aed",
112
+ "color-on-accent": "#ffffff",
113
+ "color-accent-soft": "#f1e9fe",
114
+ "radius-sm": "10px", "radius-md": "18px", "radius-lg": "28px",
115
+ "font-heading": "'Quicksand', ui-sans-serif, system-ui, sans-serif",
116
+ "font-body": "'Nunito', ui-sans-serif, system-ui, sans-serif"
117
+ }
118
+ }
119
+ ]
120
+ }
@@ -0,0 +1,239 @@
1
+ # SmartLinks Theme Tokens — host theming contract
2
+
3
+ Status: **v1 (draft) — FORTHCOMING, not yet live host-side.**
4
+ > ⚠️ **Do not build apps against this yet.** The host (Hub/Portal) does not yet set `--sl-*` tokens
5
+ > or send `smartlinks:root-state`. Until it does, apps follow the **current** theming — see
6
+ > [`theme.system.md`](./theme.system.md) (`useSmartLinksTheme()` + shadcn vars `--primary`/`--background`,
7
+ > the `?theme=` base64 payload). This document is the target model that will supersede it once the host
8
+ > serves it; it exists so the SDK preset (`theme.css`), the iframe bootstrap (`theme-boot.js`) and the
9
+ > Hub theme engine can be built against a fixed contract.
10
+
11
+ Applies to: `@proveanything/smartlinks` ^2.0 (R5), container + widget embeds
12
+ Companion to: [`css-baseline.md`](./css-baseline.md) (mechanics, live) — this doc is **brand** (colour, shape, type)
13
+
14
+ ---
15
+
16
+ ## The one idea
17
+
18
+ `sl-baseline` gives apps guaranteed **mechanics** (layout, spacing, type scale) with no colour or brand.
19
+ Theme tokens give apps the host's **brand** — colour, corner shape, fonts — as a small set of
20
+ **semantic CSS custom properties** the host sets and the app reads. An app that binds to these
21
+ follows any host theme automatically, forever, with no code change.
22
+
23
+ The durable thing here is the **token contract** (names + meanings), not any theme. A *theme* is just
24
+ a set of token *values* — disposable data, hand-authored or AI-generated. The contract is small,
25
+ semantic, and versioned; themes churn freely on top of it.
26
+
27
+ ```
28
+ host sets --sl-color-accent: #b4531f (a theme value)
29
+ │
30
+ ▼
31
+ SDK theme.css maps it onto Tailwind's token → bg-primary, text-primary, …
32
+ │
33
+ ▼
34
+ app renders in the host's brand — no per-app work
35
+ ```
36
+
37
+ ---
38
+
39
+ ## 1. Design rules (why it lasts)
40
+
41
+ 1. **Semantic, never literal.** Tokens name a *role* (`--sl-color-accent`, `--sl-color-surface`),
42
+ never a colour (`--sl-blue-600`). Roles survive redesigns; literals rot.
43
+ 2. **Small guaranteed core.** The app-facing contract is ~12 tokens. Richer, Hub-specific styling
44
+ (button weight, elevation, density, imagery) lives in **host-internal** component tokens and is
45
+ NOT part of this contract — so it can evolve without breaking apps or binding other surfaces.
46
+ 3. **Additive, versioned, retained.** New tokens are added within a version with sane fallbacks; a
47
+ removal or rename is a new version, old retained. Same discipline as the shared-dependency
48
+ contract — never hard-remove.
49
+ 4. **Each surface declares the subset it honours.** The token set is a **superset**; Hub honours all
50
+ of it, Portal a subset. Apps read defensively (every token has a fallback), so one brand renders
51
+ consistently across surfaces and missing tokens degrade gracefully.
52
+ 5. **Tokens only — never templated CSS.** Themes are declarative values, always previewable and
53
+ validatable. (Liquid/templating belongs to content, not styling.)
54
+
55
+ ---
56
+
57
+ ## 2. The v1 token set
58
+
59
+ All tokens are CSS custom properties read from the **nearest scoping element** (the app's mount
60
+ root), so different embeds on one page can theme differently. Every token has a fallback, so an app
61
+ renders correctly even against a host that sets none of them.
62
+
63
+ ### Core (v1 — guaranteed; every host honours these)
64
+
65
+ | Token | Role | Example value |
66
+ |---|---|---|
67
+ | `--sl-color-bg` | app / page background | `#ffffff` |
68
+ | `--sl-color-surface` | card / panel background | `#f7f7f8` |
69
+ | `--sl-color-fg` | primary text (on bg/surface) | `#18181b` |
70
+ | `--sl-color-muted` | secondary / muted text | `#71717a` |
71
+ | `--sl-color-border` | borders, dividers, input outlines | `#e4e4e7` |
72
+ | `--sl-color-accent` | brand / primary action | `#4f46e5` |
73
+ | `--sl-color-on-accent` | text / icon on an accent fill | `#ffffff` |
74
+ | `--sl-radius-sm` | small corner radius | `4px` |
75
+ | `--sl-radius-md` | default corner radius | `8px` |
76
+ | `--sl-radius-lg` | large corner radius | `16px` |
77
+ | `--sl-font-heading` | heading font stack | `'Inter', sans-serif` |
78
+ | `--sl-font-body` | body font stack | `'Inter', sans-serif` |
79
+
80
+ ### Extended (v1 — optional; Hub may set, Portal may not; read defensively)
81
+
82
+ | Token | Role | Fallback |
83
+ |---|---|---|
84
+ | `--sl-color-surface-raised` | elevated surface (popover, modal) | `--sl-color-surface` |
85
+ | `--sl-color-accent-soft` | soft accent fill (badges, hovers) | derived from `--sl-color-accent` |
86
+ | `--sl-color-success` | positive status | `#16a34a` |
87
+ | `--sl-color-danger` | negative / destructive status | `#dc2626` |
88
+ | `--sl-shadow-sm` / `--sl-shadow-md` | elevation | none |
89
+
90
+ > Everything richer than this — button weight (solid/soft/outline), density/compactness, image
91
+ > treatment, elevation scale — is a **host-internal component token**, driven by Hub's theme engine.
92
+ > It is deliberately NOT in this contract, so Hub can be as skinnable as it likes without it becoming
93
+ > a forever-obligation on every app and surface.
94
+
95
+ Dark mode is a *theme* (a different value-set), not a separate token set — the host swaps the values.
96
+
97
+ ---
98
+
99
+ ## 3. Theme value-sets (what the AI emits, what the lookbook stores)
100
+
101
+ A theme is data: a token version + a flat map of values. The host expands each `values` key to
102
+ `--sl-<key>` on the mount root.
103
+
104
+ ```jsonc
105
+ {
106
+ "tokensVersion": "v1",
107
+ "name": "Warm Editorial",
108
+ "values": {
109
+ "color-bg": "#faf7f2",
110
+ "color-surface": "#ffffff",
111
+ "color-fg": "#1a1a1a",
112
+ "color-muted": "#6b6b6b",
113
+ "color-border": "#e6e0d8",
114
+ "color-accent": "#b4531f",
115
+ "color-on-accent": "#ffffff",
116
+ "radius-sm": "4px", "radius-md": "10px", "radius-lg": "18px",
117
+ "font-heading": "'Fraunces', serif",
118
+ "font-body": "'Inter', sans-serif"
119
+ }
120
+ }
121
+ ```
122
+
123
+ - **Lookbook** = a gallery of these value-sets shipped as starting points; AI can generate more.
124
+ - **Refine** = AI edits `values` conversationally, previewed live in the real host, always inside the
125
+ contract → always safe, previewable, validatable.
126
+ - **Brand on-ramp** = ingest a site/brand guide → AI emits a `values` set → refine.
127
+ - **Validation** = a value-set must pass contrast (WCAG AA) for `fg`/`bg`, `muted`/`bg`,
128
+ `on-accent`/`accent` before it is offered or saved. AI output is checked, not trusted.
129
+
130
+ Store per collection as `{ tokensVersion, name, values }` — portable, diffable, exportable.
131
+
132
+ ---
133
+
134
+ ## 4. Host obligations vs app obligations
135
+
136
+ **Host (Hub / Portal):**
137
+ - Applies the active theme's `--sl-*` tokens where the embed will read them — `:root`/`<body>` for the
138
+ simple one-brand page (container/widget apps inherit for free), or the app's mount root when embeds
139
+ must theme independently or you want leak isolation for untrusted apps.
140
+ - For **iframe** apps, hands the initial state in via the URL fragment and posts live viewer-pref
141
+ updates as `smartlinks:root-state` (§6.2). Brand-theme changes = reload the embed.
142
+ - Declares the token version it serves and which subset (Hub = full, Portal = core).
143
+
144
+ **App:**
145
+ - `@import "@proveanything/smartlinks/theme.css";` after Tailwind (see §5).
146
+ - Uses semantic utilities (`bg-primary`, `text-foreground`, `border-border`, `rounded-md`,
147
+ `font-heading`) — never hardcoded palette utilities (`bg-blue-600`, `#rrggbb`).
148
+ - Authors CSS against the viewer-pref hooks (§6.1): the `.dark` class, `[data-contrast="high"]`,
149
+ `--sl-font-scale`, and the native `@media (prefers-contrast|prefers-reduced-motion)` queries.
150
+ **Never writes a message handler** — the SDK bootstrap flips the hooks.
151
+ - Iframe/standalone entries only: include `@proveanything/smartlinks/theme-boot.js` in `<head>`
152
+ (§6.2). Container/widget apps don't need it.
153
+ - Declares intent in the manifest:
154
+
155
+ ```jsonc
156
+ { "meta": { "respectsHostTheme": true, "themeTokens": "v1" } }
157
+ ```
158
+
159
+ `smartlinks-doctor` warns (not errors) on hardcoded colour utilities / hex in component source when
160
+ `respectsHostTheme` is `true`.
161
+
162
+ ---
163
+
164
+ ## 5. The SDK preset (`theme.css`)
165
+
166
+ Ships from the SDK. A Tailwind 4 `@theme` block that maps Tailwind's tokens onto the `--sl-*`
167
+ contract, with fallbacks so it is safe even where the host sets nothing:
168
+
169
+ ```css
170
+ @import "tailwindcss";
171
+ @import "@proveanything/smartlinks/theme.css";
172
+ ```
173
+
174
+ Requires Tailwind 4 (`@theme`). Apps still on Tailwind 3 stay as they are until they migrate
175
+ (step 16); the preset is opt-in and additive.
176
+
177
+ ---
178
+
179
+ ## 6. Viewer preferences, accessibility & iframe delivery
180
+
181
+ Two different kinds of "theme-ish" state, handled differently:
182
+
183
+ | | Brand theme | Viewer / accessibility prefs |
184
+ |---|---|---|
185
+ | What | accent, fonts, radius (the `--sl-*` tokens) | light/dark, contrast, font size, reduced-motion, language |
186
+ | Set by | the operator (rarely, in an editor) | the **viewer** (any time, mid-session) |
187
+ | Change model | **reload** the embed with new values | **live**, no reload (a reload on an a11y toggle *is* an a11y failure) |
188
+
189
+ **The app never writes a message handler.** It authors **CSS** against a small, fixed set of root
190
+ hooks; a generic SDK bootstrap (§6.2) flips those hooks. That's the whole obligation.
191
+
192
+ ### 6.1 The viewer-pref hooks (fixed vocabulary)
193
+
194
+ - **Dark mode** — the `.dark` class (Tailwind/shadcn convention) *and* `[data-theme="dark"|"light"]`.
195
+ - **Contrast** — `[data-contrast="high"]`. Also honour `@media (prefers-contrast: more)` — the browser
196
+ propagates the OS setting into iframes natively, so you get that slice for free.
197
+ - **Reduced motion** — honour `@media (prefers-reduced-motion: reduce)` (native, free).
198
+ - **Font size** — `--sl-font-scale` (a number, e.g. `1.25`); scale your base/rem type off it.
199
+ - **Language** — the `lang` attribute. (A language change is *content*, not just CSS — re-render/
200
+ re-fetch is the app's own i18n job; the hook just flags it. A heavy content swap may reload.)
201
+
202
+ Most of accessibility is therefore **free**: the OS-level `prefers-contrast` / `prefers-reduced-motion`
203
+ / browser zoom reach the iframe with no passing at all — just respect the standard media queries.
204
+
205
+ ### 6.2 Iframe delivery (`theme-boot.js`)
206
+
207
+ Container/widget apps inherit the host `:root` and need none of this. **Iframe/standalone** apps
208
+ (their own document — the security boundary for untrusted apps) include the SDK bootstrap
209
+ `@proveanything/smartlinks/theme-boot.js` in `<head>` (inline is best — zero flash):
210
+
211
+ - **Boot (before first paint):** reads the initial state from the URL fragment
212
+ `#slt=<base64url(JSON)>` (`{ tokensVersion, values, theme, contrast, fontScale, lang }`), plus
213
+ convenience params `?theme=&contrast=&fontScale=&lang=`, and applies it to `:root` synchronously.
214
+ This *replaces* the legacy base64-17-keys scheme — same idea (correct on first paint), but a
215
+ structured, versioned payload.
216
+ - **Live:** applies generic root-state updates the host posts as
217
+ `{ type: 'smartlinks:root-state', values?, theme?, contrast?, fontScale?, lang?, attrs? }`. This is
218
+ what makes accessibility toggles instant. The app carries none of this logic — the bootstrap is
219
+ generic and SDK-owned; the app only wrote CSS.
220
+
221
+ Payload stays small (the reason the fixed vocabulary matters): the token set + a handful of mode keys
222
+ fit comfortably in a URL fragment, no compression needed.
223
+
224
+ ---
225
+
226
+ ## 7. Versioning
227
+
228
+ - `themeTokens: "vN"` — the version an app targets.
229
+ - Within a version: additive only (new tokens get fallbacks). Never remove a token in-version.
230
+ - A removal/rename → `v(N+1)`, `vN` retained; hosts may serve several; apps declare which they target.
231
+ - Surfaces declare their honoured subset; the "mapping" between Hub and Portal is *which keys each
232
+ honours*, not a translation layer.
233
+
234
+ ## 8. Change log
235
+
236
+ - **v1 (draft, 2026-09-23):** initial semantic core (12) + extended (optional) set; value-set schema;
237
+ host mount-root scoping; manifest declaration + doctor warn; viewer-pref/accessibility hooks
238
+ (`.dark`/`data-theme`, `data-contrast`, `--sl-font-scale`, `lang`, native `prefers-*`); iframe
239
+ `theme-boot.js` (URL-boot + generic live root-state); brand change = reload, prefs = live.