arcane-os 0.59.1 → 0.60.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,79 @@
1
+ # Local image edit inputs
2
+
3
+ The SDK input decoder prepares a PNG raster for the shared native image
4
+ runtime. Decoding does not load a model or perform inference. The runtime owns
5
+ model selection, supported editing operations, native calls and their complete
6
+ results. Applications retain the original image and decide whether to save a
7
+ newly generated result.
8
+
9
+ ## Decoder contract
10
+
11
+ `src/local-ai/image-editing.mjs` exports the internal worker function
12
+ `decodeImage({data, mediaType, signal})`.
13
+
14
+ | Field | Contract |
15
+ | --- | --- |
16
+ | `data` | Complete encoded PNG in a `Uint8Array`; a Node `Buffer` is also accepted. Only the supplied view is read. |
17
+ | `mediaType` | `image/png`, an empty string, or omitted. The PNG signature is read from the actual input. Other declared formats report unsupported input. |
18
+ | `signal` | Optional `AbortSignal` belonging to the image request. |
19
+
20
+ The promise returns `{width, height, channel, data}`. Dimensions come from the
21
+ source raster; `channel` is `3` for RGB or `4` for RGBA. Returned `data` is a
22
+ separate contiguous `Uint8Array` containing interleaved samples, with pixels
23
+ ordered left to right and rows ordered top to bottom. The native request owner
24
+ retains this allocation until the native operation actually completes,
25
+ including after a cancellation request.
26
+
27
+ Input decoding accepts static, non-interlaced, 8-bit RGB and RGBA PNGs, all
28
+ five PNG row filters, and image data spread across multiple `IDAT` chunks.
29
+ RGB transparent-color information is represented as RGBA. An optional suggested
30
+ palette on an RGB or RGBA image does not change its color type.
31
+
32
+ Indexed-color, grayscale, 16-bit, interlaced and animated PNGs currently report
33
+ unsupported input. JPEG and WebP are also unsupported by this decoder. The
34
+ decoder does not silently convert those formats or select one animation frame.
35
+
36
+ The original encoded image is copied for the operation and never modified or
37
+ detached. Decoding preserves stored color samples and straight alpha, including
38
+ RGB values underneath transparent pixels. It does not composite onto a chosen
39
+ background, rotate pixels from metadata, apply a color-profile conversion,
40
+ resize, crop or create a file. Metadata remains in the original image; the
41
+ native raster record contains pixels and dimensions only.
42
+
43
+ ## Execution and cancellation
44
+
45
+ Invoke the decoder inside the shared image runtime's existing worker.
46
+ Inflation uses asynchronous Node zlib. Row reconstruction yields between
47
+ scanlines so the worker can receive cancellation. An abort during inflation is
48
+ observed after that native operation settles; it does not claim instant
49
+ interruption. A cancelled decode returns no raster for inference.
50
+
51
+ `ARCANE_IMAGE_INPUT_UNSUPPORTED` describes an unavailable input format or
52
+ encoding. `ARCANE_IMAGE_DECODE_FAILED` describes unreadable PNG structure or
53
+ scanlines. Node zlib failures retain their complete original errors, and
54
+ cancellation retains the owning signal's reason or Node `AbortError`. A decode
55
+ failure never returns partial imagery or substitutes a different image.
56
+
57
+ ## Editing semantics at the native owner
58
+
59
+ Whole-image img2img uses an initial image, prompt and strength. Masked editing
60
+ adds a separate mask; instruction/reference editing uses a model that supports
61
+ reference conditioning. These are distinct operations and must be advertised
62
+ only when the selected model and native path implement them. Ordinary img2img
63
+ does not require LoRA training. The candidate engine documents this directly
64
+ with its base SD1.4 model in the
65
+ [tagged img2img example](https://github.com/leejet/stable-diffusion.cpp/blob/master-929-3f8527a/docs/sd.md#img2img-example).
66
+
67
+ Source dimensions are decoder output, not an implicit request to change the
68
+ generation dimensions. The model owner documents its actual canvas adaptation
69
+ and reports actual output dimensions. In the candidate engine, temporary
70
+ initial images use model preprocessing while caller images remain unchanged;
71
+ even `mode=none` does not suppress all later model adaptation. See the
72
+ [native preprocessing contract](https://github.com/leejet/stable-diffusion.cpp/blob/master-929-3f8527a/docs/image_preprocessing.md).
73
+
74
+ An RGB-only model such as the selected SD1.4 candidate consumes the stored RGB
75
+ and does not preserve alpha. The SDK must not silently choose a compositing
76
+ background or claim that passing RGBA preserves its displayed appearance.
77
+ Strength zero is also not an exact-copy operation: native img2img still uses
78
+ the model's encode, sampling and decode path. These model behaviors are
79
+ separate from the decoder preserving the original input.
@@ -0,0 +1,144 @@
1
+ # Native working files from stored model assets
2
+
3
+ The selected `0.60.0` package contains the model-assets helpers as internal
4
+ preparation. The public imports and Core registration shown below belong to
5
+ the pending local-image integration and are unavailable through `0.60.0`.
6
+ These examples document that integration's intended public contract.
7
+
8
+ `prepareCoreModelAssets()` streams complete browser `File` or `Blob` members
9
+ through the existing Core connection into native working files. DBOPFS keeps
10
+ the authoritative originals. The helper performs no model download and changes
11
+ neither stored content nor the model format.
12
+
13
+ Use complete members returned by `createDbopfsModelStore().ensure()` or files
14
+ read from the application's existing DBOPFS owner. The browser model store
15
+ already assembles persisted Range parts into one logical Blob per upstream
16
+ member. Preserve that member order and the original relative filenames,
17
+ including companion files required by a model.
18
+
19
+ ## Browser preparation
20
+
21
+ ```javascript
22
+ import {prepareCoreModelAssets} from 'arcane-os/ai/core-model-assets';
23
+ import {createCoreImageRuntime} from 'arcane-os/ai/core-image';
24
+
25
+ const stored = await modelStore.ensure(selectedSource, {signal});
26
+ const projection = await prepareCoreModelAssets({
27
+ client,
28
+ workingDirectory: selectedWorkingDirectory,
29
+ members: [{path: 'sd-v1-4.ckpt', file: stored.files[0]}],
30
+ signal,
31
+ onProgress: renderPreparationProgress
32
+ });
33
+ const image = createCoreImageRuntime({client});
34
+ try {
35
+ await image.load({
36
+ model: 'sd14',
37
+ assetProjectionId: projection.id,
38
+ resourcePaths: {model: 'sd-v1-4.ckpt'},
39
+ signal
40
+ });
41
+ } finally {
42
+ // Native loading takes its own retain handle before using these files.
43
+ await projection.release();
44
+ }
45
+ const result = await image.generate({
46
+ model: 'sd14',
47
+ prompt: 'A dignified raccoon who has been put in charge of the moon.',
48
+ signal
49
+ });
50
+ // Display or save the complete returned image through the application owner.
51
+ await image.unload();
52
+ ```
53
+
54
+ This example assumes that the application selected the `sd14` model and its
55
+ complete checkpoint source. Model choice, download approval, prompts, output
56
+ storage and the selected working directory remain application-owned.
57
+
58
+ `prepareCoreModelAssets({client,workingDirectory,members,signal,onProgress})`
59
+ requires an available Core connection exposing the model-assets service.
60
+ `client` defaults to the installed Core client. Every member is
61
+ `{path,file}`, where `path` is its relative filename or companion-file path and
62
+ `file` is the complete stored Blob or File. Relative working-directory paths
63
+ resolve from the native service's `appRoot`; an application may explicitly
64
+ select an absolute working directory.
65
+
66
+ The result contains `id`, `directory`, ordered
67
+ `members: [{path,nativePath}]`, and an idempotent asynchronous `release()`.
68
+ The operation creates a unique child directory under the selected working
69
+ directory. Member paths identify files inside that child directory. Original
70
+ DBOPFS files remain untouched.
71
+
72
+ Each incoming Blob stream chunk is encoded only for the existing JSON Core
73
+ transport. The helper waits for the corresponding native write before reading
74
+ the next chunk of that member. Independent members transfer concurrently;
75
+ the complete member set is returned only after every write and file close
76
+ finishes. There is no complete-model transport buffer or application content
77
+ limit. Progress reports actual preparation phases and member paths.
78
+
79
+ Cancellation stops active readers and requests. Failure joins the participating
80
+ transfers and requests native cleanup; that cleanup joins actual file writes
81
+ before removing the projection. Cleanup uses its own uncancelled operation so
82
+ an already-aborted preparation signal cannot skip it. Original failures and
83
+ cleanup failures remain observable.
84
+
85
+ ## Native ownership
86
+
87
+ ```javascript
88
+ import {createModelAssetService} from 'arcane-os/core/model-assets';
89
+
90
+ const modelAssets = createModelAssetService({appRoot});
91
+ // Register this same service with Core and pass it to the native engine owner.
92
+ const use = modelAssets.retain(projectionId);
93
+ try {
94
+ // Load and use the complete files from use.members.
95
+ await ownNativeModelLifetime(use.members);
96
+ } finally {
97
+ // ownNativeModelLifetime must join actual model release before returning.
98
+ await use.release();
99
+ }
100
+ ```
101
+
102
+ `createModelAssetService({appRoot})` returns a Core service with `current()`,
103
+ `retain(id)` and `dispose()`. `retain()` accepts only a completed projection
104
+ and returns `{id,directory,members,release}`. A native engine takes this handle
105
+ before loading and releases it after actual unload, failed-load cleanup or
106
+ worker exit. A rejected inference/load promise alone does not establish native
107
+ release. Multiple contexts can retain one prepared set, and repeated inference
108
+ on a retained context reuses its files.
109
+
110
+ The browser's `projection.release()` relinquishes preparation ownership. It
111
+ does not delete files retained by a native engine. Deletion follows the final
112
+ native release and removes only that operation's working directory. Core closes
113
+ independent services concurrently, so the model-assets service waits for all
114
+ native retain handles during disposal instead of assuming service order.
115
+ Owners must release their handles after joining their actual engine lifetime.
116
+
117
+ Closing a browser accessor does not by itself establish native unload.
118
+ Explicitly release unused prepared projections. Core disposal also releases
119
+ preparation ownership and cleans up after retained engine uses end.
120
+
121
+ `current()` returns `{closing,projections}`. Each projection reports its `id`,
122
+ `directory`, `state`, ordered `members`, `preparationOwned`, number of native
123
+ `uses`, and `error`. Core emits `modelAssets.state` for lifecycle changes.
124
+
125
+ ## Core transport operations
126
+
127
+ | Method | Parameters | Result |
128
+ | --- | --- | --- |
129
+ | `modelAssets.open` | `{id,workingDirectory,members:[{path}]}` | Preparing projection and native member paths |
130
+ | `modelAssets.write` | `{id,memberIndex,contentBase64}` | `{id,memberIndex,written:true}` after the native write |
131
+ | `modelAssets.complete` | `{id}` | Ready projection after all files close |
132
+ | `modelAssets.release` | `{id}` | Released preparation ownership; native uses may retain the files |
133
+ | `modelAssets.status` | `{}` | Current projection lifecycle records |
134
+
135
+ The browser helper owns this transport sequence. Correlation IDs identify
136
+ operations; they are unrelated to content identity. Original model files remain
137
+ separate and complete. A retry creates a new working projection from the stored
138
+ assets; it does not redownload them or replace the authoritative DBOPFS record.
139
+
140
+ The implementation uses browser streams, the existing portable Core JSON
141
+ transport and Node filesystem operations. Native paths and cleanup follow the
142
+ host's Windows, Linux or macOS filesystem behavior. Android requires its host's
143
+ Core/filesystem adaptation. These are source-level platform contracts, not a
144
+ claim of execution on every target.
@@ -387,8 +387,12 @@ existing `audioFormat` when the catalog does not declare response formats. When
387
387
  the selected model declares `speech.responseFormats`, that setting is used only when supported; if
388
388
  the setting is the instance's `opus` default and the model rejects it, the catalog's
389
389
  `speech.defaultResponseFormat` is used, while any other unsupported setting is
390
- rejected. It propagates the caller-owned signal and returns a playable `Blob`;
391
- it does not independently choose a provider, cloud fallback, model, runtime, or
390
+ rejected. It propagates the caller-owned signal and returns a playable `Blob`.
391
+ The native `LOCAL_SPEACH` route forwards that signal separately from its
392
+ synthesis payload to `Arcane.speech.synthesize(request, {signal})`; Core aborts
393
+ are normalized to `AbortError` with `ARCANE_AI_REQUEST_ABORTED`. Actual engine
394
+ interruption belongs to the registered service. It does not independently
395
+ choose a provider, cloud fallback, model, runtime, or
392
396
  voice policy for the application. Every call removes repeated same formatting
393
397
  marks from a cloned outbound input before delegation; the caller's payload stays
394
398
  unchanged. The third `preparation` argument is reserved for SDK-internal
@@ -529,8 +533,11 @@ operation boundary, generation, and stable reason. Muting, explicit
529
533
  cancellation, permission waiting, and superseded generations do not emit a
530
534
  failure. The operation event does not rewrite provider readiness; the consuming
531
535
  Chat/Speech surface owns its visible mute and recovery state.
532
- `fetchSTT(audioFile,signal)` propagates the caller-owned signal;
533
- provider routes accept a `Blob` or `File` directly and leave media decoding,
536
+ `fetchSTT(audioFile,signal)` propagates the caller-owned signal, including the
537
+ native `Arcane.speech.transcribe(request, {signal})` route. The native signal
538
+ remains separate from the complete audio request; Core aborts are normalized
539
+ to `AbortError` with `ARCANE_AI_REQUEST_ABORTED`.
540
+ Provider routes accept a `Blob` or `File` directly and leave media decoding,
534
541
  PCM normalization, and WAV construction to the selected shared provider;
535
542
  delivery suppression is guaranteed after abort, while underlying provider-stop
536
543
  claims remain limited to that provider's cancellation contract.
@@ -4168,7 +4175,9 @@ creating a profile or migrating saved data.
4168
4175
  Exact exports: `arcaneThemeReady`, `bootstrapArcaneTheme`, `default`, and
4169
4176
  `disposeArcaneThemeBootstrap`.
4170
4177
 
4171
- `disposeArcaneThemeBootstrap()` removes both bootstrap-owned subscriptions.
4178
+ `disposeArcaneThemeBootstrap()` removes bootstrap-owned subscriptions,
4179
+ native-window theme observers and listeners, and cancels its pending window
4180
+ request.
4172
4181
  The cache is a presentation hint, not the authority for preferences or user
4173
4182
  data. Preference loading and User readiness independently reconcile it; cache
4174
4183
  failures are logged without blocking the page.
@@ -4178,6 +4187,29 @@ does not establish restoration before the first paint. Use the classic
4178
4187
  [ThemePresentation head entry](#themepresentationjs) before CSS for that early
4179
4188
  restoration. Neither entry imports User or starts its storage lifecycle.
4180
4189
 
4190
+ When a native host exposes `Arcane.window.setTheme`, the shared bootstrap
4191
+ samples the app body's inherited `--background` and `--text-color`. It resolves
4192
+ CSS variables, named colors, HSL and other browser-supported color forms through
4193
+ computed style. Modern color spaces use the browser's sRGB canvas conversion;
4194
+ the resolved alpha is retained separately, including translucency close to one.
4195
+ Authored styles remain unchanged. Missing semantic properties are omitted.
4196
+
4197
+ One listener on the shared `arcane-theme-change` owner, root/body presentation
4198
+ attribute observers, stylesheet-load events and device-color-scheme changes
4199
+ schedule samples. Identical channel values do not issue another request.
4200
+ Initial sampling and preference/profile reconciliation run independently of
4201
+ rendering and native completion. Page hide cancels the pending renderer wait;
4202
+ page restoration samples again. Print presentation is excluded. The host keeps
4203
+ its last accepted colors across navigation until the next document changes
4204
+ them. No additional theme bus, polling, storage or system-appearance mutation is
4205
+ introduced by this forwarding path.
4206
+
4207
+ Ordinary browsers and facades without the window method install no native
4208
+ sampler. An older host's unavailable-method response is recorded once in
4209
+ developer diagnostics and stops that sampler; genuine bridge failures retain
4210
+ their complete diagnostics. See the [current-window contract](core-client.md#current-window-theme)
4211
+ for per-field results, cancellation and actual adapter availability.
4212
+
4181
4213
  ### Availability and normalization
4182
4214
 
4183
4215
  **Browser/native hybrid.** Theme state normalized; storage/native errors mixed. Transport: ThemeManager + Arcane.events. [Deep protocol details](protocols.md).
@@ -6924,13 +6924,17 @@ Import it from `arcane-os/core/client`. The result includes `runtime`, `events`,
6924
6924
  `Error` and the existing `ai`, `environment`, `mail`, `speech`, `localAI`,
6925
6925
  `ollama`, application, platform, storage and other host namespaces.
6926
6926
 
6927
- `facade.speech.synthesize(request, {signal})` accepts an optional `AbortSignal`
6928
- through the same correlated request lifetime. The complete request and existing
6929
- 180,000 ms timeout remain unchanged. A pre-aborted signal sends no synthesis
6927
+ `facade.speech.synthesize(request, {signal})` and
6928
+ `facade.speech.transcribe(request, {signal})` accept an optional `AbortSignal`
6929
+ through the same correlated request lifetime. Each complete request and existing
6930
+ 180,000 ms timeout remain unchanged. A pre-aborted signal sends no speech
6930
6931
  request; an in-flight abort emits that request's `request.cancel`, rejects with
6931
6932
  `ARCANE_REQUEST_ABORTED` and leaves unrelated requests active. Late responses
6932
- cannot settle the cancelled request. Actual native synthesis interruption belongs
6933
- to the registered service and engine; this facade supplies no engine.
6933
+ cannot settle the cancelled request. Actual native synthesis or transcription
6934
+ interruption belongs to the registered service and engine; this facade supplies
6935
+ no engine. The native routes of `AI.fetchTTS(payload, signal)` and
6936
+ `AI.fetchSTT(audio, signal)` forward their signals through these calls and
6937
+ normalize cancellation to `AbortError` with `ARCANE_AI_REQUEST_ABORTED`.
6934
6938
 
6935
6939
  ### Availability and normalization
6936
6940
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.59.1",
3
+ "version": "0.60.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -107,8 +107,8 @@
107
107
  "scripts": {
108
108
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
109
109
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
110
- "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/conversation-closing-report.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
111
- "test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-host.test.mjs test/core-packaged-web.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
110
+ "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/conversation-closing-report.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/native-windows-icon.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
111
+ "test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-host.test.mjs test/core-packaged-web.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/local-image-editing.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
112
112
  "test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-macos-provider.test.mjs test/native-plan.test.mjs test/native-portable-provider.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
113
113
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/html-import-packaged-script.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
114
114
  "check": "node tools/check-source.mjs && npm test",
@@ -3,11 +3,12 @@ const is=new Is(false);
3
3
 
4
4
  import { arcaneLogging } from 'arcane-os/logging';
5
5
  import {applyUserSkin,loadAndApplyTheme} from './ThemeManager.js';
6
- import {createArcaneEventSource} from 'arcane-os/event-manager';
6
+ import {arcaneEvents,createArcaneEventSource} from 'arcane-os/event-manager';
7
7
 
8
8
  const sharedKey='arcaneThemeReady';
9
9
  const listenerKey='arcaneThemeAppearanceListener';
10
10
  const userListenerKey='arcaneThemeUserListener';
11
+ const windowListenerKey='arcaneThemeWindowListener';
11
12
  const THEME_BOOTSTRAP_EVENT_OWNER={};
12
13
  let themeBootstrapOperationSequence=0;
13
14
  const themeBootstrapEvents=createArcaneEventSource(
@@ -99,11 +100,222 @@ function installUserSkinListener(){
99
100
  applyReadyUserSkin();
100
101
  }
101
102
 
103
+ function installWindowThemeListener(ready){
104
+ const host=globalThis.Arcane;
105
+ const document=globalThis.document;
106
+ if(globalThis[windowListenerKey]||!host?.runtime?.current?.().native
107
+ ||!is.function(host?.window?.setTheme)||!document?.documentElement) return;
108
+
109
+ let active=true;
110
+ let suspended=false;
111
+ let scheduled=false;
112
+ let lastPresentation=null;
113
+ let pending=null;
114
+ let observedBody=null;
115
+ let canvasContext=null;
116
+ const root=document.documentElement;
117
+ const scheme=globalThis.matchMedia('(prefers-color-scheme: dark)');
118
+ const print=globalThis.matchMedia('print');
119
+ const probe=document.createElement('span');
120
+ probe.style.setProperty('display','none','important');
121
+
122
+ function report(error){
123
+ arcaneLogging.warn('[Arcane theme] Unable to update the native window colors.',error);
124
+ }
125
+ function colorRecord(resolved){
126
+ // Read the browser's resolved color, not an authored CSS grammar. Keep
127
+ // its alpha separately so raster conversion cannot round translucency away.
128
+ const rgb=resolved.match(/^rgba?\(([^)]+)\)$/u);
129
+ if(rgb){
130
+ const values=rgb[1].split(/[\s,/]+/u).filter(Boolean).map(Number);
131
+ if((values.length===3||values.length===4)&&values.every(Number.isFinite)){
132
+ return {red:values[0],green:values[1],blue:values[2],alpha:values[3]??1};
133
+ }
134
+ }
135
+ const alphaMatch=resolved.match(/\/\s*(none|[-+\d.eE]+%?)\s*\)$/u);
136
+ const alphaValue=alphaMatch?.[1];
137
+ const alpha=alphaValue===undefined?1:alphaValue==='none'?0:
138
+ Number.parseFloat(alphaValue)/(alphaValue.endsWith('%')?100:1);
139
+ if(!Number.isFinite(alpha)||(resolved.includes('/')&&!alphaMatch)){
140
+ throw new TypeError(`Unable to read the resolved CSS color: ${resolved}`);
141
+ }
142
+ const opaque=alphaMatch?resolved.replace(/\/[^/]*\)$/u,'/ 1)'):resolved;
143
+ if(!canvasContext){
144
+ const canvas=document.createElement('canvas');
145
+ canvas.width=1;
146
+ canvas.height=1;
147
+ canvasContext=canvas.getContext(
148
+ '2d',
149
+ {colorSpace:'srgb'}
150
+ );
151
+ if(!canvasContext) throw new Error('The browser cannot convert the resolved color to sRGB.');
152
+ }
153
+ // An unsupported fillStyle assignment leaves the old value intact.
154
+ // Two different starting values distinguish rejection from a valid black.
155
+ canvasContext.fillStyle='#000000';
156
+ canvasContext.fillStyle=opaque;
157
+ const accepted=canvasContext.fillStyle;
158
+ canvasContext.fillStyle='#ffffff';
159
+ canvasContext.fillStyle=opaque;
160
+ if(canvasContext.fillStyle!==accepted){
161
+ throw new TypeError(`The browser canvas cannot convert the resolved CSS color: ${resolved}`);
162
+ }
163
+ canvasContext.clearRect(0,0,1,1);
164
+ canvasContext.fillRect(0,0,1,1);
165
+ const [red,green,blue]=canvasContext.getImageData(0,0,1,1).data;
166
+ return {red,green,blue,alpha};
167
+ }
168
+ function readPresentation(){
169
+ const target=document.body;
170
+ if(!target) return {};
171
+ const styles=globalThis.getComputedStyle(target);
172
+ const presentation={};
173
+ target.appendChild(probe);
174
+ try {
175
+ const properties=[
176
+ ['backgroundColor','--background'],
177
+ ['textColor','--text-color']
178
+ ];
179
+ for(const [field,property] of properties){
180
+ const authored=styles.getPropertyValue(property);
181
+ if(!authored.trim()) continue;
182
+ if(!globalThis.CSS.supports('color',authored)){
183
+ report(
184
+ new TypeError(`The app theme ${property} is not a supported CSS color: ${authored}`)
185
+ );
186
+ continue;
187
+ }
188
+ // Use the target's resolved value: registered properties may
189
+ // deliberately not inherit into this child resolution element.
190
+ probe.style.setProperty('color',authored,'important');
191
+ try {
192
+ const resolved=globalThis.getComputedStyle(probe).color;
193
+ presentation[field]=colorRecord(resolved);
194
+ } catch (error) {
195
+ report(error);
196
+ }
197
+ }
198
+ } finally {
199
+ probe.remove();
200
+ }
201
+ return presentation;
202
+ }
203
+ function samePresentation(next){
204
+ return lastPresentation&&['backgroundColor','textColor'].every(
205
+ function sameColor(field){
206
+ const before=lastPresentation[field];
207
+ const after=next[field];
208
+ if(before===after) return true;
209
+ if(!before||!after) return false;
210
+ return ['red','green','blue','alpha'].every(
211
+ function sameChannel(channel){
212
+ return before[channel]===after[channel];
213
+ }
214
+ );
215
+ }
216
+ );
217
+ }
218
+ function observeElements(){
219
+ if(observedBody===document.body) return;
220
+ observer.disconnect();
221
+ observer.observe(
222
+ root,
223
+ {attributes:true,childList:true,attributeFilter:['class','style','data-color-scheme','data-user-skin','data-arcane-skin']}
224
+ );
225
+ observedBody=document.body;
226
+ if(observedBody) observer.observe(
227
+ observedBody,
228
+ {attributes:true,attributeFilter:['class','style']}
229
+ );
230
+ }
231
+ function sample(){
232
+ scheduled=false;
233
+ if(!active||suspended||print.matches) return;
234
+ observeElements();
235
+ const presentation=readPresentation();
236
+ if(!Object.keys(presentation).length||samePresentation(presentation)) return;
237
+ lastPresentation=presentation;
238
+ pending?.abort();
239
+ const controller=new AbortController();
240
+ pending=controller;
241
+ try {
242
+ const operation=host.window.setTheme(
243
+ presentation,
244
+ {signal:controller.signal}
245
+ );
246
+ Promise.resolve(operation).catch(
247
+ function reportWindowFailure(error){
248
+ if(error?.code==='ARCANE_REQUEST_ABORTED'||(controller.signal.aborted&&error?.name==='AbortError')) return;
249
+ report(error);
250
+ if(error?.code==='METHOD_NOT_ALLOWED') dispose();
251
+ }
252
+ ).finally(
253
+ function finishWindowUpdate(){
254
+ if(pending===controller) pending=null;
255
+ }
256
+ );
257
+ } catch (error) {
258
+ if(pending===controller) pending=null;
259
+ report(error);
260
+ }
261
+ }
262
+ function schedule(){
263
+ if(!active||suspended||scheduled) return;
264
+ scheduled=true;
265
+ Promise.resolve().then(sample).catch(report);
266
+ }
267
+ function stylesheetLoaded(event){
268
+ if(event.target?.tagName==='LINK'&&event.target.relList?.contains('stylesheet')) schedule();
269
+ }
270
+ function hide(){
271
+ suspended=true;
272
+ pending?.abort();
273
+ }
274
+ function show(){
275
+ suspended=false;
276
+ lastPresentation=null;
277
+ schedule();
278
+ }
279
+ function dispose(){
280
+ if(!active) return false;
281
+ active=false;
282
+ pending?.abort();
283
+ observer.disconnect();
284
+ unsubscribe();
285
+ scheme.removeEventListener('change',schedule);
286
+ print.removeEventListener('change',schedule);
287
+ document.removeEventListener('DOMContentLoaded',schedule);
288
+ document.removeEventListener('load',stylesheetLoaded,true);
289
+ globalThis.removeEventListener('pagehide',hide);
290
+ globalThis.removeEventListener('pageshow',show);
291
+ canvasContext=null;
292
+ if(globalThis[windowListenerKey]===dispose) delete globalThis[windowListenerKey];
293
+ return true;
294
+ }
295
+ const observer=new globalThis.MutationObserver(schedule);
296
+ const unsubscribe=arcaneEvents.subscribe('arcane-theme-change',schedule);
297
+ observer.observe(
298
+ root,
299
+ {attributes:true,childList:true,attributeFilter:['class','style','data-color-scheme','data-user-skin','data-arcane-skin']}
300
+ );
301
+ observeElements();
302
+ scheme.addEventListener('change',schedule);
303
+ print.addEventListener('change',schedule);
304
+ document.addEventListener('DOMContentLoaded',schedule);
305
+ document.addEventListener('load',stylesheetLoaded,true);
306
+ globalThis.addEventListener('pagehide',hide);
307
+ globalThis.addEventListener('pageshow',show);
308
+ globalThis[windowListenerKey]=dispose;
309
+ schedule();
310
+ Promise.resolve(ready).then(schedule).catch(report);
311
+ }
312
+
102
313
  export function bootstrapArcaneTheme(options={}){
103
314
  const useSharedPromise=Object.keys(options).length===0;
104
315
  if(useSharedPromise&&globalThis[sharedKey]){
105
316
  installAppearanceListener(globalThis[sharedKey]);
106
317
  installUserSkinListener();
318
+ installWindowThemeListener(globalThis[sharedKey]);
107
319
  return globalThis[sharedKey];
108
320
  }
109
321
 
@@ -116,6 +328,7 @@ export function bootstrapArcaneTheme(options={}){
116
328
  if(useSharedPromise){
117
329
  installAppearanceListener(ready);
118
330
  installUserSkinListener();
331
+ installWindowThemeListener(ready);
119
332
  }
120
333
  return ready;
121
334
  }
@@ -123,9 +336,11 @@ export function bootstrapArcaneTheme(options={}){
123
336
  export function disposeArcaneThemeBootstrap(){
124
337
  const dispose=globalThis[listenerKey];
125
338
  const disposeUser=globalThis[userListenerKey];
339
+ const disposeWindow=globalThis[windowListenerKey];
126
340
  const appearanceDisposed=is.function(dispose)?dispose():false;
127
341
  const userDisposed=is.function(disposeUser)?disposeUser():false;
128
- return appearanceDisposed||userDisposed;
342
+ const windowDisposed=is.function(disposeWindow)?disposeWindow():false;
343
+ return appearanceDisposed||userDisposed||windowDisposed;
129
344
  }
130
345
 
131
346
  export const arcaneThemeReady=bootstrapArcaneTheme();