@rohal12/spindle 0.59.12 → 0.59.13

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.
@@ -11791,7 +11791,7 @@ var InlineContext = createContext(false);
11791
11791
  /**
11792
11792
  * True while rendering inside an element whose content is not markdown: SVG
11793
11793
  * (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
11794
- * and `<textarea>`, whose text (indentation, `#`, `*`, ...) is literal.
11794
+ * `<textarea>` and `<style>`, whose text (indentation, `#`, `*`, ...) is literal.
11795
11795
  * Macro and widget bodies read it so their content stays literal too.
11796
11796
  */
11797
11797
  var RawTextContext = createContext(false);
@@ -11802,7 +11802,11 @@ var RawTextContext = createContext(false);
11802
11802
  var SvgContext = createContext(false);
11803
11803
  var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
11804
11804
  /** Elements whose content is literal text, not markdown. */
11805
- var PREFORMATTED_ELEMENTS = new Set(["pre", "textarea"]);
11805
+ var PREFORMATTED_ELEMENTS = new Set([
11806
+ "pre",
11807
+ "textarea",
11808
+ "style"
11809
+ ]);
11806
11810
  var WidgetChildrenContext = createContext(null);
11807
11811
  function placeholderHtml(nonce, index) {
11808
11812
  return `<span data-tw=${nonce}:${index}></span>`;
@@ -12021,6 +12025,7 @@ var INLINE_ELEMENTS = new Set([
12021
12025
  "span",
12022
12026
  "strong",
12023
12027
  "sub",
12028
+ "summary",
12024
12029
  "sup",
12025
12030
  "time",
12026
12031
  "u",
@@ -12225,6 +12230,12 @@ function resolveAttributeValue(name, value, scope, errors) {
12225
12230
  for (const error of result.errors) errors.push([name, error]);
12226
12231
  return result.text;
12227
12232
  }
12233
+ /** The index of the summary element opening `nodes`, or -1 when none does. */
12234
+ function leadingSummaryIndex(nodes) {
12235
+ const at = nodes.findIndex((n) => n.type !== "text" || n.value.trim() !== "");
12236
+ const first = nodes[at];
12237
+ return first?.type === "html" && first.tag.toLowerCase() === "summary" ? at : -1;
12238
+ }
12228
12239
  function HtmlNodeRenderer({ node }) {
12229
12240
  const scope = useTextScope();
12230
12241
  const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
@@ -12244,11 +12255,17 @@ function HtmlNodeRenderer({ node }) {
12244
12255
  let children = void 0;
12245
12256
  if (node.children.length > 0) if (inRaw || isRawRoot) children = renderInlineNodes(node.children);
12246
12257
  else {
12247
- children = renderNodes(node.children, {
12258
+ const summaryAt = tag === "details" ? leadingSummaryIndex(node.children) : -1;
12259
+ const body = (nodes) => renderNodes(nodes, {
12248
12260
  nobr,
12249
12261
  locals,
12250
12262
  inline: isInline
12251
12263
  });
12264
+ children = summaryAt === -1 ? body(node.children) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
12265
+ body(node.children.slice(0, summaryAt)),
12266
+ renderSingleNode(node.children[summaryAt]),
12267
+ body(node.children.slice(summaryAt + 1))
12268
+ ] });
12252
12269
  if (isInline !== parentInline) children = /* @__PURE__ */ jsx(InlineContext.Provider, {
12253
12270
  value: isInline,
12254
12271
  children
@@ -15582,6 +15599,26 @@ var clonersFor = (paths) => {
15582
15599
  function frozenCopy(value) {
15583
15600
  return freeze(cloneValue(value), true);
15584
15601
  }
15602
+ /** Whether `value` and everything it holds is frozen. */
15603
+ function deeplyFrozen(value, seen = /* @__PURE__ */ new Set()) {
15604
+ if (typeof value !== "object" || value === null || seen.has(value)) return true;
15605
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
15606
+ if (!Object.isFrozen(value)) return false;
15607
+ seen.add(value);
15608
+ return Reflect.ownKeys(value).every((key) => {
15609
+ const desc = Object.getOwnPropertyDescriptor(value, key);
15610
+ return !desc || !("value" in desc) || deeplyFrozen(desc.value, seen);
15611
+ });
15612
+ }
15613
+ /**
15614
+ * A value read from the store, made safe to hand out: the store holds plain
15615
+ * values frozen, but not registered class instances, whose methods could
15616
+ * change the recorded history through the live object. Such a value is
15617
+ * returned as a frozen copy, like a read inside mutation code.
15618
+ */
15619
+ function readOnlyValue(value) {
15620
+ return deeplyFrozen(value) ? value : frozenCopy(value);
15621
+ }
15585
15622
  /**
15586
15623
  * Apply a write that reached the store while mutations execute to their
15587
15624
  * `work` and `base` copies (see MutationScope), so the code sees it and
@@ -17265,7 +17302,8 @@ var FOCUSABLE = [
17265
17302
  "select:not([disabled])",
17266
17303
  "textarea:not([disabled])",
17267
17304
  "iframe",
17268
- "[contenteditable=\"true\"]",
17305
+ "summary",
17306
+ "[contenteditable]",
17269
17307
  "[tabindex]"
17270
17308
  ].join(",");
17271
17309
  /**
@@ -17293,6 +17331,11 @@ function tabIndexAttr(el) {
17293
17331
  function isTabStop(el) {
17294
17332
  const tabIndex = tabIndexAttr(el);
17295
17333
  if (tabIndex !== null && tabIndex < 0) return false;
17334
+ if (el.hasAttribute("contenteditable") && !el.matches("a[href], button, input, select, textarea, iframe, summary") && (!el.isContentEditable || el.parentElement?.isContentEditable)) return false;
17335
+ if (el.localName === "summary") {
17336
+ const details = el.parentElement;
17337
+ if (details?.localName !== "details" || Array.from(details.children).find((c) => c.localName === "summary") !== el) return tabIndex !== null;
17338
+ }
17296
17339
  if (el instanceof HTMLInputElement && el.type === "radio" && el.name) {
17297
17340
  const group = Array.from((el.form ?? el.getRootNode()).querySelectorAll("input[type=\"radio\"]")).filter((r) => r.name === el.name && r.form === el.form && !r.disabled);
17298
17341
  return el === (group.find((r) => r.checked) ?? group[0]);
@@ -17816,18 +17859,38 @@ var settings = {
17816
17859
  //#endregion
17817
17860
  //#region src/utils/control-edits.ts
17818
17861
  /**
17819
- * Counts the edits a reader made through a control bound to a story
17862
+ * Records the edits a reader made through a control bound to a story
17820
17863
  * variable (`{textbox}`, `{checkbox}`, ...). {for} tells these apart from a
17821
17864
  * list that is replaced: typing into a control inside an iteration changes
17822
17865
  * its item, but must not remount the iteration and drop the control's focus.
17866
+ * An edit is attributed to the variable path it wrote, so a replacement made
17867
+ * elsewhere (a watcher reacting to the edit) still remounts.
17823
17868
  */
17824
17869
  var version = 0;
17825
- function noteControlEdit() {
17826
- version++;
17870
+ var MAX_LOG = 256;
17871
+ var log = [];
17872
+ function noteControlEdit(segments) {
17873
+ log.push({
17874
+ version: ++version,
17875
+ segments
17876
+ });
17877
+ if (log.length > MAX_LOG) log.shift();
17827
17878
  }
17828
17879
  function controlEditVersion() {
17829
17880
  return version;
17830
17881
  }
17882
+ /**
17883
+ * Whether an edit since `since` wrote inside `item`, element `index` of
17884
+ * `list`: the path leads through the item (compared by reference in
17885
+ * `variables`), or, for a primitive item, ends at its slot in the list.
17886
+ */
17887
+ function editedItem(since, variables, list, item, index) {
17888
+ return log.some(({ version: v, segments }) => {
17889
+ if (v <= since) return false;
17890
+ if (typeof item === "object" && item !== null) return segments.some((_, n) => getByPath(variables, segments.slice(0, n + 1)) === item);
17891
+ return segments[segments.length - 1] === String(index) && getByPath(variables, segments.slice(0, -1)) === list;
17892
+ });
17893
+ }
17831
17894
  //#endregion
17832
17895
  //#region src/action-registry.ts
17833
17896
  /** The fields of a StoryAction that are left out when unset. */
@@ -18073,7 +18136,7 @@ function defineMacro(config, source = "builtin") {
18073
18136
  ctx.value = useStoryStore((s) => getByPath(s.variables, segments));
18074
18137
  ctx.getValue = () => getByPath(readState().variables, segments);
18075
18138
  ctx.setValue = (value) => {
18076
- noteControlEdit();
18139
+ noteControlEdit(segments);
18077
18140
  mutateState((work, adopt) => {
18078
18141
  setByPath(work.variables, segments, adopt(value), { createMissing: true });
18079
18142
  });
@@ -18208,7 +18271,7 @@ function createStoryAPI() {
18208
18271
  const source = scope ?? useStoryStore.getState();
18209
18272
  const namespace = isTransient ? source.transient : source.variables;
18210
18273
  const value = key.includes(".") ? getByPath(namespace, key.split(".")) : ownValue(namespace, key);
18211
- return scope ? frozenCopy(value) : value;
18274
+ return scope ? frozenCopy(value) : readOnlyValue(value);
18212
18275
  },
18213
18276
  set(nameOrVars, value) {
18214
18277
  const entries = typeof nameOrVars === "string" ? [[nameOrVars, value]] : Object.entries(nameOrVars);
@@ -18920,7 +18983,8 @@ var PRIMITIVE_SAMPLES = {
18920
18983
  };
18921
18984
  function inferSchema(value) {
18922
18985
  if (Array.isArray(value)) return { type: "array" };
18923
- if (value !== null && typeof value === "object") {
18986
+ if (value === null) return { type: "null" };
18987
+ if (typeof value === "object") {
18924
18988
  const fields = /* @__PURE__ */ new Map();
18925
18989
  for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
18926
18990
  return {
@@ -18983,6 +19047,7 @@ function validateRef(ref, schema) {
18983
19047
  for (let i = 1; i < parts.length; i++) {
18984
19048
  const part = parts[i];
18985
19049
  if (current.type === "array") return null;
19050
+ if (current.type === "null") return null;
18986
19051
  const sample = PRIMITIVE_SAMPLES[current.type];
18987
19052
  if (sample && part in sample) {
18988
19053
  const memberType = typeof sample[part];
@@ -19211,23 +19276,31 @@ function assignmentArgs(rawArgs, args) {
19211
19276
  /**
19212
19277
  * Evaluate `expr` against the current values — the store's state and the
19213
19278
  * enclosing locals scope's live values (`getLocals`) — and write the result
19214
- * to the target if it differs from the previous output (always, the first
19279
+ * to the target if it differs from the previous output or the target's value (always, the first
19215
19280
  * time). Reading live values rather than the render's snapshot means the
19216
19281
  * first computation sees a local a preceding {set} just assigned (the scope's
19217
19282
  * context value only catches up on its re-render), and the first computation
19218
19283
  * and later recomputations read the same source.
19219
19284
  */
19220
- function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRef, localsUpdate) {
19285
+ function computeAndApply(expr, name, isTemp, isLocal, getLocals, rawArgs, prevRef, lastNavigation, localsUpdate) {
19221
19286
  const location = currentSourceLocation();
19222
19287
  let newValue;
19288
+ let current = prevRef.current;
19289
+ let navigated = false;
19290
+ const { navigationId } = useStoryStore.getState();
19223
19291
  try {
19224
19292
  const { variables, temporary, transient } = readState();
19293
+ if (!isLocal) {
19294
+ current = ownValue(isTemp ? temporary : variables, name);
19295
+ navigated = navigationId !== lastNavigation.current;
19296
+ }
19225
19297
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
19226
19298
  } catch (err) {
19227
19299
  logMacroError(`computed ${rawArgs}`, err, location);
19228
19300
  return;
19229
19301
  }
19230
- if (!deepEqualStrict(prevRef.current, newValue)) {
19302
+ lastNavigation.current = navigationId;
19303
+ if (!deepEqualStrict(prevRef.current, newValue) || navigated && !deepEqualStrict(current, newValue)) {
19231
19304
  prevRef.current = newValue;
19232
19305
  if (isLocal) try {
19233
19306
  localsUpdate(name, newValue);
@@ -19276,7 +19349,8 @@ defineMacro({
19276
19349
  const name = target.slice(1);
19277
19350
  const localsUpdate = isLocal ? ctx.update : null;
19278
19351
  const prevOutput = ctx.hooks.useRef(UNSET);
19279
- const compute = () => computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, localsUpdate);
19352
+ const lastNavigation = ctx.hooks.useRef(-1);
19353
+ const compute = () => computeAndApply(expr, name, isTemp, isLocal, ctx.getValues, rawArgs, prevOutput, lastNavigation, localsUpdate);
19280
19354
  const ran = ctx.hooks.useRef(false);
19281
19355
  if (!ran.current) {
19282
19356
  ran.current = true;
@@ -19539,11 +19613,12 @@ defineMacro({
19539
19613
  });
19540
19614
  }
19541
19615
  const { itemVar, indexVar } = loop;
19542
- const edited = controlEditVersion() !== keys.edits;
19616
+ const since = keys.edits;
19617
+ const { variables } = readState();
19543
19618
  keys.edits = controlEditVersion();
19544
19619
  const generations = list.map((item, i) => {
19545
19620
  const contents = stableKey(item);
19546
- if (keys.generations[i] === void 0 || contents !== keys.contents[i] && !edited) keys.generations[i] = keys.next++;
19621
+ if (keys.generations[i] === void 0 || contents !== keys.contents[i] && !editedItem(since, variables, list, item, i)) keys.generations[i] = keys.next++;
19547
19622
  keys.contents[i] = contents;
19548
19623
  return keys.generations[i];
19549
19624
  });
@@ -19937,12 +20012,13 @@ defineMacro({
19937
20012
  const containerRef = useRef(null);
19938
20013
  const innerRef = useRef(null);
19939
20014
  const [clip, setClip] = useState();
19940
- const [totalChars, setTotalChars] = useState(0);
20015
+ const [measured, setMeasured] = useState(null);
20016
+ const totalChars = measured ?? 0;
19941
20017
  const [visibleChars, setVisibleChars] = useState(0);
19942
20018
  const visibleCharsRef = useRef(0);
19943
20019
  visibleCharsRef.current = visibleChars;
19944
20020
  useEffect(() => {
19945
- if (containerRef.current) setTotalChars((containerRef.current.textContent || "").length);
20021
+ if (containerRef.current) setMeasured((containerRef.current.textContent || "").length);
19946
20022
  });
19947
20023
  useEffect(() => {
19948
20024
  if (totalChars === 0) return;
@@ -19958,7 +20034,7 @@ defineMacro({
19958
20034
  }, speed);
19959
20035
  return () => clearInterval(timer);
19960
20036
  }, [totalChars, speed]);
19961
- const done = visibleChars >= totalChars && totalChars > 0;
20037
+ const done = measured !== null && visibleChars >= totalChars;
19962
20038
  useLayoutEffect(() => {
19963
20039
  const root = containerRef.current;
19964
20040
  const inner = innerRef.current;
@@ -19989,7 +20065,7 @@ defineMacro({
19989
20065
  class: "macro-type-inner",
19990
20066
  style: {
19991
20067
  display: "inline",
19992
- visibility: totalChars === 0 ? "hidden" : "visible",
20068
+ visibility: measured === null ? "hidden" : "visible",
19993
20069
  clipPath: clip
19994
20070
  },
19995
20071
  children: ctx.renderInlineNodes(children)
@@ -11082,7 +11082,8 @@ var VALID_VAR_TYPES = new Set([
11082
11082
  ]);
11083
11083
  function inferSchema(value) {
11084
11084
  if (Array.isArray(value)) return { type: "array" };
11085
- if (value !== null && typeof value === "object") {
11085
+ if (value === null) return { type: "null" };
11086
+ if (typeof value === "object") {
11086
11087
  const fields = /* @__PURE__ */ new Map();
11087
11088
  for (const [key, val] of Object.entries(value)) fields.set(key, inferSchema(val));
11088
11089
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohal12/spindle",
3
- "version": "0.59.12",
3
+ "version": "0.59.13",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -5,7 +5,7 @@ import { deepEqualStrict } from '../../structural';
5
5
  import { defineMacro } from '../../define-macro';
6
6
  import { MacroError, logMacroError } from './MacroError';
7
7
  import { currentSourceLocation } from '../../utils/source-location';
8
- import { checkVariableName } from '../../utils/namespace';
8
+ import { checkVariableName, ownValue } from '../../utils/namespace';
9
9
 
10
10
  /**
11
11
  * The previous output before the first successful evaluation. Distinct from
@@ -39,7 +39,7 @@ function assignmentArgs(
39
39
  /**
40
40
  * Evaluate `expr` against the current values — the store's state and the
41
41
  * enclosing locals scope's live values (`getLocals`) — and write the result
42
- * to the target if it differs from the previous output (always, the first
42
+ * to the target if it differs from the previous output or the target's value (always, the first
43
43
  * time). Reading live values rather than the render's snapshot means the
44
44
  * first computation sees a local a preceding {set} just assigned (the scope's
45
45
  * context value only catches up on its re-render), and the first computation
@@ -53,22 +53,39 @@ function computeAndApply(
53
53
  getLocals: () => Record<string, unknown>,
54
54
  rawArgs: string,
55
55
  prevRef: { current: unknown },
56
+ lastNavigation: { current: number },
56
57
  localsUpdate: ((key: string, value: unknown) => void) | null,
57
58
  ): void {
58
59
  // Before evaluating: the expression may navigate away from this passage
59
60
  const location = currentSourceLocation();
60
61
  let newValue: unknown;
62
+ let current: unknown = prevRef.current;
63
+ let navigated = false;
64
+ const { navigationId } = useStoryStore.getState();
61
65
  try {
62
66
  // In program order, also when mutation code sets this off
63
67
  const { variables, temporary, transient } = readState();
68
+ // A navigation (or restored session) resets the temporary namespace,
69
+ // which a persistent {computed} survives: the target is then gone though
70
+ // the result is unchanged, and is written again. Between navigations a
71
+ // target the story removed or set itself stays as it is.
72
+ if (!isLocal) {
73
+ current = ownValue(isTemp ? temporary : variables, name);
74
+ navigated = navigationId !== lastNavigation.current;
75
+ }
64
76
  newValue = evaluate(expr, variables, temporary, getLocals(), transient);
65
77
  } catch (err) {
66
78
  logMacroError(`computed ${rawArgs}`, err, location);
67
79
  return;
68
80
  }
69
81
 
82
+ lastNavigation.current = navigationId;
83
+
70
84
  // Strict: a change of only the aliases inside the result is a change
71
- if (!deepEqualStrict(prevRef.current, newValue)) {
85
+ if (
86
+ !deepEqualStrict(prevRef.current, newValue) ||
87
+ (navigated && !deepEqualStrict(current, newValue))
88
+ ) {
72
89
  prevRef.current = newValue;
73
90
  if (isLocal) {
74
91
  try {
@@ -111,6 +128,8 @@ defineMacro({
111
128
  const localsUpdate = isLocal ? ctx.update : null;
112
129
 
113
130
  const prevOutput = ctx.hooks.useRef<unknown>(UNSET);
131
+ // The navigation the target was last checked in
132
+ const lastNavigation = ctx.hooks.useRef(-1);
114
133
 
115
134
  const compute = () =>
116
135
  computeAndApply(
@@ -121,6 +140,7 @@ defineMacro({
121
140
  ctx.getValues,
122
141
  rawArgs,
123
142
  prevOutput,
143
+ lastNavigation,
124
144
  localsUpdate,
125
145
  );
126
146
 
@@ -3,9 +3,10 @@ import { LocalsValuesContext } from '../../markup/render';
3
3
  import { defineMacro } from '../../define-macro';
4
4
  import { MacroError } from './MacroError';
5
5
  import { stableKey } from '../../utils/stable-key';
6
- import { controlEditVersion } from '../../utils/control-edits';
6
+ import { controlEditVersion, editedItem } from '../../utils/control-edits';
7
7
  import type { ASTNode } from '../../markup/ast';
8
8
  import { checkVariableName } from '../../utils/namespace';
9
+ import { readState } from '../../execute-mutation';
9
10
  import { LocalsScope } from './locals-scope';
10
11
 
11
12
  /**
@@ -131,13 +132,15 @@ defineMacro({
131
132
  // macros run again for the new item (#45) -- unless the change is a
132
133
  // reader editing the item through a control: remounting would drop the
133
134
  // control's focus after the first keystroke.
134
- const edited = controlEditVersion() !== keys.edits;
135
+ const since = keys.edits;
136
+ const { variables } = readState();
135
137
  keys.edits = controlEditVersion();
136
138
  const generations = list.map((item, i) => {
137
139
  const contents = stableKey(item);
138
140
  if (
139
141
  keys.generations[i] === undefined ||
140
- (contents !== keys.contents[i] && !edited)
142
+ (contents !== keys.contents[i] &&
143
+ !editedItem(since, variables, list, item, i))
141
144
  ) {
142
145
  keys.generations[i] = keys.next++;
143
146
  }
@@ -49,7 +49,10 @@ defineMacro({
49
49
  const containerRef = useRef<HTMLSpanElement>(null);
50
50
  const innerRef = useRef<HTMLSpanElement>(null);
51
51
  const [clip, setClip] = useState<string | undefined>();
52
- const [totalChars, setTotalChars] = useState(0);
52
+ // Null until the content has been measured: zero characters is then a
53
+ // result (nothing to animate), not a pending measurement.
54
+ const [measured, setMeasured] = useState<number | null>(null);
55
+ const totalChars = measured ?? 0;
53
56
  const [visibleChars, setVisibleChars] = useState(0);
54
57
  const visibleCharsRef = useRef(0);
55
58
  visibleCharsRef.current = visibleChars;
@@ -59,7 +62,7 @@ defineMacro({
59
62
  useEffect(() => {
60
63
  if (containerRef.current) {
61
64
  const text = containerRef.current.textContent || '';
62
- setTotalChars(text.length);
65
+ setMeasured(text.length);
63
66
  }
64
67
  });
65
68
 
@@ -81,7 +84,8 @@ defineMacro({
81
84
  return () => clearInterval(timer);
82
85
  }, [totalChars, speed]);
83
86
 
84
- const done = visibleChars >= totalChars && totalChars > 0;
87
+ // Empty text can still become reactive text later, which restarts typing
88
+ const done = measured !== null && visibleChars >= totalChars;
85
89
 
86
90
  // Clip to the characters revealed so far, as laid out: remeasured when
87
91
  // the count changes and when the width the text wraps in does.
@@ -117,7 +121,7 @@ defineMacro({
117
121
  class="macro-type-inner"
118
122
  style={{
119
123
  display: 'inline',
120
- visibility: totalChars === 0 ? 'hidden' : 'visible',
124
+ visibility: measured === null ? 'hidden' : 'visible',
121
125
  clipPath: clip,
122
126
  }}
123
127
  >
@@ -229,7 +229,7 @@ export function defineMacro<const P extends readonly ParameterDef[] = []>(
229
229
  // In program order, also when mutation code performs the input
230
230
  ctx.getValue = () => getByPath(readState().variables, segments);
231
231
  ctx.setValue = (value: unknown) => {
232
- noteControlEdit();
232
+ noteControlEdit(segments);
233
233
  // The commit mutation code uses, which keeps the references among
234
234
  // variables: a write to a draft copies only the path written, and
235
235
  // other variables sharing the object would keep the old one.
@@ -138,6 +138,32 @@ export function frozenCopy<T>(value: T): T {
138
138
  return freeze(cloneValue(value), true);
139
139
  }
140
140
 
141
+ /** Whether `value` and everything it holds is frozen. */
142
+ function deeplyFrozen(value: unknown, seen = new Set<object>()): boolean {
143
+ if (typeof value !== 'object' || value === null || seen.has(value)) {
144
+ return true;
145
+ }
146
+ // Leaves a freeze cannot lock (typed array elements) and that a copy would
147
+ // detach from what they share (a buffer): handed out as they are
148
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return true;
149
+ if (!Object.isFrozen(value)) return false;
150
+ seen.add(value);
151
+ return Reflect.ownKeys(value).every((key) => {
152
+ const desc = Object.getOwnPropertyDescriptor(value, key);
153
+ return !desc || !('value' in desc) || deeplyFrozen(desc.value, seen);
154
+ });
155
+ }
156
+
157
+ /**
158
+ * A value read from the store, made safe to hand out: the store holds plain
159
+ * values frozen, but not registered class instances, whose methods could
160
+ * change the recorded history through the live object. Such a value is
161
+ * returned as a frozen copy, like a read inside mutation code.
162
+ */
163
+ export function readOnlyValue<T>(value: T): T {
164
+ return deeplyFrozen(value) ? value : frozenCopy(value);
165
+ }
166
+
141
167
  /**
142
168
  * Apply a write that reached the store while mutations execute to their
143
169
  * `work` and `base` copies (see MutationScope), so the code sees it and
@@ -9,7 +9,8 @@ const FOCUSABLE = [
9
9
  'select:not([disabled])',
10
10
  'textarea:not([disabled])',
11
11
  'iframe',
12
- '[contenteditable="true"]',
12
+ 'summary',
13
+ '[contenteditable]',
13
14
  '[tabindex]',
14
15
  ].join(',');
15
16
 
@@ -42,6 +43,25 @@ function tabIndexAttr(el: HTMLElement): number | null {
42
43
  function isTabStop(el: HTMLElement): boolean {
43
44
  const tabIndex = tabIndexAttr(el);
44
45
  if (tabIndex !== null && tabIndex < 0) return false;
46
+ // Only an editing host takes focus, not `contenteditable="false"` or
47
+ // content nested in a host
48
+ if (
49
+ el.hasAttribute('contenteditable') &&
50
+ !el.matches('a[href], button, input, select, textarea, iframe, summary') &&
51
+ (!el.isContentEditable || el.parentElement?.isContentEditable)
52
+ ) {
53
+ return false;
54
+ }
55
+ // Only a details element's first summary child is its toggle
56
+ if (el.localName === 'summary') {
57
+ const details = el.parentElement;
58
+ if (
59
+ details?.localName !== 'details' ||
60
+ Array.from(details.children).find((c) => c.localName === 'summary') !== el
61
+ ) {
62
+ return tabIndex !== null;
63
+ }
64
+ }
45
65
  if (el instanceof HTMLInputElement && el.type === 'radio' && el.name) {
46
66
  const group = Array.from(
47
67
  (
@@ -52,7 +52,7 @@ export const InlineContext = createContext(false);
52
52
  /**
53
53
  * True while rendering inside an element whose content is not markdown: SVG
54
54
  * (whose namespace `<p>` wrappers would break) and the preformatted `<pre>`
55
- * and `<textarea>`, whose text (indentation, `#`, `*`, ...) is literal.
55
+ * `<textarea>` and `<style>`, whose text (indentation, `#`, `*`, ...) is literal.
56
56
  * Macro and widget bodies read it so their content stays literal too.
57
57
  */
58
58
  export const RawTextContext = createContext(false);
@@ -66,7 +66,7 @@ const SvgContext = createContext(false);
66
66
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
67
67
 
68
68
  /** Elements whose content is literal text, not markdown. */
69
- const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea']);
69
+ const PREFORMATTED_ELEMENTS = new Set(['pre', 'textarea', 'style']);
70
70
  export const WidgetChildrenContext = createContext<ASTNode[] | null>(null);
71
71
 
72
72
  /**
@@ -392,6 +392,7 @@ const INLINE_ELEMENTS = new Set([
392
392
  'span',
393
393
  'strong',
394
394
  'sub',
395
+ 'summary',
395
396
  'sup',
396
397
  'time',
397
398
  'u',
@@ -632,6 +633,15 @@ function resolveAttributeValue(
632
633
  return result.text;
633
634
  }
634
635
 
636
+ /** The index of the summary element opening `nodes`, or -1 when none does. */
637
+ function leadingSummaryIndex(nodes: ASTNode[]): number {
638
+ const at = nodes.findIndex((n) => n.type !== 'text' || n.value.trim() !== '');
639
+ const first = nodes[at];
640
+ return first?.type === 'html' && first.tag.toLowerCase() === 'summary'
641
+ ? at
642
+ : -1;
643
+ }
644
+
635
645
  function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
636
646
  const scope = useTextScope();
637
647
  const { nobr, locals, raw: inRaw, inline: parentInline } = useRenderOptions();
@@ -658,7 +668,22 @@ function HtmlNodeRenderer({ node }: { node: HtmlNode }) {
658
668
  if (inRaw || isRawRoot) {
659
669
  children = renderInlineNodes(node.children);
660
670
  } else {
661
- children = renderNodes(node.children, { nobr, locals, inline: isInline });
671
+ // A details element's summary stays its direct child: it is rendered
672
+ // apart from the markdown of the rest of the content.
673
+ const summaryAt =
674
+ tag === 'details' ? leadingSummaryIndex(node.children) : -1;
675
+ const body = (nodes: ASTNode[]) =>
676
+ renderNodes(nodes, { nobr, locals, inline: isInline });
677
+ children =
678
+ summaryAt === -1 ? (
679
+ body(node.children)
680
+ ) : (
681
+ <>
682
+ {body(node.children.slice(0, summaryAt))}
683
+ {renderSingleNode(node.children[summaryAt]!)}
684
+ {body(node.children.slice(summaryAt + 1))}
685
+ </>
686
+ );
662
687
  if (isInline !== parentInline) {
663
688
  children = (
664
689
  <InlineContext.Provider value={isInline}>
package/src/story-api.ts CHANGED
@@ -23,6 +23,7 @@ import { getBackendType } from './saves/storage';
23
23
  import { registerClass } from './class-registry';
24
24
  import {
25
25
  frozenCopy,
26
+ readOnlyValue,
26
27
  getActiveMutationScope,
27
28
  mutateState,
28
29
  } from './execute-mutation';
@@ -286,7 +287,7 @@ function createStoryAPI(): StoryAPI {
286
287
  const value = key.includes('.')
287
288
  ? getByPath(namespace, key.split('.'))
288
289
  : ownValue(namespace, key);
289
- return scope ? frozenCopy(value) : value;
290
+ return scope ? frozenCopy(value) : readOnlyValue(value);
290
291
  },
291
292
 
292
293
  set(nameOrVars: string | Record<string, unknown>, value?: unknown): void {
@@ -32,7 +32,13 @@ function tokensOf(text: string, textMode: boolean): Token[] {
32
32
  }
33
33
  }
34
34
 
35
- export type VarType = 'number' | 'string' | 'boolean' | 'array' | 'object';
35
+ export type VarType =
36
+ | 'number'
37
+ | 'string'
38
+ | 'boolean'
39
+ | 'array'
40
+ | 'object'
41
+ | 'null';
36
42
 
37
43
  export interface FieldSchema {
38
44
  type: VarType;
@@ -66,7 +72,9 @@ function inferSchema(value: unknown): FieldSchema {
66
72
  if (Array.isArray(value)) {
67
73
  return { type: 'array' };
68
74
  }
69
- if (value !== null && typeof value === 'object') {
75
+ // A default of null means "nothing yet"; the value may later be anything
76
+ if (value === null) return { type: 'null' };
77
+ if (typeof value === 'object') {
70
78
  const fields = new Map<string, FieldSchema>();
71
79
  for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
72
80
  fields.set(key, inferSchema(val));
@@ -156,6 +164,8 @@ function validateRef(
156
164
  // Arrays have built-in methods/properties (push, find, length, etc.)
157
165
  // so any field access on an array is allowed.
158
166
  if (current.type === 'array') return null;
167
+ // A null default may later hold a value of any shape
168
+ if (current.type === 'null') return null;
159
169
 
160
170
  // Primitives expose their wrapper's built-ins (length, toUpperCase,
161
171
  // toFixed, etc.). Keep validating past properties of primitive type.