@proveanything/smartlinks 2.0.15 → 2.0.16

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.
@@ -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.16 | Generated: 2026-09-23T19:24:45.314Z
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;
@@ -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,232 @@
1
+ # SmartLinks Theme Tokens — host theming contract
2
+
3
+ Status: **v1 (draft)**
4
+ Applies to: `@proveanything/smartlinks` ^2.0 (R5), container + widget embeds
5
+ Companion to: [`css-baseline.md`](./css-baseline.md) (mechanics) — this doc is **brand** (colour, shape, type)
6
+
7
+ ---
8
+
9
+ ## The one idea
10
+
11
+ `sl-baseline` gives apps guaranteed **mechanics** (layout, spacing, type scale) with no colour or brand.
12
+ Theme tokens give apps the host's **brand** — colour, corner shape, fonts — as a small set of
13
+ **semantic CSS custom properties** the host sets and the app reads. An app that binds to these
14
+ follows any host theme automatically, forever, with no code change.
15
+
16
+ The durable thing here is the **token contract** (names + meanings), not any theme. A *theme* is just
17
+ a set of token *values* — disposable data, hand-authored or AI-generated. The contract is small,
18
+ semantic, and versioned; themes churn freely on top of it.
19
+
20
+ ```
21
+ host sets --sl-color-accent: #b4531f (a theme value)
22
+ │
23
+ ▼
24
+ SDK theme.css maps it onto Tailwind's token → bg-primary, text-primary, …
25
+ │
26
+ ▼
27
+ app renders in the host's brand — no per-app work
28
+ ```
29
+
30
+ ---
31
+
32
+ ## 1. Design rules (why it lasts)
33
+
34
+ 1. **Semantic, never literal.** Tokens name a *role* (`--sl-color-accent`, `--sl-color-surface`),
35
+ never a colour (`--sl-blue-600`). Roles survive redesigns; literals rot.
36
+ 2. **Small guaranteed core.** The app-facing contract is ~12 tokens. Richer, Hub-specific styling
37
+ (button weight, elevation, density, imagery) lives in **host-internal** component tokens and is
38
+ NOT part of this contract — so it can evolve without breaking apps or binding other surfaces.
39
+ 3. **Additive, versioned, retained.** New tokens are added within a version with sane fallbacks; a
40
+ removal or rename is a new version, old retained. Same discipline as the shared-dependency
41
+ contract — never hard-remove.
42
+ 4. **Each surface declares the subset it honours.** The token set is a **superset**; Hub honours all
43
+ of it, Portal a subset. Apps read defensively (every token has a fallback), so one brand renders
44
+ consistently across surfaces and missing tokens degrade gracefully.
45
+ 5. **Tokens only — never templated CSS.** Themes are declarative values, always previewable and
46
+ validatable. (Liquid/templating belongs to content, not styling.)
47
+
48
+ ---
49
+
50
+ ## 2. The v1 token set
51
+
52
+ All tokens are CSS custom properties read from the **nearest scoping element** (the app's mount
53
+ root), so different embeds on one page can theme differently. Every token has a fallback, so an app
54
+ renders correctly even against a host that sets none of them.
55
+
56
+ ### Core (v1 — guaranteed; every host honours these)
57
+
58
+ | Token | Role | Example value |
59
+ |---|---|---|
60
+ | `--sl-color-bg` | app / page background | `#ffffff` |
61
+ | `--sl-color-surface` | card / panel background | `#f7f7f8` |
62
+ | `--sl-color-fg` | primary text (on bg/surface) | `#18181b` |
63
+ | `--sl-color-muted` | secondary / muted text | `#71717a` |
64
+ | `--sl-color-border` | borders, dividers, input outlines | `#e4e4e7` |
65
+ | `--sl-color-accent` | brand / primary action | `#4f46e5` |
66
+ | `--sl-color-on-accent` | text / icon on an accent fill | `#ffffff` |
67
+ | `--sl-radius-sm` | small corner radius | `4px` |
68
+ | `--sl-radius-md` | default corner radius | `8px` |
69
+ | `--sl-radius-lg` | large corner radius | `16px` |
70
+ | `--sl-font-heading` | heading font stack | `'Inter', sans-serif` |
71
+ | `--sl-font-body` | body font stack | `'Inter', sans-serif` |
72
+
73
+ ### Extended (v1 — optional; Hub may set, Portal may not; read defensively)
74
+
75
+ | Token | Role | Fallback |
76
+ |---|---|---|
77
+ | `--sl-color-surface-raised` | elevated surface (popover, modal) | `--sl-color-surface` |
78
+ | `--sl-color-accent-soft` | soft accent fill (badges, hovers) | derived from `--sl-color-accent` |
79
+ | `--sl-color-success` | positive status | `#16a34a` |
80
+ | `--sl-color-danger` | negative / destructive status | `#dc2626` |
81
+ | `--sl-shadow-sm` / `--sl-shadow-md` | elevation | none |
82
+
83
+ > Everything richer than this — button weight (solid/soft/outline), density/compactness, image
84
+ > treatment, elevation scale — is a **host-internal component token**, driven by Hub's theme engine.
85
+ > It is deliberately NOT in this contract, so Hub can be as skinnable as it likes without it becoming
86
+ > a forever-obligation on every app and surface.
87
+
88
+ Dark mode is a *theme* (a different value-set), not a separate token set — the host swaps the values.
89
+
90
+ ---
91
+
92
+ ## 3. Theme value-sets (what the AI emits, what the lookbook stores)
93
+
94
+ A theme is data: a token version + a flat map of values. The host expands each `values` key to
95
+ `--sl-<key>` on the mount root.
96
+
97
+ ```jsonc
98
+ {
99
+ "tokensVersion": "v1",
100
+ "name": "Warm Editorial",
101
+ "values": {
102
+ "color-bg": "#faf7f2",
103
+ "color-surface": "#ffffff",
104
+ "color-fg": "#1a1a1a",
105
+ "color-muted": "#6b6b6b",
106
+ "color-border": "#e6e0d8",
107
+ "color-accent": "#b4531f",
108
+ "color-on-accent": "#ffffff",
109
+ "radius-sm": "4px", "radius-md": "10px", "radius-lg": "18px",
110
+ "font-heading": "'Fraunces', serif",
111
+ "font-body": "'Inter', sans-serif"
112
+ }
113
+ }
114
+ ```
115
+
116
+ - **Lookbook** = a gallery of these value-sets shipped as starting points; AI can generate more.
117
+ - **Refine** = AI edits `values` conversationally, previewed live in the real host, always inside the
118
+ contract → always safe, previewable, validatable.
119
+ - **Brand on-ramp** = ingest a site/brand guide → AI emits a `values` set → refine.
120
+ - **Validation** = a value-set must pass contrast (WCAG AA) for `fg`/`bg`, `muted`/`bg`,
121
+ `on-accent`/`accent` before it is offered or saved. AI output is checked, not trusted.
122
+
123
+ Store per collection as `{ tokensVersion, name, values }` — portable, diffable, exportable.
124
+
125
+ ---
126
+
127
+ ## 4. Host obligations vs app obligations
128
+
129
+ **Host (Hub / Portal):**
130
+ - Applies the active theme's `--sl-*` tokens where the embed will read them — `:root`/`<body>` for the
131
+ simple one-brand page (container/widget apps inherit for free), or the app's mount root when embeds
132
+ must theme independently or you want leak isolation for untrusted apps.
133
+ - For **iframe** apps, hands the initial state in via the URL fragment and posts live viewer-pref
134
+ updates as `smartlinks:root-state` (§6.2). Brand-theme changes = reload the embed.
135
+ - Declares the token version it serves and which subset (Hub = full, Portal = core).
136
+
137
+ **App:**
138
+ - `@import "@proveanything/smartlinks/theme.css";` after Tailwind (see §5).
139
+ - Uses semantic utilities (`bg-primary`, `text-foreground`, `border-border`, `rounded-md`,
140
+ `font-heading`) — never hardcoded palette utilities (`bg-blue-600`, `#rrggbb`).
141
+ - Authors CSS against the viewer-pref hooks (§6.1): the `.dark` class, `[data-contrast="high"]`,
142
+ `--sl-font-scale`, and the native `@media (prefers-contrast|prefers-reduced-motion)` queries.
143
+ **Never writes a message handler** — the SDK bootstrap flips the hooks.
144
+ - Iframe/standalone entries only: include `@proveanything/smartlinks/theme-boot.js` in `<head>`
145
+ (§6.2). Container/widget apps don't need it.
146
+ - Declares intent in the manifest:
147
+
148
+ ```jsonc
149
+ { "meta": { "respectsHostTheme": true, "themeTokens": "v1" } }
150
+ ```
151
+
152
+ `smartlinks-doctor` warns (not errors) on hardcoded colour utilities / hex in component source when
153
+ `respectsHostTheme` is `true`.
154
+
155
+ ---
156
+
157
+ ## 5. The SDK preset (`theme.css`)
158
+
159
+ Ships from the SDK. A Tailwind 4 `@theme` block that maps Tailwind's tokens onto the `--sl-*`
160
+ contract, with fallbacks so it is safe even where the host sets nothing:
161
+
162
+ ```css
163
+ @import "tailwindcss";
164
+ @import "@proveanything/smartlinks/theme.css";
165
+ ```
166
+
167
+ Requires Tailwind 4 (`@theme`). Apps still on Tailwind 3 stay as they are until they migrate
168
+ (step 16); the preset is opt-in and additive.
169
+
170
+ ---
171
+
172
+ ## 6. Viewer preferences, accessibility & iframe delivery
173
+
174
+ Two different kinds of "theme-ish" state, handled differently:
175
+
176
+ | | Brand theme | Viewer / accessibility prefs |
177
+ |---|---|---|
178
+ | What | accent, fonts, radius (the `--sl-*` tokens) | light/dark, contrast, font size, reduced-motion, language |
179
+ | Set by | the operator (rarely, in an editor) | the **viewer** (any time, mid-session) |
180
+ | Change model | **reload** the embed with new values | **live**, no reload (a reload on an a11y toggle *is* an a11y failure) |
181
+
182
+ **The app never writes a message handler.** It authors **CSS** against a small, fixed set of root
183
+ hooks; a generic SDK bootstrap (§6.2) flips those hooks. That's the whole obligation.
184
+
185
+ ### 6.1 The viewer-pref hooks (fixed vocabulary)
186
+
187
+ - **Dark mode** — the `.dark` class (Tailwind/shadcn convention) *and* `[data-theme="dark"|"light"]`.
188
+ - **Contrast** — `[data-contrast="high"]`. Also honour `@media (prefers-contrast: more)` — the browser
189
+ propagates the OS setting into iframes natively, so you get that slice for free.
190
+ - **Reduced motion** — honour `@media (prefers-reduced-motion: reduce)` (native, free).
191
+ - **Font size** — `--sl-font-scale` (a number, e.g. `1.25`); scale your base/rem type off it.
192
+ - **Language** — the `lang` attribute. (A language change is *content*, not just CSS — re-render/
193
+ re-fetch is the app's own i18n job; the hook just flags it. A heavy content swap may reload.)
194
+
195
+ Most of accessibility is therefore **free**: the OS-level `prefers-contrast` / `prefers-reduced-motion`
196
+ / browser zoom reach the iframe with no passing at all — just respect the standard media queries.
197
+
198
+ ### 6.2 Iframe delivery (`theme-boot.js`)
199
+
200
+ Container/widget apps inherit the host `:root` and need none of this. **Iframe/standalone** apps
201
+ (their own document — the security boundary for untrusted apps) include the SDK bootstrap
202
+ `@proveanything/smartlinks/theme-boot.js` in `<head>` (inline is best — zero flash):
203
+
204
+ - **Boot (before first paint):** reads the initial state from the URL fragment
205
+ `#slt=<base64url(JSON)>` (`{ tokensVersion, values, theme, contrast, fontScale, lang }`), plus
206
+ convenience params `?theme=&contrast=&fontScale=&lang=`, and applies it to `:root` synchronously.
207
+ This *replaces* the legacy base64-17-keys scheme — same idea (correct on first paint), but a
208
+ structured, versioned payload.
209
+ - **Live:** applies generic root-state updates the host posts as
210
+ `{ type: 'smartlinks:root-state', values?, theme?, contrast?, fontScale?, lang?, attrs? }`. This is
211
+ what makes accessibility toggles instant. The app carries none of this logic — the bootstrap is
212
+ generic and SDK-owned; the app only wrote CSS.
213
+
214
+ Payload stays small (the reason the fixed vocabulary matters): the token set + a handful of mode keys
215
+ fit comfortably in a URL fragment, no compression needed.
216
+
217
+ ---
218
+
219
+ ## 7. Versioning
220
+
221
+ - `themeTokens: "vN"` — the version an app targets.
222
+ - Within a version: additive only (new tokens get fallbacks). Never remove a token in-version.
223
+ - A removal/rename → `v(N+1)`, `vN` retained; hosts may serve several; apps declare which they target.
224
+ - Surfaces declare their honoured subset; the "mapping" between Hub and Portal is *which keys each
225
+ honours*, not a translation layer.
226
+
227
+ ## 8. Change log
228
+
229
+ - **v1 (draft, 2026-09-23):** initial semantic core (12) + extended (optional) set; value-set schema;
230
+ host mount-root scoping; manifest declaration + doctor warn; viewer-pref/accessibility hooks
231
+ (`.dark`/`data-theme`, `data-contrast`, `--sl-font-scale`, `lang`, native `prefers-*`); iframe
232
+ `theme-boot.js` (URL-boot + generic live root-state); brand change = reload, prefs = live.
package/dist/openapi.yaml CHANGED
@@ -17959,6 +17959,10 @@ components:
17959
17959
  type: string
17960
17960
  cssBaseline:
17961
17961
  type: string
17962
+ respectsHostTheme:
17963
+ type: boolean
17964
+ themeTokens:
17965
+ type: string
17962
17966
  globals:
17963
17967
  type: object
17964
17968
  additionalProperties:
@@ -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.16 | Generated: 2026-09-23T19:24:45.314Z
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;
@@ -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,232 @@
1
+ # SmartLinks Theme Tokens — host theming contract
2
+
3
+ Status: **v1 (draft)**
4
+ Applies to: `@proveanything/smartlinks` ^2.0 (R5), container + widget embeds
5
+ Companion to: [`css-baseline.md`](./css-baseline.md) (mechanics) — this doc is **brand** (colour, shape, type)
6
+
7
+ ---
8
+
9
+ ## The one idea
10
+
11
+ `sl-baseline` gives apps guaranteed **mechanics** (layout, spacing, type scale) with no colour or brand.
12
+ Theme tokens give apps the host's **brand** — colour, corner shape, fonts — as a small set of
13
+ **semantic CSS custom properties** the host sets and the app reads. An app that binds to these
14
+ follows any host theme automatically, forever, with no code change.
15
+
16
+ The durable thing here is the **token contract** (names + meanings), not any theme. A *theme* is just
17
+ a set of token *values* — disposable data, hand-authored or AI-generated. The contract is small,
18
+ semantic, and versioned; themes churn freely on top of it.
19
+
20
+ ```
21
+ host sets --sl-color-accent: #b4531f (a theme value)
22
+ │
23
+ ▼
24
+ SDK theme.css maps it onto Tailwind's token → bg-primary, text-primary, …
25
+ │
26
+ ▼
27
+ app renders in the host's brand — no per-app work
28
+ ```
29
+
30
+ ---
31
+
32
+ ## 1. Design rules (why it lasts)
33
+
34
+ 1. **Semantic, never literal.** Tokens name a *role* (`--sl-color-accent`, `--sl-color-surface`),
35
+ never a colour (`--sl-blue-600`). Roles survive redesigns; literals rot.
36
+ 2. **Small guaranteed core.** The app-facing contract is ~12 tokens. Richer, Hub-specific styling
37
+ (button weight, elevation, density, imagery) lives in **host-internal** component tokens and is
38
+ NOT part of this contract — so it can evolve without breaking apps or binding other surfaces.
39
+ 3. **Additive, versioned, retained.** New tokens are added within a version with sane fallbacks; a
40
+ removal or rename is a new version, old retained. Same discipline as the shared-dependency
41
+ contract — never hard-remove.
42
+ 4. **Each surface declares the subset it honours.** The token set is a **superset**; Hub honours all
43
+ of it, Portal a subset. Apps read defensively (every token has a fallback), so one brand renders
44
+ consistently across surfaces and missing tokens degrade gracefully.
45
+ 5. **Tokens only — never templated CSS.** Themes are declarative values, always previewable and
46
+ validatable. (Liquid/templating belongs to content, not styling.)
47
+
48
+ ---
49
+
50
+ ## 2. The v1 token set
51
+
52
+ All tokens are CSS custom properties read from the **nearest scoping element** (the app's mount
53
+ root), so different embeds on one page can theme differently. Every token has a fallback, so an app
54
+ renders correctly even against a host that sets none of them.
55
+
56
+ ### Core (v1 — guaranteed; every host honours these)
57
+
58
+ | Token | Role | Example value |
59
+ |---|---|---|
60
+ | `--sl-color-bg` | app / page background | `#ffffff` |
61
+ | `--sl-color-surface` | card / panel background | `#f7f7f8` |
62
+ | `--sl-color-fg` | primary text (on bg/surface) | `#18181b` |
63
+ | `--sl-color-muted` | secondary / muted text | `#71717a` |
64
+ | `--sl-color-border` | borders, dividers, input outlines | `#e4e4e7` |
65
+ | `--sl-color-accent` | brand / primary action | `#4f46e5` |
66
+ | `--sl-color-on-accent` | text / icon on an accent fill | `#ffffff` |
67
+ | `--sl-radius-sm` | small corner radius | `4px` |
68
+ | `--sl-radius-md` | default corner radius | `8px` |
69
+ | `--sl-radius-lg` | large corner radius | `16px` |
70
+ | `--sl-font-heading` | heading font stack | `'Inter', sans-serif` |
71
+ | `--sl-font-body` | body font stack | `'Inter', sans-serif` |
72
+
73
+ ### Extended (v1 — optional; Hub may set, Portal may not; read defensively)
74
+
75
+ | Token | Role | Fallback |
76
+ |---|---|---|
77
+ | `--sl-color-surface-raised` | elevated surface (popover, modal) | `--sl-color-surface` |
78
+ | `--sl-color-accent-soft` | soft accent fill (badges, hovers) | derived from `--sl-color-accent` |
79
+ | `--sl-color-success` | positive status | `#16a34a` |
80
+ | `--sl-color-danger` | negative / destructive status | `#dc2626` |
81
+ | `--sl-shadow-sm` / `--sl-shadow-md` | elevation | none |
82
+
83
+ > Everything richer than this — button weight (solid/soft/outline), density/compactness, image
84
+ > treatment, elevation scale — is a **host-internal component token**, driven by Hub's theme engine.
85
+ > It is deliberately NOT in this contract, so Hub can be as skinnable as it likes without it becoming
86
+ > a forever-obligation on every app and surface.
87
+
88
+ Dark mode is a *theme* (a different value-set), not a separate token set — the host swaps the values.
89
+
90
+ ---
91
+
92
+ ## 3. Theme value-sets (what the AI emits, what the lookbook stores)
93
+
94
+ A theme is data: a token version + a flat map of values. The host expands each `values` key to
95
+ `--sl-<key>` on the mount root.
96
+
97
+ ```jsonc
98
+ {
99
+ "tokensVersion": "v1",
100
+ "name": "Warm Editorial",
101
+ "values": {
102
+ "color-bg": "#faf7f2",
103
+ "color-surface": "#ffffff",
104
+ "color-fg": "#1a1a1a",
105
+ "color-muted": "#6b6b6b",
106
+ "color-border": "#e6e0d8",
107
+ "color-accent": "#b4531f",
108
+ "color-on-accent": "#ffffff",
109
+ "radius-sm": "4px", "radius-md": "10px", "radius-lg": "18px",
110
+ "font-heading": "'Fraunces', serif",
111
+ "font-body": "'Inter', sans-serif"
112
+ }
113
+ }
114
+ ```
115
+
116
+ - **Lookbook** = a gallery of these value-sets shipped as starting points; AI can generate more.
117
+ - **Refine** = AI edits `values` conversationally, previewed live in the real host, always inside the
118
+ contract → always safe, previewable, validatable.
119
+ - **Brand on-ramp** = ingest a site/brand guide → AI emits a `values` set → refine.
120
+ - **Validation** = a value-set must pass contrast (WCAG AA) for `fg`/`bg`, `muted`/`bg`,
121
+ `on-accent`/`accent` before it is offered or saved. AI output is checked, not trusted.
122
+
123
+ Store per collection as `{ tokensVersion, name, values }` — portable, diffable, exportable.
124
+
125
+ ---
126
+
127
+ ## 4. Host obligations vs app obligations
128
+
129
+ **Host (Hub / Portal):**
130
+ - Applies the active theme's `--sl-*` tokens where the embed will read them — `:root`/`<body>` for the
131
+ simple one-brand page (container/widget apps inherit for free), or the app's mount root when embeds
132
+ must theme independently or you want leak isolation for untrusted apps.
133
+ - For **iframe** apps, hands the initial state in via the URL fragment and posts live viewer-pref
134
+ updates as `smartlinks:root-state` (§6.2). Brand-theme changes = reload the embed.
135
+ - Declares the token version it serves and which subset (Hub = full, Portal = core).
136
+
137
+ **App:**
138
+ - `@import "@proveanything/smartlinks/theme.css";` after Tailwind (see §5).
139
+ - Uses semantic utilities (`bg-primary`, `text-foreground`, `border-border`, `rounded-md`,
140
+ `font-heading`) — never hardcoded palette utilities (`bg-blue-600`, `#rrggbb`).
141
+ - Authors CSS against the viewer-pref hooks (§6.1): the `.dark` class, `[data-contrast="high"]`,
142
+ `--sl-font-scale`, and the native `@media (prefers-contrast|prefers-reduced-motion)` queries.
143
+ **Never writes a message handler** — the SDK bootstrap flips the hooks.
144
+ - Iframe/standalone entries only: include `@proveanything/smartlinks/theme-boot.js` in `<head>`
145
+ (§6.2). Container/widget apps don't need it.
146
+ - Declares intent in the manifest:
147
+
148
+ ```jsonc
149
+ { "meta": { "respectsHostTheme": true, "themeTokens": "v1" } }
150
+ ```
151
+
152
+ `smartlinks-doctor` warns (not errors) on hardcoded colour utilities / hex in component source when
153
+ `respectsHostTheme` is `true`.
154
+
155
+ ---
156
+
157
+ ## 5. The SDK preset (`theme.css`)
158
+
159
+ Ships from the SDK. A Tailwind 4 `@theme` block that maps Tailwind's tokens onto the `--sl-*`
160
+ contract, with fallbacks so it is safe even where the host sets nothing:
161
+
162
+ ```css
163
+ @import "tailwindcss";
164
+ @import "@proveanything/smartlinks/theme.css";
165
+ ```
166
+
167
+ Requires Tailwind 4 (`@theme`). Apps still on Tailwind 3 stay as they are until they migrate
168
+ (step 16); the preset is opt-in and additive.
169
+
170
+ ---
171
+
172
+ ## 6. Viewer preferences, accessibility & iframe delivery
173
+
174
+ Two different kinds of "theme-ish" state, handled differently:
175
+
176
+ | | Brand theme | Viewer / accessibility prefs |
177
+ |---|---|---|
178
+ | What | accent, fonts, radius (the `--sl-*` tokens) | light/dark, contrast, font size, reduced-motion, language |
179
+ | Set by | the operator (rarely, in an editor) | the **viewer** (any time, mid-session) |
180
+ | Change model | **reload** the embed with new values | **live**, no reload (a reload on an a11y toggle *is* an a11y failure) |
181
+
182
+ **The app never writes a message handler.** It authors **CSS** against a small, fixed set of root
183
+ hooks; a generic SDK bootstrap (§6.2) flips those hooks. That's the whole obligation.
184
+
185
+ ### 6.1 The viewer-pref hooks (fixed vocabulary)
186
+
187
+ - **Dark mode** — the `.dark` class (Tailwind/shadcn convention) *and* `[data-theme="dark"|"light"]`.
188
+ - **Contrast** — `[data-contrast="high"]`. Also honour `@media (prefers-contrast: more)` — the browser
189
+ propagates the OS setting into iframes natively, so you get that slice for free.
190
+ - **Reduced motion** — honour `@media (prefers-reduced-motion: reduce)` (native, free).
191
+ - **Font size** — `--sl-font-scale` (a number, e.g. `1.25`); scale your base/rem type off it.
192
+ - **Language** — the `lang` attribute. (A language change is *content*, not just CSS — re-render/
193
+ re-fetch is the app's own i18n job; the hook just flags it. A heavy content swap may reload.)
194
+
195
+ Most of accessibility is therefore **free**: the OS-level `prefers-contrast` / `prefers-reduced-motion`
196
+ / browser zoom reach the iframe with no passing at all — just respect the standard media queries.
197
+
198
+ ### 6.2 Iframe delivery (`theme-boot.js`)
199
+
200
+ Container/widget apps inherit the host `:root` and need none of this. **Iframe/standalone** apps
201
+ (their own document — the security boundary for untrusted apps) include the SDK bootstrap
202
+ `@proveanything/smartlinks/theme-boot.js` in `<head>` (inline is best — zero flash):
203
+
204
+ - **Boot (before first paint):** reads the initial state from the URL fragment
205
+ `#slt=<base64url(JSON)>` (`{ tokensVersion, values, theme, contrast, fontScale, lang }`), plus
206
+ convenience params `?theme=&contrast=&fontScale=&lang=`, and applies it to `:root` synchronously.
207
+ This *replaces* the legacy base64-17-keys scheme — same idea (correct on first paint), but a
208
+ structured, versioned payload.
209
+ - **Live:** applies generic root-state updates the host posts as
210
+ `{ type: 'smartlinks:root-state', values?, theme?, contrast?, fontScale?, lang?, attrs? }`. This is
211
+ what makes accessibility toggles instant. The app carries none of this logic — the bootstrap is
212
+ generic and SDK-owned; the app only wrote CSS.
213
+
214
+ Payload stays small (the reason the fixed vocabulary matters): the token set + a handful of mode keys
215
+ fit comfortably in a URL fragment, no compression needed.
216
+
217
+ ---
218
+
219
+ ## 7. Versioning
220
+
221
+ - `themeTokens: "vN"` — the version an app targets.
222
+ - Within a version: additive only (new tokens get fallbacks). Never remove a token in-version.
223
+ - A removal/rename → `v(N+1)`, `vN` retained; hosts may serve several; apps declare which they target.
224
+ - Surfaces declare their honoured subset; the "mapping" between Hub and Portal is *which keys each
225
+ honours*, not a translation layer.
226
+
227
+ ## 8. Change log
228
+
229
+ - **v1 (draft, 2026-09-23):** initial semantic core (12) + extended (optional) set; value-set schema;
230
+ host mount-root scoping; manifest declaration + doctor warn; viewer-pref/accessibility hooks
231
+ (`.dark`/`data-theme`, `data-contrast`, `--sl-font-scale`, `lang`, native `prefers-*`); iframe
232
+ `theme-boot.js` (URL-boot + generic live root-state); brand change = reload, prefs = live.
package/openapi.yaml CHANGED
@@ -17959,6 +17959,10 @@ components:
17959
17959
  type: string
17960
17960
  cssBaseline:
17961
17961
  type: string
17962
+ respectsHostTheme:
17963
+ type: boolean
17964
+ themeTokens:
17965
+ type: string
17962
17966
  globals:
17963
17967
  type: object
17964
17968
  additionalProperties:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.0.15",
3
+ "version": "2.0.16",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,6 +23,8 @@
23
23
  },
24
24
  "./baseline.css": "./dist/baseline.css",
25
25
  "./baseline.classes.json": "./dist/baseline.classes.json",
26
+ "./theme.css": "./dist/theme.css",
27
+ "./theme-boot.js": "./dist/theme-boot.js",
26
28
  "./package.json": "./package.json"
27
29
  },
28
30
  "bin": {
@@ -16,7 +16,7 @@
16
16
  // Exit code: 0 = clean, 1 = violations (CI-friendly).
17
17
  // =============================================================================
18
18
 
19
- import { readFileSync, existsSync } from 'node:fs';
19
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
20
20
  import { resolve, dirname, join } from 'node:path';
21
21
  import { fileURLToPath, pathToFileURL } from 'node:url';
22
22
  import { bareImportsOf } from './lib/bare-imports.mjs';
@@ -184,6 +184,54 @@ if (meta.cssBaseline) {
184
184
  }
185
185
  }
186
186
 
187
+ // ---- Host theming (theme tokens) -------------------------------------------
188
+ // If the app declares meta.respectsHostTheme, warn on hardcoded Tailwind PALETTE utilities in
189
+ // component SOURCE (e.g. bg-blue-600, text-zinc-900) — those pin a colour instead of following the
190
+ // host brand via the semantic tokens (bg-primary, text-foreground, border-border). Scans source,
191
+ // not compiled bundles (bundles are full of legitimate hex). Heuristic + WARN-only. See
192
+ // docs/theme-tokens.md.
193
+ if (meta.respectsHostTheme) {
194
+ const THEME_TOKENS_VERSION = 'v1';
195
+ if (!meta.themeTokens) {
196
+ warnings.push(`meta.respectsHostTheme is true but meta.themeTokens is not declared — set it to "${THEME_TOKENS_VERSION}".`);
197
+ } else if (meta.themeTokens !== THEME_TOKENS_VERSION) {
198
+ warnings.push(`meta.themeTokens is "${meta.themeTokens}" but this SDK ships theme tokens "${THEME_TOKENS_VERSION}".`);
199
+ }
200
+
201
+ const PALETTE = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
202
+ const UTIL = 'bg|text|border|ring|divide|from|via|to|fill|stroke|outline|decoration|shadow|accent|caret';
203
+ const paletteRe = new RegExp(`\\b(?:${UTIL})-(?:${PALETTE})-(?:50|100|200|300|400|500|600|700|800|900|950)\\b`, 'g');
204
+
205
+ const SRC_EXT = /\.(tsx|ts|jsx|js|vue|html|svelte)$/;
206
+ const SKIP_DIR = new Set(['node_modules', 'dist', '.nuxt', '.output', '.git', 'public']);
207
+ function walk(dir, out = []) {
208
+ let entries = [];
209
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
210
+ for (const e of entries) {
211
+ if (e.isDirectory()) { if (!SKIP_DIR.has(e.name)) walk(join(dir, e.name), out); }
212
+ else if (SRC_EXT.test(e.name)) out.push(join(dir, e.name));
213
+ }
214
+ return out;
215
+ }
216
+
217
+ const srcRoot = existsSync(join(appDir, 'src')) ? join(appDir, 'src') : appDir;
218
+ const offenders = [];
219
+ let totalHits = 0;
220
+ for (const f of walk(srcRoot)) {
221
+ const hits = [...new Set((readFileSync(f, 'utf8').match(paletteRe) || []))];
222
+ if (hits.length) { offenders.push({ file: f.replace(appDir + '/', '').replace(appDir + '\\', ''), hits }); totalHits += hits.length; }
223
+ }
224
+
225
+ if (offenders.length === 0) {
226
+ console.log(`${GREEN}✓${RESET} theme ${DIM}(respectsHostTheme)${RESET} — no hardcoded palette utilities in source`);
227
+ } else {
228
+ console.log(`${YELLOW}⚠${RESET} theme ${DIM}(respectsHostTheme)${RESET} — hardcoded palette utilities in ${offenders.length} file${offenders.length === 1 ? '' : 's'} (use bg-primary / text-foreground / border-border instead):`);
229
+ for (const o of offenders.slice(0, 15)) console.log(` ${DIM}${o.file}${RESET} ${YELLOW}${o.hits.slice(0, 6).join(' ')}${o.hits.length > 6 ? ' …' : ''}${RESET}`);
230
+ if (offenders.length > 15) console.log(` ${DIM}…and ${offenders.length - 15} more file(s)${RESET}`);
231
+ warnings.push(`respectsHostTheme is true but ${totalHits} hardcoded palette utilit${totalHits === 1 ? 'y' : 'ies'} found in source — replace with semantic tokens, or drop respectsHostTheme if the app intentionally brings its own look.`);
232
+ }
233
+ }
234
+
187
235
  console.log('');
188
236
  for (const w of warnings) console.log(`${YELLOW}⚠ ${w}${RESET}`);
189
237