# Plugin Development Build a plugin with the `trek-plugin-sdk` package. A plugin is a directory with a manifest (`trek-plugin.json`), a built server entry, and — for page/widget plugins — a static client bundle. TREK runs your server code in an **isolated child process** and reaches it only over RPC; the browser part runs in a **sandboxed, opaque-origin iframe**. There is no other way in or out. ## Before you start Two resources sit alongside this page: - **[Plugin-Skill](https://github.com/liketrek/Plugin-Skill)** — an agent skill that teaches Claude Code and other SKILL.md-compatible coding agents how to build, test and publish a TREK plugin. It covers the same ground as this page, but the agent reads it instead of you. Add it to the repo you build your plugin in and it loads automatically whenever the task touches TREK plugins. - **[TREK-Plugins](https://github.com/liketrek/TREK-Plugins)** — the community registry. It is a static index with no server and no account: you list a plugin by opening a pull request that adds one JSON file. Your plugin's code stays in your own repository; the registry only points at it. See [Plugin-Publishing](Plugin-Publishing) for the submission flow and the CI gates. Neither is required. You can build, install and run a plugin without touching either — the registry only matters when you want other people's TREK instances to find it. ## Scaffold ```bash npx trek-plugin-sdk create # interactive wizard npx trek-plugin-sdk create my-plugin --type integration|page|widget|trip-page # or direct cd my-plugin ``` The wizard (run `create` with no name) asks for the id, type, author, icon (checked against lucide — a typo there is invisible locally but rejected by the registry), permissions, egress hosts and required addons; the direct form takes them as flags. This emits: ``` my-plugin/ trek-plugin.json # manifest — including `icon`, and a widget's `slot` package.json # CommonJS marker + the SDK as a devDependency server/index.js # your plugin code (built, plain JS) client/index.html # native UI via the design kit (page / widget / trip-page only) README.md # fill this in — the registry requires four sections and a screenshot docs/ # where docs/screenshot.png goes; `trek-plugin shot` writes it .gitattributes # LF everywhere, so the packed artifact's sha256 is reproducible ``` The scaffold declares `"trek": ">=3.4.0 <4.0.0"` — the `ctx` namespaces it is written against only exist from 3.4.0, and claiming an older floor would let the plugin install on a host where they are `undefined`. What you get **runs and packs immediately**, but it does **not** pass `validate` yet: the README is still a template and there is no screenshot. That is deliberate — those are yours to write — and `trek-plugin status` says exactly what is missing. ## Run it locally with hot reload ```bash npx trek-plugin-sdk dev # http://localhost:4317 ``` `dev` works straight after `create` — no `npm install` needed, because it injects `require('trek-plugin-sdk')` from the CLI itself, exactly like TREK injects it in production. It loads your `server/index.js` through the same `definePlugin` contract the host uses and gives you a **real request loop without a full TREK**: a dashboard listing your routes, the routes served under `/api/`, your page/widget UI at `/ui`, a **themed host preview at `/preview`** (a real sandboxed frame with a theme/accent/appearance toggle, `trek.invoke()` proxied to your routes), and a reload on every save. The injected `ctx` is the **full surface** — every capability (`costs`, `packing`, `files`, `notify`, `ai`, `settings`, `scheduler`, `meta`, `oauth`, `weather`, `rates`, `journal`, `db.tx`, …), not just routes + `db:own` — and **enforces exactly the permissions your manifest grants**: an ungranted call throws `PERMISSION_DENIED`, so you catch a missing grant here rather than after install. `db:own` is backed by a real SQLite file (`.trek-dev/db.sqlite`) when the runtime has `node:sqlite`; every other capability is served from your fixtures with the same rules as production. - Hit a route as an unauthenticated request with `?_anon=1` (an `auth: true` route then returns 401, mirroring the host). - Feed the fixtures by dropping a `dev-fixtures.json` next to the manifest — it takes the **same shape as `createMockHost` options**, so you can seed trips, users, costs, settings, weather, canned AI results, etc.: `{ "actingUserId": 1, "trips": { "1": { "members": [1], "data": { … }, "costs": [ … ] } }, "users": {} }`. - Fire a **non-route** entry point with `GET /__dev/fire/[/][/]` (a JSON body or query params become the payload/args): `/__dev/fire/job/refresh`, `/__dev/fire/scheduled/daily`, `/__dev/fire/event/place:created`, `/__dev/fire/hook/tripCardProvider/getCards`, `/__dev/fire/deleteUserData?userId=1` — so jobs, scheduled timers, event subscriptions and provider hooks are all testable in the same hot-reload loop as your routes. ### Test against a real instance's data (dev-link) `dev` above is fast but its host data is **synthetic** (fixtures / a scratch `db:own`). When you need your plugin to run against **real** trips, places, reservations, costs and real membership/permissions, link it into a running TREK instance instead of packing + uploading it every time. On the **server** (a local `trek-dev` or a dev instance — **never production**), set: ```bash TREK_PLUGINS_DEV_LINK=1 ``` With the flag set, the **Admin → Plugins** tab shows a **“Link a local plugin”** field. Paste the absolute path to your **built** plugin directory (the one holding `trek-plugin.json` + `server/index.js` — build first, the loader runs the compiled artifact, not TS source) and hit **Link**. The plugin is symlinked into the plugins volume and registered INACTIVE, and shows a **Dev-link** badge in the list. Prefer scripting? The same thing over HTTP: ```bash curl -XPOST /api/admin/plugins/link -H 'content-type: application/json' \ -d '{ "path": "/abs/path/to/your/plugin" }' ``` Activate it in the admin UI and consent to its permissions as usual. It now runs through the **same capability RPC host** as any installed plugin: real, membership-gated data, the acting user resolved host-side, no impersonation — code origin never touches the security gate. **Hot-reload:** rebuild your plugin (e.g. `tsc --watch` emitting `server/index.js`) and TREK re-forks it automatically (a file-watch on the linked dir). To force it, `POST /api/admin/plugins/:id/reload`, or just hit **Restart** in the admin UI — both re-fork the child, picking up the new code (a rebuilt manifest that widened permissions still requires explicit re-consent). > Dev-link is gated behind `TREK_PLUGINS_DEV_LINK` on top of admin + the plugin > kill-switch, and is **off by default**. It loads unsigned local code that mutates > live between restarts, and under `npm run dev` the OS permission jail is off — so > only ever enable it on a machine you control, pointed at a dev instance. ## The plugin types - **integration** — background logic (jobs, routes) with no UI of its own. Most provider hooks are **live** (placeDetailProvider, warningProvider, tableContributor, mapMarkerProvider, pdfSectionProvider, atlasLayerProvider, journalEntryProvider); only `photoProvider` / `calendarSource` are declared in the SDK but **not yet wired into the host** — see [Provider hooks](#provider-hooks). - **page** — adds a nav entry that opens a full-page sandboxed iframe. - **widget** — adds a card to the dashboard (`sidebar` slot), a hero-bar overlay (`hero` slot), a panel inside the trip planner's **place-detail** view (`place-detail` slot — the frame also receives the open `placeId` in `trek:context`, so it can show place-specific info like reviews or ratings), a panel inside the **day-detail** view (`day-detail` slot — receives the open `dayId`; the home for per-day content like outfit planning, live flight status or logistics), or a panel at the foot of a booking card in the **reservation-detail** view (`reservation-detail` slot — receives the open `reservationId`, for things like live check-in status or a seat map). Set the slot in `capabilities.widget.slot`. - **trip-page** — adds a tab **inside every trip planner**, so your UI lives in the trip alongside Plan / Transports / Files. The frame is the same sandboxed iframe as a `page`, but it receives the current `tripId` in `trek:context` (so you can scope data to the open trip) and it has no dashboard nav entry. The tab shows on desktop and mobile. Via `capabilities.tripPage` the plugin can also **take over core tabs**: `replaces: ['transports', 'buchungen', …]` hides the named tabs while the plugin is active (they return the moment it's deactivated; `plan` is never replaceable), and `position` picks the 0-based index of your tab in the bar instead of appending it. A plugin that replaces tabs gets a "Replaces planner tabs" chip in the admin list, so the takeover is visible before activation. ## The SDK package `trek-plugin-sdk` is **injected at runtime** — the host makes `require('trek-plugin-sdk')` resolve inside the child, so **do not vendor it** into your artifact. Add it as a **devDependency** only, so you get types, `createMockHost` for tests, and the `trek-plugin` CLI: ```bash npm i -D trek-plugin-sdk ``` ## Writing the server Your `server/index.js` exports a `definePlugin(...)` object. Everything reaches TREK through the `ctx` argument. ```js const { definePlugin } = require('trek-plugin-sdk') module.exports = definePlugin({ // Runs once when the plugin is activated. NOTE: onLoad has no user context — // ctx.trips.* is refused here (see the ctx table). async onLoad(ctx) { await ctx.db.migrate('001_init', 'CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, v TEXT)') ctx.log.info('loaded') }, // Runs once on deactivation/stop. Use it to flush or release resources. async onUnload(ctx) { ctx.log.info('unloading') }, // HTTP routes, mounted at /api/plugins/. routes: [ { method: 'GET', path: '/status', auth: true, async handler(req, ctx) { const rows = await ctx.db.query('SELECT COUNT(*) AS n FROM cache') return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ n: rows[0].n, user: req.user?.username }), } }}, ], // Scheduled jobs — TREK owns the cron and calls your handler on the schedule. // Requires the `jobs:run` permission (opt-in: scheduled work runs with NO user, // so its trip reads are refused — a job can only use ctx.db and declared egress). // An invalid cron expression is skipped; jobs stop when the plugin is deactivated. jobs: [ { id: 'refresh', schedule: '*/15 * * * *', async handler(ctx) { /* … */ } }, ], // Persistent, userless timers you arm at runtime with ctx.scheduler — they // survive restarts and call this handler when due. Also needs `jobs:run`. // await ctx.scheduler.in(3_600_000, 'remind', { tripId }) // once, in 1h // await ctx.scheduler.every(86_400_000, 'digest') // daily async scheduled({ name, payload }, ctx) { if (name === 'remind') { /* … one-shot fired; re-arm if you want another */ } }, // GDPR data-subject rights (needs `hook:user-data`). Userless — you only get the // userId and act on your OWN db. deleteUserData is delivered DURABLY (queued and // retried until it succeeds, even across restarts), so make it idempotent. async deleteUserData({ userId }, ctx) { await ctx.db.exec('DELETE FROM my_prefs WHERE user_id = ?', userId) }, async exportUserData({ userId }, ctx) { return await ctx.db.query('SELECT * FROM my_prefs WHERE user_id = ?', userId) }, }) ``` The routes and job ids you declare here are the **authoritative** ones: the host reads them off your loaded definition (a route's array index is its internal id). The `routes` block the scaffold writes into `trek-plugin.json` is only a declaration for readers — the manifest parser does not consume it. ### The `ctx` object | Area | Methods | Requires | |---|---|---| | `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` / `tx(ops)` against your **own** SQLite file. `tx([{sql, args?}, …])` runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?}|{rows?}, …] }` | `db:own` | | `ctx.trips` | `getById` / `getPlaces` / `getReservations` / `getDays` / `getAccommodations` / `listMine()` — enumerate every trip the acting user can access (membership-checked). `getDays` includes each day's `assignments` + `notes_items`; `getReservations` includes `endpoints` + `day_positions` | `db:read:trips` | | `ctx.trips.update(tripId, fields)` | update trip fields (title/dates/currency/reminder_days/…) | `db:write:trips` | | `ctx.trips.create(input)` | create a **new trip owned by the acting user** (importers) — `title` required, plus `description?`/`start_date?`/`end_date?`/`currency?`/`reminder_days?`/`day_count?` | `db:create:trips` (+ `trip_create`) | | `ctx.places` | `create(tripId, fields)` / `update(tripId, placeId, fields)` / `delete(tripId, placeId)` | `db:write:places` | | `ctx.days` | `create(tripId, {date?, notes?})` / `update(tripId, dayId, {notes?, title?})` / `delete(tripId, dayId)` | `db:write:days` | | `ctx.itinerary` | `assign(tripId, dayId, placeId, notes?)` / `unassign(tripId, assignmentId)` — place↔day | `db:write:itinerary` | | `ctx.meta` | `get` / `set` / `list` / `delete` your **own** namespaced data on a `trip`/`place`/`day` (enrich core entities without forking the schema) | `db:meta` | | `ctx.packing` | `list(tripId)` — a trip's packing items (membership-checked, respects private-item visibility) | `db:read:packing` | | `ctx.files` | `list(tripId)` — a trip's files, trash excluded (membership-checked) | `db:read:files` | | `ctx.files.getContent` | `getContent(tripId, fileId)` → `{ name, mimetype, size, content_base64 }` — a file's bytes as base64 (10MB cap; trashed files refused) | `db:read:files:content` | | `ctx.journal` | `listMine()` — the acting user's own travel journals; `getEntries(journeyId)` — one journey's entries (photos/story/checkins, access-checked) | `db:read:journal` (+ Journey addon) | | `ctx.atlas` | `visited()` — the acting user's visited countries + regions; `bucketList()` — their bucket-list items | `db:read:atlas` (+ Atlas addon) | | `ctx.vacay` | `mine()` — the acting user's vacation plan | `db:read:vacay` (+ Vacay addon) | | `ctx.collections` | `listMine()` / `get(id)` — the acting user's saved-place collections | `db:read:collections` (+ Collections addon) | | `ctx.collections` (write) | `create(input)` / `update(id, input)` / `savePlace(input)` / `copyToTrip(input)` / `deletePlace(placeId)` — per-collection role enforced by the service | `db:write:collections` (+ Collections addon) | | `ctx.atlas` (write) | `markCountry(code)` / `unmarkCountry` / `markRegion(regionCode, countryCode, regionName?)` / `unmarkRegion` / `createBucketItem(input)` / `deleteBucketItem(itemId)` — the acting user's own rows | `db:write:atlas` (+ Atlas addon) | | `ctx.vacay` (write) | `toggleEntry(date)` — the acting user's own PTO day on their active plan; `toggleCompanyHoliday(date, note?)` | `db:write:vacay` (+ Vacay addon) | | `ctx.journal` (write) | `createEntry(journeyId, {entry_date, ...})` / `updateEntry(entryId, fields)` / `deleteEntry(entryId)` — owner/contributor-gated; `createJourney({title, subtitle?, trip_ids?})` / `deleteJourney(journeyId)` — a new journal owned by the acting user (importers bootstrap the journal they then fill) | `db:write:journal` (+ Journey addon) | | `ctx.files` (write) | `create(tripId, {name, content_base64, mimetype?, ...})` (10MB cap, blocked extensions refused) / `createLink(tripId, fileId, opts)` / `update` / `softDelete` — broadcasts `file:*` | `db:write:files` (+ the acting user's `file_upload`/`file_edit`/`file_delete`) | | `ctx.collab` | `listNotes(tripId)` / `listPolls(tripId)` / `listMessages(tripId, before?)` — a trip's notes, polls (with options + voters) and chat (newest 100, oldest first; `before` = a message id to page back), membership-checked | `db:read:collab` (+ Collab addon) | | `ctx.collab` (write) | `createNote(tripId, {title, ...})` / `createPoll(tripId, {question, options})` / `votePoll(tripId, pollId, optionIndex)` / `createMessage(tripId, text, replyTo?)` — broadcasts `collab:*` | `db:write:collab` (+ `collab_edit`, Collab addon) | | `ctx.trips.addMember` / `.removeMember` | `addMember(tripId, userId)` — **grants trip access**; `removeMember(tripId, userId)` — revokes it (never the owner), so a directory-sync integration can reconcile departures too | `db:write:members` (+ `member_manage`) | | `ctx.notify` | `send({title, body, link?, scope, targetId})` — bell inbox + email/ntfy fan-out; recipient forced to the acting user (`scope:'user'`) or a trip they belong to (`scope:'trip'`) | `notify:send` | | `ctx.ai` | `complete(prompt, system?)` → `{ text }`; `extract(text, jsonSchema, prompt?)` → `{ results }` — the admin/user-configured provider; host holds the key; output is DATA (no auto-writes) | `ai:invoke` | | `ctx.oauth` | `getAccessToken()` → a **short-lived access token** for the acting user of a third-party service the host connected on their behalf (Settings → Plugins → Connect); `null` if not connected / userless. Host holds the refresh token + client secret | `oauth:client` | | `ctx.scheduler` | `at(whenMs, name, payload?)` / `in(ms, name, payload?)` / `every(ms, name, payload?)` / `cancel(name)` — **persistent, userless** timers that survive restarts and fire your `scheduled(input, ctx)` handler. `set` is an upsert by `name`; caps: ≤100 tasks, 8 KB payload, recurring interval ≥ 60 s, ≤ ~1 year out. Same risk class as `jobs` (no acting user → trip reads refused) | `jobs:run` | | `ctx.settings` | `get(key)` — the **acting user's** own value for one of your `scope:'user'` settings fields (decrypted host-side). Returns `undefined` for an unset value or a userless context (job/onLoad) — fall back to `ctx.config` there. Users fill these in under **Settings → Plugins**; secrets are stored encrypted and never echoed back | none (your own settings) | | `ctx.daynotes` | `list(tripId, dayId)` — a day's notes (membership-checked) | `db:read:daynotes` | | `ctx.daynotes` (write) | `create(tripId, dayId, {text, time?, icon?, sort_order?})` / `update(tripId, dayId, noteId, fields)` / `delete(tripId, dayId, noteId)` — broadcasts `dayNote:*` | `db:write:daynotes` | | `ctx.packing` (write) | `create(tripId, {name, category?, checked?, is_private?, visibility?, recipient_ids?})` / `update(tripId, itemId, fields)` / `delete(tripId, itemId)` — broadcasts `packing:*`, private items (#858) stay owner-scoped | `db:write:packing` | | `ctx.packing` (bags) | `listBags(tripId)` / `createBag(tripId, {name, color?})` / `updateBag` / `deleteBag` / `setBagMembers(tripId, bagId, userIds)` — bags carry no privacy | `db:write:packing` | | `ctx.weather` | `get(lat, lng, date?)` — the host's cached forecast (tenant-free) | `weather:read` | | `ctx.rates` | `get(base)` → a map of quote → rate relative to `base` (e.g. `'EUR'` → `{ USD: 1.08, … }`; cached upstream, tenant-free); `null` on an upstream failure | `rates:read` | | `ctx.categories` | `list()` — the global place-category list | `db:read:categories` | | `ctx.tags` | `list()` / `create({name, color?})` / `update(tagId, fields)` / `delete(tagId)` — the acting user's own tags | `db:read:tags` / `db:write:tags` | | `ctx.trips.members` | `members(tripId)` — the trip roster (id + display fields), membership-checked | `db:read:trips` | | `ctx.todos` | `list(tripId)` / `create(tripId, {name, ...})` / `update(tripId, todoId, fields)` / `delete(tripId, todoId)` — broadcasts `todo:*` | `db:read:todos` / `db:write:todos` (+ `packing_edit`) | | `ctx.costs` | `getByTrip(tripId)` / `listMine()` — read budget items (membership-checked) | `db:read:costs` | | `ctx.costs` (write) | `create(tripId, input)` / `update(tripId, itemId, input)` / `delete(tripId, itemId)` — broadcasts `budget:*` | `db:write:costs` | | `ctx.reservations` | `listMine()` — every booking across the acting user's accessible trips (membership-checked) | `db:read:trips` | | `ctx.reservations` (write) | `create(tripId, input)` / `update(tripId, reservationId, input)` / `delete(tripId, reservationId)` — full parity with the app (accommodation, budget-sync, notification, broadcasts `reservation:*`). `input.endpoints` = array of `{ role: 'from'\|'to'\|'stop', name, lat, lng, code?, sequence?, timezone?, local_time?, local_date? }`; update semantics: omitted = keep, `[]` = delete all, array = replace (endpoint ids are not stable) | `db:write:reservations` | | `ctx.accommodations` | `create(tripId, { place_id, start_day_id, end_day_id, check_in?, check_in_end?, check_out?, confirmation?, notes? })` / `update(tripId, accommodationId, fields)` / `delete(tripId, accommodationId)` — lodging blocks; create auto-creates the partner hotel reservation, delete cascades it (broadcasts like the app) | `db:write:accommodations` (+ `day_edit`) | | `ctx.users` | `getById(id)` — public profile only (`id, username, display_name, avatar`) | `db:read:users` | | `ctx.ws.broadcastToTrip(tripId, event, data)` | broadcast to a trip's members (event forced to `plugin::`) | `ws:broadcast:trip` | | `ctx.ws.broadcastToUser(userId, event, data)` | broadcast to one user | `ws:broadcast:user` | | `ctx.plugins.call(id, fn, args?)` | call a function another plugin **exposes** and get its result — `id` must be a declared, satisfied `pluginDependency` that lists `fn` in its `capabilities.provides` | a plugin dependency (no permission) | | `ctx.events.emit(name, payload?)` | publish an event to dependents that subscribed — `name` must be in your `capabilities.emits` | — (no permission) | | `ctx.config` | your resolved settings (secrets delivered decrypted) | — | | `ctx.log` | `info` / `warn` / `error` → your error log | — | | `ctx.id` | your plugin id (string) | — | Calling a method your manifest didn't grant returns `PERMISSION_DENIED`; a method the host doesn't expose at all returns `UNKNOWN_METHOD`. **`ctx.trips` only works inside a route handler.** The host binds the acting user from the authenticated request and membership-checks every trip read against it. `onLoad` and `jobs` have **no user**, so their trip reads are refused with `RESOURCE_FORBIDDEN`. The SDK's `getById(tripId, asUserId?)` signature keeps an `asUserId` parameter for source compatibility, but **the host ignores it** — you cannot read another user's trips by passing an id. **Writes (`ctx.trips.update` / `ctx.places` / `ctx.days` / `ctx.itinerary` / `ctx.costs.create`) are route-context only too, and doubly gated:** the host checks the acting user can **access** the trip AND holds the app's edit permission for that entity (`place_edit` / `day_edit` / `trip_edit`), exactly like the web UI. They run through the same services and broadcast the same events, so open sessions update live. Input is validated against TREK's own schemas (a bad payload is `BAD_PARAMS`), and every write is recorded in the tamper-evident capability audit log against the acting user. A plugin can only change what its user could change by hand. **`ctx.costs` ("costs" = budget items)** behaves exactly like `ctx.trips`: reads are membership-checked against the request's user and only work **inside a route handler** (`onLoad`/`jobs` have no user → `RESOURCE_FORBIDDEN`). `getByTrip(tripId)` returns one trip's budget items (hydrated with members/payers); `listMine()` aggregates budget items across every trip the acting user can access. `create/update/delete(tripId, …)` mutate a trip's budget items — gated exactly like a normal budget write (the same model the planner write scopes `db:write:places`/`days`/`itinerary`/`trips` use): the acting user needs the **`budget_edit`** permission on that trip, the input is validated against TREK's budget schema, and a successful create broadcasts the same `budget:created` event the app emits. **Every `ctx.costs.*` call also requires the Costs (budget) addon to be enabled** — if the admin has turned it off, the call is refused with `RESOURCE_FORBIDDEN`. ### Route auth Routes are authenticated by default (`req.user` is the logged-in user). Set `auth: false` for OAuth callbacks or webhooks that can't carry a session. The proxy forwards `{ method, path, query, body, headers, user }`. **`req.headers` is populated ONLY on `auth: false` routes** (an authenticated route gets `{}`) and only an explicit, credential-free **allowlist** — the common provider signature + event headers (`stripe-signature`, `x-hub-signature-256`, `svix-signature`, `x-gitlab-event`, `content-type`, `user-agent`, …). **`Cookie`, `Authorization`, `X-Socket-Id` and every session/forwarded-auth header are stripped** and never reach your code, so a forwarded header can't leak a TREK session. To trust a webhook, verify the provider's signature over the raw body against a secret you hold in `ctx.config` (admin-set instance setting) or `ctx.settings` (per-user). ## Writing the client (page / widget) The iframe is served same-origin from `/plugin-frame//…` but sandboxed **without `allow-same-origin`**, so it runs at an **opaque origin**: it can't read cookies or the parent DOM. It talks to TREK only via `postMessage` (target origin must be `'*'` — an opaque frame has no nameable origin). Your whole `client/` directory is served (and shipped by `pack`) — not just `index.html`. The frame CSP allows the plugin its **own** static files (and nothing from any other host), so a **multi-file build works as-is**: point a bundler at `client/` and use **relative asset paths** (Vite: `base: './'`), drop the output in, done. That includes React/Vue/Svelte builds — no more inlining the bundle into `index.html` (inline still works, and is what the design-kit marker does). ### The design kit (recommended) Because the frame can't load TREK's stylesheet, we ship it. Drop **one line** in your `client/index.html` ``: ```html ``` `dev` and `pack` expand that marker into the inlined **TREK design kit** — a token-driven stylesheet plus a `window.trek` bridge. It costs nothing to keep the source a one-liner, and a rebuild always ships the current kit. The kit: - gives you native components — **glass panels, cards, buttons, inputs, chips, list rows, hover** — that swap correctly between light and dark; - follows the user's live **accent scheme, custom accent and high-contrast** (it applies the tokens TREK sends); - mirrors the host's **appearance flags** (reduced-motion, no-transparency, density); - **auto-reports your height** (widgets/pages self-size — no manual `trek:resize`); - installs `window.trek` so you never hand-roll `postMessage`. `window.trek` also carries **`trek.ui`** — tiny DOM builders that emit kit-styled elements, so you can build UI with no bundler and no CSS: ```js const { ui } = trek ui.mount(ui.card([ ui.el('div', { class: 'trek-title', text: 'Nearby' }), ui.button('Refresh', { variant: 'primary', onClick: refresh }), ui.chip('open now', 'success'), ])) // ui.el(tag, props, children) is the general builder; props take class/text/html/on:{event}. ``` The scaffold seeds a working example. A minimal client: ```html
Your plugin

…

``` **Component classes** (the bootstrap adds `trek-ui` to ``): | Class | What | |---|---| | `.trek-glass` | the signature frosted-glass surface | | `.trek-card` | a solid card | | `.trek-interactive` | add to a glass/card for the native hover-lift | | `.trek-btn` + `--primary` / `--secondary` / `--ghost` / `--danger` | buttons | | `.trek-input` / `.trek-textarea` / `.trek-select` / `.trek-label` | form controls | | `.trek-chip` + `--accent` / `--success` / `--danger` / `--warning` / `--info` | chips / badges | | `.trek-row` | a hover-highlight list row | | `.trek-title` / `.trek-muted` / `.trek-faint` | text helpers | | `.trek-stack` / `.trek-cluster` | vertical / horizontal flex with gap | | `.trek-menu-enter` / `.trek-menu-enter-left` | dropdown enter (scales from its trigger corner) | | `.trek-popover-enter` / `.trek-modal-enter` / `.trek-backdrop-enter` / `.trek-toast-enter` | the host's enter animations (`trek-modal-enter` becomes a bottom drawer below 640px) | | `.trek-page-enter` | subtle fade-up on mount | | `.trek-stagger` | children fade up with a 40ms cascade | | `.trek-skeleton` | shimmer loading placeholder | | `.trek-pie-reveal` / `.trek-bar-fill` | chart reveal animations (plus the `trek-progress-fill` keyframe, driven by `--trek-progress-to`) | All animations honour reduced motion: under `[data-reduce-motion]` or `prefers-reduced-motion` they degrade to a gentle 120ms fade, and the skeleton stops shimmering. **Selects are auto-upgraded.** With the kit inlined, every native `` as the value/form source (it still fires `change`). Write a plain `