arcane-os 0.36.0 → 0.37.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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.37.0
4
+
5
+ - Add opt-in `markdown-editor` fit layout and preview following through
6
+ `configure({fit:true, followPreview:true})` or `data-fit` and
7
+ `data-follow-preview`. Both options default to false. Fit mode keeps the
8
+ title, formatting controls, input, preview and save/status actions visible
9
+ in a bounded responsive layout with independent input and preview scrolling.
10
+ - Follow body edits and formatting at their rendered Markdown block without
11
+ moving the input or containing page. Title edits and manual preview reading
12
+ preserve the reader's position. Expose named CSS parts for application styling.
13
+ - Add `new MD(raw,{sourceMap:true})` with ordered original UTF-16 source ranges
14
+ and rendered comment anchors. Preserve complete raw content, ordinary default
15
+ rendering, formatting, save payloads, cancellation and cleanup. No saved-data
16
+ migration or model/provider/default changes.
17
+
18
+ ## 0.36.1
19
+
20
+ - Infer ordinary audio MIME types from mp3, wav, ogg, oga, opus, aac, m4a,
21
+ flac and weba filenames when stored files have no supplied MIME metadata.
22
+ Native file previews can select audio controls without decoding audio as text.
23
+ - Preserve supplied MIME types, existing non-audio classification, the original
24
+ file and complete payload. An untyped webm remains ambiguous and retains its
25
+ ordinary download behavior. No stored files are rewritten.
26
+
3
27
  ## 0.36.0
4
28
 
5
29
  - Add optional continuous microphone capture to `voice-transcription` through
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.36.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.37.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -515,7 +515,7 @@
515
515
  {
516
516
  "file": "runtime/arcane/components/markdown-editor.html",
517
517
  "name": "markdown-editor.html",
518
- "purpose": "Configurable Markdown authoring, toolbar, preview, title, and save surface.",
518
+ "purpose": "Configurable Markdown authoring, toolbar, preview, plain-text title, and save surface with optional bounded input/preview panes and edit-position following.",
519
519
  "methods": [
520
520
  "configure()",
521
521
  "focus()",
@@ -535,7 +535,7 @@
535
535
  ],
536
536
  "availability": "Browser and supported native WebViews",
537
537
  "transport": "HTMLImport + DOM; injected Arcane/provider modules where listed",
538
- "normalization": "Editor values normalized; injected save result mixed"
538
+ "normalization": "Editor values and optional presentation behavior normalized; injected save result mixed. fit and followPreview default false, preserve omitted prior configuration, and accept explicit false resets; data-fit and data-follow-preview select the initial options. Fit requires an application-bounded host and provides independent input/preview scrolling in roomy columns or narrow stacked panes without changing the application shell. Following responds to body typing/format edits, leaves manual preview scrolling independent until the next edit, and does not treat title edits or programmatic value replacement as follow requests. Public parts editor/title/toolbar/input/preview/actions/status/save-action allow application styling while the title stays native plain text. Existing controls, complete save payload, save cancellation, and destruction behavior remain."
539
539
  },
540
540
  {
541
541
  "file": "runtime/arcane/components/media-embed.html",
@@ -417,7 +417,7 @@
417
417
  "availability": "Cross-host with an injected event constructor outside DOM hosts",
418
418
  "protocol": "In-process only",
419
419
  "normalization": "Fully normalized labels, rows, definitions, visibility, formats, editor and voice options, plus capability-neutral STT activation intent and presentation state. Complete finite progress measures remain visible, including fractional and over-total values.",
420
- "surface": "Constant sets plus normalization, complete finite-progress formatting, and explicit STT activation helpers. normalizeVoiceOptions preserves manual capture and standard controls by default and accepts continuous capture timing/activity options, simple controls, showComplete, and retry/cancel labels."
420
+ "surface": "Constant sets plus normalization, complete finite-progress formatting, and explicit STT activation helpers. normalizeVoiceOptions preserves manual capture and standard controls by default and accepts continuous capture timing/activity options, simple controls, showComplete, and retry/cancel labels. normalizeMarkdownOptions adds fit and followPreview strict booleans default false, retains omitted prior values, and accepts explicit false resets without changing existing editor options."
421
421
  },
422
422
  {
423
423
  "file": "runtime/arcane/modules/ConfiguredAIChatSession.js",
@@ -885,11 +885,11 @@
885
885
  "exports": [
886
886
  "default"
887
887
  ],
888
- "summary": "Renders complete Markdown with Marked and exposes the complete rendered markup.",
888
+ "summary": "Renders complete Markdown with Marked and optionally maps source blocks to rendered comment anchors.",
889
889
  "availability": "Browser / native WebView",
890
890
  "protocol": "Marked",
891
- "normalization": "Complete raw and rendered Marked values; parse errors remain vendor-native.",
892
- "surface": "default `MD`; `raw`, `rendered`, `safeRendered`, `append()`."
891
+ "normalization": "Complete raw and rendered Marked values; parse errors remain vendor-native. Opt-in source mapping preserves original UTF-16 offsets and adds only comment anchors to complete rendered output. Lists, quotes, and tables map at top-level block granularity; invisible source or unavailable parsed anchors use the consumer's nearest available block behavior. Mapping is rebuilt on raw replacement and append, without persistence or viewport movement.",
892
+ "surface": "default MD; new MD(raw,{sourceMap:true}) enables ordered sourceMap records {start,end,marker,type}, with inclusive start/exclusive end in original UTF-16 source positions and marker equal to generated comment data. Locate anchors with a comment TreeWalker, not a CSS selector. Markers are opaque and may change after edits. sourceMap exposes the current mutable array, replaced after each successful render. raw, rendered, safeRendered, and append() remain; default sourceMap is empty and ordinary output is unchanged. Rendering and getters are DOM-independent."
893
893
  },
894
894
  {
895
895
  "file": "runtime/arcane/modules/MemoryRecords.js",
@@ -77,7 +77,7 @@ appropriate.
77
77
  | [`integration-settings.html`](#integration-settingshtml) | Edits non-secret communication service configuration and service actions. | `configure()`<br>`getValues()`<br>`setStatus()`<br>`destroy()` | `integration-settings-ready`<br>`integration-settings-save`<br>`integration-settings-close`<br>`integration-action` | Normalized non-secret values |
78
78
  | [`local-ai-status.html`](#local-ai-statushtml) | Presents local-AI standby, failure, recovery, guidance, retry, and dismissal states. | `configure()`<br>`begin()`<br>`present()`<br>`destroy()`<br>`hidden` | `local-ai-status-ready`<br>`local-ai-status-dismissed`<br>`local-ai-retry` | Fully normalized LocalAIReadiness report |
79
79
  | [`markdown-document.html`](#markdown-documenthtml) | Renders and navigates a complete Markdown document with focusable fragments. | `configure()`<br>`load()`<br>`render()`<br>`clear()`<br>`fail()`<br>`focus()`<br>`focusFragment()`<br>`destroy()` | `markdown-document-ready`<br>`markdown-document-state`<br>`markdown-document-loading`<br>`markdown-document-rendered`<br>`markdown-document-empty`<br>`markdown-document-error`<br>`markdown-document-navigate` | Complete Markdown/state normalized; malformed input and Marked/DOM failures remain visible |
80
- | [`markdown-editor.html`](#markdown-editorhtml) | Configurable Markdown authoring, toolbar, preview, title, and save surface. | `configure()`<br>`focus()`<br>`clear()`<br>`saveEntry()`<br>`destroy()` | `markdown-editor-ready`<br>`markdown-editor-change`<br>`markdown-editor-saved` | Editor values normalized; injected save result mixed |
80
+ | [`markdown-editor.html`](#markdown-editorhtml) | Configurable Markdown authoring with optional bounded input/preview panes and edit-position following. | `configure()`<br>`focus()`<br>`clear()`<br>`saveEntry()`<br>`destroy()` | `markdown-editor-ready`<br>`markdown-editor-change`<br>`markdown-editor-saved` | Editor values, opt-in fit layout, and preview following normalized; injected save result mixed |
81
81
  | [`media-embed.html`](#media-embedhtml) | Loads a parsed YouTube video or playlist embed with ordinary hosting by default, optional privacy enhancement, and an external-platform action. | `configure()`<br>`load()`<br>`destroy()` | `media-embed-ready`<br>`media-load`<br>`media-error`<br>`media-open-platform` | URL/error normalized; iframe/platform behavior native |
82
82
  | [`modal.html`](#modalhtml) | Generic modal with population, configurable user dismissal, actions, and concurrent task execution. | `configure()`<br>`populate()`<br>`open()`<br>`close()`<br>`runTasks()`<br>`destroy()`<br>`running`<br>`opened` | `modal-ready`<br>`modal-opened`<br>`modal-closed`<br>`modal-action` | Modal state normalized; injected task results mixed |
83
83
  | [`output-panel.html`](#output-panelhtml) | Presents status, output, body, coverage, actions, pending, error, and cleared states. | `configure()`<br>`setOutput()`<br>`setBody()`<br>`setCoverage()`<br>`setActions()`<br>`setPending()`<br>`setStatus()`<br>`setError()`<br>`clear()`<br>`destroy()` | `output-panel-ready`<br>`output-panel-state`<br>`output-panel-change`<br>`output-panel-action`<br>`output-panel-error`<br>`output-panel-cleared` | DOM-normalized |
@@ -1021,11 +1021,56 @@ subject to the bound app's capabilities. [Deep protocol details](protocols.md).
1021
1021
 
1022
1022
  ### Overview
1023
1023
 
1024
- Configurable Markdown authoring, toolbar, preview, title, and save surface.
1024
+ Configurable Markdown authoring, toolbar, preview, plain-text title, and save
1025
+ surface. The default editor retains its growing stacked layout and existing
1026
+ controls. Optional fit mode keeps the Markdown input and rendered preview
1027
+ inside an application-supplied bounded area.
1025
1028
 
1026
1029
  ### Public surface
1027
1030
 
1028
- Methods/properties: `configure()`, `focus()`, `clear()`, `saveEntry()`, `destroy()`.
1031
+ Methods/properties: `configure()`, `focus()`, `clear()`, `saveEntry()`,
1032
+ `destroy()`, `value`, `entryTitle`, `options`.
1033
+
1034
+ `configure({fit:true,followPreview:true})` opts into the two independent
1035
+ behaviors. Both options default to `false`; omitted options retain their
1036
+ previous values during reconfiguration, and explicit `false` disables either.
1037
+ Initial markup can use `data-fit="true"` and `data-follow-preview="true"`.
1038
+
1039
+ `fit:true` fills the host's available height. The application must supply a
1040
+ bounded host height and allow its enclosing flex/grid item to shrink, for
1041
+ example with `min-height:0`. Input and preview have independent scroll areas:
1042
+ two columns when room permits and two bounded stacked panes on narrow widths.
1043
+ Title, toolbar, status, and save remain outside those scrolling panes. Hiding
1044
+ the preview leaves the input the available pane space. Fit mode does not
1045
+ change the application's fixed shell or scroll the page.
1046
+
1047
+ `followPreview:true` follows the Markdown body's active edit position after
1048
+ typing or applying a toolbar format. It scrolls the preview only as needed
1049
+ to bring a short corresponding block into view; a block taller than the pane
1050
+ uses the caret's approximate fraction within that block. Users can scroll the
1051
+ preview independently, and rerendering retains that reading position; the next
1052
+ body edit may reposition it. Title edits,
1053
+ programmatic value replacement, and ordinary preview scrolling do not request
1054
+ edit-position following. Moving the selection alone does not trigger following,
1055
+ and following never scrolls the input pane or containing page. The title
1056
+ remains a native plain-text input.
1057
+ Following uses the shared [Markdown block mapping](runtime-modules.md#mdjs);
1058
+ lists, quotes, and tables follow their block, while invisible source positions
1059
+ use a nearby available rendered block rather than exact glyph geometry.
1060
+
1061
+ Public CSS parts are `editor`, `title`, `toolbar`, `input`, `preview`,
1062
+ `actions`, `status`, and `save-action`. Applications can make the title
1063
+ borderless and compact through `::part(title)` while retaining its native
1064
+ editing behavior and visible keyboard focus. These parts are styling hooks,
1065
+ not slots or replacement controls.
1066
+
1067
+ Existing `showTitle`, `showToolbar`, `showSave`, `showPreview`, `readOnly`,
1068
+ `clearOnSave`, labels, formats, and callbacks keep their behavior. `onSave`
1069
+ or the host's `save` receives complete `{title,markdown}` plus a second
1070
+ `{signal,operationId}` context; layout and following add no fields to the
1071
+ saved content. Edits, replacement, clear, and destruction retain the existing
1072
+ save cancellation and late-result suppression. Destruction also cancels the
1073
+ pending preview frame.
1029
1074
 
1030
1075
  Events: `markdown-editor-ready`, `markdown-editor-change`, `markdown-editor-saved`.
1031
1076
 
@@ -1033,7 +1078,10 @@ Shared dependencies: [`MD.js`](runtime-modules.md#mdjs), [`ComponentContracts.js
1033
1078
 
1034
1079
  ### Availability and normalization
1035
1080
 
1036
- **Browser and supported native WebViews.** Editor values normalized; injected save result mixed. HTMLImport + DOM; injected Arcane/provider modules where listed. Native methods remain subject to the bound app's capabilities. [Deep protocol details](protocols.md).
1081
+ **Browser and supported native WebViews.** Editor values, opt-in fit layout,
1082
+ and preview following are normalized; the injected save result remains
1083
+ application-owned. HTMLImport + DOM; native methods remain subject to the
1084
+ bound app's capabilities. [Deep protocol details](protocols.md).
1037
1085
 
1038
1086
  ### Example
1039
1087
 
@@ -1044,6 +1092,28 @@ Shared dependencies: [`MD.js`](runtime-modules.md#mdjs), [`ComponentContracts.js
1044
1092
  </html-import>
1045
1093
  ```
1046
1094
 
1095
+ For a bounded editor, choose the height in the application and style only the
1096
+ public title part:
1097
+
1098
+ ```html
1099
+ <style>
1100
+ #field-notes { display:block; height:32rem; min-height:0; }
1101
+ #field-notes::part(title) {
1102
+ border:0;
1103
+ border-radius:0;
1104
+ background:transparent;
1105
+ padding:.35em 0;
1106
+ }
1107
+ </style>
1108
+ <html-import
1109
+ id="field-notes"
1110
+ href="/arcane/components/markdown-editor.html"
1111
+ data-fit="true"
1112
+ data-follow-preview="true"
1113
+ data-title-placeholder="Dragon field notes">
1114
+ </html-import>
1115
+ ```
1116
+
1047
1117
  ## media-embed.html
1048
1118
 
1049
1119
  ### Overview
@@ -98,7 +98,7 @@ own asynchronous work, cancellation, and backpressure.
98
98
  | [`MailTransport.mjs`](#mailtransportmjs) | esm | Sends one complete mail report to a normalized HTTP(S) endpoint. | Browser/server with fetch + cloud | Normalized endpoint and transport errors; remote detail preserved. |
99
99
  | [`MarkdownSpeech.js`](#markdownspeechjs) | esm | Removes repeated Markdown formatting marks from streamed narration. | Cross-host | Speech-only filtering; single marks and ordinary punctuation remain literal. |
100
100
  | [`Marked.min.js`](#markedminjs) | esm | Vendored Marked 18.0.5 Markdown lexer, parser, renderer, extension, and walk-token API. | Cross-host vendor module | Vendor-native Marked contract. |
101
- | [`MD.js`](#mdjs) | esm | Renders complete Markdown with Marked and exposes the complete rendered markup. | Browser / native WebView | Complete raw and rendered Marked values; parse errors vendor-native. |
101
+ | [`MD.js`](#mdjs) | esm | Renders complete Markdown with Marked and optionally maps source blocks to rendered comment anchors. | Browser / native WebView | Complete raw and rendered Marked values; opt-in original-source offsets; parse errors vendor-native. |
102
102
  | [`MemoryRecords.js`](#memoryrecordsjs) | esm | Normalizes memory content and detects meaningful stored memory. | Cross-host | Fully normalized string/boolean results. |
103
103
  | [`MessageAdvisory.js`](#messageadvisoryjs) | esm | Normalizes message content advisories and contains per-message inspection failures. | Cross-host | Normalized advisory records; inspector failures converted to unavailable results. |
104
104
  | [`ModelDefinition.js`](#modeldefinitionjs) | esm | Parses the deterministic packaged Modelfile subset and extracts the SYSTEM prompt. | Cross-host | Complete mutable definition with coded malformed-input errors. |
@@ -1647,6 +1647,14 @@ the shared voice component. Positive quiet/chunk durations and nonnegative
1647
1647
  pre-roll/activity values must be finite. Complete callback payloads remain
1648
1648
  owned by the component and application.
1649
1649
 
1650
+ `normalizeMarkdownOptions(input,previous)` adds strict opt-in `fit` and
1651
+ `followPreview` booleans, both defaulting to `false`. Omitted fields preserve
1652
+ the corresponding previous value; explicit `false` disables the option.
1653
+ Existing Markdown labels, formats, visibility, read-only state, save behavior,
1654
+ callbacks, complete initial Markdown, and plain initial title remain intact.
1655
+ The shared editor consumes these options for bounded responsive panes and
1656
+ body-edit preview following; the normalizer itself performs no DOM work.
1657
+
1650
1658
  `createSTTActivationController({host,button,progress=null,onChange,EventClass=CustomEvent})`
1651
1659
  consumes only normalized
1652
1660
  [`AIRuntimeState`](#airuntimestatejs) `stt` role records. Its mutable controller
@@ -2759,25 +2767,66 @@ console.log(Object.keys(module));
2759
2767
  ### Overview
2760
2768
 
2761
2769
  Renders complete Markdown with Marked and exposes the same complete rendered
2762
- markup through `rendered` and `safeRendered`.
2763
-
2764
- ### Public surface
2765
-
2766
- default `MD`; `raw`, `rendered`, `safeRendered`, `append()`.
2770
+ markup through `rendered` and `safeRendered`. Optional source mapping lets a
2771
+ consumer locate rendered blocks without adding visible wrappers or changing
2772
+ the authored Markdown.
2773
+
2774
+ ### Public surface
2775
+
2776
+ Default `MD`; `raw`, `rendered`, `safeRendered`, `sourceMap`, `append()`.
2777
+
2778
+ `new MD(raw,{sourceMap:true})` enables block mapping. The `sourceMap` getter
2779
+ returns ordered `{start,end,marker,type}` records. `start` is inclusive and
2780
+ `end` is exclusive, measured in UTF-16 positions in the exact original `raw`
2781
+ string, matching native textarea selection offsets. `marker` is the complete
2782
+ data of a generated HTML comment, not a CSS selector. A consumer can locate
2783
+ it after rendering with `document.createTreeWalker(root,NodeFilter.SHOW_COMMENT)`.
2784
+ The generated marker prefix is chosen so it does not collide with authored
2785
+ source comments. Marker values are opaque and may change after an edit.
2786
+ The getter exposes the current mutable array; each successful render replaces
2787
+ it with a new array, so a previously retained array describes the prior render.
2788
+
2789
+ In this opt-in mode, `rendered` and `safeRendered` contain the complete rendered
2790
+ Markdown plus comment anchors; no element wrappers or attributes are added.
2791
+ Lists, block quotes, and tables map at their top-level block rather than to
2792
+ individual characters. Blank space and definitions have no visible block;
2793
+ raw HTML can also absorb a comment during DOM parsing. A consumer follows the
2794
+ nearest available rendered block in those cases. This is block-position
2795
+ mapping, not exact rendered glyph or caret geometry.
2796
+
2797
+ Assigning `raw` or calling `append(string)` rebuilds both the complete rendered
2798
+ result and its current mapping. Without `sourceMap:true`, `sourceMap` is empty
2799
+ and the ordinary rendered output is unchanged. Existing raw content, link
2800
+ behavior, no-op `rendered` setter, and complete `append()` result remain.
2767
2801
 
2768
2802
  Exact exports: `default`.
2769
2803
 
2770
2804
  ### Availability and normalization
2771
2805
 
2772
2806
  **Browser / native WebView.** Raw Marked behavior is preserved; parse errors are
2773
- vendor-native. Transport: Marked. [Deep protocol details](protocols.md).
2807
+ vendor-native. Rendering and the getters are DOM-independent. DOM lookup and
2808
+ scrolling belong to the consuming component;
2809
+ mapping performs no persistence or viewport movement. Transport: Marked.
2810
+ [Deep protocol details](protocols.md).
2774
2811
 
2775
2812
  ### Example
2776
2813
 
2777
2814
  ```javascript
2778
- import * as module from '/arcane/modules/MD.js';
2815
+ import MD from '/arcane/modules/MD.js';
2779
2816
 
2780
- console.log(Object.keys(module));
2817
+ const notes=new MD('# Dragon field notes\n\nThe library dragon ate the index.',{
2818
+ sourceMap:true
2819
+ });
2820
+ const preview=document.querySelector('#preview');
2821
+ preview.innerHTML=notes.safeRendered;
2822
+ const comments=document.createTreeWalker(preview,NodeFilter.SHOW_COMMENT);
2823
+ const firstBlock=notes.sourceMap[0];
2824
+ while(comments.nextNode()){
2825
+ if(comments.currentNode.data===firstBlock.marker){
2826
+ console.log(firstBlock,comments.currentNode.nextSibling);
2827
+ break;
2828
+ }
2829
+ }
2781
2830
  ```
2782
2831
 
2783
2832
  ## MemoryRecords.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.36.0",
3
+ "version": "0.37.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",
@@ -86,7 +86,7 @@
86
86
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
87
87
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
88
88
  "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/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",
89
- "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-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.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/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/modal.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/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
89
+ "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-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.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/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-source-map.test.mjs test/modal.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/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/twin-cloud.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
90
90
  "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-plan.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",
91
91
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.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",
92
92
  "check": "node tools/check-source.mjs && npm test",
@@ -4,6 +4,12 @@
4
4
  display:block;
5
5
  }
6
6
 
7
+ :host([data-fit="true"]){
8
+ min-width:0;
9
+ min-height:0;
10
+ container:arcane-markdown-editor / inline-size;
11
+ }
12
+
7
13
  .markdown-editor{
8
14
  display:grid;
9
15
  gap:.75em;
@@ -57,19 +63,62 @@
57
63
  margin-right:auto;
58
64
  }
59
65
 
66
+ .markdown-editor.fit{
67
+ height:100%;
68
+ min-height:0;
69
+ min-width:0;
70
+ grid-template-columns:minmax(0,1fr) minmax(0,1fr);
71
+ grid-template-rows:auto auto minmax(0,1fr) auto;
72
+ grid-template-areas:"title title" "toolbar toolbar" "input preview" "actions actions";
73
+ }
74
+
75
+ .fit .entry-title{grid-area:title;}
76
+ .fit .toolbar{grid-area:toolbar;}
77
+ .fit .entry-markdown{grid-area:input;}
78
+ .fit .preview{grid-area:preview;}
79
+ .fit .actions{grid-area:actions;}
80
+
81
+ .fit .entry-markdown,
82
+ .fit .preview{
83
+ box-sizing:border-box;
84
+ min-width:0;
85
+ min-height:0;
86
+ height:100%;
87
+ overflow:auto;
88
+ overscroll-behavior:contain;
89
+ }
90
+
91
+ .fit .entry-markdown{resize:none;}
92
+ .fit .preview{overflow-wrap:anywhere;}
93
+ .fit .preview img{max-width:100%;height:auto;}
94
+ .fit.without-preview{grid-template-areas:"title title" "toolbar toolbar" "input input" "actions actions";}
95
+
96
+ @container arcane-markdown-editor (max-width:44rem){
97
+ .markdown-editor.fit{
98
+ grid-template-columns:minmax(0,1fr);
99
+ grid-template-rows:auto auto minmax(0,1fr) minmax(0,1fr) auto;
100
+ grid-template-areas:"title" "toolbar" "input" "preview" "actions";
101
+ }
102
+
103
+ .markdown-editor.fit.without-preview{
104
+ grid-template-rows:auto auto minmax(0,1fr) auto;
105
+ grid-template-areas:"title" "toolbar" "input" "actions";
106
+ }
107
+ }
108
+
60
109
  .hidden{
61
110
  display:none;
62
111
  }
63
112
  </style>
64
113
 
65
- <section class="markdown-editor">
66
- <input id="entryTitle" class="entry-title" type="text">
67
- <section id="preview" class="preview"></section>
68
- <section id="toolbar" class="toolbar"></section>
69
- <textarea id="entryMarkdown" class="entry-markdown"></textarea>
70
- <section class="actions">
71
- <span id="status" class="status" aria-live="polite"></span>
72
- <button id="save" type="button" disabled></button>
114
+ <section class="markdown-editor" part="editor">
115
+ <input id="entryTitle" class="entry-title" part="title" type="text">
116
+ <section id="preview" class="preview" part="preview" tabindex="0"></section>
117
+ <section id="toolbar" class="toolbar" part="toolbar"></section>
118
+ <textarea id="entryMarkdown" class="entry-markdown" part="input"></textarea>
119
+ <section class="actions" part="actions">
120
+ <span id="status" class="status" part="status" aria-live="polite"></span>
121
+ <button id="save" part="save-action" type="button" disabled></button>
73
122
  </section>
74
123
  </section>
75
124
 
@@ -88,6 +137,7 @@
88
137
  const {createArcaneEventSource,projectArcaneDOMEvent}=await import('arcane-os/event-manager');
89
138
  const events=createArcaneEventSource(host,{source:'arcane.component.markdown-editor',eventTypes:['markdown-editor-change','markdown-editor-saved','markdown-editor-ready']});
90
139
  const lifecycleController=new AbortController();
140
+ const editor=host.shadowRoot.querySelector('.markdown-editor');
91
141
  const titleInput=host.shadowRoot.querySelector('#entryTitle');
92
142
  const preview=host.shadowRoot.querySelector('#preview');
93
143
  const toolbar=host.shadowRoot.querySelector('#toolbar');
@@ -96,6 +146,7 @@
96
146
  const status=host.shadowRoot.querySelector('#status');
97
147
 
98
148
  let previewFrame=0;
149
+ let previewFollowPending=false;
99
150
  let operationSequence=0;
100
151
  let saveGeneration=0;
101
152
  let saveController=null;
@@ -133,6 +184,9 @@
133
184
  titleInput.addEventListener('input',contentChanged,{signal:lifecycleController.signal});
134
185
  toolbar.addEventListener('click',formatMarkdown,{signal:lifecycleController.signal});
135
186
  saveButton.addEventListener('click',saveEntry,{signal:lifecycleController.signal});
187
+ for(const type of ['wheel','pointerdown','touchstart','keydown']){
188
+ preview.addEventListener(type,retainPreviewReadingPosition,{signal:lifecycleController.signal,passive:true});
189
+ }
136
190
 
137
191
  function optionsFromDataset(){
138
192
  const dataset=host.dataset||{};
@@ -151,6 +205,8 @@
151
205
  }
152
206
  for(const field of [
153
207
  'clearOnSave',
208
+ 'fit',
209
+ 'followPreview',
154
210
  'readOnly',
155
211
  'showPreview',
156
212
  'showSave',
@@ -191,8 +247,21 @@
191
247
  }
192
248
 
193
249
  function renderOptions(){
250
+ host.dataset.fit=String(options.fit);
251
+ editor.classList.toggle('fit',options.fit);
252
+ editor.classList.toggle('without-preview',!options.showPreview);
253
+ // Keyboard order follows the displayed order in each layout.
254
+ if(options.fit){
255
+ editor.insertBefore(toolbar,preview);
256
+ editor.insertBefore(textarea,preview);
257
+ }else{
258
+ editor.insertBefore(preview,toolbar);
259
+ editor.insertBefore(textarea,toolbar.nextSibling);
260
+ }
194
261
  titleInput.placeholder=options.labels.titlePlaceholder;
262
+ titleInput.setAttribute('aria-label',options.labels.titlePlaceholder);
195
263
  textarea.placeholder=options.labels.bodyPlaceholder;
264
+ textarea.setAttribute('aria-label',options.labels.bodyPlaceholder);
196
265
  preview.setAttribute('aria-label',options.labels.preview);
197
266
  preview.dataset.emptyLabel=options.labels.preview;
198
267
  toolbar.setAttribute('aria-label',options.labels.toolbar);
@@ -221,12 +290,14 @@
221
290
  toolbar.replaceChildren(fragment);
222
291
  }
223
292
 
224
- function contentChanged(){
293
+ function contentChanged(event){
225
294
  if(destroyed){
226
295
  return;
227
296
  }
228
297
  supersedeSave('markdown-content-edited');
229
- schedulePreview();
298
+ if(event?.currentTarget!==titleInput){
299
+ schedulePreview(options.followPreview);
300
+ }
230
301
  const detail={
231
302
  markdown:textarea.value,
232
303
  title:titleInput.value
@@ -239,17 +310,28 @@
239
310
  }
240
311
  }
241
312
 
242
- function schedulePreview(){
313
+ function schedulePreview(follow=false){
243
314
  if(destroyed){
244
315
  return;
245
316
  }
246
317
  if(previewFrame){
247
318
  cancelAnimationFrame(previewFrame);
248
319
  }
249
- previewFrame=requestAnimationFrame(updatePreview);
320
+ previewFollowPending=previewFollowPending||follow;
321
+ previewFrame=requestAnimationFrame(renderPendingPreview);
250
322
  }
251
323
 
252
- function updatePreview(){
324
+ function renderPendingPreview(){
325
+ const follow=previewFollowPending;
326
+ previewFrame=0;
327
+ updatePreview(follow);
328
+ }
329
+
330
+ function retainPreviewReadingPosition(){
331
+ previewFollowPending=false;
332
+ }
333
+
334
+ function updatePreview(follow=false){
253
335
  if(destroyed){
254
336
  return;
255
337
  }
@@ -257,13 +339,87 @@
257
339
  cancelAnimationFrame(previewFrame);
258
340
  }
259
341
  previewFrame=0;
260
- preview.innerHTML=new MD(textarea.value).safeRendered;
342
+ previewFollowPending=false;
343
+ const scrollTop=preview.scrollTop;
344
+ const scrollLeft=preview.scrollLeft;
345
+ const markdown=new MD(textarea.value,{sourceMap:options.followPreview});
346
+ preview.innerHTML=markdown.safeRendered;
261
347
  preview.querySelectorAll('a').forEach(
262
348
  link=>{
263
349
  link.target='_blank';
264
350
  link.removeAttribute('rel');
265
351
  }
266
352
  );
353
+ preview.scrollTop=scrollTop;
354
+ preview.scrollLeft=scrollLeft;
355
+ if(follow&&options.followPreview&&options.showPreview){
356
+ followTypingPosition(markdown.sourceMap);
357
+ }
358
+ }
359
+
360
+ function followTypingPosition(sourceMap){
361
+ const records=new Map(sourceMap.map(record=>[record.marker,record]));
362
+ const walker=document.createTreeWalker(preview,NodeFilter.SHOW_COMMENT);
363
+ const anchors=[];
364
+ for(let node=walker.nextNode();node;node=walker.nextNode()){
365
+ const record=records.get(node.data);
366
+ if(record){
367
+ anchors.push({node,record});
368
+ }
369
+ }
370
+ if(!anchors.length||!preview.clientHeight){
371
+ return;
372
+ }
373
+ const caret=textarea.selectionDirection==='backward'
374
+ ?textarea.selectionStart:textarea.selectionEnd;
375
+ let selected=0;
376
+ for(let index=0;index<anchors.length;index+=1){
377
+ if(anchors[index].record.start>caret){
378
+ break;
379
+ }
380
+ selected=index;
381
+ }
382
+ // Raw HTML can absorb an anchor or have no visible geometry. Use the
383
+ // nearest rendered block; never move the containing page or input pane.
384
+ let left=selected;
385
+ let right=selected+1;
386
+ while(left>=0||right<anchors.length){
387
+ const useLeft=right>=anchors.length||(
388
+ left>=0&&Math.abs(caret-anchors[left].record.end)<=Math.abs(anchors[right].record.start-caret)
389
+ );
390
+ const index=useLeft?left--:right++;
391
+ const anchor=anchors[index];
392
+ const range=document.createRange();
393
+ range.setStartAfter(anchor.node);
394
+ if(anchors[index+1]){
395
+ range.setEndBefore(anchors[index+1].node);
396
+ }else{
397
+ range.setEnd(preview,preview.childNodes.length);
398
+ }
399
+ const bounds=range.getBoundingClientRect();
400
+ if(!bounds.height){
401
+ continue;
402
+ }
403
+ const viewportTop=preview.getBoundingClientRect().top+preview.clientTop;
404
+ const viewportBottom=viewportTop+preview.clientHeight;
405
+ if(bounds.height<=preview.clientHeight){
406
+ if(bounds.top<viewportTop){
407
+ preview.scrollTop+=bounds.top-viewportTop;
408
+ }else if(bounds.bottom>viewportBottom){
409
+ preview.scrollTop+=bounds.bottom-viewportBottom;
410
+ }
411
+ }else{
412
+ // A top-level block may be a long paragraph, list or code fence.
413
+ // Follow approximate position within it without rewriting text.
414
+ const span=anchor.record.end-anchor.record.start;
415
+ const fraction=span?Math.max(0,Math.min(1,(caret-anchor.record.start)/span)):0;
416
+ const position=bounds.top+fraction*bounds.height;
417
+ if(position<viewportTop||position>viewportBottom){
418
+ preview.scrollTop+=position-viewportTop-preview.clientHeight/2;
419
+ }
420
+ }
421
+ return;
422
+ }
267
423
  }
268
424
 
269
425
  function formatMarkdown(event){
@@ -414,6 +570,7 @@
414
570
  destroyed=true;
415
571
  supersedeSave('markdown-editor-destroyed');
416
572
  lifecycleController.abort('markdown-editor-destroyed');
573
+ previewFollowPending=false;
417
574
  if(previewFrame){
418
575
  cancelAnimationFrame(previewFrame);
419
576
  previewFrame=0;
@@ -80,8 +80,22 @@ class FileEntity {
80
80
  (this.fileName.lastIndexOf('.')+1)
81
81
  ).toLowerCase();
82
82
 
83
+ // Storage may omit MIME metadata; preserve a supplied type before inferring audio.
84
+ const audioMimeTypes={
85
+ "mp3": "audio/mpeg",
86
+ "wav": "audio/wav",
87
+ "ogg": "audio/ogg",
88
+ "oga": "audio/ogg",
89
+ "opus": "audio/ogg",
90
+ "aac": "audio/aac",
91
+ "m4a": "audio/mp4",
92
+ "flac": "audio/flac",
93
+ "weba": "audio/webm"
94
+ };
95
+
83
96
  const mime=mimeTypes[file.ext]||
84
97
  file.type||
98
+ audioMimeTypes[file.ext]||
85
99
  'application/octet-stream';
86
100
 
87
101
  file.mime=mime;
@@ -1101,6 +1101,8 @@ function normalizeMarkdownOptions(input={},previous={}){
1101
1101
  showPreview:option(input,previous,'showPreview',true)!==false,
1102
1102
  showToolbar:option(input,previous,'showToolbar',true)!==false,
1103
1103
  showSave:option(input,previous,'showSave',true)!==false,
1104
+ fit:option(input,previous,'fit',false)===true,
1105
+ followPreview:option(input,previous,'followPreview',false)===true,
1104
1106
  readOnly:option(input,previous,'readOnly',false)===true,
1105
1107
  clearOnSave:option(input,previous,'clearOnSave',true)!==false,
1106
1108
  onSave:optionalCallback(input,previous,['onSave','save'],'onSave'),
@@ -1,30 +1,36 @@
1
1
  import { arcaneLogging } from 'arcane-os/logging';
2
- import { marked } from './Marked.min.js';
2
+ import { Marked, marked } from './Marked.min.js';
3
3
  import Is from 'strong-type';
4
4
 
5
- marked.use(
6
- {
7
- async: false,
8
- pedantic: false,
9
- gfm: true,
10
- renderer: {
11
- link(href, title, text) {
12
- const link = marked.Renderer.prototype.link.call(this, href, title, text);
13
- return link.replace("<a","<a target='_blank' ");
14
- }
5
+ const markdownOptions = {
6
+ async: false,
7
+ pedantic: false,
8
+ gfm: true,
9
+ renderer: {
10
+ link(href, title, text) {
11
+ const link = marked.Renderer.prototype.link.call(this, href, title, text);
12
+ return link.replace("<a","<a target='_blank' ");
15
13
  }
16
14
  }
17
- );
15
+ };
16
+
17
+ marked.use(markdownOptions);
18
18
 
19
19
  const is = new Is(false);
20
20
 
21
21
  class MD {
22
22
  #raw='';
23
- #rendered=''
23
+ #rendered='';
24
+ #sourceMapEnabled=false;
25
+ #sourceMap=[];
24
26
 
25
- constructor(raw=''){
27
+ constructor(raw='', options={}){
28
+ this.#sourceMapEnabled=options?.sourceMap===true;
26
29
  this.raw=raw;
27
- this.rendered=marked.parse(raw);
30
+ if(!is.string(raw)){
31
+ // Preserve the constructor's existing Marked error for invalid input.
32
+ this.rendered=marked.parse(raw);
33
+ }
28
34
  return this;
29
35
  }
30
36
 
@@ -36,6 +42,10 @@ class MD {
36
42
  return this.#rendered;
37
43
  }
38
44
 
45
+ get sourceMap(){
46
+ return this.#sourceMap;
47
+ }
48
+
39
49
  set rendered(value=''){
40
50
  return this.#rendered;
41
51
  }
@@ -50,7 +60,7 @@ class MD {
50
60
  return this.#raw;
51
61
  }
52
62
  this.#raw = value;
53
- this.#rendered = marked.parse(value);
63
+ this.#render();
54
64
  return this.#raw;
55
65
  }
56
66
 
@@ -60,9 +70,130 @@ class MD {
60
70
  return this.#raw;
61
71
  }
62
72
  this.#raw += value;
63
- this.#rendered = marked.parse(this.#raw);
73
+ this.#render();
64
74
  return this.#raw;
65
75
  }
76
+
77
+ #render(){
78
+ if(!this.#sourceMapEnabled){
79
+ this.#rendered=marked.parse(this.#raw);
80
+ this.#sourceMap=[];
81
+ return;
82
+ }
83
+
84
+ const result=renderWithSourceMap(this.#raw);
85
+ this.#rendered=result.rendered;
86
+ this.#sourceMap=result.sourceMap;
87
+ }
88
+ }
89
+
90
+ function renderWithSourceMap(raw){
91
+ const tokenRanges=new WeakMap();
92
+ const sourceMap=[];
93
+ let normalizedEnd=0;
94
+ let normalizedOffset=0;
95
+ let originalOffset=0;
96
+ let observedRoot=false;
97
+ let previousOffset=0;
98
+ let previousCount=0;
99
+ let previousRaw;
100
+ const authoredGenerations=new Set();
101
+ for(const match of raw.matchAll(/arcane-md-source-(\d+):/g)){
102
+ authoredGenerations.add(match[1]);
103
+ }
104
+ let markerGeneration=0;
105
+ while(authoredGenerations.has(String(markerGeneration))){
106
+ markerGeneration++;
107
+ }
108
+ const markerPrefix=`arcane-md-source-${markerGeneration}:`;
109
+
110
+ function captureBoundary(tokens, offset){
111
+ // Follow Marked's CRLF/CR cursor without rewriting raw or retaining a per-character map.
112
+ while(normalizedOffset<offset){
113
+ if(raw[originalOffset]==='\r' && raw[originalOffset+1]==='\n'){
114
+ originalOffset++;
115
+ }
116
+ originalOffset++;
117
+ normalizedOffset++;
118
+ }
119
+ for(let index=previousCount; index<tokens.length; index++){
120
+ tokenRanges.set(
121
+ tokens[index],
122
+ {
123
+ start:previousOffset,
124
+ end:originalOffset
125
+ }
126
+ );
127
+ }
128
+
129
+ const last=tokens.at(-1);
130
+ if(tokens.length===previousCount && last && last.raw!==previousRaw){
131
+ // Marked can merge a newline or indented continuation into a prior token.
132
+ const range=tokenRanges.get(last);
133
+ if(range){
134
+ range.end=originalOffset;
135
+ }
136
+ }
137
+ previousOffset=originalOffset;
138
+ previousCount=tokens.length;
139
+ previousRaw=last?.raw;
140
+ }
141
+
142
+ const renderer={};
143
+ const blockTypes=['code','blockquote','html','heading','hr','list','paragraph','table','text'];
144
+ for(const type of blockTypes){
145
+ const render=marked.Renderer.prototype[type];
146
+ renderer[type]=function renderMappedBlock(token){
147
+ const html=render.call(this,token);
148
+ const range=tokenRanges.get(token);
149
+ if(!range || !html){
150
+ return html;
151
+ }
152
+
153
+ const marker=`${markerPrefix}${sourceMap.length}`;
154
+ sourceMap.push(
155
+ {
156
+ start:range.start,
157
+ end:range.end,
158
+ marker,
159
+ type:token.type
160
+ }
161
+ );
162
+ return `<!--${marker}-->${html}`;
163
+ };
164
+ }
165
+
166
+ const parser=new Marked(
167
+ markdownOptions,
168
+ {
169
+ extensions:[
170
+ {
171
+ name:'arcaneSourceMap',
172
+ level:'block',
173
+ tokenizer(src,tokens){
174
+ if(tokens===this.lexer.tokens){
175
+ if(!observedRoot){
176
+ normalizedEnd=src.length;
177
+ observedRoot=true;
178
+ }
179
+ // Observe consumption, including definitions that produce no token.
180
+ captureBoundary(tokens,normalizedEnd-src.length);
181
+ }
182
+ return undefined;
183
+ }
184
+ }
185
+ ],
186
+ hooks:{
187
+ processAllTokens(tokens){
188
+ captureBoundary(tokens,normalizedEnd);
189
+ return tokens;
190
+ }
191
+ },
192
+ renderer
193
+ }
194
+ );
195
+ const rendered=parser.parse(raw);
196
+ return {rendered,sourceMap};
66
197
  }
67
198
 
68
199
  export default MD;