@wix/himalaya-cli 0.1177.0 → 0.1179.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.
Files changed (46) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +7 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/rich-content/src/index.d.ts +8 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-rich-content.mjs +24 -0
  4. package/dist/cli.mjs +16 -16
  5. package/dist/preview-assets.generated.bin +0 -0
  6. package/guides/sdk-reference.json +7 -1
  7. package/package.json +2 -2
  8. package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  9. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  10. package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  11. package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  12. package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  13. package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  14. package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  15. package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  16. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  17. package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  18. package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  19. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  20. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  21. package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  22. package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  23. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  24. package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  25. package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +57 -39
  26. package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  27. package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +16 -10
  28. package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  29. package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  30. package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  31. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  32. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  33. package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  34. package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  35. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  36. package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  37. package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  38. package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  39. package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  40. package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  41. package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  42. package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
  43. package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
  44. package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
  45. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
  46. package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
@@ -8,6 +8,13 @@ export interface ServiceDefinition<S> {
8
8
  * (native side) updates the service's state and notifies subscribers.
9
9
  */
10
10
  actions?: Record<string, (state: S, ...args: never[]) => S | undefined | Promise<S | undefined>>;
11
+ /**
12
+ * Keys a cold boot takes from `state`, never from the persisted snapshot (cold-boot
13
+ * hydration). For UI state that must describe THIS run — an editor's one-shot seed, a
14
+ * "still loading" gate, an open sheet — where last run's value would be wrong, not just stale.
15
+ * Runtimes that honour it set `himalaya.hydrationOptOut`; an older one ignores the option.
16
+ */
17
+ notHydrated?: readonly (keyof S & string)[];
11
18
  }
12
19
  export interface ServiceHandle<S> {
13
20
  /** Snapshot of the current state. Cheap; safe to call from anywhere. */
@@ -38,6 +38,14 @@ export declare const RICH_CONTENT_LIMITS: {
38
38
  readonly maxNodes: 5000;
39
39
  readonly maxDepth: 16;
40
40
  };
41
+ /** Which of the rich-content limits (`RICH_CONTENT_LIMITS`) a document's JSON string breaks — checked as every viewer does, in its order:
42
+ * the raw string's UTF-8 bytes (before parsing), then nesting depth (top-level nodes are depth 1), then the node
43
+ * count (every object in a `nodes` array). `null` when within all three. A worker runs it on the string it is
44
+ * about to WRITE: a body past a limit renders as every viewer's fallback, so it must not be saved. The document's
45
+ * shape is not checked here (a string that does not parse is within the limits) — `parseRicosJson` does that.
46
+ * Pass `parsed` (the string's own parse) when the caller has it, so the document is parsed once; pass `null`
47
+ * to check the bytes alone, before deciding to parse. */
48
+ export declare function richContentOverLimits(json: string, parsed?: unknown): "too_large" | "too_deep" | "too_many_nodes" | null;
41
49
  /** Reads a Ricos document from a JSON string or an already-parsed object (page messages arrive as either,
42
50
  * depending on the platform). Returns null for empty input, invalid JSON, or anything without a `nodes` array. */
43
51
  export declare function parseRicosJson(value: unknown): RicosDocument | null;
@@ -121,6 +121,29 @@ function ricosVideoUrl(src) {
121
121
 
122
122
  // ../../stdlib/wix-sdk/rich-content/src/index.ts
123
123
  var RICH_CONTENT_LIMITS = { maxBytes: 262144, maxNodes: 5e3, maxDepth: 16 };
124
+ function richContentOverLimits(json, parsed) {
125
+ if (utf8Length(json) > RICH_CONTENT_LIMITS.maxBytes) return "too_large";
126
+ let doc = parsed;
127
+ if (doc === void 0) {
128
+ try {
129
+ doc = JSON.parse(json);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ const nodes = isObj(doc) && Array.isArray(doc.nodes) ? doc.nodes : null;
135
+ if (!nodes) return null;
136
+ if (deeperThan(nodes, 1, RICH_CONTENT_LIMITS.maxDepth)) return "too_deep";
137
+ return countNodes(nodes) > RICH_CONTENT_LIMITS.maxNodes ? "too_many_nodes" : null;
138
+ }
139
+ function deeperThan(nodes, depth, max) {
140
+ for (const n of nodes) {
141
+ if (!isObj(n)) continue;
142
+ if (depth > max) return true;
143
+ if (Array.isArray(n.nodes) && deeperThan(n.nodes, depth + 1, max)) return true;
144
+ }
145
+ return false;
146
+ }
124
147
  var isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
125
148
  function parseRicosJson(value) {
126
149
  let v = value;
@@ -340,6 +363,7 @@ export {
340
363
  isPlainRicos,
341
364
  parseRicosJson,
342
365
  plainTextToRicos,
366
+ richContentOverLimits,
343
367
  ricosToPlainText
344
368
  };
345
369
  //# sourceMappingURL=wix-rich-content.mjs.map
package/dist/cli.mjs CHANGED
@@ -31775,7 +31775,7 @@ function RichContentEditorView({ props, ctx, wrap, nodeId, renderChild, ...dom }
31775
31775
  }
31776
31776
  setLink(null);
31777
31777
  };
31778
- const root = { position: "relative", display: "flex", flexDirection: "column", width: "100%", ...fill ? { flex: "1 1 auto", minHeight: 0 } : { minHeight }, ...wrap };
31778
+ const root = { position: "relative", display: "flex", flexDirection: "column", width: "100%", ...fill ? { flex: "1 1 auto" } : {}, minHeight, ...wrap };
31779
31779
  if (!live3) {
31780
31780
  const { paddingTop: _t, paddingBottom: _b, paddingLeft: _l, paddingRight: _r, ...rootNoSides } = root;
31781
31781
  const own3 = { ...rootNoSides, border: `1px solid ${C("border", "#e3e3e3")}`, borderRadius: 8, padding: 12, boxSizing: "border-box" };
@@ -60291,21 +60291,21 @@ var init_preview_assets_generated = __esm({
60291
60291
  "../../packages/serve/src/preview-assets.generated.ts"() {
60292
60292
  init_preview_assets_format();
60293
60293
  PREVIEW_ASSET_MANIFEST = [
60294
- { key: "assets/index-DfY-tQ6i.js", contentType: "application/javascript; charset=utf-8", offset: 8, length: 1132878, sha256: "3d63b2fab780ecf40caeff8f5576743a1dade0dd92e7cbc4fa9ee26a60985b7e" },
60295
- { key: "fonts/Roboto-LICENSE.txt", contentType: "application/octet-stream", offset: 1132886, length: 4610, sha256: "994062dd7af4186a739de55dd9e151c63b6301f8e2cbafc608d35c2e87da0cd2" },
60296
- { key: "fonts/Roboto-Variable.ttf", contentType: "font/ttf", offset: 1137496, length: 488584, sha256: "d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134" },
60297
- { key: "fonts/WixMadeforDisplay-Bold.ttf", contentType: "font/ttf", offset: 1626080, length: 90924, sha256: "ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf" },
60298
- { key: "fonts/WixMadeforDisplay-ExtraBold.ttf", contentType: "font/ttf", offset: 1717004, length: 90984, sha256: "bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa" },
60299
- { key: "fonts/WixMadeforDisplay-Medium.ttf", contentType: "font/ttf", offset: 1807988, length: 90980, sha256: "368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5" },
60300
- { key: "fonts/WixMadeforDisplay-Regular.ttf", contentType: "font/ttf", offset: 1898968, length: 90872, sha256: "97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1" },
60301
- { key: "fonts/WixMadeforDisplay-SemiBold.ttf", contentType: "font/ttf", offset: 1989840, length: 90980, sha256: "642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e" },
60302
- { key: "fonts/WixMadeforText-Bold.ttf", contentType: "font/ttf", offset: 2080820, length: 108636, sha256: "37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29" },
60303
- { key: "fonts/WixMadeforText-ExtraBold.ttf", contentType: "font/ttf", offset: 2189456, length: 109084, sha256: "62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9" },
60304
- { key: "fonts/WixMadeforText-Medium.ttf", contentType: "font/ttf", offset: 2298540, length: 109048, sha256: "4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02" },
60305
- { key: "fonts/WixMadeforText-Regular.ttf", contentType: "font/ttf", offset: 2407588, length: 107700, sha256: "dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5" },
60306
- { key: "fonts/WixMadeforText-SemiBold.ttf", contentType: "font/ttf", offset: 2515288, length: 88108, sha256: "dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b" },
60307
- { key: "index.html", contentType: "text/html; charset=utf-8", offset: 2603396, length: 4947, sha256: "4c476f213d92ec00b1f5e5fe4f88d458ccdd9cf6c6e7684f49f115f70930f9bb" },
60308
- { key: "t5-frame.html", contentType: "text/html; charset=utf-8", offset: 2608343, length: 17344, sha256: "d057298d75e7373e5e2c4d11b7d1bcbea88e03af555ba63d97641c78509c58e4" }
60294
+ { key: "assets/index-B7qO1NPG.js", contentType: "application/javascript; charset=utf-8", offset: 8, length: 1133532, sha256: "b950705c43efae2ae266278ca6b108a89b7de1c88172890d9b862808cfcf9d83" },
60295
+ { key: "fonts/Roboto-LICENSE.txt", contentType: "application/octet-stream", offset: 1133540, length: 4610, sha256: "994062dd7af4186a739de55dd9e151c63b6301f8e2cbafc608d35c2e87da0cd2" },
60296
+ { key: "fonts/Roboto-Variable.ttf", contentType: "font/ttf", offset: 1138150, length: 488584, sha256: "d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134" },
60297
+ { key: "fonts/WixMadeforDisplay-Bold.ttf", contentType: "font/ttf", offset: 1626734, length: 90924, sha256: "ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf" },
60298
+ { key: "fonts/WixMadeforDisplay-ExtraBold.ttf", contentType: "font/ttf", offset: 1717658, length: 90984, sha256: "bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa" },
60299
+ { key: "fonts/WixMadeforDisplay-Medium.ttf", contentType: "font/ttf", offset: 1808642, length: 90980, sha256: "368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5" },
60300
+ { key: "fonts/WixMadeforDisplay-Regular.ttf", contentType: "font/ttf", offset: 1899622, length: 90872, sha256: "97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1" },
60301
+ { key: "fonts/WixMadeforDisplay-SemiBold.ttf", contentType: "font/ttf", offset: 1990494, length: 90980, sha256: "642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e" },
60302
+ { key: "fonts/WixMadeforText-Bold.ttf", contentType: "font/ttf", offset: 2081474, length: 108636, sha256: "37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29" },
60303
+ { key: "fonts/WixMadeforText-ExtraBold.ttf", contentType: "font/ttf", offset: 2190110, length: 109084, sha256: "62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9" },
60304
+ { key: "fonts/WixMadeforText-Medium.ttf", contentType: "font/ttf", offset: 2299194, length: 109048, sha256: "4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02" },
60305
+ { key: "fonts/WixMadeforText-Regular.ttf", contentType: "font/ttf", offset: 2408242, length: 107700, sha256: "dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5" },
60306
+ { key: "fonts/WixMadeforText-SemiBold.ttf", contentType: "font/ttf", offset: 2515942, length: 88108, sha256: "dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b" },
60307
+ { key: "index.html", contentType: "text/html; charset=utf-8", offset: 2604050, length: 4947, sha256: "dca3aa5b2c45744b1c376ca6462b1c64093e59076238eccc4b9fcb30025fe850" },
60308
+ { key: "t5-frame.html", contentType: "text/html; charset=utf-8", offset: 2608997, length: 17344, sha256: "d057298d75e7373e5e2c4d11b7d1bcbea88e03af555ba63d97641c78509c58e4" }
60309
60309
  ];
60310
60310
  }
60311
60311
  });
Binary file
@@ -8,7 +8,7 @@
8
8
  "name": "ServiceDefinition",
9
9
  "kind": "interface",
10
10
  "jsdoc": "",
11
- "decl": "export interface ServiceDefinition<S> {\n /** Initial state for the service. Must be JSON-serializable. */\n state: S;\n\n /**\n * Actions: pure functions of `(state, ...args) → newState` (or async). The\n * platform wraps each action so calling it from JS or from `__invokeAction`\n * (native side) updates the service's state and notifies subscribers.\n */\n actions?: Record<\n string,\n // `never[]`, not `unknown[]` — same reason as defineService's constraint below:\n // parameters are contravariant, so `unknown[]` rejects every handler that narrows\n // its payload. Leaving this one loose would reintroduce the same TS2344 for anyone\n // who annotates a `ServiceDefinition<S>` explicitly instead of inferring it.\n (state: S, ...args: never[]) => S | undefined | Promise<S | undefined>\n >;\n}"
11
+ "decl": "export interface ServiceDefinition<S> {\n /** Initial state for the service. Must be JSON-serializable. */\n state: S;\n\n /**\n * Actions: pure functions of `(state, ...args) → newState` (or async). The\n * platform wraps each action so calling it from JS or from `__invokeAction`\n * (native side) updates the service's state and notifies subscribers.\n */\n actions?: Record<\n string,\n // `never[]`, not `unknown[]` — same reason as defineService's constraint below:\n // parameters are contravariant, so `unknown[]` rejects every handler that narrows\n // its payload. Leaving this one loose would reintroduce the same TS2344 for anyone\n // who annotates a `ServiceDefinition<S>` explicitly instead of inferring it.\n (state: S, ...args: never[]) => S | undefined | Promise<S | undefined>\n >;\n\n /**\n * Keys a cold boot takes from `state`, never from the persisted snapshot (cold-boot\n * hydration). For UI state that must describe THIS run — an editor's one-shot seed, a\n * \"still loading\" gate, an open sheet — where last run's value would be wrong, not just stale.\n * Runtimes that honour it set `himalaya.hydrationOptOut`; an older one ignores the option.\n */\n notHydrated?: readonly (keyof S & string)[];\n}"
12
12
  },
13
13
  {
14
14
  "name": "ServiceHandle",
@@ -21599,6 +21599,12 @@
21599
21599
  "jsdoc": "/** The RichContent viewer's ceilings; every renderer rejects a document past any of them. Kept equal to the platform limits file by a test. */",
21600
21600
  "decl": "export const RICH_CONTENT_LIMITS = { maxBytes: 262144, maxNodes: 5000, maxDepth: 16 } as const;"
21601
21601
  },
21602
+ {
21603
+ "name": "richContentOverLimits",
21604
+ "kind": "function",
21605
+ "jsdoc": "/** Which of the rich-content limits (`RICH_CONTENT_LIMITS`) a document's JSON string breaks — checked as every viewer does, in its order:\n * the raw string's UTF-8 bytes (before parsing), then nesting depth (top-level nodes are depth 1), then the node\n * count (every object in a `nodes` array). `null` when within all three. A worker runs it on the string it is\n * about to WRITE: a body past a limit renders as every viewer's fallback, so it must not be saved. The document's\n * shape is not checked here (a string that does not parse is within the limits) — `parseRicosJson` does that.\n * Pass `parsed` (the string's own parse) when the caller has it, so the document is parsed once; pass `null`\n * to check the bytes alone, before deciding to parse. */",
21606
+ "decl": "export function richContentOverLimits(json: string, parsed?: unknown): \"too_large\" | \"too_deep\" | \"too_many_nodes\" | null;"
21607
+ },
21602
21608
  {
21603
21609
  "name": "parseRicosJson",
21604
21610
  "kind": "function",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1177.0",
3
+ "version": "0.1179.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -67,5 +67,5 @@
67
67
  "artifactId": "himalaya-cli"
68
68
  }
69
69
  },
70
- "falconPackageHash": "15229e67f419da11dc290da268c774bb17a7e61e83d8ae6a395fd5fb"
70
+ "falconPackageHash": "73904175073399d3fbbcc0f3bf365e9dfe2b2ce4bef46062df2f0a88"
71
71
  }
@@ -8,7 +8,7 @@
8
8
  // drops nodes the owner never touched).
9
9
  // Inside the platform source this import is a relative path, so node test runs resolve it; an app's
10
10
  // copy imports "@himalaya/wix-rich-content".
11
- import { parseRicosJson, isPlainRicos, RICH_CONTENT_LIMITS, type RicosDocument } from "@himalaya/wix-rich-content";
11
+ import { parseRicosJson, isPlainRicos, richContentOverLimits, type RicosDocument } from "@himalaya/wix-rich-content";
12
12
 
13
13
  export type EditorValue =
14
14
  | { kind: "absent" } // leave the stored body untouched
@@ -17,34 +17,6 @@ export type EditorValue =
17
17
  | { kind: "too_large" } // past the rich-content limits: save the rest, say why
18
18
  | { kind: "invalid" }; // not a Ricos document: refuse, write nothing
19
19
 
20
- function utf8Bytes(s: string): number {
21
- let n = 0;
22
- for (let i = 0; i < s.length; i++) {
23
- const c = s.charCodeAt(i);
24
- if (c < 0x80) n += 1;
25
- else if (c < 0x800) n += 2;
26
- else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++; }
27
- else n += 3;
28
- }
29
- return n;
30
- }
31
-
32
- /** Every viewer renders a body past the rich-content limits as its fallback, so it is never written.
33
- * The viewer's order: bytes (before parsing), then depth, then the node count. */
34
- function overLimits(doc: RicosDocument): boolean {
35
- let count = 0;
36
- const work: Array<[unknown, number]> = doc.nodes.map((n) => [n, 1] as [unknown, number]);
37
- while (work.length) {
38
- const [n, depth] = work.pop()!;
39
- if (!n || typeof n !== "object" || Array.isArray(n)) continue;
40
- if (depth > RICH_CONTENT_LIMITS.maxDepth) return true;
41
- if (++count > RICH_CONTENT_LIMITS.maxNodes) return true;
42
- const kids = (n as { nodes?: unknown }).nodes;
43
- if (Array.isArray(kids)) for (const k of kids) work.push([k, depth + 1]);
44
- }
45
- return false;
46
- }
47
-
48
20
  const TEXT_ONLY = new Set(["PARAGRAPH", "TEXT", "HEADING", "BULLETED_LIST", "ORDERED_LIST", "LIST_ITEM", "BLOCKQUOTE", "CODE_BLOCK"]);
49
21
  /** No visible text and no non-text node (an image, a divider, an embed…): an empty body. */
50
22
  export function isEmptyDocument(doc: RicosDocument): boolean {
@@ -55,10 +27,12 @@ export function isEmptyDocument(doc: RicosDocument): boolean {
55
27
 
56
28
  /** A Ricos JSON string checked as every viewer would: the limits first, then the shape. */
57
29
  function checkedDocument(raw: string): EditorValue {
58
- if (utf8Bytes(raw) > RICH_CONTENT_LIMITS.maxBytes) return { kind: "too_large" };
30
+ // Every viewer renders a body past the limits as its fallback, so it is never written. One parse
31
+ // feeds both checks; a string past the byte limit is refused before it is parsed at all.
32
+ if (richContentOverLimits(raw, null) === "too_large") return { kind: "too_large" };
59
33
  const doc = parseRicosJson(raw);
60
34
  if (!doc) return { kind: "invalid" };
61
- return overLimits(doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
35
+ return richContentOverLimits(raw, doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
62
36
  }
63
37
 
64
38
  /** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
@@ -108,15 +82,59 @@ export function cachedRichVerdict(doc: RicosDocument | null, knownPlain: boolean
108
82
  return knownPlain ? "false" : "true";
109
83
  }
110
84
 
111
- /** Cold boot: the runtime lays this service's last persisted snapshot over its declared state, so the
112
- * editor's first seed would be the document as it was when the screen last ran — and a Save after
113
- * typing would write it over whatever the record holds now. Blank the hydrated seed and reset the gate
114
- * before anything renders, in place (no action has run yet). `extra` resets further UI state. */
115
- export function resetHydratedEditor(svc: unknown, keys: { json: string; pending: string; dirty: string }, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
85
+ /** The keys of one editor field that describe THIS run, never the last one: the seed (`<key>Json`),
86
+ * the load gate (`<key>Pending`), the edit flag (`<key>Dirty`) and its counter (`<key>Edits`). A form
87
+ * lists them in `defineService(name, { notHydrated })`, so a cold boot takes their DECLARED values: the
88
+ * editor seeds once, and a persisted seed would be painted as its content and saved back over a newer
89
+ * record. */
90
+ export function editorRunKeys<K extends string>(key: K): [`${K}Json`, `${K}Pending`, `${K}Dirty`, `${K}Edits`] {
91
+ return [`${key}Json`, `${key}Pending`, `${key}Dirty`, `${key}Edits`];
92
+ }
93
+
94
+ /** The same keys on a runtime that ignores `notHydrated` (a binary from before #3644, still served new
95
+ * bundles over the air): reset them in place, before any action runs. A no-op where the runtime
96
+ * honours the option (`himalaya.hydrationOptOut`). */
97
+ export function resetHydratedEditorOnOlderRuntime(svc: unknown, key: string, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
98
+ if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut === true) return;
116
99
  const booted = (svc as { getState?: () => Record<string, unknown> | undefined }).getState?.();
117
100
  if (!booted || typeof booted !== "object") return;
118
- if (booted[keys.json]) booted[keys.json] = "";
119
- booted[keys.pending] = pendingAtBoot;
120
- booted[keys.dirty] = false;
101
+ if (booted[`${key}Json`]) booted[`${key}Json`] = "";
102
+ booted[`${key}Pending`] = pendingAtBoot;
103
+ booted[`${key}Dirty`] = false;
104
+ booted[`${key}Edits`] = 0;
121
105
  Object.assign(booted, extra);
122
106
  }
107
+
108
+ /** What an awaited save leaves the field as, read from the LIVE state when it lands. An on_change that
109
+ * arrived while the save was in flight (`<key>Edits` moved) is an edit the save did not write: the
110
+ * field stays dirty and the save's confirmation is stale. Otherwise a save that wrote the field
111
+ * re-bases it as saved. `live` is the service's state now (none, or one that never moved: the start state). */
112
+ export function editorAfterSave(key: string, start: Record<string, unknown>, live: Record<string, unknown> | undefined, wrote: boolean):
113
+ { dirty: boolean; edits: number; editedDuringSave: boolean } {
114
+ const atStart = Number(start[`${key}Edits`] ?? 0);
115
+ const now = Number(live?.[`${key}Edits`] ?? atStart);
116
+ // Only a counter that MOVED ON counts; one behind the start is a host that does not apply results.
117
+ const editedDuringSave = now > atStart;
118
+ return editedDuringSave
119
+ ? { dirty: true, edits: now, editedDuringSave }
120
+ : { dirty: wrote ? false : Boolean(start[`${key}Dirty`]), edits: atStart, editedDuringSave };
121
+ }
122
+
123
+ /** The live state of a service handle, when the runtime gives one. */
124
+ export function liveState(svc: unknown): Record<string, unknown> | undefined {
125
+ const st = (svc as { getState?: () => unknown } | undefined)?.getState?.();
126
+ return st && typeof st === "object" ? st as Record<string, unknown> : undefined;
127
+ }
128
+
129
+ /** An awaited action's result, laid over the LIVE state: only the keys the action CHANGED (against the
130
+ * state it started from) are written, so anything the owner did while it awaited — an edit, a field in
131
+ * the details sheet, closing the sheet — survives a save that lands after it, success or failure. A
132
+ * key both changed goes to the action. Where the host gives no live state, the result as it is. */
133
+ export function settleOverLive<S extends object>(svc: unknown, start: S, result: S): S {
134
+ const now = liveState(svc) as S | undefined;
135
+ if (!now || now === start || now === result) return result;
136
+ const out = { ...now } as Record<string, unknown>;
137
+ const r = result as Record<string, unknown>, s0 = start as Record<string, unknown>;
138
+ for (const k of Object.keys(r)) if (!Object.is(r[k], s0[k])) out[k] = r[k];
139
+ return out as S;
140
+ }
@@ -4,7 +4,7 @@
4
4
  // screen (himiOwnerLitePost.publish). Bound by buildForm ($service:himiOwnerLitePostForm.*).
5
5
 
6
6
  import { postBodyText, plainPostBody, postCoverUrl } from "../_shared/owner-post-content.js";
7
- import { editorSubmitValue, queuedDocument, editorChanged, resetHydratedEditor } from "../_shared/owner-editor-value.js";
7
+ import { editorSubmitValue, queuedDocument, editorChanged, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } from "../_shared/owner-editor-value.js";
8
8
  import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
9
9
  import { wixCall } from "@himalaya/wix-site-media";
10
10
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "../_shared/himi-store.js";
@@ -46,6 +46,8 @@ interface PostFormState {
46
46
  * the body was loaded or saved, and (edit mode) the editor is hidden until this run's load lands. */
47
47
  bodyDirty: boolean;
48
48
  bodyPending: boolean;
49
+ /** Counts the editor's on_change events, so a save can tell an edit made while it was in flight. */
50
+ bodyEdits: number;
49
51
  coverUrl: string;
50
52
  originalCoverUrl: string;
51
53
  coverPreview: string;
@@ -120,6 +122,7 @@ function shape(s: Partial<PostFormState>): PostFormState {
120
122
  detailsOpen: s.detailsOpen ?? false,
121
123
  bodyDirty: s.bodyDirty ?? false,
122
124
  bodyPending: s.bodyPending ?? false,
125
+ bodyEdits: s.bodyEdits ?? 0,
123
126
  coverUrl: s.coverUrl ?? "",
124
127
  originalCoverUrl: s.originalCoverUrl ?? "",
125
128
  coverPreview: wixMediaUrl(s.coverUrl ?? "", WIX_DETAIL_IMAGE),
@@ -150,9 +153,8 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
150
153
 
151
154
  // [rico] #3371 — the RichContentEditor's image tool (the editor's upload contract). The component picks
152
155
  // and PUTs the bytes; this service mints the signed Wix Media URL with the worker's own wixApi
153
- // (@himalaya/wix-site-media rides the `wix_api` grant), so no token reaches the page. Registered
154
- // BEFORE the form's service on purpose: the worker test harnesses treat a bundle's
155
- // LAST defineService as its service.
156
+ // (@himalaya/wix-site-media rides the `wix_api` grant), so no token reaches the page. Any order:
157
+ // the worker test harnesses find each service by name.
156
158
  ricosUploadService("himiOwnerLitePostFormUploads");
157
159
 
158
160
  const svc = defineService<PostFormState, {
@@ -165,6 +167,9 @@ const svc = defineService<PostFormState, {
165
167
  submit: (s: PostFormState, values?: Record<string, unknown>) => Promise<PostFormState>;
166
168
  }>("himiOwnerLitePostForm", {
167
169
  state: shape({ mode: startMode, id: initialInput.id ?? "", bodyPending: startMode === "edit" }),
170
+ // [rico] #3644 — this run's editor state and UI: a cold boot takes these from `state`, never from
171
+ // the persisted snapshot (the editor seeds once; last run's sheet is not this run's).
172
+ notHydrated: [...editorRunKeys("body"), "detailsOpen"],
168
173
  actions: {
169
174
  load: async (s, arg) => {
170
175
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -236,8 +241,9 @@ const svc = defineService<PostFormState, {
236
241
  detailsOpen: (s) => shape({ ...s, detailsOpen: true }),
237
242
  detailsClose: (s) => shape({ ...s, detailsOpen: false }),
238
243
  // The editor's `on_change` carries `args.dirty` only; the document stays in the form bag.
239
- bodyChanged: (s) => shape({ ...s, bodyDirty: true, ...editorChanged(s) }),
240
- submit: async (s, values) => {
244
+ bodyChanged: (s) => shape({ ...s, bodyDirty: true, bodyEdits: s.bodyEdits + 1, ...editorChanged(s) }),
245
+ // Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
246
+ submit: async (s, values): Promise<PostFormState> => settleOverLive(svc, s, await (async (): Promise<PostFormState> => {
241
247
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
242
248
  const v = values ?? {};
243
249
  const title = String(v.title ?? s.title).trim();
@@ -292,14 +298,19 @@ const svc = defineService<PostFormState, {
292
298
  let kept: WixRichContent | null = bodyEdited ? richContent : (replay ? null : s.richContent);
293
299
  const localRow = (id: string): DbEntity => ({ id, name: title, title, excerpt, body: postBodyText(kept), coverUrl, ...(kept ? { richContent: JSON.stringify(kept) } : {}), isFeatured: isFeatured ? "true" : "false", postStatus: "Draft", postStatusRaw: "DRAFT" });
294
300
  const localId = editing ? targetId : `local-${Date.now()}`;
295
- const savedState = (id: string, savedMessage: string) => shape({
301
+ const savedState = (id: string, savedMessage: string) => {
302
+ // An edit typed while this save was in flight stays unsaved work ([rico] #3644).
303
+ const after = editorAfterSave("body", s as never, liveState(svc), bodyEdited);
304
+ return shape({
296
305
  ...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
297
306
  title, excerpt, body: postBodyText(kept), richContent: kept, bodyJson: bodyEdited ? bodyRaw : s.bodyJson,
298
- bodyDirty: bodyEdited ? false : s.bodyDirty,
307
+ bodyDirty: after.dirty, bodyEdits: after.edits,
299
308
  coverUrl, originalCoverUrl: coverUrl, isFeatured: isFeatured ? "true" : "false",
300
309
  status: "saved", savedMessage, savedId: id,
310
+ ...(after.editedDuringSave ? { status: "idle" as const, savedMessage: "" } : {}),
301
311
  error: bodyTooLarge ? copyParam("ownerPostForm.bodyTooLarge", "The post body is too long to save, so it was left as it was. Your other changes were saved.") : "",
302
- });
312
+ });
313
+ };
303
314
  try {
304
315
  // A replay worker starts without the loaded document. Entity upserts replace whole rows, so
305
316
  // retain the cached rich body before writing back.
@@ -349,12 +360,11 @@ const svc = defineService<PostFormState, {
349
360
  }
350
361
  return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
351
362
  }
352
- },
363
+ })()),
353
364
  },
354
365
  });
355
366
 
356
- // [rico] #3371 — adopter contract: the editor seeds once, so a cold boot must not seed it with the last
357
- // run's document. Nor does it carry this run's UI over: the details sheet is shut.
358
- resetHydratedEditor(svc, { json: "bodyJson", pending: "bodyPending", dirty: "bodyDirty" }, startMode === "edit", { detailsOpen: false });
367
+ // The same reset on a runtime from before `notHydrated` existed (an older binary on a new bundle).
368
+ resetHydratedEditorOnOlderRuntime(svc, "body", startMode === "edit", { detailsOpen: false });
359
369
 
360
370
  export { svc };
@@ -18,7 +18,7 @@ import {
18
18
  import { get as dbGet, upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "../_shared/owner-local-db.js";
19
19
  import { htmlIsTrivial, htmlPreviewText } from "../_shared/owner-rich-guard.js";
20
20
  import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
21
- import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, resetHydratedEditor } from "../_shared/owner-editor-value.js";
21
+ import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } from "../_shared/owner-editor-value.js";
22
22
  import { parseRicosJson } from "@himalaya/wix-rich-content";
23
23
  import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
24
24
  import { ownerLoadError, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
@@ -69,6 +69,8 @@ interface ProductFormState {
69
69
  * hidden until this run's load lands. */
70
70
  descriptionDirty: boolean;
71
71
  descriptionPending: boolean;
72
+ /** Counts the editor's on_change events, so a save can tell an edit made while it was in flight. */
73
+ descriptionEdits: number;
72
74
  /** Derived from the mode: "locked" shows the preview + `descriptionNote`, and refuses an edit. */
73
75
  descriptionProtected: boolean;
74
76
  descriptionEditable: boolean;
@@ -154,6 +156,7 @@ function shape(s: Partial<ProductFormState>): ProductFormState {
154
156
  descriptionEditorHidden: dMode !== "editor",
155
157
  descriptionDirty: s.descriptionDirty ?? false,
156
158
  descriptionPending: s.descriptionPending ?? false,
159
+ descriptionEdits: s.descriptionEdits ?? 0,
157
160
  descriptionProtected: dMode === "locked",
158
161
  descriptionEditable: dMode !== "locked",
159
162
  descriptionNote: dMode === "locked" ? copyParam("ownerProductForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; name, price and SKU can be changed here.") : "",
@@ -183,8 +186,8 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
183
186
 
184
187
  // The RichContentEditor's image tool (the editor's upload contract). The component picks and PUTs
185
188
  // the bytes; this service mints the signed Wix Media URL with the worker's own wixApi, so no token
186
- // reaches the page. Registered BEFORE the form's service on purpose: the worker test harnesses treat a
187
- // bundle's LAST defineService as its service.
189
+ // reaches the page. Any order: the
190
+ // worker test harnesses find each service by name.
188
191
  ricosUploadService("himiOwnerLiteProductFormUploads");
189
192
 
190
193
  const svc = defineService<ProductFormState, {
@@ -194,6 +197,8 @@ const svc = defineService<ProductFormState, {
194
197
  remove: (s: ProductFormState) => Promise<ProductFormState>;
195
198
  }>("himiOwnerLiteProductForm", {
196
199
  state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: startMode === "edit" }),
200
+ // [rico] #3644 — this run's editor state: a cold boot takes it from `state`, never the snapshot.
201
+ notHydrated: editorRunKeys("description"),
197
202
  actions: {
198
203
  load: async (s, arg) => {
199
204
  stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
@@ -279,8 +284,9 @@ const svc = defineService<ProductFormState, {
279
284
  }
280
285
  },
281
286
  // The editor's on_change carries `args.dirty` only; the document stays in the form bag.
282
- descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, ...editorChanged(s) }),
283
- submit: async (s, values) => {
287
+ descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, descriptionEdits: s.descriptionEdits + 1, ...editorChanged(s) }),
288
+ // Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
289
+ submit: async (s, values): Promise<ProductFormState> => settleOverLive(svc, s, await (async (): Promise<ProductFormState> => {
284
290
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
285
291
  const v = values ?? {};
286
292
  const name = String(v.name ?? s.name).trim();
@@ -352,9 +358,13 @@ const svc = defineService<ProductFormState, {
352
358
  const savedDescription = {
353
359
  description, originalDescription: descriptionEdited || wrote ? description : s.originalDescription,
354
360
  ...(docRaw ? { descriptionJson: docRaw } : {}),
355
- descriptionDirty: wrote ? false : s.descriptionDirty,
356
361
  error: tooLarge ? copyParam("ownerProductForm.descriptionTooLarge", "The description is too long to save, so it was left as it was. Your other changes were saved.") : "",
357
362
  };
363
+ // Read when the save lands: an edit typed while it was in flight stays unsaved work ([rico] #3644).
364
+ const afterSaved = () => {
365
+ const a = editorAfterSave("description", s as never, liveState(svc), wrote);
366
+ return { descriptionDirty: a.dirty, descriptionEdits: a.edits, ...(a.editedDuringSave ? { status: "idle" as const, savedMessage: "" } : {}) };
367
+ };
358
368
  const localId = s.mode === "edit" && s.id ? s.id : `local-${Date.now()}`;
359
369
  await cachePut(localRow(localId));
360
370
  try {
@@ -380,6 +390,7 @@ const svc = defineService<ProductFormState, {
380
390
  ...savedDescription,
381
391
  savedMessage: copyParam("ownerProductForm.savedMessage", "Product saved."),
382
392
  savedId: id,
393
+ ...afterSaved(),
383
394
  });
384
395
  } catch (e) {
385
396
  if (shouldQueueOwnerWrite(e)) {
@@ -398,11 +409,12 @@ const svc = defineService<ProductFormState, {
398
409
  visible: visible ? "true" : "false", status: "saved",
399
410
  savedMessage: copyParam("ownerProductForm.savedOffline", "Saved offline — will sync when you reconnect."),
400
411
  savedId: localId,
412
+ ...afterSaved(),
401
413
  });
402
414
  }
403
415
  return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
404
416
  }
405
- },
417
+ })()),
406
418
  remove: async (s) => {
407
419
  if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
408
420
  if (!s.id) return shape({ ...s, status: "error", error: copyParam("ownerProductForm.errorNoDelete", "No product to delete."), savedMessage: "" });
@@ -424,6 +436,6 @@ const svc = defineService<ProductFormState, {
424
436
  });
425
437
 
426
438
  // Adopter contract: the editor seeds once, so a cold boot must not seed it with the last run's document.
427
- resetHydratedEditor(svc, { json: "descriptionJson", pending: "descriptionPending", dirty: "descriptionDirty" }, startMode === "edit");
439
+ resetHydratedEditorOnOlderRuntime(svc, "description", startMode === "edit");
428
440
 
429
441
  export { svc };
@@ -8,7 +8,7 @@
8
8
  // drops nodes the owner never touched).
9
9
  // Inside the platform source this import is a relative path, so node test runs resolve it; an app's
10
10
  // copy imports "@himalaya/wix-rich-content".
11
- import { parseRicosJson, isPlainRicos, RICH_CONTENT_LIMITS, type RicosDocument } from "@himalaya/wix-rich-content";
11
+ import { parseRicosJson, isPlainRicos, richContentOverLimits, type RicosDocument } from "@himalaya/wix-rich-content";
12
12
 
13
13
  export type EditorValue =
14
14
  | { kind: "absent" } // leave the stored body untouched
@@ -17,34 +17,6 @@ export type EditorValue =
17
17
  | { kind: "too_large" } // past the rich-content limits: save the rest, say why
18
18
  | { kind: "invalid" }; // not a Ricos document: refuse, write nothing
19
19
 
20
- function utf8Bytes(s: string): number {
21
- let n = 0;
22
- for (let i = 0; i < s.length; i++) {
23
- const c = s.charCodeAt(i);
24
- if (c < 0x80) n += 1;
25
- else if (c < 0x800) n += 2;
26
- else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++; }
27
- else n += 3;
28
- }
29
- return n;
30
- }
31
-
32
- /** Every viewer renders a body past the rich-content limits as its fallback, so it is never written.
33
- * The viewer's order: bytes (before parsing), then depth, then the node count. */
34
- function overLimits(doc: RicosDocument): boolean {
35
- let count = 0;
36
- const work: Array<[unknown, number]> = doc.nodes.map((n) => [n, 1] as [unknown, number]);
37
- while (work.length) {
38
- const [n, depth] = work.pop()!;
39
- if (!n || typeof n !== "object" || Array.isArray(n)) continue;
40
- if (depth > RICH_CONTENT_LIMITS.maxDepth) return true;
41
- if (++count > RICH_CONTENT_LIMITS.maxNodes) return true;
42
- const kids = (n as { nodes?: unknown }).nodes;
43
- if (Array.isArray(kids)) for (const k of kids) work.push([k, depth + 1]);
44
- }
45
- return false;
46
- }
47
-
48
20
  const TEXT_ONLY = new Set(["PARAGRAPH", "TEXT", "HEADING", "BULLETED_LIST", "ORDERED_LIST", "LIST_ITEM", "BLOCKQUOTE", "CODE_BLOCK"]);
49
21
  /** No visible text and no non-text node (an image, a divider, an embed…): an empty body. */
50
22
  export function isEmptyDocument(doc: RicosDocument): boolean {
@@ -55,10 +27,12 @@ export function isEmptyDocument(doc: RicosDocument): boolean {
55
27
 
56
28
  /** A Ricos JSON string checked as every viewer would: the limits first, then the shape. */
57
29
  function checkedDocument(raw: string): EditorValue {
58
- if (utf8Bytes(raw) > RICH_CONTENT_LIMITS.maxBytes) return { kind: "too_large" };
30
+ // Every viewer renders a body past the limits as its fallback, so it is never written. One parse
31
+ // feeds both checks; a string past the byte limit is refused before it is parsed at all.
32
+ if (richContentOverLimits(raw, null) === "too_large") return { kind: "too_large" };
59
33
  const doc = parseRicosJson(raw);
60
34
  if (!doc) return { kind: "invalid" };
61
- return overLimits(doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
35
+ return richContentOverLimits(raw, doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
62
36
  }
63
37
 
64
38
  /** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
@@ -108,15 +82,59 @@ export function cachedRichVerdict(doc: RicosDocument | null, knownPlain: boolean
108
82
  return knownPlain ? "false" : "true";
109
83
  }
110
84
 
111
- /** Cold boot: the runtime lays this service's last persisted snapshot over its declared state, so the
112
- * editor's first seed would be the document as it was when the screen last ran — and a Save after
113
- * typing would write it over whatever the record holds now. Blank the hydrated seed and reset the gate
114
- * before anything renders, in place (no action has run yet). `extra` resets further UI state. */
115
- export function resetHydratedEditor(svc: unknown, keys: { json: string; pending: string; dirty: string }, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
85
+ /** The keys of one editor field that describe THIS run, never the last one: the seed (`<key>Json`),
86
+ * the load gate (`<key>Pending`), the edit flag (`<key>Dirty`) and its counter (`<key>Edits`). A form
87
+ * lists them in `defineService(name, { notHydrated })`, so a cold boot takes their DECLARED values: the
88
+ * editor seeds once, and a persisted seed would be painted as its content and saved back over a newer
89
+ * record. */
90
+ export function editorRunKeys<K extends string>(key: K): [`${K}Json`, `${K}Pending`, `${K}Dirty`, `${K}Edits`] {
91
+ return [`${key}Json`, `${key}Pending`, `${key}Dirty`, `${key}Edits`];
92
+ }
93
+
94
+ /** The same keys on a runtime that ignores `notHydrated` (a binary from before #3644, still served new
95
+ * bundles over the air): reset them in place, before any action runs. A no-op where the runtime
96
+ * honours the option (`himalaya.hydrationOptOut`). */
97
+ export function resetHydratedEditorOnOlderRuntime(svc: unknown, key: string, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
98
+ if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut === true) return;
116
99
  const booted = (svc as { getState?: () => Record<string, unknown> | undefined }).getState?.();
117
100
  if (!booted || typeof booted !== "object") return;
118
- if (booted[keys.json]) booted[keys.json] = "";
119
- booted[keys.pending] = pendingAtBoot;
120
- booted[keys.dirty] = false;
101
+ if (booted[`${key}Json`]) booted[`${key}Json`] = "";
102
+ booted[`${key}Pending`] = pendingAtBoot;
103
+ booted[`${key}Dirty`] = false;
104
+ booted[`${key}Edits`] = 0;
121
105
  Object.assign(booted, extra);
122
106
  }
107
+
108
+ /** What an awaited save leaves the field as, read from the LIVE state when it lands. An on_change that
109
+ * arrived while the save was in flight (`<key>Edits` moved) is an edit the save did not write: the
110
+ * field stays dirty and the save's confirmation is stale. Otherwise a save that wrote the field
111
+ * re-bases it as saved. `live` is the service's state now (none, or one that never moved: the start state). */
112
+ export function editorAfterSave(key: string, start: Record<string, unknown>, live: Record<string, unknown> | undefined, wrote: boolean):
113
+ { dirty: boolean; edits: number; editedDuringSave: boolean } {
114
+ const atStart = Number(start[`${key}Edits`] ?? 0);
115
+ const now = Number(live?.[`${key}Edits`] ?? atStart);
116
+ // Only a counter that MOVED ON counts; one behind the start is a host that does not apply results.
117
+ const editedDuringSave = now > atStart;
118
+ return editedDuringSave
119
+ ? { dirty: true, edits: now, editedDuringSave }
120
+ : { dirty: wrote ? false : Boolean(start[`${key}Dirty`]), edits: atStart, editedDuringSave };
121
+ }
122
+
123
+ /** The live state of a service handle, when the runtime gives one. */
124
+ export function liveState(svc: unknown): Record<string, unknown> | undefined {
125
+ const st = (svc as { getState?: () => unknown } | undefined)?.getState?.();
126
+ return st && typeof st === "object" ? st as Record<string, unknown> : undefined;
127
+ }
128
+
129
+ /** An awaited action's result, laid over the LIVE state: only the keys the action CHANGED (against the
130
+ * state it started from) are written, so anything the owner did while it awaited — an edit, a field in
131
+ * the details sheet, closing the sheet — survives a save that lands after it, success or failure. A
132
+ * key both changed goes to the action. Where the host gives no live state, the result as it is. */
133
+ export function settleOverLive<S extends object>(svc: unknown, start: S, result: S): S {
134
+ const now = liveState(svc) as S | undefined;
135
+ if (!now || now === start || now === result) return result;
136
+ const out = { ...now } as Record<string, unknown>;
137
+ const r = result as Record<string, unknown>, s0 = start as Record<string, unknown>;
138
+ for (const k of Object.keys(r)) if (!Object.is(r[k], s0[k])) out[k] = r[k];
139
+ return out as S;
140
+ }