@stage5/lumine 0.3.46 → 0.3.48

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.
@@ -13,6 +13,8 @@ export const OWNER_TRACE_PERF_TYPES = [
13
13
  "slow-request",
14
14
  "stall",
15
15
  "page-load",
16
+ "scroll-perf",
17
+ "scroll-frame",
16
18
  ];
17
19
 
18
20
  const MAX_LIMIT = 2000;
@@ -134,6 +136,10 @@ export function describeOwnerTraceEvent(event) {
134
136
  ].filter(Boolean);
135
137
  return `${data.m || "?"} ${data.api || "?"} → ${data.status ?? "?"} in ${data.ms}ms${extra.length ? ` (${extra.join(", ")})` : ""}`;
136
138
  }
139
+ case "scroll-perf":
140
+ return describeScrollPerf(data);
141
+ case "scroll-frame":
142
+ return describeScrollFrame(data);
137
143
  case "stall":
138
144
  return `main thread blocked ${data.ms}ms${data.n > 1 ? ` (${data.n} stalls, ${data.total}ms blocked over ${data.span}ms)` : ""}`;
139
145
  case "page-load": {
@@ -154,6 +160,55 @@ export function describeOwnerTraceEvent(event) {
154
160
  }
155
161
  }
156
162
 
163
+ // One Home/page scroll burst: frame pacing plus what was on screen.
164
+ function describeScrollPerf(data) {
165
+ const cards = (data.context?.cards || [])
166
+ .map((card) => card.key)
167
+ .filter(Boolean);
168
+ const source = data.frameSource
169
+ ? `${data.frameSource.source === "loaf" ? `LoAF ${data.frameSource.loafFrames ?? 0} long frame(s)` : "no LoAF"} · card renders ${data.frameSource.cardRenders} mounts ${data.frameSource.cardMounts} · img loads ${data.frameSource.imgLoads}`
170
+ : "";
171
+ return [
172
+ `${data.frames ?? 0} frames over ${data.spanMs ?? 0}ms`,
173
+ `mean ${data.meanFrameMs ?? "?"}ms max ${data.maxFrameMs ?? "?"}ms`,
174
+ `>50ms ${data.over50 ?? 0} >100ms ${data.over100 ?? 0}`,
175
+ cards.length ? `cards ${cards.join(",")}` : "",
176
+ source,
177
+ data.reason ? `(${data.reason})` : "",
178
+ ]
179
+ .filter(Boolean)
180
+ .join(" · ");
181
+ }
182
+
183
+ // One slow frame of a scroll burst: Long Animation Frame scripts (Chrome) or
184
+ // the feed card types that rendered in it (every browser).
185
+ function describeScrollFrame(data) {
186
+ const counts = (label, map) => {
187
+ const entries = Object.entries(map || {});
188
+ return entries.length
189
+ ? `${label} ${entries.map(([type, n]) => `${type}×${n}`).join(",")}`
190
+ : "";
191
+ };
192
+ const loaf = data.loaf;
193
+ const head = loaf
194
+ ? `slow frame ${loaf.ms}ms (LoAF, blocking ${loaf.blockingMs ?? "?"}ms${loaf.styleLayoutShare != null ? `, style/layout ${Math.round(loaf.styleLayoutShare * 100)}%` : ""}${data.gapMs != null ? `, rAF gap ${data.gapMs}ms` : ""})`
195
+ : `slow frame ${data.gapMs}ms (rAF gap, no LoAF)`;
196
+ const scripts = (loaf?.scripts || []).map(
197
+ (script) =>
198
+ `${script.ms}ms ${script.src || "?"}${script.fn ? ` ${script.fn}` : ""} [${script.type || "?"}${script.invoker ? ` ${script.invoker}` : ""}]${script.forcedLayoutMs ? ` forced layout ${script.forcedLayoutMs}ms` : ""}`,
199
+ );
200
+ return [
201
+ head,
202
+ data.offsetMs != null ? `+${data.offsetMs}ms` : "",
203
+ scripts.length ? `scripts: ${scripts.join("; ")}` : "",
204
+ counts("rendered", data.renders),
205
+ counts("mounted", data.mounts),
206
+ data.imgLoads ? `${data.imgLoads} img load(s)` : "",
207
+ ]
208
+ .filter(Boolean)
209
+ .join(" · ");
210
+ }
211
+
157
212
  // "tap→page 4210ms · PUSH via nav · url +90 · gate +4180 (probe timeout,
158
213
  // waited 4100ms) · commit +4210 · ready +4600": offsets are ms from the tap.
159
214
  function describeNavTiming(data) {
package/lib/admin.js CHANGED
@@ -7622,6 +7622,12 @@ function printAdminResult({ operation, result }) {
7622
7622
  `Confirmed Build #${data.review.buildId} runtime at published artifact #${data.review.publishedArtifactVersionId}.`,
7623
7623
  );
7624
7624
  console.log(`Screenshot: ${data.screenshotPath}`);
7625
+ const teardown = data.review.browser?.teardownWarning;
7626
+ if (teardown) {
7627
+ console.log(
7628
+ `Teardown warning: Chrome ended with ${teardown.signal ? `signal ${teardown.signal}` : `code ${teardown.exitCode}`} (${teardown.signature}) after the evidence was captured and Browser.close was sent; the review is still confirmed.`,
7629
+ );
7630
+ }
7625
7631
  if (data.review.interaction) {
7626
7632
  console.log(
7627
7633
  `Interaction script: ${data.review.interaction.stepsCompleted}/${data.review.interaction.stepsPlanned} step(s) ${data.review.interaction.status}.`,
@@ -745,6 +745,9 @@ async function runChromeReview({
745
745
  });
746
746
  let stdout = "";
747
747
  let stderr = "";
748
+ // Only what Chrome printed after our own Browser.close, so a teardown
749
+ // signature is never matched against a message from before the close.
750
+ let stderrAfterClose = null;
748
751
  const consoleLines = [];
749
752
  const append = (current, chunk) => `${current}${chunk}`.slice(-2_000_000);
750
753
  child.stdout.on("data", (chunk) => {
@@ -752,6 +755,9 @@ async function runChromeReview({
752
755
  });
753
756
  child.stderr.on("data", (chunk) => {
754
757
  stderr = append(stderr, chunk);
758
+ if (stderrAfterClose !== null) {
759
+ stderrAfterClose = `${stderrAfterClose}${chunk}`.slice(-200_000);
760
+ }
755
761
  });
756
762
  const closed = new Promise((resolve, reject) => {
757
763
  child.once("error", reject);
@@ -908,22 +914,36 @@ async function runChromeReview({
908
914
  }
909
915
  }
910
916
  }
917
+ // Everything the receipt relies on is in hand before the close; a
918
+ // nonzero exit can only be read as teardown when that is true and Chrome
919
+ // was still running when we asked it to close.
920
+ const evidenceCompleteBeforeClose =
921
+ Boolean(screenshot.data) &&
922
+ isRuntimeReady(readiness) &&
923
+ (!interaction || interactionResult?.status === "completed");
924
+ const exitedBeforeClose =
925
+ child.exitCode !== null || child.signalCode !== null;
926
+ const closeRequestedAt = Date.now();
927
+ stderrAfterClose = "";
911
928
  await cdp.send("Browser.close").catch(() => {});
912
929
  let forcedTermination = false;
913
930
  const closeResult = await Promise.race([
914
931
  closed,
915
932
  delay(5_000).then(() => {
916
933
  forcedTermination = true;
917
- child.kill("SIGTERM");
918
- return closed;
934
+ return stopChrome(child, closed);
919
935
  }),
920
936
  ]);
921
937
  return {
922
938
  ...closeResult,
923
939
  closeRequested: true,
924
940
  forcedTermination,
941
+ evidenceCompleteBeforeClose,
942
+ exitedBeforeClose,
943
+ closeDurationMs: Date.now() - closeRequestedAt,
925
944
  stdout,
926
945
  stderr,
946
+ stderrAfterClose,
927
947
  consoleLines: consoleLines.slice(-MAX_CAPTURED_LOG_LINES),
928
948
  runtimeReadiness: {
929
949
  ...(readiness || {}),
@@ -934,24 +954,85 @@ async function runChromeReview({
934
954
  };
935
955
  } finally {
936
956
  clearTimeout(timer);
937
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
957
+ if (child.exitCode === null && child.signalCode === null)
958
+ await stopChrome(child, closed).catch(() => {});
938
959
  }
939
960
  }
940
961
 
962
+ // SIGTERM, then SIGKILL if Chrome is still there after 3 s: a browser wedged
963
+ // in teardown (the case this review must tolerate) can ignore SIGTERM, and
964
+ // the review must never hang or leave Chrome running.
965
+ async function stopChrome(child, closed) {
966
+ child.kill("SIGTERM");
967
+ const ended = await Promise.race([closed, delay(3_000).then(() => null)]);
968
+ if (ended) return ended;
969
+ child.kill("SIGKILL");
970
+ return closed;
971
+ }
972
+
941
973
  const CHROME_KILLED_AFTER_CLOSE_CODE = 2;
942
974
 
943
- // Chrome sometimes exits 2 (RESULT_CODE_KILLED) while tearing down a
944
- // GPU-heavy page after our own Browser.close. Every piece of evidence was
945
- // captured before that close, so that one shutdown result is cleanup, not a
946
- // failed review. Any other code, a signal, or our forced kill still fails.
947
- export function reviewBrowserExitedCleanly(browser) {
948
- return (
949
- browser?.code === 0 ||
950
- (browser?.code === CHROME_KILLED_AFTER_CLOSE_CODE &&
951
- !browser.signal &&
952
- browser.closeRequested === true &&
953
- browser.forcedTermination === false)
954
- );
975
+ // Lines Chrome prints when its own shutdown, not the page, went wrong.
976
+ // "Teardown watchdog expired" is the network-service watchdog that kills a
977
+ // browser whose services do not stop in time after Browser.close.
978
+ // Anchored to the start of the line: Chrome prints the bare message (seen
979
+ // 10-19) or behind its own ERROR/FATAL prefix. A page console line is
980
+ // "[pid:tid:...:INFO:CONSOLE:n] \"...\"", so page text cannot match.
981
+ const CHROME_TEARDOWN_SIGNATURES = [
982
+ {
983
+ id: "teardown_watchdog_expired",
984
+ pattern:
985
+ /^(?:\[[^\]]*:(?:ERROR|FATAL):[^\]]*\]\s*)?teardown watchdog expired/i,
986
+ },
987
+ ];
988
+
989
+ function chromeTeardownSignature(stderrAfterClose) {
990
+ for (const line of String(stderrAfterClose || "").split(/\r?\n/)) {
991
+ const match = CHROME_TEARDOWN_SIGNATURES.find(({ pattern }) =>
992
+ pattern.test(line),
993
+ );
994
+ if (match) return { id: match.id, line: line.trim().slice(0, 400) };
995
+ }
996
+ return null;
997
+ }
998
+
999
+ // Capture validity and process cleanup are separate questions. Exit 0 is
1000
+ // clean. A nonzero exit (or our forced stop) is accepted as a teardown
1001
+ // warning only when all page evidence was complete before our own
1002
+ // Browser.close, Chrome was still running when we sent it, and the exit is
1003
+ // a known shutdown outcome: exit 2 (RESULT_CODE_KILLED) on its own, or any
1004
+ // exit with a teardown signature printed after the close. Anything else,
1005
+ // including every exit before the evidence was complete, fails the review.
1006
+ export function classifyReviewBrowserExit(browser) {
1007
+ if (browser?.code === 0 && !browser?.signal) {
1008
+ return { accepted: true, teardownWarning: null };
1009
+ }
1010
+ const afterOwnClose =
1011
+ browser?.closeRequested === true &&
1012
+ browser?.exitedBeforeClose === false &&
1013
+ browser?.evidenceCompleteBeforeClose === true;
1014
+ if (!afterOwnClose) return { accepted: false, teardownWarning: null };
1015
+ const signature = chromeTeardownSignature(browser.stderrAfterClose);
1016
+ const killedAfterClose =
1017
+ browser.code === CHROME_KILLED_AFTER_CLOSE_CODE &&
1018
+ !browser.signal &&
1019
+ browser.forcedTermination === false;
1020
+ if (!signature && !killedAfterClose) {
1021
+ return { accepted: false, teardownWarning: null };
1022
+ }
1023
+ return {
1024
+ accepted: true,
1025
+ teardownWarning: {
1026
+ exitCode: browser.code,
1027
+ signal: browser.signal || null,
1028
+ forcedTermination: browser.forcedTermination === true,
1029
+ closeDurationMs: Number.isFinite(browser.closeDurationMs)
1030
+ ? browser.closeDurationMs
1031
+ : null,
1032
+ signature: signature?.id || "exit_2_after_close",
1033
+ stderrLine: signature?.line || null,
1034
+ },
1035
+ };
955
1036
  }
956
1037
 
957
1038
  // The last few Chrome stderr lines, for diagnosing a nonzero exit.
@@ -1032,7 +1113,8 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
1032
1113
  const interactionOutcome = browser.interaction || null;
1033
1114
  const interactionCompleted =
1034
1115
  !interaction || interactionOutcome?.status === "completed";
1035
- const browserExitedCleanly = reviewBrowserExitedCleanly(browser);
1116
+ const browserExit = classifyReviewBrowserExit(browser);
1117
+ const browserExitedCleanly = browserExit.accepted;
1036
1118
  const confirmed =
1037
1119
  browserExitedCleanly &&
1038
1120
  screenshotBytes > 0 &&
@@ -1057,7 +1139,15 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
1057
1139
  signal: browser.signal,
1058
1140
  closeRequested: browser.closeRequested === true,
1059
1141
  forcedTermination: browser.forcedTermination === true,
1060
- exitedCleanly: browserExitedCleanly,
1142
+ exitedCleanly: browser.code === 0 && !browser.signal,
1143
+ evidenceCompleteBeforeClose: browser.evidenceCompleteBeforeClose === true,
1144
+ exitedBeforeClose: browser.exitedBeforeClose === true,
1145
+ closeDurationMs: Number.isFinite(browser.closeDurationMs)
1146
+ ? browser.closeDurationMs
1147
+ : null,
1148
+ // Set when the page evidence is valid but Chrome's own shutdown ended
1149
+ // nonzero; the review stays confirmed and this says what happened.
1150
+ teardownWarning: browserExit.teardownWarning,
1061
1151
  stderrTail: browser.code === 0 ? null : chromeStderrTail(browser.stderr),
1062
1152
  runtimeReadiness: browser.runtimeReadiness,
1063
1153
  },
@@ -1097,7 +1187,9 @@ export async function runManagedBuildReview({ options, authToken, buildId }) {
1097
1187
  screenshotBytes > 0 &&
1098
1188
  browser.runtimeReadiness?.ready === true &&
1099
1189
  interactionCompleted
1100
- ? `Chrome exited with ${browser.signal ? `signal ${browser.signal}` : `code ${browser.code}`}${browser.forcedTermination ? " after the CLI had to stop it" : ""} once the review evidence was captured. The app loaded and the screenshots are in the receipt, but the receipt is not confirmed; see browser.stderrTail and review again.`
1190
+ ? browser.exitedBeforeClose === true
1191
+ ? `Chrome exited with ${browser.signal ? `signal ${browser.signal}` : `code ${browser.code}`} before the CLI asked it to close, so the run is not trusted. The screenshots are in the receipt; see browser.stderrTail and review again.`
1192
+ : `Chrome exited with ${browser.signal ? `signal ${browser.signal}` : `code ${browser.code}`}${browser.forcedTermination ? " after the CLI had to stop it" : ""} while closing, with no known teardown signature. The app loaded and the screenshots are in the receipt, but the receipt is not confirmed; see browser.stderrTail and review again.`
1101
1193
  : interactionFailure
1102
1194
  ? `Interaction step ${interactionOutcome?.failedStep?.index ?? "?"} (${interactionOutcome?.failedStep?.kind ?? "?"}) failed: ${interactionOutcome?.failedStep?.error ?? "unknown"} The receipt lists the ${interactionOutcome?.stepsCompleted ?? 0} completed step(s) and their screenshots; fix the script and review again.`
1103
1195
  : "The isolated Build runtime did not become visibly reviewable before the observation window ended.",
package/lib/commands.js CHANGED
@@ -3311,7 +3311,7 @@ export function printHelp() {
3311
3311
  lumine admin reward-bank coverage <buildId> [--max-step 3] [--json]
3312
3312
  lumine admin reward-bank seed <buildId> [--max-step 3] [--identity zero|ciel|auto] [--json] (writes ONE missing seed question, lowest step first, billed to Zero/Ciel's AI Energy; inside a daily run or on its own, owner only)
3313
3313
  lumine admin reward-bank report <buildId> [--days 1] [--json] (question-bank telemetry: generation outcomes, learners, payouts, ladder, questions to review)
3314
- lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load] [--perf] [--path <substring>] [--limit 300] [--json] (what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load; --perf shows only those; kept 30 days)
3314
+ lumine admin owner-trace [--since 2h|30m|3d|<iso>] [--type route,feed-open,scroll-anchor,content-scroll,error,rejection,session,nav-timing,slow-request,stall,page-load,scroll-perf,scroll-frame] [--perf] [--path <substring>] [--limit 300] [--json] (what the website did on Mikey's own devices: routes with PUSH/POP/REPLACE, Home feed card opens, scroll-anchor restores, content scroll positions, window errors, and performance: tap-to-page timing, API requests over 1 s, main-thread stalls over 200 ms, page load, scroll frame pacing and slow-frame attribution (Long Animation Frame scripts on Chrome, feed card renders on Safari); --perf shows only those; kept 30 days)
3315
3315
  lumine admin roles [--min-level 1] [--limit 50] [--json] (every user type with its management level and who holds it; admin pages need level 3, achievement points never pass level 2)
3316
3316
  lumine admin comment draft <target> [--type subject|comment|build|aiStory|dailyReflection] [--file <comment.md>] [--review-receipt <review.json>|--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
3317
3317
  lumine admin comment reply comment:<id> [--file <reply.md>] [--reviewed-version <id> --reviewed-via runtime|code] [--review-context <context.json>] [--identity zero|ciel|auto] [--json]
package/lib/constants.js CHANGED
@@ -350,7 +350,7 @@ lumine save --summary "Describe the change"
350
350
  - Use local project files with relative or root-local imports only. Do not add package imports, CDN scripts, external network calls, or app-local /api/* routes.
351
351
  - Build apps run in sandboxed iframes without allow-forms. Do not use <form> elements, native form submission, requestSubmit(), or browser form navigation. Build input flows with JavaScript-handled inputs and buttons instead.
352
352
  ${LUMINE_MOBILE_SELECTION_GUIDANCE}
353
- - CAUTION: the preview runtime AUTO-DETECTS "game apps" — any <canvas> in the body (even a decorative background canvas) switches the app to viewport-app mode: html/body get overflow:hidden !important and body becomes a centering flexbox, so tall document-flow pages clip and stop scrolling. Document-style apps that use a canvas must call Twinkle.preview.subscribe (or getLayout/reserveInsets) early at boot — any of those opts out of auto game mode — then pad by layout.safeInsets and scroll within layout.viewport.height.
353
+ - CAUTION: the preview runtime AUTO-DETECTS "game apps" — any <canvas> in the body except a small one on screen (both sides at most 160 CSS px: a visualizer, a sparkle, an icon) switches the app to viewport-app mode: html/body get overflow:hidden !important and body becomes a centering flexbox, so tall document-flow pages clip and stop scrolling. A hidden or not-yet-sized canvas counts too, and a page that sets data-twinkle-game keeps this layout with any canvas. Document-style apps with a larger canvas (a chart, a drawing pad, a background effect) set data-twinkle-document on <html> or <body>, or call Twinkle.preview.subscribe (or getLayout/reserveInsets) early at boot and then pad by layout.safeInsets and scroll within layout.viewport.height.
354
354
  - For canvas, WebGL, Three.js, fullscreen, or game builds, use Twinkle.preview for layout. Do not size roots from 100vh, 100vw, 100dvh, 100dvw, window.innerWidth, window.innerHeight, visualViewport, or document viewport dimensions.
355
355
  ${LUMINE_THREE_VENDOR_GUIDANCE}
356
356
  ${LUMINE_MATH_VENDOR_GUIDANCE}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage5/lumine",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
4
4
  "description": "Command line tools for launching Lumine builds on Twinkle.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  Version: 1.68.0
4
4
  Updated: 2026-10-16
5
- Generated: 2026-10-18T22:57:11.632Z
5
+ Generated: 2026-10-19T05:26:13.264Z
6
6
 
7
7
  ## Notes
8
8
  - This SDK is injected into Build iframes via the Build preview/runtime.
@@ -25,7 +25,7 @@ Generated: 2026-10-18T22:57:11.632Z
25
25
  - Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
26
26
  - Live web search is enabled by default for Twinkle.ai.chat and for Medium/High Twinkle.ai.generateObject and Twinkle.characters.chat requests. App authors can pass webSearch: false to disable it for their app. Search uses the provider's live web-search tool and is included in AI Energy usage; structured and character Lite Mode remains tool-free.
27
27
  - Mobile long-press must not select game UI or open browser Copy/Look Up/image menus. The SDK provides no-selection/callout defaults for standard buttons, button-like ARIA controls, and canvases. Mark the entire custom gameplay wrapper data-twinkle-no-select, including HUD, labels, scores, menus, controls, and empty play space; also style it with user-select: none, -webkit-user-select: none, and -webkit-touch-callout: none for local previews. Protecting only one button or the canvas is insufficient. This behavior is independent of Twinkle.preview layout mode.
28
- - Played game pages block text selection, the long-press menu and page pinch-zoom everywhere except inputs, textareas, contenteditable and data-twinkle-selectable text, so text a game shows for copying (a chat log, a word to copy) needs data-twinkle-selectable. A page is a played game when the app sets data-twinkle-game on <html> or <body>, or, for apps that do not lay themselves out with Twinkle.preview, when a play-sized canvas is on screen: rendered, not pointer-events:none, and covering at least 30% of the viewport by its layout size; the page then stays a played game while that canvas is in the document, even hidden between screens. Set data-twinkle-game on a game that sizes itself with Twinkle.preview, a DOM-only game, or a small-canvas game; set data-twinkle-document to keep a canvas page (charts, drawing pad) an ordinary page; set data-twinkle-allow-zoom to keep page pinch-zoom on a game. Page layout is not affected by any of this.
28
+ - Played game pages block text selection, the long-press menu and page pinch-zoom everywhere except inputs, textareas, contenteditable and data-twinkle-selectable text, so text a game shows for copying (a chat log, a word to copy) needs data-twinkle-selectable. A page is a played game when the app sets data-twinkle-game on <html> or <body>, or, for apps that do not lay themselves out with Twinkle.preview, when a play-sized canvas is on screen: rendered, not pointer-events:none, and covering at least 30% of the viewport by its layout size; the page then stays a played game while that canvas is in the document, even hidden between screens. Set data-twinkle-game on a game that sizes itself with Twinkle.preview, a DOM-only game, or a small-canvas game; set data-twinkle-document to keep a canvas page (charts, drawing pad) an ordinary page, which also keeps it out of viewport-app layout so it scrolls; set data-twinkle-allow-zoom to keep page pinch-zoom on a game. Viewport-app layout (html/body pinned to the screen, no page scroll) turns on for any canvas except a small one on screen (both sides at most 160 CSS px); data-twinkle-game keeps it for any canvas, and data-twinkle-document or laying the page out with Twinkle.preview turns it off.
29
29
  - Keep inputs/contenteditable usable. Mark genuinely copyable story/chat/user-written text data-twinkle-selectable and style it with user-select: text, -webkit-user-select: text, and -webkit-touch-callout: default. SDK defaults have low specificity so existing explicit copyable-text styles remain effective. Preserve document/reader selection; do not block document-wide touch/pointer events or disable scrolling/zoom to prevent selection.
30
30
  - Verify mobile long presses on controls, HUD, and empty play space in Safari and Chromium; confirm held controls still work and release/cancel correctly, and scrolling, typing, and copying still work. lumine check only detects a missing no-selection rule, not selector coverage or mobile behavior.
31
31
  - Build app tab mute is enforced by the host runtime automatically for standard media elements and Web Audio connections to AudioContext.destination. Apps with custom audio engines can also observe Twinkle.onAudioMuteChange and check Twinkle.isAudioMuted.