@proveanything/smartlinks 2.0.14 → 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.
- package/dist/docs/API_SUMMARY.md +3 -1
- package/dist/docs/deploying-apps.md +46 -15
- package/dist/docs/host-dependency-contract.md +21 -0
- package/dist/docs/theme-tokens.md +232 -0
- package/dist/openapi.yaml +4 -0
- package/dist/theme-boot.js +78 -0
- package/dist/theme.css +55 -0
- package/dist/types/appManifest.d.ts +12 -0
- package/docs/API_SUMMARY.md +3 -1
- package/docs/deploying-apps.md +46 -15
- package/docs/host-dependency-contract.md +21 -0
- package/docs/theme-lookbook.v1.json +120 -0
- package/docs/theme-tokens.md +232 -0
- package/openapi.yaml +4 -0
- package/package.json +3 -1
- package/scripts/doctor.mjs +49 -1
package/dist/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 2.0.
|
|
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 —
|
|
38
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -236,14 +254,24 @@ today.)
|
|
|
236
254
|
|
|
237
255
|
---
|
|
238
256
|
|
|
239
|
-
## Wiring it into your build
|
|
257
|
+
## Wiring it into your build (the KEYED path — CI / controlled env / beta·stable)
|
|
258
|
+
|
|
259
|
+
> ⚠️ **This is the deploy-key path — not the default for a Lovable dev publish.** If you build in
|
|
260
|
+
> Lovable (no deploy key in the build), your dev publishes register via the **key-free `refresh-dev`
|
|
261
|
+
> ping** in **[Section A](#a-from-lovable--hit-publish-no-key-anywhere-recommended-for-lovable-apps)** — put *that* in `postbuild`, not the
|
|
262
|
+
> binary below. Reach for `smartlinks-register-release` only where you (a) hold a deploy key and
|
|
263
|
+
> (b) set `SMARTLINKS_CHANNEL` — i.e. a controlled dev/CI environment, or a formal **beta/stable**
|
|
264
|
+
> deploy. In a keyless Lovable postbuild this binary **skips silently** (no channel ⇒ no-op), so a
|
|
265
|
+
> dev publish would register *nothing* — that's the trap. One rule: **dev publish → `refresh-dev`;
|
|
266
|
+
> keyed/formal deploy → `register-release`.**
|
|
240
267
|
|
|
241
|
-
Registration is the **last step of
|
|
268
|
+
Registration is the **last step of a keyed build** — after bundles are built and hashed. Run a
|
|
242
269
|
small script that reads your built manifest and POSTs it, and **exits non-zero on failure**
|
|
243
270
|
so a bad install fails the publish.
|
|
244
271
|
|
|
245
|
-
The SDK ships the script, so you don't copy-paste it — run it as your postbuild step
|
|
246
|
-
your built manifest, POSTs it, prints any warnings, and exits non-zero on
|
|
272
|
+
The SDK ships the script, so you don't copy-paste it — run it as your postbuild step **in a keyed
|
|
273
|
+
environment**. It reads your built manifest, POSTs it, prints any warnings, and exits non-zero on
|
|
274
|
+
failure.
|
|
247
275
|
|
|
248
276
|
```jsonc
|
|
249
277
|
// package.json
|
|
@@ -266,13 +294,16 @@ It is driven entirely by env vars, so the same command works for dev (Lovable) a
|
|
|
266
294
|
|
|
267
295
|
> The full source is at `scripts/register-release.mjs` in the SDK package if you'd rather vendor it.
|
|
268
296
|
|
|
269
|
-
Registration is gated by **`SMARTLINKS_CHANNEL`**, so
|
|
297
|
+
Registration is gated by **`SMARTLINKS_CHANNEL`**, so a keyed build behaves correctly by intent:
|
|
270
298
|
|
|
271
299
|
| Build | `SMARTLINKS_CHANNEL` | Result |
|
|
272
300
|
|---|---|---|
|
|
273
|
-
| **Preview / live-edit** | unset | **skips quietly** — never registers, never fails |
|
|
274
|
-
| **
|
|
275
|
-
| **Prod (CI)** | `stable` | registers to `stable` with the prod/master key; a missing key **hard-fails** |
|
|
301
|
+
| **Preview / live-edit / plain Lovable dev publish** | unset | **skips quietly** — never registers, never fails. (A Lovable dev publish is meant to register via the key-free `refresh-dev` ping in [Section A](#a-from-lovable--hit-publish-no-key-anywhere-recommended-for-lovable-apps), not this binary.) |
|
|
302
|
+
| **Controlled dev env (you hold a dev key)** | `dev` | registers to `dev` with the dev key + your `SMARTLINKS_BUNDLE_BASE_URL` |
|
|
303
|
+
| **Prod (CI / formal deploy)** | `stable` | registers to `stable` with the prod/master key; a missing key **hard-fails** |
|
|
304
|
+
|
|
305
|
+
> The middle row is a *controlled* dev environment where you deliberately hold a dev key and set the
|
|
306
|
+
> channel — **not** a stock Lovable publish, which carries neither and so should use `refresh-dev`.
|
|
276
307
|
|
|
277
308
|
**Two secrets, two scopes:** the **deploy key** is channel-scoped and can be a *workspace-level*
|
|
278
309
|
Lovable Build Secret shared by every app (a dev key only writes `dev`, so sharing it is safe). The
|
|
@@ -178,3 +178,24 @@ if (host && host.version !== SHARED_DEPENDENCY_CONTRACT_VERSION) {
|
|
|
178
178
|
The host publishes its live contract on `window.__SMARTLINKS_SHARED__` (`{ version, specifiers }`),
|
|
179
179
|
which `getHostSharedDependencies()` reads. Import-map shim paths follow `importMapPathFor(specifier)`
|
|
180
180
|
(`/sl-shared/<version>/<slug>.js`), so both the portal and the SDK generate identical paths.
|
|
181
|
+
|
|
182
|
+
## Version retention — hosts MUST keep every published version (load-bearing)
|
|
183
|
+
|
|
184
|
+
The version in the shim path (`/sl-shared/vN/`) exists **so multiple contract versions coexist**. A
|
|
185
|
+
deployed app pins the version it was built against (`meta.sharedDependencies`) and resolves its shims
|
|
186
|
+
from that path forever. Therefore:
|
|
187
|
+
|
|
188
|
+
> **A contract bump is ADDITIVE. Generating `/sl-shared/v7/` MUST NOT delete `/sl-shared/v5/` or
|
|
189
|
+
> `/sl-shared/v6/`.** The shims are tiny re-export files — keep them.
|
|
190
|
+
|
|
191
|
+
If the host's shim generator *replaces* the previous version instead of *appending*, the versioning
|
|
192
|
+
buys nothing: every bump silently breaks every already-deployed app not yet rebuilt (bare-specifier
|
|
193
|
+
resolution failure → blank container — the exact failure this whole contract prevents). "The host
|
|
194
|
+
serves all versions while apps migrate" is not aspirational; it's a hard requirement of the host.
|
|
195
|
+
|
|
196
|
+
**Retiring a version:** only remove `/sl-shared/vN/` once no installed app still declares that
|
|
197
|
+
`meta.sharedDependencies` version. You can determine that from the app registry (each app's declared
|
|
198
|
+
version × where it's installed); until it's provably unreferenced, keep it. Prefer a long deprecation
|
|
199
|
+
window over reclaiming a few KB.
|
|
200
|
+
|
|
201
|
+
(The same rule applies to any versioned CSS-baseline paths — additive, never delete a live version.)
|
|
@@ -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
|
package/docs/API_SUMMARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Smartlinks API Summary
|
|
2
2
|
|
|
3
|
-
Version: 2.0.
|
|
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;
|
package/docs/deploying-apps.md
CHANGED
|
@@ -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 —
|
|
38
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -236,14 +254,24 @@ today.)
|
|
|
236
254
|
|
|
237
255
|
---
|
|
238
256
|
|
|
239
|
-
## Wiring it into your build
|
|
257
|
+
## Wiring it into your build (the KEYED path — CI / controlled env / beta·stable)
|
|
258
|
+
|
|
259
|
+
> ⚠️ **This is the deploy-key path — not the default for a Lovable dev publish.** If you build in
|
|
260
|
+
> Lovable (no deploy key in the build), your dev publishes register via the **key-free `refresh-dev`
|
|
261
|
+
> ping** in **[Section A](#a-from-lovable--hit-publish-no-key-anywhere-recommended-for-lovable-apps)** — put *that* in `postbuild`, not the
|
|
262
|
+
> binary below. Reach for `smartlinks-register-release` only where you (a) hold a deploy key and
|
|
263
|
+
> (b) set `SMARTLINKS_CHANNEL` — i.e. a controlled dev/CI environment, or a formal **beta/stable**
|
|
264
|
+
> deploy. In a keyless Lovable postbuild this binary **skips silently** (no channel ⇒ no-op), so a
|
|
265
|
+
> dev publish would register *nothing* — that's the trap. One rule: **dev publish → `refresh-dev`;
|
|
266
|
+
> keyed/formal deploy → `register-release`.**
|
|
240
267
|
|
|
241
|
-
Registration is the **last step of
|
|
268
|
+
Registration is the **last step of a keyed build** — after bundles are built and hashed. Run a
|
|
242
269
|
small script that reads your built manifest and POSTs it, and **exits non-zero on failure**
|
|
243
270
|
so a bad install fails the publish.
|
|
244
271
|
|
|
245
|
-
The SDK ships the script, so you don't copy-paste it — run it as your postbuild step
|
|
246
|
-
your built manifest, POSTs it, prints any warnings, and exits non-zero on
|
|
272
|
+
The SDK ships the script, so you don't copy-paste it — run it as your postbuild step **in a keyed
|
|
273
|
+
environment**. It reads your built manifest, POSTs it, prints any warnings, and exits non-zero on
|
|
274
|
+
failure.
|
|
247
275
|
|
|
248
276
|
```jsonc
|
|
249
277
|
// package.json
|
|
@@ -266,13 +294,16 @@ It is driven entirely by env vars, so the same command works for dev (Lovable) a
|
|
|
266
294
|
|
|
267
295
|
> The full source is at `scripts/register-release.mjs` in the SDK package if you'd rather vendor it.
|
|
268
296
|
|
|
269
|
-
Registration is gated by **`SMARTLINKS_CHANNEL`**, so
|
|
297
|
+
Registration is gated by **`SMARTLINKS_CHANNEL`**, so a keyed build behaves correctly by intent:
|
|
270
298
|
|
|
271
299
|
| Build | `SMARTLINKS_CHANNEL` | Result |
|
|
272
300
|
|---|---|---|
|
|
273
|
-
| **Preview / live-edit** | unset | **skips quietly** — never registers, never fails |
|
|
274
|
-
| **
|
|
275
|
-
| **Prod (CI)** | `stable` | registers to `stable` with the prod/master key; a missing key **hard-fails** |
|
|
301
|
+
| **Preview / live-edit / plain Lovable dev publish** | unset | **skips quietly** — never registers, never fails. (A Lovable dev publish is meant to register via the key-free `refresh-dev` ping in [Section A](#a-from-lovable--hit-publish-no-key-anywhere-recommended-for-lovable-apps), not this binary.) |
|
|
302
|
+
| **Controlled dev env (you hold a dev key)** | `dev` | registers to `dev` with the dev key + your `SMARTLINKS_BUNDLE_BASE_URL` |
|
|
303
|
+
| **Prod (CI / formal deploy)** | `stable` | registers to `stable` with the prod/master key; a missing key **hard-fails** |
|
|
304
|
+
|
|
305
|
+
> The middle row is a *controlled* dev environment where you deliberately hold a dev key and set the
|
|
306
|
+
> channel — **not** a stock Lovable publish, which carries neither and so should use `refresh-dev`.
|
|
276
307
|
|
|
277
308
|
**Two secrets, two scopes:** the **deploy key** is channel-scoped and can be a *workspace-level*
|
|
278
309
|
Lovable Build Secret shared by every app (a dev key only writes `dev`, so sharing it is safe). The
|
|
@@ -178,3 +178,24 @@ if (host && host.version !== SHARED_DEPENDENCY_CONTRACT_VERSION) {
|
|
|
178
178
|
The host publishes its live contract on `window.__SMARTLINKS_SHARED__` (`{ version, specifiers }`),
|
|
179
179
|
which `getHostSharedDependencies()` reads. Import-map shim paths follow `importMapPathFor(specifier)`
|
|
180
180
|
(`/sl-shared/<version>/<slug>.js`), so both the portal and the SDK generate identical paths.
|
|
181
|
+
|
|
182
|
+
## Version retention — hosts MUST keep every published version (load-bearing)
|
|
183
|
+
|
|
184
|
+
The version in the shim path (`/sl-shared/vN/`) exists **so multiple contract versions coexist**. A
|
|
185
|
+
deployed app pins the version it was built against (`meta.sharedDependencies`) and resolves its shims
|
|
186
|
+
from that path forever. Therefore:
|
|
187
|
+
|
|
188
|
+
> **A contract bump is ADDITIVE. Generating `/sl-shared/v7/` MUST NOT delete `/sl-shared/v5/` or
|
|
189
|
+
> `/sl-shared/v6/`.** The shims are tiny re-export files — keep them.
|
|
190
|
+
|
|
191
|
+
If the host's shim generator *replaces* the previous version instead of *appending*, the versioning
|
|
192
|
+
buys nothing: every bump silently breaks every already-deployed app not yet rebuilt (bare-specifier
|
|
193
|
+
resolution failure → blank container — the exact failure this whole contract prevents). "The host
|
|
194
|
+
serves all versions while apps migrate" is not aspirational; it's a hard requirement of the host.
|
|
195
|
+
|
|
196
|
+
**Retiring a version:** only remove `/sl-shared/vN/` once no installed app still declares that
|
|
197
|
+
`meta.sharedDependencies` version. You can determine that from the app registry (each app's declared
|
|
198
|
+
version × where it's installed); until it's provably unreferenced, keep it. Prefer a long deprecation
|
|
199
|
+
window over reclaiming a few KB.
|
|
200
|
+
|
|
201
|
+
(The same rule applies to any versioned CSS-baseline paths — additive, never delete a live version.)
|
|
@@ -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.
|
|
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": {
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
|