@tapcue/extension-sdk 0.1.0
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/LICENSE +21 -0
- package/README.md +28 -0
- package/package.json +45 -0
- package/src/capabilities.ts +1037 -0
- package/src/components.ts +277 -0
- package/src/define-extension.ts +205 -0
- package/src/errors.ts +90 -0
- package/src/i18n.ts +64 -0
- package/src/index.ts +13 -0
- package/src/json.ts +24 -0
- package/src/jsx-runtime.ts +118 -0
- package/src/manifest.ts +1572 -0
- package/src/overlay-jsx-runtime.ts +72 -0
- package/src/overlay.ts +130 -0
- package/src/permission-units.ts +109 -0
- package/src/reactive.ts +374 -0
- package/src/scene.ts +297 -0
- package/src/testing/http-fake.ts +240 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/test-host.ts +2330 -0
- package/src/types.ts +614 -0
- package/src/view-runtime.ts +270 -0
- package/src/view.ts +116 -0
package/src/reactive.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reactive core: signals, actions, and the bounded derivation vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* This is Solid-shaped, not React-shaped (docs/architecture/extension-ui-interactive.md §3).
|
|
5
|
+
* A `view` handler runs start-to-finish to build a tree of bindings into a serializable
|
|
6
|
+
* model; it does not re-run per keystroke, and there is no hook state living in the isolate.
|
|
7
|
+
* The shell holds the model; the isolate is handed it back to run an action (§4).
|
|
8
|
+
*
|
|
9
|
+
* Signals are keyed positionally (`s0`, `s1`, …) in creation order, and actions likewise
|
|
10
|
+
* (`a0`, `a1`, …). The order must be stable across runs — create them unconditionally at the
|
|
11
|
+
* top of `view`, the same discipline React's rules-of-hooks enforce — because an action
|
|
12
|
+
* dispatch re-runs `view` to rebuild the graph before hydrating it from the handed-back model.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { JsonValue } from "./json.js";
|
|
16
|
+
import type { PermissionUnit } from "./manifest.js";
|
|
17
|
+
import type { ActionContext } from "./types.js";
|
|
18
|
+
import type { Bind, Derivation, PropValue } from "./view.js";
|
|
19
|
+
|
|
20
|
+
// ── The active build ────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface RegisteredAction {
|
|
23
|
+
id: string;
|
|
24
|
+
run: (ctx: ActionContext, args: JsonValue[]) => void | Promise<void>;
|
|
25
|
+
requires?: PermissionUnit;
|
|
26
|
+
destructive?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** State shared by everything created during one `view` run. Re-created per run. */
|
|
30
|
+
export class BuildContext {
|
|
31
|
+
readonly values = new Map<string, JsonValue>();
|
|
32
|
+
readonly order: string[] = [];
|
|
33
|
+
readonly actions = new Map<string, RegisteredAction>();
|
|
34
|
+
/** Setups registered by `whileVisible`, run once the first frame is out. */
|
|
35
|
+
readonly visibleSetups: VisibleSetup[] = [];
|
|
36
|
+
/**
|
|
37
|
+
* Notified with a cell path whenever a signal is written, while the view is being kept alive.
|
|
38
|
+
* The driver sets it to schedule a patch flush, so a `signal.set()` inside a `whileVisible`
|
|
39
|
+
* setup propagates on its own. Unset outside that, where an action's before/after diff is what
|
|
40
|
+
* reconciles a write instead.
|
|
41
|
+
*/
|
|
42
|
+
onDirty: ((path: string) => void) | null = null;
|
|
43
|
+
/**
|
|
44
|
+
* Cells created by `ctx.settings.signal(key)`, keyed by the setting they follow.
|
|
45
|
+
*
|
|
46
|
+
* A settings cell is the one kind whose value can be written by something outside the build
|
|
47
|
+
* that owns it — the *other* build, the one running an action — so it is the one kind that has
|
|
48
|
+
* to be findable by name rather than by position. Everything else is positional and private.
|
|
49
|
+
*/
|
|
50
|
+
readonly settingCells = new Map<string, string[]>();
|
|
51
|
+
private signalCount = 0;
|
|
52
|
+
private actionCount = 0;
|
|
53
|
+
|
|
54
|
+
nextSignalPath(): string {
|
|
55
|
+
return `s${this.signalCount++}`;
|
|
56
|
+
}
|
|
57
|
+
nextActionId(): string {
|
|
58
|
+
return `a${this.actionCount++}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let currentBuild: BuildContext | null = null;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run a view builder with `build` active so `signal`/`action` register into it. Async because a
|
|
66
|
+
* view may `await` its initial data (a forecast, a search) before describing itself — the `await`
|
|
67
|
+
* keeps `build` active across the suspension, and the isolate serializes calls so there is never a
|
|
68
|
+
* second build to clash with. Signal reads/writes after the build still work: a `Signal` closes
|
|
69
|
+
* over its own `BuildContext`, not over the global.
|
|
70
|
+
*/
|
|
71
|
+
export async function runInBuild<T>(build: BuildContext, fn: () => T | Promise<T>): Promise<T> {
|
|
72
|
+
const previous = currentBuild;
|
|
73
|
+
currentBuild = build;
|
|
74
|
+
try {
|
|
75
|
+
return await fn();
|
|
76
|
+
} finally {
|
|
77
|
+
currentBuild = previous;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function requireBuild(): BuildContext {
|
|
82
|
+
if (currentBuild === null) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
"signal()/action() may only be called inside a view handler — not at module top level",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return currentBuild;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Signals ─────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export interface Signal<T> {
|
|
93
|
+
/** Read the current value. Inside JSX it establishes a `$bind`; inside an action it reads. */
|
|
94
|
+
(): T;
|
|
95
|
+
/** Write the value. Produces a `set` patch when it changes during an action. */
|
|
96
|
+
set(next: T): void;
|
|
97
|
+
readonly path: string;
|
|
98
|
+
readonly __signal: true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A serializable state cell. `T` must be JSON-serializable — that is what lets the shell
|
|
103
|
+
* hold the model and hand it back to a cold isolate (§3). The bound is not written into the
|
|
104
|
+
* type so authors can use plain interfaces without an index-signature dance; the wire layer
|
|
105
|
+
* treats every value as JSON.
|
|
106
|
+
*/
|
|
107
|
+
export function signal<T>(initial: T): Signal<T> {
|
|
108
|
+
const build = requireBuild();
|
|
109
|
+
const path = build.nextSignalPath();
|
|
110
|
+
build.values.set(path, initial as unknown as JsonValue);
|
|
111
|
+
build.order.push(path);
|
|
112
|
+
const read = (() => build.values.get(path) as unknown as T) as Signal<T>;
|
|
113
|
+
Object.defineProperty(read, "set", {
|
|
114
|
+
value: (next: T) => {
|
|
115
|
+
build.values.set(path, next as unknown as JsonValue);
|
|
116
|
+
// The write *is* the event while the view is being kept alive: this schedules one patch
|
|
117
|
+
// flush, so `set` alone propagates and there is no commit. Outside that, `onDirty` is unset
|
|
118
|
+
// and the action's before/after diff does the reconciling.
|
|
119
|
+
build.onDirty?.(path);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
Object.defineProperty(read, "path", { value: path });
|
|
123
|
+
Object.defineProperty(read, "__signal", { value: true });
|
|
124
|
+
return read;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isSignal(value: unknown): value is Signal<unknown> {
|
|
128
|
+
return typeof value === "function" && (value as { __signal?: true }).__signal === true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Settings as state ───────────────────────────────────────────────────────
|
|
132
|
+
//
|
|
133
|
+
// A `whileVisible` setup and an action run in **different builds** (view-runtime.ts §driveView /
|
|
134
|
+
// §applyAction) — which is what lets a cold isolate serve an action, and which means an action's
|
|
135
|
+
// ordinary `signal.set()` is invisible to a setup that is still running. For most cells that is
|
|
136
|
+
// exactly right. For a *setting* it is not: a poll interval, a page size, a unit is precisely the
|
|
137
|
+
// kind of value a setup reads and an action changes, and "the running loop keeps the old one until
|
|
138
|
+
// you leave and come back" is not a thing anyone would design on purpose.
|
|
139
|
+
//
|
|
140
|
+
// So a settings cell is registered by name, and a write reaches **every live build** that follows
|
|
141
|
+
// that key. The registry is module-level because the two builds are in one isolate — the same SDK
|
|
142
|
+
// module instance serves both — so this is a lookup, not a channel.
|
|
143
|
+
|
|
144
|
+
/** A view that is currently being driven, and how to restart what it left running. */
|
|
145
|
+
export interface LiveBuild {
|
|
146
|
+
readonly build: BuildContext;
|
|
147
|
+
/** Re-run the `whileVisible` setups, cleaning up the previous pass first. */
|
|
148
|
+
restartSetups(): Promise<void>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const liveBuilds = new Set<LiveBuild>();
|
|
152
|
+
|
|
153
|
+
export function registerLiveBuild(live: LiveBuild): () => void {
|
|
154
|
+
liveBuilds.add(live);
|
|
155
|
+
return () => liveBuilds.delete(live);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A signal that follows one of the extension's declared settings.
|
|
160
|
+
*
|
|
161
|
+
* Reached as `ctx.settings.signal(key)`. It is an ordinary signal in every other respect — bind it
|
|
162
|
+
* in the view and the shell re-renders locally, read it in a setup and you get the current value —
|
|
163
|
+
* with one addition: when anything writes that setting, the cell is written **and the view's
|
|
164
|
+
* `whileVisible` setups are restarted**.
|
|
165
|
+
*
|
|
166
|
+
* Restarting is what makes the value actually *govern* the loop rather than merely describe it. A
|
|
167
|
+
* `setInterval` already ticking at 5 s does not start ticking at 1 s because a variable changed; it
|
|
168
|
+
* has to be torn down and re-armed. `whileVisible` setups have always promised to be idempotent and
|
|
169
|
+
* re-runnable — that is what "each time the view becomes visible" means — so re-running them is
|
|
170
|
+
* within the contract they already sign, not a new obligation.
|
|
171
|
+
*/
|
|
172
|
+
export function settingSignal<T extends JsonValue>(key: string, initial: T): Signal<T> {
|
|
173
|
+
const build = requireBuild();
|
|
174
|
+
const cell = signal<T>(initial);
|
|
175
|
+
const paths = build.settingCells.get(key) ?? [];
|
|
176
|
+
paths.push(cell.path);
|
|
177
|
+
build.settingCells.set(key, paths);
|
|
178
|
+
return cell;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A setting changed: write it into every live build that follows it, and restart what those
|
|
183
|
+
* builds left running.
|
|
184
|
+
*
|
|
185
|
+
* Called by the host glue after `ctx.settings.set` — so it covers the extension's own write. A
|
|
186
|
+
* change made *outside* the isolate (the Preferences window) cannot reach here; the host answers
|
|
187
|
+
* that one by re-booting the isolate with the new snapshot, which restarts everything anyway.
|
|
188
|
+
*/
|
|
189
|
+
export function applySettingChange(key: string, value: JsonValue): void {
|
|
190
|
+
for (const live of liveBuilds) {
|
|
191
|
+
const paths = live.build.settingCells.get(key);
|
|
192
|
+
if (paths === undefined) continue;
|
|
193
|
+
for (const path of paths) {
|
|
194
|
+
live.build.values.set(path, value);
|
|
195
|
+
live.build.onDirty?.(path);
|
|
196
|
+
}
|
|
197
|
+
// Re-arm after the write, so a setup that reads the cell on its first tick reads the new one.
|
|
198
|
+
void live.restartSetups();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Actions ─────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export interface ActionSpec {
|
|
205
|
+
readonly $action: string;
|
|
206
|
+
readonly requires?: PermissionUnit;
|
|
207
|
+
readonly destructive?: boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Isolate-tier logic. The callback receives an `ActionContext` — the same capability-bearing
|
|
212
|
+
* context `invoke` gets — because it *is* an invoke: the view body runs at query tier (no
|
|
213
|
+
* ambient authority), and only firing an action reaches the machine (§6). `requires` gates it
|
|
214
|
+
* exactly as it gates a row action; `destructive` earns the shell's confirmation.
|
|
215
|
+
*/
|
|
216
|
+
export function action(
|
|
217
|
+
// The arguments are spread from what the shell dispatched. A pane action fired while a table
|
|
218
|
+
// row is selected receives that row's id, which is how one action serves every row without
|
|
219
|
+
// being declared per row. `never[]` used to sit here and made them undeclarable.
|
|
220
|
+
run: (ctx: ActionContext, ...args: JsonValue[]) => void | Promise<void>,
|
|
221
|
+
opts?: { requires?: PermissionUnit; destructive?: boolean },
|
|
222
|
+
): ActionSpec {
|
|
223
|
+
const build = requireBuild();
|
|
224
|
+
const id = build.nextActionId();
|
|
225
|
+
build.actions.set(id, {
|
|
226
|
+
id,
|
|
227
|
+
run: (ctx, args) => run(ctx, ...args),
|
|
228
|
+
requires: opts?.requires,
|
|
229
|
+
destructive: opts?.destructive,
|
|
230
|
+
});
|
|
231
|
+
const spec: ActionSpec = { $action: id, requires: opts?.requires, destructive: opts?.destructive };
|
|
232
|
+
return spec;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function isActionSpec(value: unknown): value is ActionSpec {
|
|
236
|
+
return typeof value === "object" && value !== null && "$action" in value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── The visible window (work that outlives one render) ───────────────────────
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What a `whileVisible` setup returns to undo itself, if it has anything to undo.
|
|
243
|
+
*/
|
|
244
|
+
export type VisibleCleanup = void | (() => void);
|
|
245
|
+
|
|
246
|
+
/** A setup, run each time the view becomes visible. */
|
|
247
|
+
export type VisibleSetup = () => VisibleCleanup | Promise<VisibleCleanup>;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Run `setup` each time the view becomes visible, and its returned cleanup when it stops being.
|
|
251
|
+
*
|
|
252
|
+
* This is where anything outliving a single render belongs — a timer, a subscription, a watcher:
|
|
253
|
+
*
|
|
254
|
+
* ```tsx
|
|
255
|
+
* whileVisible(() => {
|
|
256
|
+
* const id = setInterval(() => rows.set(read()), 1500);
|
|
257
|
+
* return () => clearInterval(id);
|
|
258
|
+
* });
|
|
259
|
+
* ```
|
|
260
|
+
*
|
|
261
|
+
* **It must be a callback, not statements in the view body.** The body re-runs in full on every
|
|
262
|
+
* action dispatch — that is how a cold isolate rebuilds the signal graph before hydrating it — so
|
|
263
|
+
* a bare `setInterval` in the body would start a second timer on the first click and leak one per
|
|
264
|
+
* dispatch. A setup is exempt from that re-run: it fires once per visible period, however many
|
|
265
|
+
* actions happen inside it.
|
|
266
|
+
*
|
|
267
|
+
* Named for a duration rather than an event because that is what it is, and deliberately not
|
|
268
|
+
* `onMount`: that name promises once per lifetime, and this runs again on every reopen — which is
|
|
269
|
+
* also why **the setup has to be idempotent**. Starting a timer is; incrementing a counter is not.
|
|
270
|
+
*
|
|
271
|
+
* The setup runs at the same tier as the view body: it may read, not act. To act it fires an
|
|
272
|
+
* `action`, exactly as a button would.
|
|
273
|
+
*/
|
|
274
|
+
export function whileVisible(setup: VisibleSetup): void {
|
|
275
|
+
requireBuild().visibleSetups.push(setup);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Derivations (the closed binding vocabulary the shell can evaluate) ────────
|
|
279
|
+
|
|
280
|
+
export interface Derived {
|
|
281
|
+
readonly $derive: Derivation;
|
|
282
|
+
}
|
|
283
|
+
export type Bindable<T> = T | Signal<T> | Derived;
|
|
284
|
+
|
|
285
|
+
export function not(of: Bindable<unknown>): Derived {
|
|
286
|
+
return { $derive: { op: "not", of: toWire(of) } };
|
|
287
|
+
}
|
|
288
|
+
export function isEmpty(of: Bindable<unknown>): Derived {
|
|
289
|
+
return { $derive: { op: "isEmpty", of: toWire(of) } };
|
|
290
|
+
}
|
|
291
|
+
export function notEmpty(of: Bindable<unknown>): Derived {
|
|
292
|
+
return { $derive: { op: "notEmpty", of: toWire(of) } };
|
|
293
|
+
}
|
|
294
|
+
export function format(of: Bindable<number>, as: "number" | "percent" | "date"): Derived {
|
|
295
|
+
return { $derive: { op: "format", of: toWire(of), as } };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Look `of` up in a table built when the view was described. The bar for a binding transform is
|
|
300
|
+
* higher than for a component (extension-ui-interactive §12): it must be pure, total, and recur
|
|
301
|
+
* across extensions rather than serve one. A lookup qualifies on all three — a translator naming
|
|
302
|
+
* the selected language's script, a unit converter naming the selected unit's symbol, and a font
|
|
303
|
+
* preview naming the selected family's classification are the same operation.
|
|
304
|
+
*
|
|
305
|
+
* If it needs to *think*, it is still an action. This only indexes.
|
|
306
|
+
*/
|
|
307
|
+
export function lookup<T>(of: Bindable<string>, table: Record<string, T>): Derived {
|
|
308
|
+
return { $derive: { op: "lookup", of: toWire(of), in: toWire(table) } } as Derived;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function isDerived(value: unknown): value is Derived {
|
|
312
|
+
return typeof value === "object" && value !== null && "$derive" in value;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── `<For>` item scope ────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
/** Symbol-keyed so it can never collide with a real model field name. */
|
|
318
|
+
const ITEM = Symbol("tapcue.item");
|
|
319
|
+
|
|
320
|
+
function itemBind(path: string): unknown {
|
|
321
|
+
return { [ITEM]: path };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function itemMarker(value: unknown): string | null {
|
|
325
|
+
if (value === null || (typeof value !== "object" && typeof value !== "function")) return null;
|
|
326
|
+
const marker = (value as Record<symbol, unknown>)[ITEM];
|
|
327
|
+
return typeof marker === "string" ? marker : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A proxy standing in for one `<For>` element at build time. Used whole, it binds the element
|
|
332
|
+
* itself (`@item`, for a `string[]`); a field access binds that field (`@item.<field>`, for an
|
|
333
|
+
* object list). The shell resolves these per element — so a list renders and grows without
|
|
334
|
+
* waking the isolate. You may pass item fields straight into props; you may not compute on them
|
|
335
|
+
* (the shell cannot run extension code), which is why per-item formatting is a `format`
|
|
336
|
+
* derivation, not `item.value.toFixed(1)`.
|
|
337
|
+
*/
|
|
338
|
+
export function itemProxy<T>(base = "@item"): T {
|
|
339
|
+
return new Proxy(
|
|
340
|
+
{},
|
|
341
|
+
{
|
|
342
|
+
get(_target, prop) {
|
|
343
|
+
if (prop === ITEM) return base;
|
|
344
|
+
if (typeof prop !== "string") return undefined;
|
|
345
|
+
return itemBind(`${base}.${prop}`);
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
) as T;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The `@index` marker handed to a `<For>` callback's second parameter. */
|
|
352
|
+
export const itemIndex: number = itemBind("@index") as number;
|
|
353
|
+
|
|
354
|
+
// ── The compiler: author value → wire `PropValue` ─────────────────────────────
|
|
355
|
+
|
|
356
|
+
export function toWire(value: unknown): PropValue {
|
|
357
|
+
if (value === null || value === undefined) return null;
|
|
358
|
+
const kind = typeof value;
|
|
359
|
+
if (kind === "string" || kind === "number" || kind === "boolean") return value as PropValue;
|
|
360
|
+
const marker = itemMarker(value);
|
|
361
|
+
if (marker !== null) return { $bind: marker } satisfies Bind;
|
|
362
|
+
if (isSignal(value)) return { $bind: value.path } satisfies Bind;
|
|
363
|
+
if (isDerived(value)) return { $derive: value.$derive };
|
|
364
|
+
if (isActionSpec(value)) return { $action: value.$action };
|
|
365
|
+
if (Array.isArray(value)) return value.map(toWire);
|
|
366
|
+
if (kind === "object") {
|
|
367
|
+
const out: Record<string, PropValue> = {};
|
|
368
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
369
|
+
out[key] = toWire(entry);
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
package/src/scene.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declarative UI vocabulary. Extensions describe scenes; the native shell
|
|
3
|
+
* renders them. No HTML, CSS, or platform layout code crosses this boundary.
|
|
4
|
+
*
|
|
5
|
+
* See docs/architecture/extension-ui.md for the rendering contract, the
|
|
6
|
+
* sanitization rules, and what the shell owns.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PermissionUnit } from "./manifest.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Icon reference grammar. The shell resolves it; an unresolvable reference
|
|
13
|
+
* degrades to the extension's own icon rather than failing the scene.
|
|
14
|
+
*
|
|
15
|
+
* - `asset:<path>` — a file inside the installed package (`asset:icons/rain.svg`)
|
|
16
|
+
* - `symbol:<name>` — a Tapcue semantic symbol, mapped per platform
|
|
17
|
+
* - `color:<hex>` — a solid colour chip, `color:#RRGGBB`
|
|
18
|
+
* - `font:<family>` — a specimen tile drawn in that family, `font:Helvetica Neue`
|
|
19
|
+
* - `process:<pid>` — the running process's app icon, resolved by the shell from the pid
|
|
20
|
+
* - `favicon:<host>` — the site's own icon, fetched and cached by the shell (`favicon:github.com`)
|
|
21
|
+
* - `extension` — the extension's own icon
|
|
22
|
+
*
|
|
23
|
+
* `process:` is the icon sibling of `font:` and `color:`: the *shell* owns the pixels. An extension
|
|
24
|
+
* that lists processes has their pids but no way — and no permission — to read an app's icon off
|
|
25
|
+
* disk; it names the process (`process:1234`), and the shell resolves the running app's icon itself
|
|
26
|
+
* (`NSRunningApplication`), degrading to a generic executable glyph for a daemon that is not an app.
|
|
27
|
+
* No icon bytes ever cross into the extension.
|
|
28
|
+
*
|
|
29
|
+
* `favicon:` is the same bargain one step further out: a row whose *subject is a website* — a saved
|
|
30
|
+
* login, a bookmark — has no packaged asset that could stand for it, and a list where every row
|
|
31
|
+
* wears the extension's own glyph is a column of identical marks where picking the right one is
|
|
32
|
+
* reading rather than seeing. The extension names the host; the shell fetches, caches, and draws.
|
|
33
|
+
* **It is not `imageURL` with a nicer name**: an image URL is a download on the drawing path, while
|
|
34
|
+
* a favicon is resolved off it, which is what makes a hundred of them affordable. Until it lands —
|
|
35
|
+
* and forever, for a site that publishes none — the row wears the extension's own icon, so nothing
|
|
36
|
+
* ever paints as a blank. No icon bytes cross into the extension, and no permission is involved:
|
|
37
|
+
* the extension names a host it already knows, and never learns whether the fetch succeeded.
|
|
38
|
+
*
|
|
39
|
+
* `font:` is the colour chip's sibling and exists for the same reason: a row whose *subject is a
|
|
40
|
+
* typeface* has no other way to show it — the family is known only at runtime, so no packaged
|
|
41
|
+
* asset can carry it, and the shell is the only thing that can draw it. **The shell picks the
|
|
42
|
+
* sample glyph**, not the extension: a Latin face gets "Ag", a Japanese one "あ", chosen from what
|
|
43
|
+
* the family actually supports and the user's locale. An unresolvable family degrades to the
|
|
44
|
+
* extension's own icon, exactly as an unresolvable `asset:` does.
|
|
45
|
+
*/
|
|
46
|
+
export type IconRef =
|
|
47
|
+
| `asset:${string}`
|
|
48
|
+
| `symbol:${string}`
|
|
49
|
+
| `color:${string}`
|
|
50
|
+
| `font:${string}`
|
|
51
|
+
| `process:${string}`
|
|
52
|
+
| `favicon:${string}`
|
|
53
|
+
| "extension";
|
|
54
|
+
|
|
55
|
+
/** `#RRGGBB`, the one colour literal the vocabulary accepts. */
|
|
56
|
+
export const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How a scope's body is laid out. The shell owns everything else.
|
|
60
|
+
*
|
|
61
|
+
* `list` / `grid` / `twoColumn` / `table` are row layouts: the scope's `items` produce rows and the
|
|
62
|
+
* shell searches, ranks, and renders them.
|
|
63
|
+
*
|
|
64
|
+
* - **`table`** is a multi-column grid — a process list, a downloads table. Each column is declared
|
|
65
|
+
* in `columns`, and a row fills a cell with `cell(id, value)` (a numeric column also takes a sort
|
|
66
|
+
* value so `"9 MB"` sorts below `"10 MB"`). The shell draws the header, the sorting, and the row
|
|
67
|
+
* icon; the extension supplies only text. Rows that move come from an `items` generator that
|
|
68
|
+
* keeps yielding — nothing about a table declares that, because "these rows change" is a fact
|
|
69
|
+
* about the rows and not about the layout drawing them.
|
|
70
|
+
*
|
|
71
|
+
* `pane` and `canvas` both have **no rows**, and the difference between them is the whole point.
|
|
72
|
+
*
|
|
73
|
+
* - **`pane`** is a single *semantic* pane: the scope's `view` (or `detail`) handler fills the
|
|
74
|
+
* panel body, and the shell draws it from the same vocabulary a two-column detail uses. It
|
|
75
|
+
* exists for the class of small tools that take an input, a choice, and show a result — a
|
|
76
|
+
* translator (text, language, translation), a text transform, a hash, a unit conversion, a font
|
|
77
|
+
* specimen. None of them have a list, and before this they had to invent one: a row column with
|
|
78
|
+
* nothing meaningful in it, or the WebView. Two independent cases (`fonts`' preview and a
|
|
79
|
+
* translator) is what carried it over extension-ui §9's bar; one would not have.
|
|
80
|
+
* - **`canvas`** renders the extension's own HTML in a WebView (architecture §12.1) because the
|
|
81
|
+
* semantic vocabulary genuinely cannot reach it — freehand ink is the canonical case. It costs a
|
|
82
|
+
* higher trust tier. Reach for `pane` first; `canvas` is for what `pane` cannot describe.
|
|
83
|
+
*
|
|
84
|
+
* A scope declares exactly one source for its body: `items` for a row layout, `surface` for
|
|
85
|
+
* `canvas`, `view`/`detail` for `pane`. The host rejects the mismatches — a handler the shell will
|
|
86
|
+
* never call is a handler an author will believe in.
|
|
87
|
+
*/
|
|
88
|
+
export interface SceneScheme {
|
|
89
|
+
layout: "list" | "grid" | "twoColumn" | "table" | "canvas" | "pane";
|
|
90
|
+
/** Show the facet chip row (filter chips built from row `facetId`s). Row layouts only. */
|
|
91
|
+
facetBar?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Which of the shell's two row shapes a `list` / `twoColumn` scope draws (default `"inline"`).
|
|
94
|
+
*
|
|
95
|
+
* - **`"inline"`** — one line: the subtitle continues the title on the same line. Right when the
|
|
96
|
+
* subtitle is a short qualifier of the row's name, which is most rows.
|
|
97
|
+
* - **`"twoLine"`** — the title on its own line with the subtitle beneath it in smaller type, and
|
|
98
|
+
* a larger icon. Right when the subtitle is *metadata about the subject* rather than more of
|
|
99
|
+
* its name — "10 styles · Menlo.ttc · OpenType (TrueType)" is a description of a font, and
|
|
100
|
+
* inline it reads as an over-long name and truncates before it says anything.
|
|
101
|
+
*
|
|
102
|
+
* This is a **closed choice from the shell's own vocabulary**, the same kind `layout` is, and it
|
|
103
|
+
* is the only thing about a row an extension picks: the fonts, the metrics, the truncation, and
|
|
104
|
+
* the spacing stay the shell's, and it is what makes an extension's two-line rows identical to
|
|
105
|
+
* the ones Clipboard and Files draw. There is no way to set a size, a colour, or a third shape.
|
|
106
|
+
*/
|
|
107
|
+
rowLayout?: "inline" | "twoLine";
|
|
108
|
+
/** For `twoColumn`: stay single-column until a row actually resolves a detail. */
|
|
109
|
+
collapsesDetailPaneUntilAvailable?: boolean;
|
|
110
|
+
/** Required for `layout: "table"`: the ordered columns. Ignored by other layouts. */
|
|
111
|
+
columns?: TableColumn[];
|
|
112
|
+
/** For `layout: "table"`: the column id to sort by on entry (default: the first). */
|
|
113
|
+
defaultSortColumn?: string;
|
|
114
|
+
/** For `layout: "table"`: sort descending on entry (default ascending). */
|
|
115
|
+
defaultSortDescending?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* For `layout: "table"`: moving the pointer over a row makes it the selection (default `true`,
|
|
118
|
+
* matching every other row layout). Set `false` when the rows re-sort while the cursor sits
|
|
119
|
+
* still — a refreshing process table slides new rows under the pointer, and hover selection would
|
|
120
|
+
* make the highlight twitch through them on its own. With it off only a click selects, so the
|
|
121
|
+
* highlight moves only when the user moves it.
|
|
122
|
+
*/
|
|
123
|
+
hoverSelects?: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A `table` row's value for one column: a display string, or — for a `numeric` column — a string
|
|
128
|
+
* plus the number the shell sorts by, so `{ value: "9 MB", sort: 9_000_000 }` sorts below `"10 MB"`.
|
|
129
|
+
*/
|
|
130
|
+
export type TableCell = string | { value: string; sort?: number };
|
|
131
|
+
|
|
132
|
+
/** A column of a `table` scope. The shell renders the header and sorting; the row fills the cell. */
|
|
133
|
+
export interface TableColumn {
|
|
134
|
+
/** Stable id. A row fills this column with `cell(id, value)`; the header sorts by it. */
|
|
135
|
+
id: string;
|
|
136
|
+
/** Header text. */
|
|
137
|
+
title: string;
|
|
138
|
+
/** Fixed width in points. The one column with no `width` stretches to fill. */
|
|
139
|
+
width?: number;
|
|
140
|
+
minWidth?: number;
|
|
141
|
+
/** Cell text alignment. Numbers usually want `"trailing"`. */
|
|
142
|
+
align?: "leading" | "trailing" | "center";
|
|
143
|
+
/** Clicking the header sorts by this column. Defaults to `true`. */
|
|
144
|
+
sortable?: boolean;
|
|
145
|
+
/** Sort by the row's numeric side-value for this column (`cell`'s second arg), not the text — so
|
|
146
|
+
* `"9 MB"` sorts below `"10 MB"`. */
|
|
147
|
+
numeric?: boolean;
|
|
148
|
+
/** The leading column that carries the row's icon. At most one column should set this. */
|
|
149
|
+
showsIcon?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface MetadataRow {
|
|
153
|
+
label: string;
|
|
154
|
+
value: string;
|
|
155
|
+
icon?: IconRef;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface MetadataSection {
|
|
159
|
+
title?: string;
|
|
160
|
+
rows: MetadataRow[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A typed chart. Series in one chart share one axis and one unit — put values
|
|
165
|
+
* with different units in separate charts rather than asking for a second axis.
|
|
166
|
+
*/
|
|
167
|
+
export interface Chart {
|
|
168
|
+
type: "line" | "bar" | "area";
|
|
169
|
+
/** Categorical x labels, one per value index. */
|
|
170
|
+
labels: string[];
|
|
171
|
+
series: ChartSeries[];
|
|
172
|
+
/** Unit suffix shown by the shell on the value axis, e.g. "°C", "%". */
|
|
173
|
+
unit?: string;
|
|
174
|
+
/** Optional fixed value range; omit to let the shell fit the data. */
|
|
175
|
+
min?: number;
|
|
176
|
+
max?: number;
|
|
177
|
+
/** Highlighted x positions, e.g. "now" or the selected hour. */
|
|
178
|
+
markers?: ChartMarker[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ChartSeries {
|
|
182
|
+
id: string;
|
|
183
|
+
name?: string;
|
|
184
|
+
/** One value per `labels` entry; `null` renders a gap. */
|
|
185
|
+
values: (number | null)[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface ChartMarker {
|
|
189
|
+
/** Index into `labels`. */
|
|
190
|
+
at: number;
|
|
191
|
+
label?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type SceneBlock =
|
|
195
|
+
| { kind: "markdown"; text: string }
|
|
196
|
+
| { kind: "chart"; chart: Chart }
|
|
197
|
+
| { kind: "metadata"; sections: MetadataSection[] };
|
|
198
|
+
|
|
199
|
+
export interface SceneAction {
|
|
200
|
+
actionId: string;
|
|
201
|
+
title: string;
|
|
202
|
+
icon?: IconRef;
|
|
203
|
+
/** The unit this action needs. Tapcue drops it if that was not granted — see `CommandItem`. */
|
|
204
|
+
requires?: PermissionUnit;
|
|
205
|
+
/** The shell requires confirmation before running a destructive action. */
|
|
206
|
+
destructive?: boolean;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The detail pane of a `twoColumn` scope, or a command's detail scene. */
|
|
210
|
+
export interface DetailScene {
|
|
211
|
+
kind: "detail";
|
|
212
|
+
title?: string;
|
|
213
|
+
subtitle?: string;
|
|
214
|
+
/** Large leading icon for the pane header. */
|
|
215
|
+
icon?: IconRef;
|
|
216
|
+
/** Ordered content blocks rendered top to bottom. */
|
|
217
|
+
body?: SceneBlock[];
|
|
218
|
+
actions?: SceneAction[];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A WebView surface — the one place an extension renders instead of describes.
|
|
223
|
+
*
|
|
224
|
+
* The rest of this file is the semantic vocabulary: an extension says "a chart with this
|
|
225
|
+
* data", never "a rectangle here", and the shell owns type, colour, and layout across three
|
|
226
|
+
* platforms (extension-ui §1). A `WebViewScene` is the deliberate exception (architecture
|
|
227
|
+
* §12.1) — for the long tail the vocabulary cannot reach, of which a drawing canvas is the
|
|
228
|
+
* canonical case. It runs the extension's own HTML/JS from `entry`, so it costs a higher
|
|
229
|
+
* trust tier (`ui.webview`) and it is the only scene the shell does not draw itself.
|
|
230
|
+
*
|
|
231
|
+
* What the shell still owns, and the extension cannot touch: the surrounding chrome — the
|
|
232
|
+
* titlebar above and the footer below are native, drawn from `title` and `actions` — the
|
|
233
|
+
* identity badge, and every permission dialog, none of which the WebView can cover. The
|
|
234
|
+
* surface has no network of its own (`connect-src 'none'`); everything it does that leaves
|
|
235
|
+
* the sandbox flows back through the extension isolate over `ctx.bridge` (capabilities.ts).
|
|
236
|
+
*/
|
|
237
|
+
export interface WebViewScene {
|
|
238
|
+
kind: "webview";
|
|
239
|
+
/**
|
|
240
|
+
* A package asset, resolved and loaded through the host's custom scheme handler — the
|
|
241
|
+
* same package-relative, non-traversing grammar as an `asset:` icon, without the prefix.
|
|
242
|
+
* Remote URLs never load: the surface's only inputs are package bytes and bridge messages.
|
|
243
|
+
*/
|
|
244
|
+
entry: string;
|
|
245
|
+
/**
|
|
246
|
+
* The native titlebar text, shown only under `titlebar: "shell"`. The shell renders it; the
|
|
247
|
+
* WebView cannot draw over it. A `"none"` titlebar has nowhere to put it, so it is ignored.
|
|
248
|
+
*/
|
|
249
|
+
title?: string;
|
|
250
|
+
/**
|
|
251
|
+
* How the shell frames the surface above it. The footer below and the scope's identity are
|
|
252
|
+
* the shell's either way; this is only about the bar between the panel's top and the canvas.
|
|
253
|
+
*
|
|
254
|
+
* - `"shell"` (default) — the shell's own titlebar (the search field, with the scope pill and
|
|
255
|
+
* the ⋯ menu) sits above the surface, exactly as on a row scope. The canvas fills the body
|
|
256
|
+
* below it.
|
|
257
|
+
* - `"none"` — no titlebar. The canvas runs from the panel's top edge down to the footer, and
|
|
258
|
+
* the scope pill (left) and the ⋯ menu (right) float over its top corners instead of riding
|
|
259
|
+
* a bar. For a surface that *is* the whole UI — a blank drawing sheet — a titlebar is a strip
|
|
260
|
+
* of chrome with nothing to say; this reclaims it while keeping the two controls that must
|
|
261
|
+
* never be the extension's (leaving the scope, and the menu) native and uncoverable.
|
|
262
|
+
*
|
|
263
|
+
* It is the panel-surface analogue of an overlay's `frame` (architecture §9.4): frameless buys
|
|
264
|
+
* a clean surface, never anonymity — the scope pill and menu stay, and the WebView cannot cover
|
|
265
|
+
* them.
|
|
266
|
+
*/
|
|
267
|
+
titlebar?: "shell" | "none";
|
|
268
|
+
/**
|
|
269
|
+
* The native footer, in priority order. The shell binds the first action to Enter, a
|
|
270
|
+
* second to ⌘Enter, and the rest to the ⌘K panel — because keyboard shortcuts are the
|
|
271
|
+
* shell's to own (extension-ui §1), the extension orders the actions and never names a key.
|
|
272
|
+
* An action whose `requires` unit was not granted is dropped, exactly as on a row.
|
|
273
|
+
*/
|
|
274
|
+
actions?: SceneAction[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface LoadingScene {
|
|
278
|
+
kind: "loading";
|
|
279
|
+
title?: string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface EmptyScene {
|
|
283
|
+
kind: "empty";
|
|
284
|
+
title: string;
|
|
285
|
+
description?: string;
|
|
286
|
+
icon?: IconRef;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface ErrorScene {
|
|
290
|
+
kind: "error";
|
|
291
|
+
title: string;
|
|
292
|
+
description?: string;
|
|
293
|
+
/** Recovery affordance, e.g. "Grant location access". */
|
|
294
|
+
actions?: SceneAction[];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export type Scene = DetailScene | WebViewScene | LoadingScene | EmptyScene | ErrorScene;
|