ocp-viewer-core 1.0.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/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "ocp-viewer-core",
3
+ "version": "1.0.0",
4
+ "description": "Shared viewer policy for ocp_vscode, ocp_viewer, Jupyter CadQuery and build123d Studio",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "peerDependencies": {
14
+ "three-cad-viewer": ">=5.0.3 <5.1.0"
15
+ },
16
+ "license": "Apache-2.0",
17
+ "author": "Bernhard Walter <b_walter@arcor.de>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/bernhard-42/ocp-viewer-core.git"
21
+ }
22
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Animation tracks.
3
+ *
4
+ * A track is `[selector, action, times, values]`, and the action decides which
5
+ * of the viewer's five track methods it becomes. `viewer.html` has this as a
6
+ * switch in its message handler; cad-viewer-widget has the same mapping spread
7
+ * across `addTrack`, `addTracks`, `animate` and `clearAnimation`.
8
+ */
9
+
10
+ /*
11
+ Copyright 2026 Bernhard Walter
12
+
13
+ Licensed under the Apache License, Version 2.0 (the "License");
14
+ you may not use this file except in compliance with the License.
15
+ You may obtain a copy of the License at
16
+
17
+ http://www.apache.org/licenses/LICENSE-2.0
18
+
19
+ Unless required by applicable law or agreed to in writing, software
20
+ distributed under the License is distributed on an "AS IS" BASIS,
21
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22
+ See the License for the specific language governing permissions and
23
+ limitations under the License.
24
+ */
25
+
26
+ /** The two-letter action codes, and the call each becomes. */
27
+ const TRACKS = {
28
+ t: (v, sel, times, values) => v.addPositionTrack(sel, times, values),
29
+ q: (v, sel, times, values) => v.addQuaternionTrack(sel, times, values),
30
+ tx: (v, sel, times, values) => v.addTranslationTrack(sel, "x", times, values),
31
+ ty: (v, sel, times, values) => v.addTranslationTrack(sel, "y", times, values),
32
+ tz: (v, sel, times, values) => v.addTranslationTrack(sel, "z", times, values),
33
+ rx: (v, sel, times, values) => v.addRotationTrack(sel, "x", times, values),
34
+ ry: (v, sel, times, values) => v.addRotationTrack(sel, "y", times, values),
35
+ rz: (v, sel, times, values) => v.addRotationTrack(sel, "z", times, values),
36
+ };
37
+
38
+ /**
39
+ * Add one track. An unknown action is reported rather than ignored - it means a
40
+ * producer and this table disagree, and silently dropping a track produces an
41
+ * animation that is subtly wrong instead of one that fails.
42
+ */
43
+ export function addAnimationTrack(viewer, track, onUnknown) {
44
+ const [selector, action, times, values] = track;
45
+ const add = TRACKS[action];
46
+ if (add == null) {
47
+ if (onUnknown) {
48
+ onUnknown(action, track);
49
+ }
50
+ return false;
51
+ }
52
+ add(viewer, selector, times, values);
53
+ return true;
54
+ }
55
+
56
+ /** The longest time in any track, which is how long the animation runs. */
57
+ export function animationDuration(tracks) {
58
+ let duration = 0;
59
+ for (const track of tracks) {
60
+ for (const time of track[2]) {
61
+ if (time > duration) {
62
+ duration = time;
63
+ }
64
+ }
65
+ }
66
+ return duration;
67
+ }
68
+
69
+ /**
70
+ * Load a set of tracks and start the animation.
71
+ *
72
+ * Explode is turned off first: both are transforms on the same objects, and
73
+ * leaving explode on animates an already-displaced model. A speed of zero loads
74
+ * the tracks without starting - that is how a caller scrubs by hand rather than
75
+ * playing.
76
+ */
77
+ export function animate(viewer, tracks, speed, onUnknown) {
78
+ viewer.setExplode(false);
79
+ for (const track of tracks) {
80
+ addAnimationTrack(viewer, track, onUnknown);
81
+ }
82
+ const duration = animationDuration(tracks);
83
+ if (speed > 0) {
84
+ viewer.initAnimation(duration, speed);
85
+ }
86
+ return duration;
87
+ }
package/src/apply.js ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Applying a configuration to a running three-cad-viewer instance.
3
+ *
4
+ * One dispatch for every host: a set of changed keys in, a call on the viewer
5
+ * for each. A host asked to apply a configuration - from Python, or from a
6
+ * widget's own change observer - routes it through here.
7
+ *
8
+ * The keys are camelCase and the values are plain JSON, because that is what
9
+ * this side of the wire speaks. Python owns snake_case and the enums, and
10
+ * converts once, at the boundary, in `Config.to_javascript`. Nothing here
11
+ * translates a name or unwraps an enum: a key that arrives in snake_case is a
12
+ * host that has not converted, and it should surface as an unknown key rather
13
+ * than be quietly accepted in both spellings.
14
+ *
15
+ * A host whose wire format shares one name across both halves - an ipywidgets
16
+ * traitlet, say - converts before calling in. The rule is the same; only the
17
+ * place it is applied differs.
18
+ *
19
+ * The method is not derivable from the name: `grid` is `setGrids`, `glass` is
20
+ * `glassMode`, `tools` is `showTools`, `collapse` is `collapseNodes`. Deriving a
21
+ * mechanism from a name is how the option path silently lost keys, so the table
22
+ * is written out.
23
+ *
24
+ * Nothing here touches the DOM, a transport, or a host, so it can be tested
25
+ * headless against a stub viewer.
26
+ */
27
+
28
+ /*
29
+ Copyright 2026 Bernhard Walter
30
+
31
+ Licensed under the Apache License, Version 2.0 (the "License");
32
+ you may not use this file except in compliance with the License.
33
+ You may obtain a copy of the License at
34
+
35
+ http://www.apache.org/licenses/LICENSE-2.0
36
+
37
+ Unless required by applicable law or agreed to in writing, software
38
+ distributed under the License is distributed on an "AS IS" BASIS,
39
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
40
+ See the License for the specific language governing permissions and
41
+ limitations under the License.
42
+ */
43
+
44
+ // Keys whose value is a viewport dimension. Only the host knows the other two,
45
+ // so applying one means asking the host to resize rather than calling the
46
+ // viewer directly. Both hosts end in `resizeCadView`, reading the other
47
+ // dimensions from their own state.
48
+ export const GEOMETRY_KEYS = ["cadWidth", "treeWidth", "height"];
49
+
50
+ // The camera views `resetCamera` accepts beyond "reset", which is "iso" plus a
51
+ // resize. Anything else - including "keep" - deliberately does nothing, because
52
+ // `setView` on an unknown name leaves the camera where it was with no error.
53
+ export const VIEWS = ["iso", "left", "right", "top", "bottom", "rear", "front"];
54
+
55
+ const TOOLS = ["distance", "properties", "select"];
56
+
57
+ /**
58
+ * Call a viewer setter, appending the notify flag only when the host asked for
59
+ * one. cad-viewer-widget passes `true` to keep its traitlets in step;
60
+ * ocp_vscode omits it and takes the setter's own default. Passing `undefined`
61
+ * is not the same as not passing it, so the argument list is built rather than
62
+ * padded.
63
+ */
64
+ function call(viewer, method, args, notify) {
65
+ if (notify === undefined) {
66
+ viewer[method](...args);
67
+ } else {
68
+ viewer[method](...args, notify);
69
+ }
70
+ }
71
+
72
+ const SETTERS = {
73
+ axes: (v, value, ctx) => call(v, "setAxes", [value], ctx.notify),
74
+ axes0: (v, value, ctx) => call(v, "setAxes0", [value], ctx.notify),
75
+ grid: (v, value, ctx) => call(v, "setGrids", [value], ctx.notify),
76
+ centerGrid: (v, value, ctx) => call(v, "setGridCenter", [value], ctx.notify),
77
+ ortho: (v, value, ctx) => call(v, "setOrtho", [value], ctx.notify),
78
+ transparent: (v, value, ctx) => call(v, "setTransparent", [value], ctx.notify),
79
+ blackEdges: (v, value, ctx) => call(v, "setBlackEdges", [value], ctx.notify),
80
+
81
+ zoom: (v, value, ctx) => call(v, "setCameraZoom", [value], ctx.notify),
82
+ // `setCameraPosition(position, relative, notify)` takes a flag between the
83
+ // value and the notify flag that the other three camera setters do not, so
84
+ // `relative` is passed explicitly. Without it a host that asks for
85
+ // notification lands `true` in that slot and the camera moves *by* the
86
+ // vector instead of *to* it.
87
+ position: (v, value, ctx) => call(v, "setCameraPosition", [value, false], ctx.notify),
88
+ quaternion: (v, value, ctx) => call(v, "setCameraQuaternion", [value], ctx.notify),
89
+ target: (v, value, ctx) => call(v, "setCameraTarget", [value], ctx.notify),
90
+
91
+ // No `up` here, and it is not an omission. It cannot be applied to a live
92
+ // viewer: `Camera` reads `cameraUp[this.up]` once, in its constructor, so
93
+ // assigning `camera.up` afterwards changes nothing about the cameras - it only
94
+ // corrupts the lookup `presetCamera` makes, and the next click on ISO or TOP
95
+ // dies on `defaultDirections[undefined]`. Worse, the value written was the
96
+ // config's "Z", where that lookup is keyed by "z_up".
97
+ //
98
+ // `up` is a render option instead, and `Viewer.render` honours it: it builds a
99
+ // new `Camera` with `viewerOptions.up` every time. So `set_defaults(up=...)`
100
+ // takes effect on the next show, which is the only moment it can.
101
+
102
+ edgeColor: (v, value, ctx) => call(v, "setEdgeColor", [value], ctx.notify),
103
+ defaultOpacity: (v, value, ctx) => call(v, "setOpacity", [value], ctx.notify),
104
+ ambientIntensity: (v, value, ctx) => call(v, "setAmbientLight", [value], ctx.notify),
105
+ directIntensity: (v, value, ctx) => call(v, "setDirectLight", [value], ctx.notify),
106
+ metalness: (v, value, ctx) => call(v, "setMetalness", [value], ctx.notify),
107
+ roughness: (v, value, ctx) => call(v, "setRoughness", [value], ctx.notify),
108
+
109
+ // three-cad-viewer has had `setKeyMap(config)` all along (`viewer.ts:4538`)
110
+ // and nothing called it: the keymap reached only `new Display(...)`, which
111
+ // runs once per page, so the modifier keys a user configured applied in
112
+ // whichever host happened to pass them at splash and nowhere else, and could
113
+ // never be changed on a live viewer.
114
+ keymap: (v, value) => v.setKeyMap(value),
115
+
116
+ zoomSpeed: (v, value, ctx) => call(v, "setZoomSpeed", [value], ctx.notify),
117
+ panSpeed: (v, value, ctx) => call(v, "setPanSpeed", [value], ctx.notify),
118
+ rotateSpeed: (v, value, ctx) => call(v, "setRotateSpeed", [value], ctx.notify),
119
+
120
+ // "light", "dark", or "browser" to follow the surface. Applied on a live
121
+ // viewer as well as at construction, which is what lets a host whose theme
122
+ // changes under it - a VS Code colour theme - say so without rebuilding.
123
+ theme: (v, value) => v.setTheme(value),
124
+
125
+ glass: (v, value, ctx) => call(v, "glassMode", [value], ctx.notify),
126
+ tools: (v, value, ctx) => call(v, "showTools", [value], ctx.notify),
127
+ tab: (v, value, ctx) => call(v, "setActiveTab", [value], ctx.notify),
128
+ explode: (v, value, ctx) => call(v, "setExplode", [value], ctx.notify),
129
+
130
+ // The value is the CollapseState the renderer uses. Python's Collapse enum
131
+ // carries those same numbers, so unwrapping the enum is the whole of the
132
+ // translation and nothing is mapped here.
133
+ collapse: (v, value, ctx) => call(v, "collapseNodes", [value], ctx.notify),
134
+
135
+ clipIntersection: (v, value, ctx) => call(v, "setClipIntersection", [value], ctx.notify),
136
+ clipPlaneHelpers: (v, value, ctx) => call(v, "setClipPlaneHelpers", [value], ctx.notify),
137
+ clipObjectColors: (v, value, ctx) => call(v, "setClipObjectColorCaps", [value], ctx.notify),
138
+
139
+ zebraCount: (v, value, ctx) => call(v, "setZebraCount", [value], ctx.notify),
140
+ zebraOpacity: (v, value, ctx) => call(v, "setZebraOpacity", [value], ctx.notify),
141
+ zebraDirection: (v, value, ctx) => call(v, "setZebraDirection", [value], ctx.notify),
142
+ zebraColorScheme: (v, value, ctx) => call(v, "setZebraColorScheme", [value], ctx.notify),
143
+ zebraMappingMode: (v, value, ctx) => call(v, "setZebraMappingMode", [value], ctx.notify),
144
+
145
+ // The studio family. cad-viewer-widget has had these as a setter table;
146
+ // ocp_vscode accepts all eleven through set_viewer_config and has no branch
147
+ // for any of them, so sending one posts a message the viewer drops without a
148
+ // word. Unifying the dispatch closes that by construction, and it is a gain in
149
+ // capability for ocp_vscode rather than a like-for-like move.
150
+ studioEnvironment: (v, value, ctx) => call(v, "setStudioEnvironment", [value], ctx.notify),
151
+ studioEnvIntensity: (v, value, ctx) => call(v, "setStudioEnvIntensity", [value], ctx.notify),
152
+ studioEnvRotation: (v, value, ctx) => call(v, "setStudioEnvRotation", [value], ctx.notify),
153
+ studioBackground: (v, value, ctx) => call(v, "setStudioBackground", [value], ctx.notify),
154
+ studioToneMapping: (v, value, ctx) => call(v, "setStudioToneMapping", [value], ctx.notify),
155
+ studioExposure: (v, value, ctx) => call(v, "setStudioExposure", [value], ctx.notify),
156
+ studioShadowIntensity: (v, value, ctx) =>
157
+ call(v, "setStudioShadowIntensity", [value], ctx.notify),
158
+ studioShadowSoftness: (v, value, ctx) => call(v, "setStudioShadowSoftness", [value], ctx.notify),
159
+ studioAOIntensity: (v, value, ctx) => call(v, "setStudioAOIntensity", [value], ctx.notify),
160
+ studioTextureMapping: (v, value, ctx) => call(v, "setStudioTextureMapping", [value], ctx.notify),
161
+ studio4kEnvMaps: (v, value, ctx) => call(v, "setStudio4kEnvMaps", [value], ctx.notify),
162
+
163
+ // "reset" means iso plus a resize; the other view names pass through.
164
+ resetCamera: (v, value) => {
165
+ if (value === "reset") {
166
+ v.setView("iso");
167
+ v.resize();
168
+ } else if (VIEWS.includes(value)) {
169
+ v.setView(value);
170
+ }
171
+ },
172
+
173
+ // Deactivate whatever measurement tool is on before activating the new one.
174
+ // `setTool(name, false)` is a no-op when that tool was not on, so this is safe
175
+ // to run unconditionally. "off" leaves everything off.
176
+ analysisTool: (v, value) => {
177
+ const active = v.state.get("activeTool");
178
+ if (typeof active === "string" && TOOLS.includes(active)) {
179
+ v.display.setTool(active, false);
180
+ }
181
+ if (TOOLS.includes(value)) {
182
+ v.display.setTool(value, true);
183
+ }
184
+ },
185
+
186
+ // Batched on purpose: a per-key `setState` loop over a large model is one
187
+ // repaint per key and freezes the host. Paths absent from the current model
188
+ // are dropped - a state map outlives the model it was taken from.
189
+ states: (v, value, ctx) => {
190
+ const valid = Object.keys(v.treeview.getStates());
191
+ const next = {};
192
+ for (const path of Object.keys(value)) {
193
+ if (valid.includes(path)) {
194
+ next[path] = value[path];
195
+ }
196
+ }
197
+ if (Object.keys(next).length > 0) {
198
+ call(v, "setStates", [next], ctx.notify);
199
+ }
200
+ },
201
+ };
202
+
203
+ /** Clip sliders and normals are indexed by the digit their key ends with. */
204
+ function clipSetter(key) {
205
+ if (key.startsWith("clipSlider")) {
206
+ return (v, value, ctx) => call(v, "setClipSlider", [Number(key.slice(-1)), value], ctx.notify);
207
+ }
208
+ if (key.startsWith("clipNormal")) {
209
+ // The slider has to be handed back in, or setting a normal moves the plane.
210
+ return (v, value, ctx) => {
211
+ const index = Number(key.slice(-1));
212
+ call(v, "setClipNormal", [index, value, v.getClipSlider(index)], ctx.notify);
213
+ };
214
+ }
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * What the viewer currently holds for a key, or `undefined` if it cannot be
220
+ * read back.
221
+ *
222
+ * The other half of `SETTERS`, and here for the same reason: which getter
223
+ * answers for which option is three-cad-viewer's fact, not a host's, and a
224
+ * wrong pairing is silent. It exists for `applyConfig`'s `accept` hook - a host
225
+ * driven by change notifications needs to know whether a value is already in
226
+ * place, or it re-applies what the viewer just told it.
227
+ *
228
+ * `undefined` means "no way to ask", not "unset", and a caller should read that
229
+ * as "apply it". Several settings are genuinely write-only from here - explode
230
+ * and the tab are actions rather than state, and the studio family has no
231
+ * getters at all.
232
+ */
233
+ export function currentValue(viewer, key) {
234
+ const getter = GETTERS[key];
235
+ return getter === undefined ? undefined : getter(viewer);
236
+ }
237
+
238
+ const GETTERS = {
239
+ axes: (v) => v.getAxes(),
240
+ axes0: (v) => v.getAxes0(),
241
+ grid: (v) => v.getGrids(),
242
+ ortho: (v) => v.getOrtho(),
243
+ transparent: (v) => v.getTransparent(),
244
+ blackEdges: (v) => v.getBlackEdges(),
245
+ tools: (v) => v.getTools(),
246
+
247
+ zoom: (v) => v.getCameraZoom(),
248
+ position: (v) => v.getCameraPosition(),
249
+ quaternion: (v) => v.getCameraQuaternion(),
250
+ target: (v) => v.getCameraTarget(),
251
+
252
+ edgeColor: (v) => v.getEdgeColor(),
253
+ defaultOpacity: (v) => v.getOpacity(),
254
+ ambientIntensity: (v) => v.getAmbientLight(),
255
+ directIntensity: (v) => v.getDirectLight(),
256
+ metalness: (v) => v.getMetalness(),
257
+ roughness: (v) => v.getRoughness(),
258
+
259
+ zoomSpeed: (v) => v.getZoomSpeed(),
260
+ panSpeed: (v) => v.getPanSpeed(),
261
+ rotateSpeed: (v) => v.getRotateSpeed(),
262
+
263
+ clipIntersection: (v) => v.getClipIntersection(),
264
+ clipPlaneHelpers: (v) => v.getClipPlaneHelpers(),
265
+ clipObjectColors: (v) => v.getObjectColorCaps(),
266
+
267
+ clipSlider0: (v) => v.getClipSlider(0),
268
+ clipSlider1: (v) => v.getClipSlider(1),
269
+ clipSlider2: (v) => v.getClipSlider(2),
270
+ clipNormal0: (v) => v.getClipNormal(0),
271
+ clipNormal1: (v) => v.getClipNormal(1),
272
+ clipNormal2: (v) => v.getClipNormal(2),
273
+ };
274
+
275
+ /** Whether this key is one `applyConfig` knows how to apply. */
276
+ export function isApplicable(key) {
277
+ return Boolean(SETTERS[key]) || Boolean(clipSetter(key)) || GEOMETRY_KEYS.includes(key);
278
+ }
279
+
280
+ /**
281
+ * Apply a configuration to a live viewer.
282
+ *
283
+ * @param viewer a three-cad-viewer instance
284
+ * @param config {key: value}, camelCase keys and plain JSON values
285
+ * @param ctx optional hooks:
286
+ * notify - passed as the trailing flag to every setter
287
+ * that takes one; omitted entirely when undefined
288
+ * resize - (key, value) => void, for a viewport dimension,
289
+ * which only the host can resolve
290
+ * accept - (key, value) => boolean, a chance to skip a key
291
+ * whose value the viewer already holds. The widget
292
+ * needs it: driven by traitlet changes, it would
293
+ * otherwise re-apply what it just reported
294
+ * onUnknown - (key, value) => void, for a key nothing here
295
+ * handles. Left unset the key is dropped, which is
296
+ * the default, but silently
297
+ * @returns the keys that were applied
298
+ */
299
+ export function applyConfig(viewer, config, ctx = {}) {
300
+ if (viewer == null || config == null) {
301
+ return [];
302
+ }
303
+ const applied = [];
304
+ for (const key of Object.keys(config)) {
305
+ const value = config[key];
306
+
307
+ if (ctx.accept && !ctx.accept(key, value)) {
308
+ continue;
309
+ }
310
+
311
+ if (GEOMETRY_KEYS.includes(key)) {
312
+ if (ctx.resize) {
313
+ ctx.resize(key, value);
314
+ applied.push(key);
315
+ }
316
+ continue;
317
+ }
318
+
319
+ const setter = SETTERS[key] || clipSetter(key);
320
+ if (setter) {
321
+ setter(viewer, value, ctx);
322
+ applied.push(key);
323
+ } else if (ctx.onUnknown) {
324
+ ctx.onUnknown(key, value);
325
+ }
326
+ }
327
+ return applied;
328
+ }
package/src/index.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The shared viewer policy, as one entry point.
3
+ *
4
+ * Everything here is host-neutral. All of it but `page.js` takes a
5
+ * three-cad-viewer instance and plain data and touches no DOM, no transport
6
+ * and no host; `page.js` is the page, so it does. What stays with a host is
7
+ * how it starts - where it loaded these modules from, where its settings come
8
+ * from - and the sending half of its Comms.
9
+ *
10
+ * Names are camelCase and values are plain JSON, because that is what this side
11
+ * of the wire speaks. Python converts once, at the boundary, on its way out.
12
+ */
13
+
14
+ /*
15
+ Copyright 2026 Bernhard Walter
16
+
17
+ Licensed under the Apache License, Version 2.0 (the "License");
18
+ you may not use this file except in compliance with the License.
19
+ You may obtain a copy of the License at
20
+
21
+ http://www.apache.org/licenses/LICENSE-2.0
22
+
23
+ Unless required by applicable law or agreed to in writing, software
24
+ distributed under the License is distributed on an "AS IS" BASIS,
25
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
26
+ See the License for the specific language governing permissions and
27
+ limitations under the License.
28
+ */
29
+
30
+ export { applyConfig, currentValue, isApplicable, GEOMETRY_KEYS, VIEWS } from "./apply.js";
31
+
32
+ export {
33
+ buildDisplayOptions,
34
+ buildRenderOptions,
35
+ buildViewerOptions,
36
+ preset,
37
+ DISPLAY_DEFAULTS,
38
+ RENDER_DEFAULTS,
39
+ RENDER_OPTION_KEYS,
40
+ VIEWER_DEFAULTS,
41
+ VIEWER_OPTION_KEYS,
42
+ } from "./options.js";
43
+
44
+ // Drawing a model and deciding where the camera ends up - the policy every
45
+ // client has to agree on, and the one that was hardest to get right.
46
+ export { createRenderer } from "./render.js";
47
+
48
+ // The page itself: the viewer, the message handling and the resizing. The one
49
+ // module here that touches the DOM, because it is the page.
50
+ export { createPage } from "./page.js";
51
+
52
+ export {
53
+ collectStates,
54
+ currentStates,
55
+ restoreStates,
56
+ statesToRestore,
57
+ } from "./states.js";
58
+
59
+ export { addAnimationTrack, animate, animationDuration } from "./animation.js";
60
+
61
+ export { createNotifier, EVENT_KEYS } from "./notify.js";
62
+
63
+ // The splash. Data rather than policy, and here for the same reason the policy
64
+ // is: every client shows it, and four of them had their own copy.
65
+ export { logo } from "./logo.js";