silvery 0.18.2 → 0.19.1

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 (156) hide show
  1. package/dist/{animation-DhINOJk8.mjs → animation-Cn64yepo.mjs} +1 -1
  2. package/dist/{animation-DhINOJk8.mjs.map → animation-Cn64yepo.mjs.map} +1 -1
  3. package/dist/{ansi-C6Qs1Wn2.mjs → ansi-CLOitHKx.mjs} +1 -1
  4. package/dist/ansi-CLOitHKx.mjs.map +1 -0
  5. package/dist/{ansi-CsjnZtAw.d.mts → ansi-Cc33mW54.d.mts} +1 -1
  6. package/dist/{ansi-CsjnZtAw.d.mts.map → ansi-Cc33mW54.d.mts.map} +1 -1
  7. package/dist/{chunk-BSw8zbkd.mjs → chunk-Vs_PY4HZ.mjs} +1 -1
  8. package/dist/cli-BKp0YtBD.mjs +4 -0
  9. package/dist/{context-BjWgrikx.mjs → context-BU5LkkIy.mjs} +8 -7
  10. package/dist/context-BU5LkkIy.mjs.map +1 -0
  11. package/dist/devtools-9QY4teqI.mjs +2 -0
  12. package/dist/{devtools-CeO9X_uv.mjs → devtools-DxkSLXDA.mjs} +4 -5
  13. package/dist/devtools-DxkSLXDA.mjs.map +1 -0
  14. package/dist/{eta-BnQSZcWf.mjs → eta-Bb3RH3wh.mjs} +1 -1
  15. package/dist/{eta-BnQSZcWf.mjs.map → eta-Bb3RH3wh.mjs.map} +1 -1
  16. package/dist/{flexily-zero-adapter-BOM0cl8R.mjs → flexily-zero-adapter-BlQa46nr.mjs} +21 -64
  17. package/dist/flexily-zero-adapter-BlQa46nr.mjs.map +1 -0
  18. package/dist/{flexily-zero-adapter-V8R3HQtK.mjs → flexily-zero-adapter-CMxXhdOL.mjs} +1 -1
  19. package/dist/{image-B0zMbVUr.mjs → image-CTII5QWI.mjs} +3 -3
  20. package/dist/image-CTII5QWI.mjs.map +1 -0
  21. package/dist/{index-Bh3U1K09.d.mts → index-BXslOebb.d.mts} +547 -137
  22. package/dist/index-BXslOebb.d.mts.map +1 -0
  23. package/dist/{index-C4vrhbud.d.mts → index-BnA7mNpo.d.mts} +1 -1
  24. package/dist/{index-C4vrhbud.d.mts.map → index-BnA7mNpo.d.mts.map} +1 -1
  25. package/dist/index-D3saHouR.d.mts +1392 -0
  26. package/dist/index-D3saHouR.d.mts.map +1 -0
  27. package/dist/index.d.mts +5 -33
  28. package/dist/index.d.mts.map +1 -1
  29. package/dist/index.mjs +13 -13
  30. package/dist/{layout-engine--drvrWjD.mjs → layout-engine-B6Cdz1yZ.mjs} +1 -1
  31. package/dist/{layout-engine-Dr3cY5U4.mjs → layout-engine-ClUgv6jB.mjs} +3 -3
  32. package/dist/{layout-engine-Dr3cY5U4.mjs.map → layout-engine-ClUgv6jB.mjs.map} +1 -1
  33. package/dist/{multi-progress-CcdqJFlf.mjs → multi-progress-Bq9Oi_WI.mjs} +3 -3
  34. package/dist/{multi-progress-CcdqJFlf.mjs.map → multi-progress-Bq9Oi_WI.mjs.map} +1 -1
  35. package/dist/{multi-progress-DQ-uUzLf.d.mts → multi-progress-DAQC7eap.d.mts} +2 -2
  36. package/dist/{multi-progress-DQ-uUzLf.d.mts.map → multi-progress-DAQC7eap.d.mts.map} +1 -1
  37. package/dist/{node-CP5WChgr.mjs → node-BeWlnCPY.mjs} +4 -4
  38. package/dist/node-BeWlnCPY.mjs.map +1 -0
  39. package/dist/{progress-bar-IrUjkLfU.mjs → progress-bar-CXE5Qfkd.mjs} +4 -4
  40. package/dist/progress-bar-CXE5Qfkd.mjs.map +1 -0
  41. package/dist/reconciler-Cwgm8hRR.mjs +8459 -0
  42. package/dist/reconciler-Cwgm8hRR.mjs.map +1 -0
  43. package/dist/{render-string-BwLG7rIX.mjs → render-string-0mN37DLf.mjs} +1 -1
  44. package/dist/{render-string-DVfgc8xr.mjs → render-string-X-CxpTdZ.mjs} +935 -136
  45. package/dist/render-string-X-CxpTdZ.mjs.map +1 -0
  46. package/dist/runtime.d.mts +2 -2
  47. package/dist/runtime.mjs +3 -3
  48. package/dist/{spinner-BRkaJI0N.d.mts → spinner-CGo34vyR.d.mts} +2 -2
  49. package/dist/{spinner-BRkaJI0N.d.mts.map → spinner-CGo34vyR.d.mts.map} +1 -1
  50. package/dist/{spinner-BmldKx0M.mjs → spinner-CeOmcuw_.mjs} +3 -3
  51. package/dist/spinner-CeOmcuw_.mjs.map +1 -0
  52. package/dist/src-B5GjfG7g.mjs +4305 -0
  53. package/dist/src-B5GjfG7g.mjs.map +1 -0
  54. package/dist/{src-CJPXf3fC.mjs → src-Bd7ezSgG.mjs} +7560 -6474
  55. package/dist/src-Bd7ezSgG.mjs.map +1 -0
  56. package/dist/{src-D8kLrQBT.mjs → src-CChwjk0Z.mjs} +8 -86
  57. package/dist/src-CChwjk0Z.mjs.map +1 -0
  58. package/dist/{src-D_BS-as7.mjs → src-NCKb8kE5.mjs} +777 -776
  59. package/dist/src-NCKb8kE5.mjs.map +1 -0
  60. package/dist/theme.d.mts +2 -130
  61. package/dist/theme.mjs +3 -8
  62. package/dist/{types-B4A8Ebba.d.mts → types-BH_v3iMT.d.mts} +1 -1
  63. package/dist/{types-B4A8Ebba.d.mts.map → types-BH_v3iMT.d.mts.map} +1 -1
  64. package/dist/{types-e4dpfbSa.mjs → types-Bk2yw9Qj.mjs} +3 -3
  65. package/dist/types-Bk2yw9Qj.mjs.map +1 -0
  66. package/dist/ui/animation.d.mts +1 -1
  67. package/dist/ui/animation.mjs +1 -1
  68. package/dist/ui/ansi.d.mts +1 -1
  69. package/dist/ui/ansi.mjs +1 -1
  70. package/dist/ui/cli.d.mts +3 -3
  71. package/dist/ui/cli.mjs +5 -5
  72. package/dist/ui/display.d.mts +1 -1
  73. package/dist/ui/display.mjs.map +1 -1
  74. package/dist/ui/image.d.mts +1 -1
  75. package/dist/ui/image.mjs +1 -1
  76. package/dist/ui/input.d.mts +1 -1
  77. package/dist/ui/input.d.mts.map +1 -1
  78. package/dist/ui/input.mjs +2 -4
  79. package/dist/ui/input.mjs.map +1 -1
  80. package/dist/ui/progress.d.mts +3 -3
  81. package/dist/ui/progress.d.mts.map +1 -1
  82. package/dist/ui/progress.mjs +3 -3
  83. package/dist/ui/progress.mjs.map +1 -1
  84. package/dist/ui/react.d.mts +1 -1
  85. package/dist/ui/react.d.mts.map +1 -1
  86. package/dist/ui/react.mjs +2 -2
  87. package/dist/ui/react.mjs.map +1 -1
  88. package/dist/ui/utils.mjs +1 -1
  89. package/dist/ui/wrappers.d.mts +2 -2
  90. package/dist/ui/wrappers.mjs +1 -1
  91. package/dist/ui.d.mts +5 -5
  92. package/dist/ui.mjs +6 -6
  93. package/dist/{useLatest-6xqnGIU6.d.mts → useLatest-Bg2x4bfP.d.mts} +1 -1
  94. package/dist/{useLatest-6xqnGIU6.d.mts.map → useLatest-Bg2x4bfP.d.mts.map} +1 -1
  95. package/dist/{with-text-input-lUh9gYAG.d.mts → with-text-input-CRfoiFFG.d.mts} +3 -3
  96. package/dist/with-text-input-CRfoiFFG.d.mts.map +1 -0
  97. package/dist/{wrappers-JrEYTuKA.mjs → wrappers-UTADQkSY.mjs} +4 -4
  98. package/dist/wrappers-UTADQkSY.mjs.map +1 -0
  99. package/dist/{yoga-adapter-Bc8XT9cN.mjs → yoga-adapter-8oRGRw8V.mjs} +2 -2
  100. package/dist/{yoga-adapter-Bc8XT9cN.mjs.map → yoga-adapter-8oRGRw8V.mjs.map} +1 -1
  101. package/dist/yoga-adapter-D_CcxSt5.mjs +2 -0
  102. package/package.json +3 -3
  103. package/dist/UPNG-DvKjM6wE.mjs +0 -5076
  104. package/dist/UPNG-DvKjM6wE.mjs.map +0 -1
  105. package/dist/__vite-browser-external-2447137e-DPKHHqQK.mjs +0 -6
  106. package/dist/__vite-browser-external-2447137e-DPKHHqQK.mjs.map +0 -1
  107. package/dist/ansi-C6Qs1Wn2.mjs.map +0 -1
  108. package/dist/apng-CvSlLBtc.mjs +0 -3
  109. package/dist/apng-DFFVOItr.mjs +0 -70
  110. package/dist/apng-DFFVOItr.mjs.map +0 -1
  111. package/dist/assets/resvgjs.darwin-arm64-BtufyGW1.node +0 -0
  112. package/dist/backend-DU0Y938U.mjs +0 -13396
  113. package/dist/backend-DU0Y938U.mjs.map +0 -1
  114. package/dist/backends-BihMKFY_.mjs +0 -1181
  115. package/dist/backends-BihMKFY_.mjs.map +0 -1
  116. package/dist/backends-Dk_5G_gC.mjs +0 -3
  117. package/dist/cli-GwJ0S2In.mjs +0 -4
  118. package/dist/context-BjWgrikx.mjs.map +0 -1
  119. package/dist/derive-O_Kb1Bk_.d.mts +0 -28
  120. package/dist/derive-O_Kb1Bk_.d.mts.map +0 -1
  121. package/dist/devtools-CeO9X_uv.mjs.map +0 -1
  122. package/dist/devtools-nX4tj6OH.mjs +0 -2
  123. package/dist/flexily-zero-adapter-BOM0cl8R.mjs.map +0 -1
  124. package/dist/gif-B9Uq4qZA.mjs +0 -73
  125. package/dist/gif-B9Uq4qZA.mjs.map +0 -1
  126. package/dist/gif-BdrLRBmM.mjs +0 -3
  127. package/dist/gifenc-DfhOb4xr.mjs +0 -730
  128. package/dist/gifenc-DfhOb4xr.mjs.map +0 -1
  129. package/dist/image-B0zMbVUr.mjs.map +0 -1
  130. package/dist/index-Bh3U1K09.d.mts.map +0 -1
  131. package/dist/index-dehZ18K-.d.mts +0 -679
  132. package/dist/index-dehZ18K-.d.mts.map +0 -1
  133. package/dist/key-mapping-7k2ufK2b.mjs +0 -3
  134. package/dist/key-mapping-WLUmxjx1.mjs +0 -132
  135. package/dist/key-mapping-WLUmxjx1.mjs.map +0 -1
  136. package/dist/node-CP5WChgr.mjs.map +0 -1
  137. package/dist/progress-bar-IrUjkLfU.mjs.map +0 -1
  138. package/dist/reconciler-B8uxQxaU.mjs +0 -16482
  139. package/dist/reconciler-B8uxQxaU.mjs.map +0 -1
  140. package/dist/render-string-DVfgc8xr.mjs.map +0 -1
  141. package/dist/resvg-js-Cwipz-_J.mjs +0 -203
  142. package/dist/resvg-js-Cwipz-_J.mjs.map +0 -1
  143. package/dist/spinner-BmldKx0M.mjs.map +0 -1
  144. package/dist/src-C0sOQW-t.mjs +0 -3866
  145. package/dist/src-C0sOQW-t.mjs.map +0 -1
  146. package/dist/src-CJPXf3fC.mjs.map +0 -1
  147. package/dist/src-D8kLrQBT.mjs.map +0 -1
  148. package/dist/src-D_BS-as7.mjs.map +0 -1
  149. package/dist/theme.d.mts.map +0 -1
  150. package/dist/theme.mjs.map +0 -1
  151. package/dist/types-e4dpfbSa.mjs.map +0 -1
  152. package/dist/with-text-input-lUh9gYAG.d.mts.map +0 -1
  153. package/dist/wrapper-CE6GQ27z.mjs +0 -3527
  154. package/dist/wrapper-CE6GQ27z.mjs.map +0 -1
  155. package/dist/wrappers-JrEYTuKA.mjs.map +0 -1
  156. package/dist/yoga-adapter-B8LZpQcE.mjs +0 -2
@@ -1,13 +1,16 @@
1
- import { c as signal, i as syncRectSignals, o as computed, t as rectEqual } from "./types-e4dpfbSa.mjs";
2
- import { c as TermContext, o as StderrContext, s as StdoutContext } from "./context-BjWgrikx.mjs";
3
- import { At as bufferToText, Dt as TerminalBuffer, Et as DEFAULT_BG, Ft as createTextFrame, H as ensureEmojiPresentation, I as canBreakAnywhere, It as init_buffer, K as graphemeWidth, Lt as isDefaultBg, Ot as ansi256ToRgb, Pt as createMutableCell, U as getActiveLineHeight, V as displayWidthAnsi, _t as wrapText, a as hostConfig, at as parseAnsiText, b as createTerm, c as clearDirtyTracking, d as collectPlainText, et as isWordBoundary, f as advanceRenderEpoch, ft as splitGraphemes, g as isDirty, h as isCurrentEpoch, kt as bufferToStyledText, l as hasScrollDirty, lt as sliceByWidth, m as isAnyDirty, ot as runWithMeasurer, p as getRenderEpoch, pt as splitGraphemesAnsiAware, q as hasAnsi, r as getContainerRoot, t as createContainer, u as measureStats, ut as sliceByWidthFromEnd } from "./reconciler-B8uxQxaU.mjs";
4
- import { T as resolveThemeColor, k as blend, t as init_src, x as monoAttrsForColorString } from "./src-D_BS-as7.mjs";
5
- import { A as pushContextTheme, D as getActiveTheme, E as getActiveColorLevel, O as init_state, k as popContextTheme, t as init_src$1, x as init_resolve } from "./src-C0sOQW-t.mjs";
6
- import { i as isLayoutEngineInitialized, r as getLayoutEngine } from "./layout-engine-Dr3cY5U4.mjs";
1
+ import { c as signal, i as syncRectSignals, o as computed, t as rectEqual } from "./types-Bk2yw9Qj.mjs";
2
+ import { c as StdoutContext, l as TermContext, s as StderrContext } from "./context-BU5LkkIy.mjs";
3
+ import { At as bufferToText, Dt as TerminalBuffer, Et as DEFAULT_BG, Ft as createTextFrame, H as ensureEmojiPresentation, I as canBreakAnywhere, It as init_buffer, K as graphemeWidth, Lt as isDefaultBg, Ot as ansi256ToRgb, Pt as createMutableCell, U as getActiveLineHeight, V as displayWidthAnsi, Z as isLikelyEmoji, _t as wrapText, a as hostConfig, at as parseAnsiText, b as createTerm, c as clearDirtyTracking, d as collectPlainText, et as isWordBoundary, f as advanceRenderEpoch, ft as splitGraphemes, g as isDirty, h as isCurrentEpoch, kt as bufferToStyledText, l as hasScrollDirty, lt as sliceByWidth, m as isAnyDirty, ot as runWithMeasurer, p as getRenderEpoch, pt as splitGraphemesAnsiAware, q as hasAnsi, r as getContainerRoot, t as createContainer, u as measureStats, ut as sliceByWidthFromEnd } from "./reconciler-Cwgm8hRR.mjs";
4
+ import { A as backdropPlacementId, F as kittyUploadScrimImage, M as cupTo, N as kittyDeleteAllScrimPlacements, P as kittyPlaceAt, j as buildScrimPixels, k as resolveThemeColor, t as init_src, w as monoAttrsForColorString } from "./src-NCKb8kE5.mjs";
5
+ import { i as isLayoutEngineInitialized, r as getLayoutEngine } from "./layout-engine-ClUgv6jB.mjs";
6
+ import { x as ansi16DarkTheme } from "./src-B5GjfG7g.mjs";
7
7
  import React, { act } from "react";
8
8
  import { createLogger } from "loggily";
9
+ import * as Upstream from "@silvery/color";
10
+ import { relativeLuminance } from "@silvery/color";
9
11
  import Reconciler from "react-reconciler";
10
12
  //#region packages/ag-term/src/pipeline/prepared-text.ts
13
+ init_buffer();
11
14
  const MAX_FORMAT_ENTRIES = 4;
12
15
  /** Content-affecting flags that invalidate plain text. */
13
16
  const PLAIN_TEXT_DIRTY = 9;
@@ -29,6 +32,7 @@ function getOrCreate(node) {
29
32
  plainTextLineCount: 0,
30
33
  collected: null,
31
34
  collectedMaxDisplayWidth: void 0,
35
+ collectedContextTheme: null,
32
36
  formats: [],
33
37
  analysis: null
34
38
  };
@@ -61,9 +65,15 @@ function setCachedPlainText(node, text, lineCount) {
61
65
  }
62
66
  /**
63
67
  * Get cached collected text (from collectTextWithBg).
64
- * Invalidated by content, children, style, or bg changes, or maxDisplayWidth mismatch.
68
+ * Invalidated by content, children, style, or bg changes, maxDisplayWidth mismatch,
69
+ * or a context theme change (ancestor ThemeProvider changed token values).
70
+ *
71
+ * @param contextTheme - The active theme at the time of rendering (from getActiveTheme()).
72
+ * Used as a cache key so that when the nearest-ancestor ThemeProvider changes its
73
+ * merged theme, text nodes that embed $token ANSI codes in their collected text
74
+ * are re-collected with the new token values. Pass null when no theme context exists.
65
75
  */
66
- function getCachedCollectedText(node, maxDisplayWidth) {
76
+ function getCachedCollectedText(node, maxDisplayWidth, contextTheme) {
67
77
  if (_cacheDisabled) return null;
68
78
  const entry = textCaches.get(node);
69
79
  if (!entry?.collected) return null;
@@ -79,13 +89,20 @@ function getCachedCollectedText(node, maxDisplayWidth) {
79
89
  entry.analysis = null;
80
90
  return null;
81
91
  }
92
+ if (entry.collectedContextTheme !== contextTheme) {
93
+ entry.collected = null;
94
+ entry.formats = [];
95
+ entry.analysis = null;
96
+ return null;
97
+ }
82
98
  return entry.collected;
83
99
  }
84
100
  /** Store collected text in cache. */
85
- function setCachedCollectedText(node, result, maxDisplayWidth) {
101
+ function setCachedCollectedText(node, result, maxDisplayWidth, contextTheme) {
86
102
  const entry = getOrCreate(node);
87
103
  entry.collected = result;
88
104
  entry.collectedMaxDisplayWidth = maxDisplayWidth;
105
+ entry.collectedContextTheme = contextTheme;
89
106
  }
90
107
  /**
91
108
  * Get cached formatted lines for the given width/wrap/trim.
@@ -851,6 +868,44 @@ function scrollPhase(root, options = {}) {
851
868
  });
852
869
  }
853
870
  /**
871
+ * Snap scroll offset so the first visible child (after the top overflow
872
+ * indicator's reserved row) aligns with a child-top boundary.
873
+ *
874
+ * When scrolling "down to show the target at the bottom," the raw offset
875
+ * `target.bottom - effectiveHeight` assumes the entire viewport above the
876
+ * bottom-indicator is usable content. But the TOP overflow indicator also
877
+ * consumes a row when `hiddenAbove > 0`, rendering at viewport row 0 on top
878
+ * of whatever child starts there. If that row is a card's top border, the
879
+ * border is overwritten — users see a "headless" card and perceive the
880
+ * column as "gotten shorter" (see km-tui `column-top-disappears`).
881
+ *
882
+ * This snap shifts the offset so `offset + 1 === firstFullyVisibleChild.top`:
883
+ * the top-indicator row coincides with the 1-row gap ABOVE the first child,
884
+ * not with that child's content. When children have heterogeneous heights,
885
+ * this means moving the viewport DOWN by a few rows (so an earlier, shorter
886
+ * child scrolls fully off-screen and the next child starts cleanly).
887
+ *
888
+ * Guardrails:
889
+ * - Never snap past the target's own top (keeps the target visible).
890
+ * - If no suitable boundary exists above `rawOffset + 1` and ≤ `target.top`,
891
+ * returns `rawOffset` unchanged (scroll behaves as before).
892
+ * - Returns 0 unchanged — offset=0 means no top indicator, no conflict.
893
+ */
894
+ function snapOffsetToChildTop(rawOffset, childPositions, target) {
895
+ if (rawOffset <= 0) return rawOffset;
896
+ let bestChildTop = -1;
897
+ for (const cp of childPositions) {
898
+ if (cp.isSticky) continue;
899
+ if (cp.top === cp.bottom) continue;
900
+ if (cp.top > rawOffset && cp.top <= target.top) {
901
+ if (bestChildTop === -1 || cp.top < bestChildTop) bestChildTop = cp.top;
902
+ }
903
+ }
904
+ if (bestChildTop === -1) return rawOffset;
905
+ const snapped = bestChildTop - 1;
906
+ return snapped >= rawOffset ? snapped : rawOffset;
907
+ }
908
+ /**
854
909
  * Calculate scroll state for a single scrollable container.
855
910
  */
856
911
  function calculateScrollState(node, props, skipStateUpdates) {
@@ -894,8 +949,8 @@ function calculateScrollState(node, props, skipStateUpdates) {
894
949
  const effectiveHeight = viewportHeight - indicatorReserve;
895
950
  const visibleTop = scrollOffset;
896
951
  const visibleBottom = scrollOffset + effectiveHeight;
897
- if (target.top < visibleTop) scrollOffset = target.top;
898
- else if (target.bottom > visibleBottom) scrollOffset = target.bottom - effectiveHeight;
952
+ if (target.top < visibleTop) scrollOffset = target.top > 0 ? target.top - 1 : 0;
953
+ else if (target.bottom > visibleBottom) scrollOffset = snapOffsetToChildTop(target.bottom - effectiveHeight, childPositions, target);
899
954
  }
900
955
  }
901
956
  scrollOffset = Math.max(0, scrollOffset);
@@ -1209,10 +1264,66 @@ function detectPipelineFeatures(root) {
1209
1264
  };
1210
1265
  }
1211
1266
  //#endregion
1267
+ //#region packages/ag-term/src/pipeline/state.ts
1268
+ /**
1269
+ * Safe fallback theme. Never mutated — the theme flows via the AgNode tree
1270
+ * (Box theme= prop + pushContextTheme/popContextTheme in render-phase.ts).
1271
+ * This is only returned by getActiveTheme() when called from a code path that
1272
+ * has no pushContextTheme frame on the stack, e.g. a bare test that renders
1273
+ * without ThemeProvider.
1274
+ *
1275
+ * `@silvery/theme`'s `ansi16DarkTheme` ships with Sterling flat tokens baked
1276
+ * in, so bare-test render paths resolve `$fg-accent` / `$bg-surface-subtle` /
1277
+ * etc. without needing an explicit ThemeProvider.
1278
+ */
1279
+ const _activeTheme = ansi16DarkTheme;
1280
+ /** Get the active theme (fallback to ansi16DarkTheme when no context stack entry exists). */
1281
+ function getActiveTheme() {
1282
+ return _contextStack.length > 0 ? _contextStack[_contextStack.length - 1] : _activeTheme;
1283
+ }
1284
+ let _activeColorLevel = "truecolor";
1285
+ /** Set the active color level (called by the runtime based on TerminalCaps). */
1286
+ function setActiveColorLevel(level) {
1287
+ _activeColorLevel = level;
1288
+ }
1289
+ /** Get the active color level (called by parseColor / getTextStyle in render-helpers). */
1290
+ function getActiveColorLevel() {
1291
+ return _activeColorLevel;
1292
+ }
1293
+ /**
1294
+ * Stack of per-subtree theme overrides, pushed/popped during render phase
1295
+ * tree walk. When a Box has a `theme` prop, its theme is pushed before
1296
+ * rendering children and popped after. getActiveTheme() checks this stack
1297
+ * first, falling back to _activeTheme.
1298
+ *
1299
+ * This enables CSS custom property-like cascading: the nearest ancestor
1300
+ * Box with a theme prop determines $token resolution for its subtree.
1301
+ * ThemeProvider (in @silvery/ag-react) renders a <Box theme={merged}>
1302
+ * wrapper, so its theme is naturally pushed via this mechanism.
1303
+ */
1304
+ const _contextStack = [];
1305
+ /** Push a context theme (called by render phase for Box nodes with theme prop). */
1306
+ function pushContextTheme(theme) {
1307
+ _contextStack.push(theme);
1308
+ }
1309
+ /** Pop a context theme (called by render phase after processing Box subtree). */
1310
+ function popContextTheme() {
1311
+ _contextStack.pop();
1312
+ }
1313
+ //#endregion
1212
1314
  //#region packages/ag-term/src/pipeline/render-helpers.ts
1213
- init_buffer();
1214
- init_state();
1215
- init_resolve();
1315
+ /**
1316
+ * Render Helpers - Pure utility functions for content rendering.
1317
+ *
1318
+ * Contains:
1319
+ * - Color parsing (parseColor)
1320
+ * - Border character definitions (getBorderChars)
1321
+ * - Style extraction (getTextStyle)
1322
+ * - Text width utilities (getTextWidth)
1323
+ *
1324
+ * Re-exports layout helpers from helpers.ts:
1325
+ * - getPadding, getBorderSize
1326
+ */
1216
1327
  init_src();
1217
1328
  const namedColors = {
1218
1329
  black: 0,
@@ -2142,7 +2253,8 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
2142
2253
  inverse: props.inverse,
2143
2254
  strikethrough: props.strikethrough
2144
2255
  };
2145
- const cachedCollected = getCachedCollectedText(node, maxDisplayWidth);
2256
+ const contextTheme = getActiveTheme();
2257
+ const cachedCollected = getCachedCollectedText(node, maxDisplayWidth, contextTheme);
2146
2258
  if (cachedCollected) {
2147
2259
  text = cachedCollected.text;
2148
2260
  bgSegments = cachedCollected.bgSegments;
@@ -2152,7 +2264,7 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
2152
2264
  text = collected.text;
2153
2265
  bgSegments = collected.bgSegments;
2154
2266
  childSpans = collected.childSpans;
2155
- setCachedCollectedText(node, collected, maxDisplayWidth);
2267
+ setCachedCollectedText(node, collected, maxDisplayWidth, contextTheme);
2156
2268
  }
2157
2269
  const style = getTextStyle(props);
2158
2270
  if (style.fg === null && inheritedFg !== void 0) style.fg = inheritedFg;
@@ -2247,12 +2359,20 @@ function computeInlineRects(childSpans, lineOffsets, parentX, parentY, lineCount
2247
2359
  //#region packages/ag-term/src/pipeline/render-box.ts
2248
2360
  /**
2249
2361
  * Get the effective background color string for a Box.
2250
- * Returns explicit backgroundColor if set, otherwise theme.bg if theme is set.
2362
+ * Returns explicit `backgroundColor` if set, otherwise the Theme's root
2363
+ * surface background — Sterling's `bg-surface-default` if present, falling
2364
+ * back to the legacy `bg` root for any pre-Sterling Theme shape.
2251
2365
  * Used by both renderBox (paint fill) and render-phase (cascade logic).
2252
2366
  */
2253
2367
  function getEffectiveBg(props) {
2254
2368
  if (props.backgroundColor) return props.backgroundColor;
2255
- if (props.theme) return props.theme.bg;
2369
+ if (props.theme) {
2370
+ const theme = props.theme;
2371
+ const sterlingBg = theme["bg-surface-default"];
2372
+ if (typeof sterlingBg === "string") return sterlingBg;
2373
+ const legacyBg = theme["bg"];
2374
+ if (typeof legacyBg === "string") return legacyBg;
2375
+ }
2256
2376
  }
2257
2377
  /**
2258
2378
  * Render a Box node.
@@ -2575,7 +2695,8 @@ function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
2575
2695
  if (y >= buffer.height || y + layout.height <= 0) return;
2576
2696
  const effectiveBg = getEffectiveBg(props);
2577
2697
  const theme = props.theme;
2578
- const childInheritedBg = effectiveBg ? { color: parseColor(effectiveBg) } : theme ? { color: parseColor(theme.bg) } : inheritedBg;
2698
+ const themeBg = theme && typeof theme["bg-surface-default"] === "string" ? theme["bg-surface-default"] : theme && typeof theme["bg"] === "string" ? theme["bg"] : void 0;
2699
+ const childInheritedBg = effectiveBg ? { color: parseColor(effectiveBg) } : themeBg !== void 0 ? { color: parseColor(themeBg) } : inheritedBg;
2579
2700
  if (node.type === "silvery-box" && props.outlineStyle) {
2580
2701
  const boxInheritedBg = effectiveBg ? void 0 : inheritedBg.color;
2581
2702
  const positions = collectOutlineCells(layout.x, y, layout.width, layout.height, props, clipBounds, buffer);
@@ -2920,7 +3041,6 @@ function getReactiveState(node) {
2920
3041
  * Region clearing: clearNodeRegion, clearExcessArea, clippedFill
2921
3042
  */
2922
3043
  init_buffer();
2923
- init_state();
2924
3044
  const contentLog = createLogger("silvery:content");
2925
3045
  const traceLog = createLogger("silvery:content:trace");
2926
3046
  const cellLog = createLogger("silvery:content:cell");
@@ -3459,12 +3579,17 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
3459
3579
  right: 0
3460
3580
  };
3461
3581
  const padding = getPadding(props);
3462
- const childClipBounds = computeChildClipBounds(layout, props, clipBounds, 0, false, true);
3582
+ const viewportClipBounds = computeChildClipBounds(layout, props, clipBounds, 0, false, true);
3583
+ const childClipBounds = props.overflowIndicator === true && !props.borderStyle && (ss.hiddenAbove > 0 || ss.hiddenBelow > 0) ? {
3584
+ ...viewportClipBounds,
3585
+ top: ss.hiddenAbove > 0 ? viewportClipBounds.top + 1 : viewportClipBounds.top,
3586
+ bottom: ss.hiddenBelow > 0 ? viewportClipBounds.bottom - 1 : viewportClipBounds.bottom
3587
+ } : viewportClipBounds;
3463
3588
  const scrollOffsetChanged = ss.offset !== ss.prevOffset;
3464
3589
  const hasStickyChildren = !!(ss.stickyChildren && ss.stickyChildren.length > 0);
3465
3590
  const visibleRangeChanged = ss.firstVisibleChild !== ss.prevFirstVisibleChild || ss.lastVisibleChild !== ss.prevLastVisibleChild;
3466
- const clearY = childClipBounds.top;
3467
- const clearHeight = childClipBounds.bottom - childClipBounds.top;
3591
+ const clearY = viewportClipBounds.top;
3592
+ const clearHeight = viewportClipBounds.bottom - viewportClipBounds.top;
3468
3593
  const contentX = layout.x + border.left + padding.left;
3469
3594
  const contentWidth = layout.width - border.left - border.right - padding.left - padding.right;
3470
3595
  const scrollBg = scrollOffsetChanged || isDirty(node.dirtyBits, node.dirtyEpoch, 8) || childrenNeedFreshRender || visibleRangeChanged ? getEffectiveBg(props) ? parseColor(getEffectiveBg(props)) : inheritedBg.color : null;
@@ -4020,17 +4145,163 @@ function clippedFill(buffer, x, width, top, bottom, clipBounds, outerBottom, bg)
4020
4145
  });
4021
4146
  }
4022
4147
  //#endregion
4023
- //#region packages/ag-term/src/pipeline/backdrop-phase.ts
4024
- init_src$1();
4148
+ //#region packages/ag-term/src/pipeline/backdrop/color.ts
4025
4149
  init_buffer();
4026
- const FADE_ATTR = "data-backdrop-fade";
4027
- const FADE_EXCLUDE_ATTR = "data-backdrop-fade-excluded";
4150
+ /** Convert a buffer Color to a `#rrggbb` hex string, or null if unresolvable. */
4151
+ function colorToHex(color) {
4152
+ if (color === null) return null;
4153
+ if (typeof color === "number") {
4154
+ const rgb = ansi256ToRgb(color);
4155
+ return rgbToHex(rgb.r, rgb.g, rgb.b);
4156
+ }
4157
+ if (isDefaultBg(color)) return null;
4158
+ return rgbToHex(color.r, color.g, color.b);
4159
+ }
4160
+ function rgbToHex(r, g, b) {
4161
+ const clamp = (n) => {
4162
+ return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
4163
+ };
4164
+ return `#${clamp(r)}${clamp(g)}${clamp(b)}`;
4165
+ }
4028
4166
  /**
4029
- * Apply backdrop-fade to the buffer based on tree markers.
4167
+ * Parse `#rrggbb` or `#rgb` (any case, with or without leading `#`) into
4168
+ * `{ r, g, b }`. Returns null when the input is not a hex color.
4030
4169
  *
4031
- * Returns `true` if at least one region was modified; `false` if nothing
4032
- * changed (no markers found, or colorLevel is `none`).
4170
+ * Strict character-class validation `parseInt("0g", 16)` returns `0`
4171
+ * silently, which would accept malformed hex values. Regex guard rejects
4172
+ * anything outside `[0-9a-f]` regardless of case.
4033
4173
  */
4174
+ function hexToRgb$1(hex) {
4175
+ if (typeof hex !== "string") return null;
4176
+ let s = hex.trim().toLowerCase();
4177
+ if (s.startsWith("#")) s = s.slice(1);
4178
+ if (s.length === 3) {
4179
+ if (!/^[0-9a-f]{3}$/.test(s)) return null;
4180
+ s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
4181
+ } else if (!/^[0-9a-f]{6}$/.test(s)) return null;
4182
+ return {
4183
+ r: parseInt(s.slice(0, 2), 16),
4184
+ g: parseInt(s.slice(2, 4), 16),
4185
+ b: parseInt(s.slice(4, 6), 16)
4186
+ };
4187
+ }
4188
+ /**
4189
+ * Normalize any permissible hex input to a canonical `#rrggbb` lowercase
4190
+ * string. Handles `#abc` → `#aabbcc` expansion, case folding, optional
4191
+ * leading `#`, and surrounding whitespace. Returns null when the input is
4192
+ * not a hex color.
4193
+ *
4194
+ * Applied by `buildPlan` to every user-provided color option
4195
+ * (`defaultBg`, `defaultFg`, `scrimColor`) exactly once so downstream
4196
+ * comparisons (`scrim === defaultBg`, etc.) work regardless of input
4197
+ * casing or shorthand.
4198
+ */
4199
+ function normalizeHex(hex) {
4200
+ if (hex === null || hex === void 0) return null;
4201
+ const rgb = hexToRgb$1(hex);
4202
+ if (!rgb) return null;
4203
+ return rgbToHex(rgb.r, rgb.g, rgb.b);
4204
+ }
4205
+ //#endregion
4206
+ //#region packages/ag-term/src/pipeline/backdrop/plan.ts
4207
+ /**
4208
+ * Backdrop fade — stage 1: build the immutable `Plan`.
4209
+ *
4210
+ * `buildPlan(root, options)` is a PURE, capability-independent pass that
4211
+ * walks the tree, collects `data-backdrop-fade` / `data-backdrop-fade-excluded`
4212
+ * markers, enforces the single-amount invariant, and resolves the scrim +
4213
+ * default colors. The realizers (`./realize-buffer.ts`, `./realize-kitty.ts`)
4214
+ * trust the plan: they do NOT re-walk the tree, re-resolve the scrim, or
4215
+ * re-validate amounts. This module is the single source of truth.
4216
+ *
4217
+ * ## The model: per-channel alpha scrim with perceptually-aware fg
4218
+ *
4219
+ * The pass fades every covered cell by blending BOTH fg AND bg toward a
4220
+ * neutral scrim color at the caller's `amount`. Default scrim: pure black
4221
+ * for dark themes (Apple `colorWithWhite:0.0 alpha:0.4`), pure white for
4222
+ * light. Default amount: `DEFAULT_AMOUNT` (0.25) — calibrated against
4223
+ * macOS 0.20, Material 3 0.32, iOS 0.40, Flutter 0.54.
4224
+ *
4225
+ * ### Two operations, one per channel
4226
+ *
4227
+ * fg' = deemphasizeOklchToward(fg, amount, towardLight)
4228
+ * // OKLCH: L toward 0 or 1,
4229
+ * // C *= (1-α)²
4230
+ * bg' = mixSrgb(bg, scrim, amount) // sRGB source-over alpha
4231
+ *
4232
+ * Fg uses OKLCH deemphasize with explicit polarity so colored text
4233
+ * deemphasizes toward the correct theme neutral — toward black on dark
4234
+ * themes (same formula we've always used), toward white on light themes
4235
+ * (new — see `./color-compat.ts` for the math). The quadratic chroma
4236
+ * falloff compensates for the human-vision nonlinearity that reads chroma
4237
+ * relative to luminance. Bg uses sRGB source-over because the Kitty
4238
+ * graphics scrim overlay composites in sRGB at alpha at the hardware
4239
+ * level.
4240
+ *
4241
+ * ### Uniform amount per channel, heaviness tuned at call site
4242
+ *
4243
+ * Both fg and bg use the same `amount`. An earlier revision halved bg
4244
+ * amount to prevent "scene drowning" — that caused border/panel brightness
4245
+ * inversion (fg-dominated border darkens faster than bg-dominated fill).
4246
+ * Heaviness is controlled by `amount`, not by asymmetric math.
4247
+ *
4248
+ * ## Scrim color
4249
+ *
4250
+ * - Dark themes: pure black (`#000000`) — Apple's modal-sheet dimming color.
4251
+ * - Light themes: pure white (`#ffffff`) — the sign-flipped equivalent.
4252
+ *
4253
+ * Null-bg cells are resolved to `defaultBg` first, then `mixSrgb` toward
4254
+ * the scrim — empty cells darken at the same rate as explicitly-colored ones.
4255
+ *
4256
+ * Tiers (`colorLevel`): a single code path for all supported tiers. For
4257
+ * `"none"` (monochrome) the pass short-circuits to a no-op. For `basic`,
4258
+ * `256`, and `truecolor`, the per-cell operation is identical — the output
4259
+ * phase quantizes the mixed truecolor hex to the tier's palette on emit.
4260
+ *
4261
+ * ## Purity
4262
+ *
4263
+ * This module is pure: no console I/O, no buffer access, no mutable module
4264
+ * state. `buildPlan` returns a `Plan` whose `mixedAmounts` flag signals
4265
+ * multi-amount frames; the orchestrator (`./index.ts`) emits the dev-mode
4266
+ * warning so stage 1 remains a pure function of its inputs.
4267
+ */
4268
+ /** Marker prop key for include rects (fade cells INSIDE the node's rect). */
4269
+ const BACKDROP_FADE_ATTR = "data-backdrop-fade";
4270
+ /** Marker prop key for exclude rects (fade everything OUTSIDE the node's rect). */
4271
+ const BACKDROP_FADE_EXCLUDE_ATTR = "data-backdrop-fade-excluded";
4272
+ /**
4273
+ * Luminance threshold for dark/light theme detection.
4274
+ *
4275
+ * 0.18 is well below the WCAG midpoint. Standard dark terminal themes
4276
+ * (Catppuccin Mocha bg #1e1e2e, luminance ≈ 0.012; Tokyo Night bg #1a1b26,
4277
+ * ≈ 0.010) are well below. Light themes (GitHub Light #ffffff = 1.0) above.
4278
+ */
4279
+ const DARK_LUMINANCE_THRESHOLD = .18;
4280
+ /** Canonical scrim colors — Apple's `colorWithWhite:0.0` / `:1.0`. */
4281
+ const DARK_SCRIM = "#000000";
4282
+ const LIGHT_SCRIM = "#ffffff";
4283
+ /**
4284
+ * Default fade amount — the calibrated baseline used when a marker
4285
+ * materializes as a presence attribute (`<Backdrop fade />`,
4286
+ * `data-backdrop-fade=""`, `data-backdrop-fade={true}`) without an explicit
4287
+ * numeric value. Calibrated against macOS 0.20, Material 3 0.32, iOS 0.40,
4288
+ * Flutter 0.54. Re-exported from `index.ts` so downstream callers can
4289
+ * reference the same constant.
4290
+ */
4291
+ const DEFAULT_AMOUNT = .25;
4292
+ /** Sentinel "nothing to do" plan — reused across frames to avoid allocations. */
4293
+ const INACTIVE_PLAN = Object.freeze({
4294
+ active: false,
4295
+ amount: 0,
4296
+ scrim: null,
4297
+ defaultBg: null,
4298
+ defaultFg: null,
4299
+ includes: Object.freeze([]),
4300
+ excludes: Object.freeze([]),
4301
+ mixedAmounts: false,
4302
+ scrimTowardLight: false,
4303
+ kittyEnabled: false
4304
+ });
4034
4305
  /**
4035
4306
  * Quick check: does the tree contain any backdrop markers? Used as a gate so
4036
4307
  * we don't clone the buffer every frame when no fade is active. Walks the
@@ -4039,106 +4310,432 @@ const FADE_EXCLUDE_ATTR = "data-backdrop-fade-excluded";
4039
4310
  */
4040
4311
  function hasBackdropMarkers(root) {
4041
4312
  const props = root.props;
4042
- if (props[FADE_ATTR] !== void 0 || props[FADE_EXCLUDE_ATTR] !== void 0) return true;
4313
+ if (props["data-backdrop-fade"] !== void 0 || props["data-backdrop-fade-excluded"] !== void 0) return true;
4043
4314
  for (const child of root.children) if (hasBackdropMarkers(child)) return true;
4044
4315
  return false;
4045
4316
  }
4046
- function applyBackdropFade(root, buffer, options) {
4047
- const colorLevel = options?.colorLevel ?? "truecolor";
4048
- if (colorLevel === "none") return false;
4317
+ /**
4318
+ * Stage 1 build the immutable `Plan`.
4319
+ *
4320
+ * Pure function of `(tree markers, options)`. No buffer access, no Kitty
4321
+ * capability knowledge, no console I/O. The realizers read from the plan
4322
+ * exclusively; the orchestrator (`./index.ts`) handles dev-mode diagnostics
4323
+ * derived from `plan.mixedAmounts`.
4324
+ *
4325
+ * Returns `INACTIVE_PLAN` when:
4326
+ * - `colorLevel === "none"` (monochrome terminal — pass is a no-op).
4327
+ * - The tree has no backdrop markers, OR all markers have `amount <= 0`.
4328
+ */
4329
+ function buildPlan(root, options) {
4330
+ if ((options?.colorLevel ?? "truecolor") === "none") return INACTIVE_PLAN;
4049
4331
  const includes = [];
4050
4332
  const excludes = [];
4051
- collectBackdropMarkers(root, includes, excludes);
4052
- if (includes.length === 0 && excludes.length === 0) return false;
4053
- const strategy = colorLevel === "basic" ? "dim" : "blend";
4054
- let modified = false;
4055
- for (const { rect, amount } of includes) {
4056
- if (amount <= 0) continue;
4057
- if (fadeRect(buffer, rect, amount, strategy)) modified = true;
4058
- }
4059
- if (excludes.length > 0) {
4060
- const fullRect = {
4061
- x: 0,
4062
- y: 0,
4063
- width: buffer.width,
4064
- height: buffer.height
4065
- };
4066
- for (const { rect, amount } of excludes) {
4067
- if (amount <= 0) continue;
4068
- if (fadeRectExcluding(buffer, fullRect, rect, amount, strategy)) modified = true;
4333
+ const includeAmounts = [];
4334
+ const excludeAmounts = [];
4335
+ collectBackdropMarkers(root, includes, excludes, includeAmounts, excludeAmounts);
4336
+ if (includes.length === 0 && excludes.length === 0) return INACTIVE_PLAN;
4337
+ const defaultBg = normalizeHex(options?.defaultBg ?? null);
4338
+ const scrimColorOpt = options?.scrimColor;
4339
+ const scrim = (typeof scrimColorOpt === "string" && scrimColorOpt !== "auto" ? normalizeHex(scrimColorOpt) : null) ?? deriveAutoScrimColor(defaultBg);
4340
+ const scrimTowardLight = isLightScrim(scrim);
4341
+ const defaultFg = normalizeHex(options?.defaultFg) ?? (scrim === null ? null : scrimTowardLight ? "#000000" : "#ffffff");
4342
+ const { amount, hasMixedAmounts } = assertSingleAmount(includeAmounts, excludeAmounts);
4343
+ return {
4344
+ active: true,
4345
+ amount,
4346
+ scrim,
4347
+ defaultBg,
4348
+ defaultFg,
4349
+ includes,
4350
+ excludes,
4351
+ mixedAmounts: hasMixedAmounts,
4352
+ scrimTowardLight,
4353
+ kittyEnabled: options?.kittyGraphics === true && scrim !== null
4354
+ };
4355
+ }
4356
+ /**
4357
+ * Detect the single-amount invariant across all markers. Returns the
4358
+ * clamped first-observed amount AND a flag indicating whether any later
4359
+ * marker differed. The orchestrator uses the flag to emit a dev-mode warn.
4360
+ *
4361
+ * Mixed amounts currently break the Kitty overlay (one image, one alpha)
4362
+ * and have unclear composition semantics (max? source-over compound?).
4363
+ * Production behavior is first-wins but will look wrong until the markers
4364
+ * are reconciled to a single value.
4365
+ */
4366
+ function assertSingleAmount(includeAmounts, excludeAmounts) {
4367
+ const first = includeAmounts.length > 0 ? includeAmounts[0] : excludeAmounts.length > 0 ? excludeAmounts[0] : 0;
4368
+ let hasMixedAmounts = false;
4369
+ for (const a of includeAmounts) if (Math.abs(a - first) > 1e-6) {
4370
+ hasMixedAmounts = true;
4371
+ break;
4372
+ }
4373
+ if (!hasMixedAmounts) {
4374
+ for (const a of excludeAmounts) if (Math.abs(a - first) > 1e-6) {
4375
+ hasMixedAmounts = true;
4376
+ break;
4069
4377
  }
4070
4378
  }
4071
- return modified;
4379
+ return {
4380
+ amount: Math.max(0, Math.min(1, first)),
4381
+ hasMixedAmounts
4382
+ };
4383
+ }
4384
+ /**
4385
+ * Derive the auto scrim color from a normalized bg hex. Dark themes scrim
4386
+ * toward `DARK_SCRIM`; light themes scrim toward `LIGHT_SCRIM`. Returns
4387
+ * `null` when `bg` is absent or unparseable — signals legacy single-
4388
+ * channel fallback in `fadeCell`.
4389
+ */
4390
+ function deriveAutoScrimColor(bg) {
4391
+ if (!bg) return null;
4392
+ const lum = relativeLuminance(bg);
4393
+ if (lum === null) return null;
4394
+ return lum < .18 ? DARK_SCRIM : LIGHT_SCRIM;
4072
4395
  }
4073
- function collectBackdropMarkers(node, includes, excludes) {
4396
+ /**
4397
+ * Polarity detection for an arbitrary scrim color. Returns `true` when the
4398
+ * scrim is on the LIGHT side of the luminance threshold (fg should drift
4399
+ * toward white), `false` otherwise. Uses luminance, not string equality,
4400
+ * so tinted scrims (mid-gray neutrals, etc.) land on the correct branch.
4401
+ * Null scrim defaults to false (dark-theme fallback behavior).
4402
+ */
4403
+ function isLightScrim(scrim) {
4404
+ if (scrim === null) return false;
4405
+ const lum = relativeLuminance(scrim);
4406
+ if (lum === null) return false;
4407
+ return lum >= DARK_LUMINANCE_THRESHOLD;
4408
+ }
4409
+ function collectBackdropMarkers(node, includes, excludes, includeAmounts, excludeAmounts) {
4074
4410
  const props = node.props;
4075
- const includeRaw = props[FADE_ATTR];
4076
- const excludeRaw = props[FADE_EXCLUDE_ATTR];
4411
+ const includeRaw = props[BACKDROP_FADE_ATTR];
4412
+ const excludeRaw = props[BACKDROP_FADE_EXCLUDE_ATTR];
4077
4413
  if (includeRaw !== void 0 || excludeRaw !== void 0) {
4078
4414
  const rect = node.screenRect ?? node.scrollRect ?? node.boxRect;
4079
4415
  if (rect && rect.width > 0 && rect.height > 0) {
4080
4416
  const inc = parseFade(includeRaw);
4081
- if (inc !== null) includes.push({
4082
- rect,
4083
- amount: inc
4084
- });
4417
+ if (inc !== null) {
4418
+ includes.push({ rect });
4419
+ includeAmounts.push(inc);
4420
+ }
4085
4421
  const exc = parseFade(excludeRaw);
4086
- if (exc !== null) excludes.push({
4087
- rect,
4088
- amount: exc
4089
- });
4422
+ if (exc !== null) {
4423
+ excludes.push({ rect });
4424
+ excludeAmounts.push(exc);
4425
+ }
4090
4426
  }
4091
4427
  }
4092
- for (const child of node.children) collectBackdropMarkers(child, includes, excludes);
4428
+ for (const child of node.children) collectBackdropMarkers(child, includes, excludes, includeAmounts, excludeAmounts);
4093
4429
  }
4430
+ /**
4431
+ * Coerce a marker attribute value into a fade amount in (0, 1], or `null`
4432
+ * when the marker is absent / disabled.
4433
+ *
4434
+ * Accepted inputs:
4435
+ *
4436
+ * - `undefined`, `null`, `false` → `null` (marker absent)
4437
+ * - `true` → `DEFAULT_AMOUNT` (presence attribute, e.g. `<Backdrop fade />`)
4438
+ * - `""` → `DEFAULT_AMOUNT` (HTML-attribute presence idiom)
4439
+ * - finite numeric or numeric-string (including in scientific notation):
4440
+ * - `<= 0` → `null` (explicit opt-out)
4441
+ * - `> 1` → `1` (clamped)
4442
+ * - otherwise → the numeric value itself
4443
+ * - any other non-numeric string (e.g. `"bad"`) → `null`
4444
+ *
4445
+ * The presence-attribute idiom lets components emit `data-backdrop-fade`
4446
+ * without threading a numeric value through when the default is fine. The
4447
+ * React `Backdrop.tsx` / `ModalDialog.tsx` today always emit a numeric
4448
+ * attribute, but the semantic is forward-compatible so nothing breaks if
4449
+ * a future component (or a hand-written JSX usage) prefers presence-only.
4450
+ */
4094
4451
  function parseFade(raw) {
4095
4452
  if (raw === void 0 || raw === null) return null;
4453
+ if (raw === false) return null;
4454
+ if (raw === true) return DEFAULT_AMOUNT;
4455
+ if (raw === "") return DEFAULT_AMOUNT;
4096
4456
  const n = typeof raw === "number" ? raw : Number(raw);
4097
4457
  if (!Number.isFinite(n)) return null;
4098
4458
  if (n <= 0) return null;
4099
4459
  return n > 1 ? 1 : n;
4100
4460
  }
4101
- function fadeRect(buffer, rect, amount, strategy) {
4102
- const x0 = Math.max(0, rect.x);
4103
- const y0 = Math.max(0, rect.y);
4104
- const x1 = Math.min(buffer.width, rect.x + rect.width);
4105
- const y1 = Math.min(buffer.height, rect.y + rect.height);
4106
- if (x0 >= x1 || y0 >= y1) return false;
4107
- let any = false;
4108
- for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) if (fadeCell(buffer, x, y, amount, strategy)) any = true;
4109
- return any;
4110
- }
4111
- function fadeRectExcluding(buffer, outer, inner, amount, strategy) {
4112
- const ox0 = Math.max(0, outer.x);
4113
- const oy0 = Math.max(0, outer.y);
4114
- const ox1 = Math.min(buffer.width, outer.x + outer.width);
4115
- const oy1 = Math.min(buffer.height, outer.y + outer.height);
4116
- const ix0 = Math.max(ox0, inner.x);
4117
- const iy0 = Math.max(oy0, inner.y);
4118
- const ix1 = Math.min(ox1, inner.x + inner.width);
4119
- const iy1 = Math.min(oy1, inner.y + inner.height);
4120
- const innerValid = ix0 < ix1 && iy0 < iy1;
4121
- let any = false;
4122
- for (let y = oy0; y < oy1; y++) for (let x = ox0; x < ox1; x++) {
4123
- if (innerValid && x >= ix0 && x < ix1 && y >= iy0 && y < iy1) continue;
4124
- if (fadeCell(buffer, x, y, amount, strategy)) any = true;
4125
- }
4126
- return any;
4461
+ //#endregion
4462
+ //#region packages/ag-term/src/pipeline/backdrop/color-compat.ts
4463
+ /**
4464
+ * Backdrop fade `@silvery/color` compatibility shim.
4465
+ *
4466
+ * Temporary shim while `@silvery/color` lags behind on publish cycles.
4467
+ * `@silvery/color` does export `mixSrgb` and `deemphasize` from source; this
4468
+ * module prefers the upstream versions at runtime and falls back to a
4469
+ * local implementation when an upstream export is missing — e.g., when a
4470
+ * new helper is introduced in the same release cycle as the silvery
4471
+ * package that imports it (the published `@silvery/color` dist doesn't
4472
+ * ship the new name until its next publish, breaking CI verify).
4473
+ *
4474
+ * The fallback implementations are byte-identical to the upstream ones.
4475
+ * Once all downstream consumers of silvery are on a published version of
4476
+ * `@silvery/color` that exports every name we reference, delete the
4477
+ * `local*` fallbacks and collapse each export to a direct re-export.
4478
+ *
4479
+ * Light-theme-aware deemphasize (`deemphasizeOklchToward`) is NOT in
4480
+ * upstream yet it's only shipped by this module. When it lands in
4481
+ * `@silvery/color`, replace the local implementation with an upstream
4482
+ * re-export behind the same shim.
4483
+ *
4484
+ * @see ./color.ts for hex↔rgb adapter helpers and `HexColor` type.
4485
+ */
4486
+ /**
4487
+ * sRGB source-over alpha mix. `out = a * (1 - t) + b * t`.
4488
+ *
4489
+ * Prefers `@silvery/color`'s published export; falls back to the local copy
4490
+ * when upstream doesn't ship the name yet. The local implementation matches
4491
+ * `@silvery/color/src/color.ts` byte-for-byte and is safe to use while the
4492
+ * publish train catches up.
4493
+ */
4494
+ function localMixSrgb(a, b, t) {
4495
+ const ra = hexToRgb$1(a);
4496
+ const rb = hexToRgb$1(b);
4497
+ if (!ra || !rb) return a;
4498
+ const u = Math.max(0, Math.min(1, t));
4499
+ return rgbToHex(ra.r * (1 - u) + rb.r * u, ra.g * (1 - u) + rb.g * u, ra.b * (1 - u) + rb.b * u);
4500
+ }
4501
+ /** sRGB source-over mix. Prefers upstream `@silvery/color`; falls back to the local copy. */
4502
+ const mixSrgb = Upstream.mixSrgb ?? localMixSrgb;
4503
+ /**
4504
+ * OKLCH-native deemphasize that drifts toward EITHER black (dark themes)
4505
+ * OR white (light themes). `towardLight` controls the lightness target;
4506
+ * the chroma falloff is identical in both directions.
4507
+ *
4508
+ * towardLight=false (dark themes):
4509
+ * L' = L × (1 - amount) // linear toward black
4510
+ * towardLight=true (light themes):
4511
+ * L' = L + (1 - L) × amount // linear toward white
4512
+ * (both branches):
4513
+ * C' = C × (1 - amount)² // quadratic chroma falloff
4514
+ * H' = H // hue preserved
4515
+ *
4516
+ * The asymmetric chroma falloff corrects for a perceptual nonlinearity:
4517
+ * the human visual system reads chroma RELATIVE to luminance, so a modest
4518
+ * OKLCH C at extreme L *appears* distinctly more chromatic than the same C
4519
+ * near mid-L. Proportional L+C scaling (`C *= 1-α`, preserving C/L) feels
4520
+ * "more saturated when darkened" to viewers — the exact complaint that
4521
+ * prompted the quadratic version.
4522
+ *
4523
+ * Using `(1-α)²` for chroma reduces saturation faster than lightness on
4524
+ * both polarities:
4525
+ *
4526
+ * α=0.25 → C *= 0.563 (C/L drops to 75% of original)
4527
+ * α=0.40 → C *= 0.360 (C/L drops to 60%)
4528
+ * α=0.50 → C *= 0.250 (C/L drops to 50%)
4529
+ * α=1.00 → C *= 0 (fully faded to the target luminance).
4530
+ *
4531
+ * Light-theme case (towardLight=true): a bright colored text on a light
4532
+ * backdrop is made paler by raising L toward 1 and dropping C — the
4533
+ * symmetric "fade toward the page color" behavior macOS ships in light
4534
+ * mode. Without the polarity flip, the dark-only formula `L *= (1 - α)`
4535
+ * would darken colored text on a light bg, which reads as "text popping"
4536
+ * against the faded scrim rather than receding.
4537
+ */
4538
+ function localDeemphasizeOklchToward(hex, amount, towardLight) {
4539
+ const o = upstreamHexToOklch(hex);
4540
+ if (!o) return hex;
4541
+ const a = Math.max(0, Math.min(1, amount));
4542
+ const chromaFactor = (1 - a) * (1 - a);
4543
+ const L = towardLight ? o.L + (1 - o.L) * a : o.L * (1 - a);
4544
+ return upstreamOklchToHex({
4545
+ L: Math.max(0, Math.min(1, L)),
4546
+ C: Math.max(0, o.C * chromaFactor),
4547
+ H: o.H
4548
+ });
4549
+ }
4550
+ const upstreamHexToOklch = Upstream.hexToOklch;
4551
+ const upstreamOklchToHex = Upstream.oklchToHex;
4552
+ const deemphasizeOklchToward = Upstream.deemphasizeOklchToward ?? localDeemphasizeOklchToward;
4553
+ //#endregion
4554
+ //#region packages/ag-term/src/pipeline/backdrop/region.ts
4555
+ /**
4556
+ * Walk every cell covered by the plan's include and exclude rects and
4557
+ * invoke `visit(x, y)` for each unique cell.
4558
+ *
4559
+ * `includes` cells are those INSIDE any include rect.
4560
+ * `excludes` cells are those OUTSIDE any exclude rect (i.e., excluded from
4561
+ * the exclude's interior — the modal "cuts a hole" pattern).
4562
+ *
4563
+ * Rects are clipped to the buffer bounds (`[0, bufferWidth)` ×
4564
+ * `[0, bufferHeight)`). Zero-size rects are skipped. Cells are deduped
4565
+ * across all rects via a `Uint8Array` bitset — a cell belonging to two
4566
+ * overlapping includes is visited once, not twice.
4567
+ *
4568
+ * Returns the count of unique cells visited. Useful for short-circuiting
4569
+ * the "was any cell modified?" signal in realizers.
4570
+ */
4571
+ function forEachFadeRegionCell(bufferWidth, bufferHeight, includes, excludes, visit) {
4572
+ if (bufferWidth <= 0 || bufferHeight <= 0) return 0;
4573
+ if (includes.length === 0 && excludes.length === 0) return 0;
4574
+ const seen = new Uint8Array(bufferWidth * bufferHeight);
4575
+ let count = 0;
4576
+ const once = (x, y) => {
4577
+ const i = y * bufferWidth + x;
4578
+ if (seen[i] !== 0) return;
4579
+ seen[i] = 1;
4580
+ count += 1;
4581
+ visit(x, y);
4582
+ };
4583
+ for (const { rect } of includes) {
4584
+ const x0 = Math.max(0, rect.x);
4585
+ const y0 = Math.max(0, rect.y);
4586
+ const x1 = Math.min(bufferWidth, rect.x + rect.width);
4587
+ const y1 = Math.min(bufferHeight, rect.y + rect.height);
4588
+ if (x0 >= x1 || y0 >= y1) continue;
4589
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) once(x, y);
4590
+ }
4591
+ if (excludes.length > 0) {
4592
+ const clipped = [];
4593
+ for (const { rect } of excludes) {
4594
+ const x0 = Math.max(0, rect.x);
4595
+ const y0 = Math.max(0, rect.y);
4596
+ const x1 = Math.min(bufferWidth, rect.x + rect.width);
4597
+ const y1 = Math.min(bufferHeight, rect.y + rect.height);
4598
+ if (x0 < x1 && y0 < y1) clipped.push({
4599
+ x0,
4600
+ y0,
4601
+ x1,
4602
+ y1
4603
+ });
4604
+ }
4605
+ if (clipped.length > 0) for (let y = 0; y < bufferHeight; y++) for (let x = 0; x < bufferWidth; x++) {
4606
+ let insideAnyExclude = false;
4607
+ for (const r of clipped) if (x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1) {
4608
+ insideAnyExclude = true;
4609
+ break;
4610
+ }
4611
+ if (!insideAnyExclude) once(x, y);
4612
+ }
4613
+ else for (let y = 0; y < bufferHeight; y++) for (let x = 0; x < bufferWidth; x++) once(x, y);
4614
+ }
4615
+ return count;
4616
+ }
4617
+ //#endregion
4618
+ //#region packages/ag-term/src/pipeline/backdrop/realize-buffer.ts
4619
+ /**
4620
+ * Stage 2a — apply the plan's cell-level transform to the buffer.
4621
+ *
4622
+ * Walks every include + exclude cell once via `forEachFadeRegionCell` and
4623
+ * applies `fadeCell` with the plan's single `amount`. The buffer is mutated
4624
+ * in place.
4625
+ *
4626
+ * When `plan.kittyEnabled === true`, emoji cells (detected via
4627
+ * `isLikelyEmoji(cell.char)`) are SKIPPED — the Kitty overlay realizer
4628
+ * composites the scrim on top of the unmixed cell. When
4629
+ * `plan.kittyEnabled === false`, emoji cells go through the per-cell mix
4630
+ * AND get SGR 2 (`attrs.dim`) stamped on lead + continuation.
4631
+ *
4632
+ * Returns `true` when at least one buffer cell was mutated.
4633
+ */
4634
+ function realizeToBuffer(plan, buffer) {
4635
+ if (!plan.active) return false;
4636
+ if (plan.amount <= 0) return false;
4637
+ let modified = false;
4638
+ forEachFadeRegionCell(buffer.width, buffer.height, plan.includes, plan.excludes, (x, y) => {
4639
+ if (fadeCell(buffer, x, y, plan)) modified = true;
4640
+ });
4641
+ return modified;
4127
4642
  }
4128
4643
  /**
4129
4644
  * Fade a single cell. Returns true if the cell was modified.
4130
4645
  *
4131
- * - `blend` strategy: mix fg toward bg in OKLab. When either color is null or
4132
- * the default-bg sentinel, also stamps `dim`.
4133
- * - `dim` strategy: stamp `dim` attribute.
4646
+ * Two-channel transform (see `./plan.ts` for the full color model):
4647
+ *
4648
+ * fg' = deemphasizeOklchToward(fg, amount, scrimTowardLight)
4649
+ * bg' = mixSrgb(bg, scrim, amount)
4134
4650
  *
4135
- * Wide-char continuation cells are skipped they share styling with the
4136
- * leading cell and modifying them separately would desync.
4651
+ * Fg uses OKLCH deemphasize (not sRGB mixing) so colored text deemphasizes
4652
+ * perceptually pale lavender becomes dull slate on dark themes, pale
4653
+ * grey on light themes. The polarity flag `scrimTowardLight` (from the
4654
+ * plan) steers L toward 0 or 1; chroma falloff is symmetric. Bg uses sRGB
4655
+ * source-over because the Kitty graphics scrim overlay composites in sRGB
4656
+ * at alpha at the hardware level.
4657
+ *
4658
+ * `null`/`DEFAULT_BG` cells are resolved to `plan.defaultBg` first (that
4659
+ * IS the color the terminal paints), then mixed toward the scrim — so
4660
+ * empty cells darken at the same rate as explicitly-colored cells.
4661
+ *
4662
+ * Uniform amounts for fg + bg preserve relative brightness ordering across
4663
+ * borders vs fills. Heaviness is controlled by `plan.amount` (default
4664
+ * 0.25, calibrated against macOS 0.20, Material 3 0.32, iOS 0.40, Flutter
4665
+ * 0.54).
4666
+ *
4667
+ * The `scrim !== null` gate activates the full two-channel path: fg always
4668
+ * deemphasizes, and bg mixes toward the scrim when a resolvable bg hex is
4669
+ * available (`cell.bg` non-null OR `defaultBg` non-null). When both
4670
+ * `scrim` and a resolvable bg are null (no theme context at all): falls
4671
+ * back to mixing fg toward `cell.bg` so the cell still reads as "receded"
4672
+ * without needing external theme info.
4673
+ *
4674
+ * ### Wide-char / emoji handling
4675
+ *
4676
+ * Terminals render emoji using the glyph's own bitmap colors — the fg mix
4677
+ * has no visible effect on the emoji glyph. Two paths, mutually exclusive:
4678
+ *
4679
+ * 1. Kitty graphics available: `fadeCell` SKIPS emoji wide cells entirely.
4680
+ * The Kitty overlay composites the scrim at alpha=amount on top, landing
4681
+ * at `cell * (1 - amount) + scrim * amount` — same as surrounding cells.
4682
+ * 2. Kitty unavailable: mix the cell bg + stamp `attrs.dim` on lead +
4683
+ * continuation. Terminals honoring SGR 2 on emoji fade the glyph. Wide
4684
+ * TEXT (CJK etc.) goes through the normal deemphasize path on both
4685
+ * branches — the fg mix works fine and SGR 2 on CJK over-fades.
4137
4686
  */
4138
- function fadeCell(buffer, x, y, amount, strategy) {
4687
+ function fadeCell(buffer, x, y, plan) {
4139
4688
  if (buffer.isCellContinuation(x, y)) return false;
4140
4689
  const cell = buffer.getCell(x, y);
4141
- if (strategy === "dim") {
4690
+ const isEmojiGlyph = cell.wide && isLikelyEmoji(cell.char ?? "");
4691
+ if (plan.kittyEnabled && isEmojiGlyph) return false;
4692
+ const { amount, scrim, defaultBg, defaultFg, scrimTowardLight } = plan;
4693
+ const rawFgHex = colorToHex(cell.fg);
4694
+ if (scrim !== null) {
4695
+ const fgHex = rawFgHex ?? defaultFg ?? (scrimTowardLight ? "#000000" : "#ffffff");
4696
+ const bgHex = colorToHex(cell.bg) ?? defaultBg;
4697
+ const mixedBgHex = bgHex !== null ? mixSrgb(bgHex, scrim, amount) : null;
4698
+ const mixedBg = mixedBgHex !== null ? hexToRgb$1(mixedBgHex) : null;
4699
+ const stampEmojiDim = isEmojiGlyph;
4700
+ const newAttrs = stampEmojiDim && !cell.attrs.dim ? {
4701
+ ...cell.attrs,
4702
+ dim: true
4703
+ } : cell.attrs;
4704
+ const mixedFg = hexToRgb$1(deemphasizeOklchToward(fgHex, amount, scrimTowardLight));
4705
+ if (mixedFg) {
4706
+ if (mixedBg) {
4707
+ buffer.setCell(x, y, {
4708
+ ...cell,
4709
+ fg: mixedFg,
4710
+ bg: mixedBg,
4711
+ attrs: newAttrs
4712
+ });
4713
+ propagateToContinuation(buffer, cell, x, y, {
4714
+ bg: mixedBg,
4715
+ dim: stampEmojiDim
4716
+ });
4717
+ return true;
4718
+ }
4719
+ buffer.setCell(x, y, {
4720
+ ...cell,
4721
+ fg: mixedFg,
4722
+ attrs: newAttrs
4723
+ });
4724
+ if (stampEmojiDim) propagateToContinuation(buffer, cell, x, y, { dim: true });
4725
+ return true;
4726
+ }
4727
+ if (mixedBg) {
4728
+ buffer.setCell(x, y, {
4729
+ ...cell,
4730
+ bg: mixedBg,
4731
+ attrs: newAttrs
4732
+ });
4733
+ propagateToContinuation(buffer, cell, x, y, {
4734
+ bg: mixedBg,
4735
+ dim: stampEmojiDim
4736
+ });
4737
+ return true;
4738
+ }
4142
4739
  if (cell.attrs.dim) return false;
4143
4740
  buffer.setCell(x, y, {
4144
4741
  ...cell,
@@ -4149,14 +4746,14 @@ function fadeCell(buffer, x, y, amount, strategy) {
4149
4746
  });
4150
4747
  return true;
4151
4748
  }
4152
- const fgHex = colorToHex(cell.fg);
4749
+ const fgHex = rawFgHex;
4153
4750
  const bgHex = colorToHex(cell.bg);
4154
4751
  if (fgHex && bgHex) {
4155
- const blendedRgb = hexToRgb(blend(fgHex, bgHex, amount));
4156
- if (!blendedRgb) return false;
4752
+ const mixedRgb = hexToRgb$1(mixSrgb(fgHex, bgHex, amount));
4753
+ if (!mixedRgb) return false;
4157
4754
  buffer.setCell(x, y, {
4158
4755
  ...cell,
4159
- fg: blendedRgb
4756
+ fg: mixedRgb
4160
4757
  });
4161
4758
  return true;
4162
4759
  }
@@ -4168,42 +4765,167 @@ function fadeCell(buffer, x, y, amount, strategy) {
4168
4765
  dim: true
4169
4766
  }
4170
4767
  });
4768
+ if (cell.wide && x + 1 < buffer.width) {
4769
+ const cont = buffer.getCell(x + 1, y);
4770
+ if (!cont.attrs.dim) buffer.setCell(x + 1, y, {
4771
+ ...cont,
4772
+ attrs: {
4773
+ ...cont.attrs,
4774
+ dim: true
4775
+ }
4776
+ });
4777
+ }
4171
4778
  return true;
4172
4779
  }
4173
- /** Convert a buffer Color to a `#rrggbb` hex string, or null if unresolvable. */
4174
- function colorToHex(color) {
4175
- if (color === null) return null;
4176
- if (typeof color === "number") {
4177
- const rgb = ansi256ToRgb(color);
4178
- return rgbToHex(rgb.r, rgb.g, rgb.b);
4179
- }
4180
- if (isDefaultBg(color)) return null;
4181
- return rgbToHex(color.r, color.g, color.b);
4780
+ /**
4781
+ * Propagate lead-cell updates to the continuation cell of a wide char.
4782
+ *
4783
+ * When a wide char (emoji, CJK) has its bg or dim attribute changed on the
4784
+ * lead cell, the continuation cell at `x+1` must track in lockstep or the
4785
+ * two halves of the glyph render inconsistently (different bg → visually
4786
+ * split glyph; missing dim → half-faded emoji).
4787
+ *
4788
+ * `patch.bg` copies the mixed bg onto the continuation. `patch.dim` stamps
4789
+ * `attrs.dim`. Either or both may be provided; the function is a no-op
4790
+ * when neither is set.
4791
+ */
4792
+ function propagateToContinuation(buffer, leadCell, x, y, patch) {
4793
+ if (!leadCell.wide) return;
4794
+ if (x + 1 >= buffer.width) return;
4795
+ const cont = buffer.getCell(x + 1, y);
4796
+ if (!cont.continuation) return;
4797
+ const stampDim = patch.dim === true && !cont.attrs.dim;
4798
+ const writeBg = patch.bg !== void 0;
4799
+ if (!stampDim && !writeBg) return;
4800
+ const attrs = stampDim ? {
4801
+ ...cont.attrs,
4802
+ dim: true
4803
+ } : cont.attrs;
4804
+ if (writeBg) buffer.setCell(x + 1, y, {
4805
+ ...cont,
4806
+ bg: patch.bg,
4807
+ attrs
4808
+ });
4809
+ else buffer.setCell(x + 1, y, {
4810
+ ...cont,
4811
+ attrs
4812
+ });
4182
4813
  }
4183
- function rgbToHex(r, g, b) {
4184
- const clamp = (n) => {
4185
- return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
4814
+ //#endregion
4815
+ //#region packages/ag-term/src/pipeline/backdrop/realize-kitty.ts
4816
+ init_src();
4817
+ /**
4818
+ * Stage 2b — emit the Kitty graphics overlay for the plan.
4819
+ *
4820
+ * The output always begins with `CURSOR_SAVE + kittyDeleteAllScrimPlacements()
4821
+ * + CURSOR_RESTORE` when `plan.active` is true — even when zero emoji cells
4822
+ * fall inside the faded region this frame. The unconditional clear is what
4823
+ * erases stale placements from a previous frame.
4824
+ *
4825
+ * Returns `""` when `plan.active` is false. Callers that also need to
4826
+ * suppress the overlay because Kitty graphics are not available should NOT
4827
+ * call this function at all — the orchestrator (`./index.ts`) guards the
4828
+ * call site with `plan.kittyEnabled`.
4829
+ */
4830
+ function realizeToKitty(plan, buffer) {
4831
+ if (!plan.active) return "";
4832
+ const cells = collectEmojiCellsInFadeRegion(buffer, plan);
4833
+ const tint = hexToRgb$1(plan.scrim ?? plan.defaultBg ?? "#000000") ?? {
4834
+ r: 0,
4835
+ g: 0,
4836
+ b: 0
4186
4837
  };
4187
- return `#${clamp(r)}${clamp(g)}${clamp(b)}`;
4838
+ const scrimAlpha = Math.max(0, Math.min(255, Math.round(plan.amount * 255)));
4839
+ const parts = [];
4840
+ parts.push("\x1B7");
4841
+ if (cells.length === 0) {
4842
+ parts.push(kittyDeleteAllScrimPlacements());
4843
+ parts.push("\x1B8");
4844
+ return parts.join("");
4845
+ }
4846
+ const pixels = buildScrimPixels(tint, scrimAlpha);
4847
+ parts.push(kittyUploadScrimImage(pixels, 2, 2));
4848
+ parts.push(kittyDeleteAllScrimPlacements());
4849
+ for (const { x, y } of cells) {
4850
+ parts.push(cupTo(x, y));
4851
+ parts.push(kittyPlaceAt({
4852
+ placementId: backdropPlacementId(x, y),
4853
+ cols: 2,
4854
+ rows: 1,
4855
+ z: 1
4856
+ }));
4857
+ }
4858
+ parts.push("\x1B8");
4859
+ return parts.join("");
4188
4860
  }
4189
- function hexToRgb(hex) {
4190
- if (typeof hex !== "string") return null;
4191
- let s = hex;
4192
- if (s.startsWith("#")) s = s.slice(1);
4193
- if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
4194
- if (s.length !== 6) return null;
4195
- const r = parseInt(s.slice(0, 2), 16);
4196
- const g = parseInt(s.slice(2, 4), 16);
4197
- const b = parseInt(s.slice(4, 6), 16);
4198
- if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
4861
+ /**
4862
+ * Collect the coordinates of every EMOJI lead cell inside the plan's faded
4863
+ * region. CJK and other wide TEXT cells are excluded — they respond to fg
4864
+ * color mixing like normal text and don't need the Kitty overlay. Only
4865
+ * bitmap-glyph cells (detected via `isLikelyEmoji(cell.char)`) need an
4866
+ * overlay because their rendering ignores the fg color.
4867
+ *
4868
+ * The iteration order is deterministic (delegated to
4869
+ * `forEachFadeRegionCell`), matching the buffer realizer's order. STRICT
4870
+ * mode compares the overlay string across fresh and incremental paths —
4871
+ * any drift in this order would fail the comparison.
4872
+ */
4873
+ function collectEmojiCellsInFadeRegion(buffer, plan) {
4874
+ const out = [];
4875
+ forEachFadeRegionCell(buffer.width, buffer.height, plan.includes, plan.excludes, (x, y) => {
4876
+ if (x + 1 >= buffer.width) return;
4877
+ if (!buffer.isCellWide(x, y)) return;
4878
+ if (buffer.isCellContinuation(x, y)) return;
4879
+ if (!isLikelyEmoji(buffer.getCell(x, y).char ?? "")) return;
4880
+ out.push({
4881
+ x,
4882
+ y
4883
+ });
4884
+ });
4885
+ return out;
4886
+ }
4887
+ //#endregion
4888
+ //#region packages/ag-term/src/pipeline/backdrop/index.ts
4889
+ const EMPTY_RESULT = Object.freeze({
4890
+ modified: false,
4891
+ overlay: ""
4892
+ });
4893
+ /**
4894
+ * Apply backdrop-fade to the buffer based on tree markers.
4895
+ *
4896
+ * Thin orchestrator over the mask → realize stages:
4897
+ *
4898
+ * plan = buildPlan(root, options)
4899
+ * modified = realizeToBuffer(plan, buffer)
4900
+ * overlay = plan.kittyEnabled ? realizeToKitty(plan, buffer) : ""
4901
+ *
4902
+ * Returns a `BackdropResult`:
4903
+ * - `modified` — any buffer cells changed.
4904
+ * - `overlay` — out-of-band ANSI escapes. Non-empty only when the plan is
4905
+ * active AND Kitty graphics are enabled. An active overlay always begins
4906
+ * with a delete-all command so last-frame placements get erased even if
4907
+ * this frame has no wide cells.
4908
+ *
4909
+ * **Inactive frames are silent.** When `plan.active` is false this returns
4910
+ * `EMPTY_RESULT` regardless of `options.kittyGraphics`. Stale scrim
4911
+ * placements from a prior active frame must be cleaned up at the
4912
+ * deactivation EDGE by the caller (e.g., `ag.ts` tracks `_kittyActive`
4913
+ * across frames and emits a one-shot delete-all when active→inactive).
4914
+ * Emitting the delete-all every inactive frame here would spam the
4915
+ * terminal — Modal's default `fade={0}` would push a cleanup string every
4916
+ * frame indefinitely.
4917
+ */
4918
+ function applyBackdrop(root, buffer, options) {
4919
+ const plan = buildPlan(root, options);
4920
+ if (plan.mixedAmounts && process.env.NODE_ENV !== "production") console.warn(`[silvery:backdrop] multiple fade amounts in one frame (using ${plan.amount}); Kitty overlay will use the first observed amount. See plan.ts / assertSingleAmount.`);
4921
+ if (!plan.active) return EMPTY_RESULT;
4199
4922
  return {
4200
- r,
4201
- g,
4202
- b
4923
+ modified: realizeToBuffer(plan, buffer),
4924
+ overlay: plan.kittyEnabled ? realizeToKitty(plan, buffer) : ""
4203
4925
  };
4204
4926
  }
4205
4927
  //#endregion
4206
- //#region \0@oxc-project+runtime@0.122.0/helpers/usingCtx.js
4928
+ //#region \0@oxc-project+runtime@0.126.0/helpers/usingCtx.js
4207
4929
  function _usingCtx() {
4208
4930
  var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
4209
4931
  var n = Error();
@@ -4278,13 +5000,78 @@ function _usingCtx() {
4278
5000
  * ```
4279
5001
  */
4280
5002
  init_buffer();
5003
+ init_src();
4281
5004
  const log = createLogger("silvery:render");
4282
5005
  const baseLog = createLogger("@silvery/ag-react");
5006
+ /**
5007
+ * Walk the ag tree top-down to find the root ThemeProvider's background color.
5008
+ *
5009
+ * ThemeProvider in @silvery/ag-react renders a `<Box theme={merged}>` wrapper.
5010
+ * The render phase pushes/pops this theme via pushContextTheme/popContextTheme,
5011
+ * so the module-level theme stack is empty after the render phase completes.
5012
+ * We walk the tree directly to recover the root bg without requiring the
5013
+ * render phase to be running.
5014
+ *
5015
+ * Returns the first Box node's Sterling `bg-surface-default` (with legacy `bg`
5016
+ * fallback for backdrop-only Themes that pre-date Sterling's flat surface
5017
+ * tokens) found in a depth-first walk, or `null` if no theme node is present
5018
+ * (bare tests without ThemeProvider).
5019
+ */
5020
+ function findRootThemeBg(root) {
5021
+ const props = root.props;
5022
+ if (props.theme) {
5023
+ const theme = props.theme;
5024
+ const sterlingBg = theme["bg-surface-default"];
5025
+ if (typeof sterlingBg === "string") return sterlingBg;
5026
+ const legacyBg = theme["bg"];
5027
+ if (typeof legacyBg === "string") return legacyBg;
5028
+ }
5029
+ for (const child of root.children) {
5030
+ const found = findRootThemeBg(child);
5031
+ if (found !== null) return found;
5032
+ }
5033
+ return null;
5034
+ }
5035
+ /**
5036
+ * Env heuristic: should the backdrop-fade pass emit Kitty graphics overlays?
5037
+ *
5038
+ * This is the MVP gate — a lightweight capability detector used when the
5039
+ * caller doesn't pass `kittyGraphics` explicitly. Matches the Option C design
5040
+ * intent: emit only on modern terminals where Kitty graphics are known to
5041
+ * work (Kitty, Ghostty, WezTerm), NOT inside tmux (DCS passthrough is
5042
+ * unreliable), with an explicit `SILVERY_KITTY_GRAPHICS` override.
5043
+ *
5044
+ * - `SILVERY_KITTY_GRAPHICS=0` → always off
5045
+ * - `SILVERY_KITTY_GRAPHICS=1` → always on (bypasses tmux + term checks)
5046
+ * - `TMUX` env var present → off (unless forced on above)
5047
+ * - `TERM_PROGRAM` in {Ghostty, WezTerm} → on
5048
+ * - `TERM` contains "kitty" → on
5049
+ * - `KITTY_WINDOW_ID` set → on
5050
+ * - otherwise → off
5051
+ *
5052
+ * The long-term plan is to promote this to a `TerminalCaps.kittyGraphics`
5053
+ * consumer. That field exists (see `@silvery/ansi` detectTerminalCaps) but
5054
+ * isn't threaded into the render pipeline yet — tracked as a follow-up.
5055
+ */
5056
+ function isKittyGraphicsEnabledFromEnv() {
5057
+ const env = typeof process !== "undefined" ? process.env : {};
5058
+ const override = env.SILVERY_KITTY_GRAPHICS;
5059
+ if (override === "0" || override === "false") return false;
5060
+ if (override === "1" || override === "true") return true;
5061
+ if (env.TMUX) return false;
5062
+ const program = env.TERM_PROGRAM ?? "";
5063
+ if (program === "ghostty" || program === "Ghostty" || program === "WezTerm") return true;
5064
+ if ((env.TERM ?? "").includes("kitty")) return true;
5065
+ if (env.KITTY_WINDOW_ID) return true;
5066
+ return false;
5067
+ }
4283
5068
  function createAg(root, options) {
4284
5069
  const measurer = options?.measurer;
4285
5070
  const colorLevel = options?.colorLevel ?? "truecolor";
5071
+ const kittyGraphics = options?.kittyGraphics !== void 0 ? options.kittyGraphics : isKittyGraphicsEnabledFromEnv();
4286
5072
  const ctx = measurer ? { measurer } : void 0;
4287
5073
  let _prevBuffer = null;
5074
+ let _kittyActive = false;
4288
5075
  let hasScroll = false;
4289
5076
  let hasSticky = false;
4290
5077
  function doLayout(cols, rows, opts) {
@@ -4411,14 +5198,24 @@ function createAg(root, options) {
4411
5198
  log.debug?.(`content: ${tContent.toFixed(2)}ms`);
4412
5199
  }
4413
5200
  let carryForwardBuffer;
4414
- if (hasBackdropMarkers(root)) {
5201
+ let overlay = "";
5202
+ const backdropActive = hasBackdropMarkers(root);
5203
+ if (backdropActive) {
4415
5204
  carryForwardBuffer = buffer.clone();
4416
5205
  if (!opts?.fresh) _prevBuffer = carryForwardBuffer;
4417
- applyBackdropFade(root, buffer, { colorLevel });
5206
+ const defaultBg = findRootThemeBg(root) ?? void 0;
5207
+ overlay = applyBackdrop(root, buffer, {
5208
+ colorLevel,
5209
+ defaultBg,
5210
+ kittyGraphics
5211
+ }).overlay;
4418
5212
  } else {
4419
5213
  carryForwardBuffer = buffer;
4420
5214
  if (!opts?.fresh) _prevBuffer = buffer;
4421
5215
  }
5216
+ const kittyActiveThisFrame = backdropActive && overlay.length > 0;
5217
+ if (_kittyActive && !kittyActiveThisFrame) overlay = "\x1B7" + kittyDeleteAllScrimPlacements() + "\x1B8";
5218
+ _kittyActive = kittyActiveThisFrame;
4422
5219
  clearDirtyTracking();
4423
5220
  const acc = globalThis.__silvery_bench_phases;
4424
5221
  if (acc) {
@@ -4430,7 +5227,8 @@ function createAg(root, options) {
4430
5227
  buffer,
4431
5228
  carryForwardBuffer,
4432
5229
  prevBuffer,
4433
- tContent
5230
+ tContent,
5231
+ overlay
4434
5232
  };
4435
5233
  }
4436
5234
  function agCreateNode(type, props) {
@@ -4482,7 +5280,8 @@ function createAg(root, options) {
4482
5280
  frame: result.frame,
4483
5281
  buffer: result.buffer,
4484
5282
  carryForwardBuffer: result.carryForwardBuffer,
4485
- prevBuffer: result.prevBuffer
5283
+ prevBuffer: result.prevBuffer,
5284
+ overlay: result.overlay
4486
5285
  };
4487
5286
  },
4488
5287
  resetBuffer() {
@@ -4563,7 +5362,7 @@ init_buffer();
4563
5362
  let engineInitialized = false;
4564
5363
  async function ensureLayoutEngine() {
4565
5364
  if (engineInitialized || isLayoutEngineInitialized()) return;
4566
- const { ensureDefaultLayoutEngine } = await import("./layout-engine--drvrWjD.mjs");
5365
+ const { ensureDefaultLayoutEngine } = await import("./layout-engine-B6Cdz1yZ.mjs");
4567
5366
  await ensureDefaultLayoutEngine();
4568
5367
  engineInitialized = true;
4569
5368
  }
@@ -4710,6 +5509,6 @@ function withActEnvironment(fn) {
4710
5509
  }
4711
5510
  }
4712
5511
  //#endregion
4713
- export { _usingCtx as i, renderStringSync as n, createAg as r, renderString as t };
5512
+ export { setActiveColorLevel as a, _usingCtx as i, renderStringSync as n, createAg as r, renderString as t };
4714
5513
 
4715
- //# sourceMappingURL=render-string-DVfgc8xr.mjs.map
5514
+ //# sourceMappingURL=render-string-X-CxpTdZ.mjs.map