@dolphy-app/extension-sdk 0.3.0 → 0.5.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.
@@ -0,0 +1,366 @@
1
+ # Recipe: calls between the parts, the engine and the window
2
+
3
+ A component of the client part can ask the server part for something, call the
4
+ engine itself and drive the window: open a course, show a toast, mount a
5
+ component into an element. This recipe builds a panel that lists the courses
6
+ and records an attempt, with `defineRpc`, `useRpc`, `useEngine`, `useApp` and
7
+ `server.engine`. There is no template for it; add the pieces to any project
8
+ (see [quick-start.md](quick-start.md)). The contracts use `zod`, so the project
9
+ lists `zod` in its `dependencies`; the build puts it into the bundles. The
10
+ command and panel basics are in [recipe-command-panel.md](recipe-command-panel.md).
11
+
12
+ ## The manifest
13
+
14
+ File `extension.json` (rpc-and-app):
15
+
16
+ ```json
17
+ {
18
+ "id": "acme.direct",
19
+ "version": "1.0.0",
20
+ "apiVersion": 1
21
+ }
22
+ ```
23
+
24
+ ## The contracts
25
+
26
+ File `src/index.ts` (rpc-and-app):
27
+
28
+ ```ts
29
+ export { client } from './client.ts';
30
+ export { server } from './server.ts';
31
+ ```
32
+
33
+ File `src/shared/rpc.ts` (rpc-and-app):
34
+
35
+ ```ts
36
+ import { defineRpc } from '@dolphy-app/extension-sdk';
37
+ import { z } from 'zod';
38
+
39
+ // imported by both parts: the name and the schemas of a call
40
+ export const courseNames = defineRpc({
41
+ name: 'courses.names',
42
+ input: z.object({}),
43
+ output: z.object({ names: z.array(z.string()) }),
44
+ });
45
+
46
+ export const markKnown = defineRpc({
47
+ name: 'attempts.mark-known',
48
+ input: z.object({ exerciseId: z.string().min(1) }),
49
+ output: z.object({ eventId: z.string() }),
50
+ });
51
+ ```
52
+
53
+ - `defineRpc({ name, input, output })` returns the contract as is. `name` is
54
+ lower-case segments separated by dots, at least two, up to 120 characters
55
+ (`greeting.say-hello`); a malformed name throws. `input` and `output` are
56
+ `zod` schemas. `defineRpc` does not import `vue`, so server code imports it
57
+ too (it is also exported from `@dolphy-app/extension-sdk/rpc`).
58
+ - Both sides validate: `useRpc` checks the input before the call and the
59
+ answer after it, the server checks the input before the handler and the
60
+ result after it. The data crosses the process boundary as JSON, so keep it
61
+ plain values (at most 200 000 characters of input).
62
+
63
+ ## The server part
64
+
65
+ File `src/server.ts` (rpc-and-app):
66
+
67
+ ```ts
68
+ import { defineServer } from '@dolphy-app/extension-sdk';
69
+ import { courseNames, markKnown } from './shared/rpc.ts';
70
+
71
+ export const server = defineServer((s) => {
72
+ s.handle(courseNames, async () => {
73
+ const page = await s.engine.library.listCourses();
74
+ return { names: page.items.map((course) => course.name) };
75
+ });
76
+
77
+ s.handle(markKnown, async ({ exerciseId }) => {
78
+ const result = await s.engine.practice.recordAttempt({
79
+ requestId: crypto.randomUUID(),
80
+ exerciseId,
81
+ grade: 5,
82
+ });
83
+ return { eventId: result.eventId };
84
+ });
85
+ });
86
+ ```
87
+
88
+ - `server.handle(contract, handler)` answers a contract: one handler per name,
89
+ at most 64 per extension. The handler has 10 seconds. An error it throws
90
+ reaches the component with its message.
91
+ - `server.engine` is the engine client (`ExtensionEngine`): every method of the
92
+ engine contract, writing ones included, and `subscribe` for the engine
93
+ events, but not `close`. A write goes into the same log as the window's own
94
+ writes, so record only what the learner really did.
95
+
96
+ ## The client part
97
+
98
+ File `src/client.ts` (rpc-and-app):
99
+
100
+ ```ts
101
+ import { defineClient } from '@dolphy-app/extension-sdk';
102
+ import { Panel } from './panel.ts';
103
+
104
+ export const client = defineClient((c) => {
105
+ c.addPanel({
106
+ id: 'acme.direct.view',
107
+ title: { en: 'Direct', ru: 'Прямой доступ' },
108
+ component: Panel,
109
+ });
110
+ });
111
+ ```
112
+
113
+ File `src/panel.ts` (rpc-and-app):
114
+
115
+ ```ts
116
+ import { useApp, useEngine, useRpc } from '@dolphy-app/extension-sdk/client';
117
+ import { defineComponent, h, ref } from 'vue';
118
+ import { courseNames, markKnown } from './shared/rpc.ts';
119
+
120
+ export const Panel = defineComponent({
121
+ setup() {
122
+ const app = useApp();
123
+ const engine = useEngine();
124
+ const loadNames = useRpc(courseNames);
125
+ const mark = useRpc(markKnown);
126
+ const names = ref<string[]>([]);
127
+
128
+ const report = (error: unknown) =>
129
+ app.notify(
130
+ error instanceof Error ? error.message : String(error),
131
+ 'error',
132
+ );
133
+
134
+ // through the server part: `courses.names`
135
+ const loadFromServer = async () => {
136
+ try {
137
+ names.value = (await loadNames({})).names;
138
+ } catch (error) {
139
+ report(error);
140
+ }
141
+ };
142
+
143
+ // straight from the component: the same engine client
144
+ const loadHere = async () => {
145
+ try {
146
+ const page = await engine.library.listCourses();
147
+ names.value = page.items.map((course) => course.name);
148
+ } catch (error) {
149
+ report(error);
150
+ }
151
+ };
152
+
153
+ const markExercise = async (exerciseId: string) => {
154
+ try {
155
+ await mark({ exerciseId });
156
+ app.notify('Recorded');
157
+ } catch (error) {
158
+ report(error);
159
+ }
160
+ };
161
+
162
+ return () =>
163
+ h('div', [
164
+ h(
165
+ 'button',
166
+ { 'data-role': 'server', onClick: loadFromServer },
167
+ 'Server',
168
+ ),
169
+ h('button', { 'data-role': 'here', onClick: loadHere }, 'Here'),
170
+ h(
171
+ 'button',
172
+ { 'data-role': 'mark', onClick: () => markExercise('') },
173
+ 'Mark with an empty id',
174
+ ),
175
+ h(
176
+ 'ul',
177
+ names.value.map((name) => h('li', name)),
178
+ ),
179
+ ]);
180
+ },
181
+ });
182
+ ```
183
+
184
+ - `useRpc(contract)` is called in `setup` and returns `(input) => Promise<output>`.
185
+ A schema violation, an error of the handler and an unavailable server reject
186
+ the promise with an `Error` that carries the message.
187
+ - `useEngine()` is the client of the window itself: `engine.library`,
188
+ `engine.practice`, `engine.settings`, … and `engine.subscribe`.
189
+ - `useApp()` is a fixed list of window capabilities: `openCourse(courseId)`,
190
+ `openLesson(courseId, lessonId)`, `openExercise(courseId, lessonId, exerciseId)`,
191
+ `openPanel(extensionId, panelId, props?)`, `openSettings(extensionId?)`,
192
+ `notify(message, kind?)`, the reactive `theme` (`{ id, dark }`) and `locale`
193
+ (`'en' | 'ru'`), `runCommand(commandKey)` for a palette command
194
+ (`extension:<extension id>:<command id>`) and `mountAt(target, component,
195
+ props?)`. The window's stores and router are not reachable.
196
+ - `app.mountAt('#some-element', Component, props)` mounts a component into an
197
+ element of the window (a CSS selector is resolved once, at the call; a
198
+ missing element throws) and returns a `Disposable`. The component gets
199
+ Vuetify, the theme, `useApp()`, `useEngine()` and `useRpc()` like a panel.
200
+ For a component that follows the DOM, use `client.addInjection`.
201
+ - All three work only in a component the app draws; elsewhere they throw.
202
+
203
+ ## The tests
204
+
205
+ File `test/index.test.ts` (rpc-and-app):
206
+
207
+ ```ts
208
+ // @vitest-environment happy-dom
209
+ import {
210
+ APP_KEY,
211
+ ENGINE_KEY,
212
+ EXTENSION_ID_KEY,
213
+ } from '@dolphy-app/extension-sdk';
214
+ import type { AppApi, ExtensionEngine } from '@dolphy-app/extension-sdk';
215
+ import {
216
+ createTestClient,
217
+ createTestServer,
218
+ } from '@dolphy-app/extension-sdk/testing';
219
+ import { afterEach, describe, expect, it } from 'vitest';
220
+ import { createApp, h, nextTick } from 'vue';
221
+ import { client, server } from '../src/index.ts';
222
+ import { Panel } from '../src/panel.ts';
223
+ import { courseNames, markKnown } from '../src/shared/rpc.ts';
224
+
225
+ const disposables: { dispose(): unknown }[] = [];
226
+ afterEach(async () => {
227
+ await Promise.all(disposables.splice(0).map((item) => item.dispose()));
228
+ });
229
+
230
+ // only the methods the code under test calls
231
+ const recorded: unknown[] = [];
232
+ const engine = {
233
+ library: {
234
+ listCourses: async () => ({ items: [{ name: 'Git' }, { name: 'SQL' }] }),
235
+ },
236
+ practice: {
237
+ recordAttempt: async (request: unknown) => {
238
+ recorded.push(request);
239
+ return { eventId: 'event-1' };
240
+ },
241
+ },
242
+ } as unknown as ExtensionEngine;
243
+
244
+ describe('acme.direct: server', () => {
245
+ it('answers the contracts through the engine', async () => {
246
+ const running = await createTestServer(server, {
247
+ extensionId: 'acme.direct',
248
+ engine,
249
+ });
250
+ disposables.push(running);
251
+ expect(running.registration.rpcs).toEqual([
252
+ 'courses.names',
253
+ 'attempts.mark-known',
254
+ ]);
255
+ expect(await running.rpc(courseNames, {})).toEqual({
256
+ names: ['Git', 'SQL'],
257
+ });
258
+ expect(await running.rpc(markKnown, { exerciseId: 'git::a::q1' })).toEqual({
259
+ eventId: 'event-1',
260
+ });
261
+ expect(recorded).toMatchObject([{ exerciseId: 'git::a::q1', grade: 5 }]);
262
+ });
263
+
264
+ it('rejects an input that breaks the contract before the handler runs', async () => {
265
+ const running = await createTestServer(server, {
266
+ extensionId: 'acme.direct',
267
+ engine,
268
+ });
269
+ disposables.push(running);
270
+ const before = recorded.length;
271
+ await expect(running.rpc(markKnown, { exerciseId: '' })).rejects.toThrow();
272
+ expect(recorded).toHaveLength(before);
273
+ });
274
+ });
275
+
276
+ describe('acme.direct: client', () => {
277
+ it('adds the panel', async () => {
278
+ const running = await createTestClient(client, {
279
+ extensionId: 'acme.direct',
280
+ });
281
+ disposables.push(running);
282
+ expect(running.panels.map((panel) => panel.id)).toEqual([
283
+ 'acme.direct.view',
284
+ ]);
285
+ });
286
+ });
287
+
288
+ // draws the panel the way the app does: the keys are provided to the component
289
+ const mountPanel = (invoked: string[], toasts: string[]) => {
290
+ const app = {
291
+ notify: (message: string, kind?: string) =>
292
+ toasts.push(`${kind ?? 'info'}: ${message}`),
293
+ } as unknown as AppApi;
294
+ const windowEngine = {
295
+ ...engine,
296
+ extensions: {
297
+ invokeRpc: async (request: { name: string }) => {
298
+ invoked.push(request.name);
299
+ return { names: ['From the server'] };
300
+ },
301
+ },
302
+ } as unknown as ExtensionEngine;
303
+ const host = document.createElement('div');
304
+ document.body.append(host);
305
+ const root = createApp({ render: () => h(Panel) });
306
+ root.provide(EXTENSION_ID_KEY, 'acme.direct');
307
+ root.provide(APP_KEY, app);
308
+ root.provide(ENGINE_KEY, windowEngine);
309
+ root.mount(host);
310
+ disposables.push({
311
+ dispose: () => {
312
+ root.unmount();
313
+ host.remove();
314
+ },
315
+ });
316
+ return host;
317
+ };
318
+
319
+ const click = async (host: HTMLElement, role: string) => {
320
+ host.querySelector<HTMLButtonElement>(`[data-role="${role}"]`)?.click();
321
+ await new Promise((resolve) => setTimeout(resolve, 0));
322
+ await nextTick();
323
+ };
324
+
325
+ describe('acme.direct: panel', () => {
326
+ it('lists the courses through the server and straight from the engine', async () => {
327
+ const invoked: string[] = [];
328
+ const host = mountPanel(invoked, []);
329
+ await click(host, 'server');
330
+ expect(invoked).toEqual(['courses.names']);
331
+ expect(host.querySelector('li')?.textContent).toBe('From the server');
332
+
333
+ await click(host, 'here');
334
+ expect(
335
+ [...host.querySelectorAll('li')].map((li) => li.textContent),
336
+ ).toEqual(['Git', 'SQL']);
337
+ });
338
+
339
+ it('shows a rejected call as an error toast and does not reach the server', async () => {
340
+ const invoked: string[] = [];
341
+ const toasts: string[] = [];
342
+ const host = mountPanel(invoked, toasts);
343
+ await click(host, 'mark');
344
+ expect(invoked).toEqual([]);
345
+ expect(toasts).toHaveLength(1);
346
+ expect(toasts[0]).toMatch(/^error: /);
347
+ });
348
+ });
349
+ ```
350
+
351
+ `createTestServer(server, { extensionId, engine })` gives the code the engine
352
+ you pass as `s.engine`; without it any use of `s.engine` throws. `running.rpc(contract, input)`
353
+ calls the handler the way the host does: the input must pass the contract's
354
+ schema and so must the result; an unregistered name and an error of the handler
355
+ reject the promise. `running.registration.rpcs` lists the names.
356
+
357
+ A component that uses `useRpc`, `useApp` or `useEngine` is mounted like any Vue
358
+ component in `happy-dom`: `app.provide(EXTENSION_ID_KEY, id)`,
359
+ `app.provide(APP_KEY, appApi)` and `app.provide(ENGINE_KEY, engine)` give it
360
+ what the app would. `createTestClient(client, { extensionId, app, engine })`
361
+ passes `app` and `engine` to the entry as `client.app` and `client.engine`.
362
+
363
+ ## Try and ship
364
+
365
+ Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS` and open the "Direct"
366
+ panel from the sidebar menu.
@@ -0,0 +1,183 @@
1
+ # Recipe: settings
2
+
3
+ Let the user configure the extension in Settings → Extensions. The code
4
+ registers the definitions; the app draws the form, validates the values and
5
+ stores them; your code reads them. This recipe has no template of its own:
6
+ start from `blank` and replace the three files below, which are checked as a
7
+ whole project. See [quick-start.md](quick-start.md) for the commands.
8
+
9
+ ## The manifest
10
+
11
+ File `extension.json` (settings):
12
+
13
+ ```json
14
+ {
15
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
16
+ "id": "acme.hello",
17
+ "version": "0.1.0",
18
+ "apiVersion": 1,
19
+ "name": "Hello settings",
20
+ "description": "A greeting command whose text follows the user's settings.",
21
+ "author": "your-github-login",
22
+ "tags": ["productivity"]
23
+ }
24
+ ```
25
+
26
+ The manifest holds the identity only; the settings are registered by the code.
27
+
28
+ ## The code
29
+
30
+ File `src/index.ts` (settings):
31
+
32
+ ```ts
33
+ import { defineServer, notify } from '@dolphy-app/extension-sdk';
34
+
35
+ export const server = defineServer((s) => {
36
+ s.registerSettings([
37
+ {
38
+ id: 'acme.hello.name',
39
+ type: 'string',
40
+ label: { en: 'Name to greet', ru: 'Кого приветствовать' },
41
+ default: 'world',
42
+ maxLength: 40,
43
+ },
44
+ {
45
+ id: 'acme.hello.times',
46
+ type: 'number',
47
+ label: { en: 'Exclamation marks', ru: 'Восклицательные знаки' },
48
+ default: 1,
49
+ min: 1,
50
+ max: 5,
51
+ integer: true,
52
+ },
53
+ {
54
+ id: 'acme.hello.style',
55
+ type: 'enum',
56
+ label: { en: 'Style', ru: 'Стиль' },
57
+ default: 'plain',
58
+ options: [
59
+ { value: 'plain', label: { en: 'Plain', ru: 'Обычный' } },
60
+ { value: 'loud', label: { en: 'Loud', ru: 'Громкий' } },
61
+ ],
62
+ },
63
+ ]);
64
+
65
+ s.registerCommand({
66
+ id: 'acme.hello.greet',
67
+ title: { en: 'Greet', ru: 'Поприветствовать' },
68
+ run: () => {
69
+ // `get` is synchronous and returns the user's value or the default
70
+ const name = String(s.settings.get('acme.hello.name'));
71
+ const marks = '!'.repeat(Number(s.settings.get('acme.hello.times')));
72
+ const text = `Hello, ${name}${marks}`;
73
+ return notify(
74
+ s.settings.get('acme.hello.style') === 'loud'
75
+ ? text.toUpperCase()
76
+ : text,
77
+ );
78
+ },
79
+ });
80
+
81
+ // a change in Settings → Extensions reaches the running extension
82
+ s.settings.onDidChange((change) => {
83
+ s.logger.info({ id: change.id, value: change.value }, 'setting changed');
84
+ });
85
+ });
86
+ ```
87
+
88
+ - `server.registerSettings(definitions)` adds the settings to Settings →
89
+ Extensions. Types: `boolean`, `string` (`maxLength`), `text` (a multi-line
90
+ string), `color` (`#rrggbb`), `list` (`maxItems`, `itemMaxLength`), `number`
91
+ (`min`, `max`, `integer`) and `enum` (`options: [{ value, label }]`).
92
+ - A setting `id` is the extension id or starts with `<id>.`, and is registered
93
+ once. `default` must satisfy the constraints, otherwise the registration
94
+ fails and the extension shows `load-failed`.
95
+ - `label`, `description` and `group` are `LocalizedText`: a string, or
96
+ `{ en, ru }` as here. `order` sorts the form; `visibleWhen: { setting, equals }`
97
+ hides a field while another setting of the extension has a different value.
98
+ - `server.settings.get(id)` returns the user's value or the default (an id
99
+ nobody registered throws) as a `SettingValue`; narrow it where you use it,
100
+ as `String(…)` and `Number(…)` do here. Read a value where you use it. Cache
101
+ it only if you also subscribe with `onDidChange`.
102
+ - The app validates every value (type, range, integer, length, `options`)
103
+ before it reaches you, so the code needs no checks of its own.
104
+
105
+ ## The test
106
+
107
+ File `test/index.test.ts` (settings):
108
+
109
+ ```ts
110
+ import { createTestServer } from '@dolphy-app/extension-sdk/testing';
111
+ import { expect, it } from 'vitest';
112
+ import { server } from '../src/index.ts';
113
+
114
+ const start = (settingValues = {}) =>
115
+ createTestServer(server, { extensionId: 'acme.hello', settingValues });
116
+
117
+ it('the greeting follows the settings, also after a change', async () => {
118
+ const running = await start();
119
+ expect(await running.commands.run('acme.hello.greet')).toEqual({
120
+ kind: 'notify',
121
+ text: 'Hello, world!',
122
+ });
123
+
124
+ await running.settings.set('acme.hello.name', 'Ada');
125
+ await running.settings.set('acme.hello.times', 3);
126
+ await running.settings.set('acme.hello.style', 'loud');
127
+ expect(await running.commands.run('acme.hello.greet')).toEqual({
128
+ kind: 'notify',
129
+ text: 'HELLO, ADA!!!',
130
+ });
131
+ await running.dispose();
132
+ });
133
+
134
+ it('a user value of a setting replaces the default', async () => {
135
+ const running = await start({ 'acme.hello.name': 'Grace' });
136
+ expect(await running.commands.run('acme.hello.greet')).toMatchObject({
137
+ text: 'Hello, Grace!',
138
+ });
139
+ await running.dispose();
140
+ });
141
+
142
+ it('the test settings reject a value the app would reject', async () => {
143
+ const running = await start();
144
+ await expect(running.settings.set('acme.hello.times', 9)).rejects.toThrow(
145
+ 'acme.hello.times',
146
+ );
147
+ await expect(
148
+ running.settings.set('acme.hello.style', 'quiet'),
149
+ ).rejects.toThrow('acme.hello.style');
150
+ await running.dispose();
151
+ });
152
+
153
+ it('a change is logged', async () => {
154
+ const logged: object[] = [];
155
+ const logger = {
156
+ debug: () => undefined,
157
+ info: (fields: object) => void logged.push(fields),
158
+ warn: () => undefined,
159
+ error: () => undefined,
160
+ };
161
+ const running = await createTestServer(server, {
162
+ extensionId: 'acme.hello',
163
+ logger,
164
+ });
165
+ await running.settings.set('acme.hello.name', 'Ada');
166
+ expect(logged).toEqual([{ id: 'acme.hello.name', value: 'Ada' }]);
167
+ await running.dispose();
168
+ });
169
+ ```
170
+
171
+ `createTestServer` starts `server` with the settings in memory:
172
+ `running.settings.get` returns the default until a value is set, and
173
+ `running.settings.set(id, value)` checks the value against the definition the
174
+ way the app does and calls the `onDidChange` subscribers, so the test changes a
175
+ value as the user does in the dialog. `settingValues` starts the extension with
176
+ user values in place of the defaults; every id must be registered by `server`.
177
+ The `logger` option receives what `server.logger` is called with.
178
+
179
+ ## Try and ship
180
+
181
+ Run `pnpm dev`, start the app with `DOLPHY_DEV_EXTENSIONS`, open Settings →
182
+ Extensions, press "Settings" on the extension, change a value and run "Greet"
183
+ from the palette.
@@ -0,0 +1,148 @@
1
+ # Recipe: a theme
2
+
3
+ A theme is data that the client part registers: no server part, no `main.mjs`.
4
+ This recipe is the `theme` template
5
+ (`npx @dolphy-app/create-extension <dir> --id acme.hello --template theme`).
6
+ The files below are exactly what the generator writes for the id `acme.hello`.
7
+ See [quick-start.md](quick-start.md) for the commands.
8
+
9
+ ## The manifest
10
+
11
+ File `extension.json` (theme):
12
+
13
+ ```json
14
+ {
15
+ "$schema": "./node_modules/@dolphy-app/extension-api/dist/extension.schema.json",
16
+ "id": "acme.hello",
17
+ "version": "0.1.0",
18
+ "apiVersion": 1,
19
+ "name": "Midnight",
20
+ "description": "A dark color theme with an amber accent for the Dolphy app.",
21
+ "author": "your-github-login",
22
+ "tags": ["theme"]
23
+ }
24
+ ```
25
+
26
+ The manifest holds the identity only; the colors are in the code.
27
+
28
+ ## The code
29
+
30
+ File `src/theme.ts` (theme):
31
+
32
+ ```ts
33
+ import type { ThemeRegistration } from '@dolphy-app/extension-sdk';
34
+
35
+ // the allowed color and variable keys are `THEME_COLOR_KEYS` and
36
+ // `THEME_VARIABLE_KEYS` of '@dolphy-app/extension-sdk'
37
+ export const midnight: ThemeRegistration = {
38
+ id: 'acme.hello',
39
+ label: 'Midnight',
40
+ dark: true,
41
+ colors: {
42
+ background: '#101820',
43
+ surface: '#1B2733',
44
+ 'on-background': '#E6EDF3',
45
+ 'on-surface': '#E6EDF3',
46
+ primary: '#FFB000',
47
+ 'on-primary': '#101820',
48
+ },
49
+ variables: { 'border-opacity': 0.2 },
50
+ };
51
+ ```
52
+
53
+ File `src/index.ts` (theme):
54
+
55
+ ```ts
56
+ import { defineClient } from '@dolphy-app/extension-sdk';
57
+ import { midnight } from './theme.ts';
58
+
59
+ // runs in the app window: a theme is data, there is no server part
60
+ export const client = defineClient((c) => {
61
+ c.addTheme(midnight);
62
+ });
63
+ ```
64
+
65
+ - `client.addTheme(registration)` adds a tile to Settings → Appearance next to
66
+ System, Light and Dark. `id` is the extension id or starts with it and a dot,
67
+ and is not `system`, `light` or `dark`; `label` is a `LocalizedText` of 1–60
68
+ characters; `dark` says whether the theme is dark, which picks the base colors
69
+ of the interface that `colors` then override.
70
+ - `colors` are `#rrggbb` or `#rrggbbaa` values for a fixed list of roles
71
+ (`background`, `surface`, `primary`, the `on-…` colors for text drawn on them,
72
+ `error`, `success`…). The list is `THEME_COLOR_KEYS` of the SDK; a key outside
73
+ it fails the registration. Pair every background with a readable text color.
74
+ - `variables` are optional tokens from `THEME_VARIABLE_KEYS`; here
75
+ `border-opacity`, a number from 0 to 1.
76
+ - The build of this project writes `extension.json` and `client.mjs` to
77
+ `dist-ext`: there is no `server` export, so no `main.mjs`.
78
+
79
+ ## The test
80
+
81
+ File `test/theme.test.ts` (theme):
82
+
83
+ ```ts
84
+ import { createTestClient } from '@dolphy-app/extension-sdk/testing';
85
+ import { describe, expect, it } from 'vitest';
86
+ import { client } from '../src/index.ts';
87
+ import { midnight } from '../src/theme.ts';
88
+
89
+ const colors = midnight.colors;
90
+
91
+ // WCAG relative luminance of a #rrggbb color
92
+ const luminance = (hex: string): number => {
93
+ const [r, g, b] = [1, 3, 5].map((start) => {
94
+ const channel = Number.parseInt(hex.slice(start, start + 2), 16) / 255;
95
+ return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
96
+ }) as [number, number, number];
97
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
98
+ };
99
+
100
+ const contrast = (foreground: string, background: string): number => {
101
+ const [light, dark] = [luminance(foreground), luminance(background)].sort(
102
+ (a, b) => b - a,
103
+ ) as [number, number];
104
+ return (light + 0.05) / (dark + 0.05);
105
+ };
106
+
107
+ describe('acme.hello: theme', () => {
108
+ it('the client adds the theme', async () => {
109
+ const running = await createTestClient(client, { extensionId: 'acme.hello' });
110
+ expect(running.themes).toEqual([midnight]);
111
+ await running.dispose();
112
+ });
113
+
114
+ it.each([
115
+ ['on-surface', 'surface'],
116
+ ['on-background', 'background'],
117
+ ['on-primary', 'primary'],
118
+ ])('%s on %s has a contrast of at least 4.5:1', (foreground, background) => {
119
+ expect(contrast(colors[foreground] as string, colors[background] as string))
120
+ .toBeGreaterThanOrEqual(4.5);
121
+ });
122
+
123
+ it('a dark theme has a dark background and a light text', () => {
124
+ const background = luminance(colors['background'] as string);
125
+ const text = luminance(colors['on-background'] as string);
126
+ expect(midnight.dark ? background < text : background > text).toBe(true);
127
+ });
128
+ });
129
+ ```
130
+
131
+ `createTestClient(client, { extensionId })` runs `client` on a context that
132
+ records what it adds, so the test sees the theme in `running.themes`. The rest
133
+ checks the WCAG contrast of each text color against its background (at least
134
+ 4.5:1) and that `dark` agrees with the colors. A failing contrast is a real bug
135
+ the learner would see. Keep the test when you change the colors.
136
+
137
+ ## Try and ship
138
+
139
+ ```sh
140
+ pnpm install
141
+ pnpm test
142
+ pnpm dev
143
+ ```
144
+
145
+ With `DOLPHY_DEV_EXTENSIONS` pointing at `dist-ext` (see the quick start) the
146
+ theme appears as a tile in Settings → Appearance; saving a file is picked up by
147
+ the running app. Change the id, the label and the colors; change `tags` and
148
+ `description` too, the catalog shows them.