@jsenv/navi 0.29.322 → 0.29.323

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.
@@ -33840,19 +33840,27 @@ const writeInSignal = (signal, value, { history }) => {
33840
33840
  // is open (`?seat=<gameId>` over a list of cards), and closed is the signal
33841
33841
  // holding none of their values — `undefined`, which a state signal reads as
33842
33842
  // its default.
33843
+ //
33844
+ // A popup without a `value` reads anything the signal holds — save `false`,
33845
+ // `null` and `undefined` — as open: a single sheet bound to `?open=<id>` is
33846
+ // open on whichever id the address names, and the id is the popup's content
33847
+ // to read, not its to write. Opening writes `true` only into a signal that
33848
+ // reads closed; closing writes `false` where the signal held `true`, so a
33849
+ // boolean signal keeps its boolean, and `undefined` for anything else.
33843
33850
  const writeOpenedInSignal = (signal, opened, event, popupValue) => {
33844
- if (readOpened(signal.peek(), popupValue) === opened) {
33851
+ const signalValue = signal.peek();
33852
+ if (readOpened(signalValue, popupValue) === opened) {
33845
33853
  // The signal already says so, meaning this open/close IS what it asked
33846
33854
  // for: a back press that took the popup out of the url, the application
33847
33855
  // writing it. Nothing to write back — and nothing to go back to either,
33848
33856
  // since the navigation navBack would undo is the one that asked for this.
33849
33857
  return;
33850
33858
  }
33851
- const closedValue = popupValue === undefined ? false : undefined;
33852
33859
  if (opened) {
33853
33860
  signal.value = popupValue === undefined ? true : popupValue;
33854
33861
  return;
33855
33862
  }
33863
+ const closedValue = readClosedValue(signalValue);
33856
33864
  if (
33857
33865
  signal.options?.getHistory?.() === "push" &&
33858
33866
  // Nothing of this document behind: the popup was opened by the url itself
@@ -33873,10 +33881,15 @@ const writeOpenedInSignal = (signal, opened, event, popupValue) => {
33873
33881
  };
33874
33882
  const readOpened = (signalValue, popupValue) => {
33875
33883
  if (popupValue === undefined) {
33876
- return signalValue;
33884
+ return (
33885
+ signalValue !== undefined && signalValue !== null && signalValue !== false
33886
+ );
33877
33887
  }
33878
33888
  return signalValue === popupValue;
33879
33889
  };
33890
+ const readClosedValue = (openSignalValue) => {
33891
+ return openSignalValue === true ? false : undefined;
33892
+ };
33880
33893
 
33881
33894
  /**
33882
33895
  * Keeps an open controller in sync with where the caller says the popup should
@@ -33918,6 +33931,13 @@ const useOpenPropsEffectOnOpenController = (
33918
33931
  : signal
33919
33932
  ? readOpened(signal.value, value)
33920
33933
  : props.open;
33934
+ // What the signal holds while it reads open — `true`, the popup's `value`,
33935
+ // or the id a single sheet is open on. Put back when a close is refused,
33936
+ // since by then the signal has been written closed.
33937
+ const openSignalValueRef = useRef(undefined);
33938
+ if (signal && open) {
33939
+ openSignalValueRef.current = signal.value;
33940
+ }
33921
33941
  // Assigned on every render, like openEffect, so it always closes over the
33922
33942
  // latest prop: a popup that opens or closes on its own (Escape, backdrop, a
33923
33943
  // --navi-close command) writes what happened where the caller keeps it, so
@@ -33998,10 +34018,11 @@ const useOpenPropsEffectOnOpenController = (
33998
34018
  // request already reads in the signal, and with a `value` what the
33999
34019
  // signal holds may be another popup's, which the write would erase.
34000
34020
  if (signal && openController.opened !== open) {
34001
- const opened = openController.opened;
34002
34021
  writeInSignal(
34003
34022
  signal,
34004
- value === undefined ? opened : opened ? value : undefined,
34023
+ openController.opened
34024
+ ? openSignalValueRef.current
34025
+ : readClosedValue(signal.peek()),
34005
34026
  { history: "replace" },
34006
34027
  );
34007
34028
  }
@@ -36486,6 +36507,46 @@ const acknowledgeOwnAction = (controller) => {
36486
36507
  controller.acknowledgeUIState();
36487
36508
  };
36488
36509
 
36510
+ // What a group of checkboxes or radios is worth is a claim about values, and
36511
+ // a value whose box is not there cannot be contradicted by that box: a row
36512
+ // scrolled out of a virtualized list, a filtered-out option, a whole page being
36513
+ // parked while a route action reruns — its children leave one at a time, and
36514
+ // each partial reading would otherwise be taken for the answer and written
36515
+ // into whatever the group is bound to (an array signal emptied down to `[]`
36516
+ // by its own unmount). `kept` is what the group holds as it is asked; a value
36517
+ // in it stays until a DRAWN child carrying that value says otherwise.
36518
+ //
36519
+ // Read off the group rather than remembered here on purpose: a value put ON
36520
+ // the group (a `value` prop, a signal, a reopened popup) replaces the whole
36521
+ // selection, and a private memory would go on holding the rows it could not
36522
+ // see being unselected.
36523
+ const keptAndCheckedChildUIStates = (children, fallbackState, kept) => {
36524
+ const drawnValues = new Set(children.map((child) => child.props.value));
36525
+ const values = Array.isArray(kept)
36526
+ ? kept.filter((value) => !drawnValues.has(value))
36527
+ : [];
36528
+ for (const child of children) {
36529
+ if (child.uiState !== undefined) {
36530
+ values.push(child.uiState);
36531
+ }
36532
+ }
36533
+ return values.length === 0 ? undefined : values;
36534
+ };
36535
+ const keptOrFirstDefinedChildUIState = (children, fallbackState, kept) => {
36536
+ for (const child of children) {
36537
+ if (child.uiState !== undefined) {
36538
+ return child.uiState;
36539
+ }
36540
+ }
36541
+ // No drawn child claims it. If the child that held it IS drawn, it was really
36542
+ // deselected; if it is not, the group keeps what it holds.
36543
+ const keptIsDrawn = children.some((child) => child.props.value === kept);
36544
+ if (keptIsDrawn) {
36545
+ return undefined;
36546
+ }
36547
+ return kept;
36548
+ };
36549
+
36489
36550
  // Default aggregate/distribute implementations keyed by controlType or stateType.
36490
36551
  // Looked up in useUIGroupStateController to fill in omitted aggregateChildStates /
36491
36552
  // distributeChildUIState. If neither a default nor an explicit impl is found for a
@@ -36494,7 +36555,7 @@ const GROUP_DEFAULTS = {
36494
36555
  radio_group: {
36495
36556
  childControlFilter: (child) =>
36496
36557
  child.controlType === "input" && child.controlHostProps?.type === "radio",
36497
- aggregateChildStates: firstDefinedChildUIState,
36558
+ aggregateChildStates: keptOrFirstDefinedChildUIState,
36498
36559
  distributeChildUIState: (newUIState, childUIStateController) => {
36499
36560
  const childSelected = childUIStateController.props.value === newUIState;
36500
36561
  if (childSelected) {
@@ -36507,16 +36568,7 @@ const GROUP_DEFAULTS = {
36507
36568
  childControlFilter: (child) =>
36508
36569
  child.controlType === "input" &&
36509
36570
  child.controlHostProps?.type === "checkbox",
36510
- aggregateChildStates: (children) => {
36511
- const values = [];
36512
- for (const child of children) {
36513
- const childUIState = child.uiState;
36514
- if (childUIState !== undefined) {
36515
- values.push(childUIState);
36516
- }
36517
- }
36518
- return values.length === 0 ? undefined : values;
36519
- },
36571
+ aggregateChildStates: keptAndCheckedChildUIStates,
36520
36572
  distributeChildUIState: (newUIState, childUIStateController) => {
36521
36573
  const childSelected =
36522
36574
  Array.isArray(newUIState) &&
@@ -59418,14 +59470,15 @@ const css$E = /* css */`
59418
59470
  * the focus only leaves it for something that asked by name (`autoFocus` on
59419
59471
  * that element, which outranks whatever the dialog says).
59420
59472
  * @param {boolean} [props.open] - Controlled open state.
59421
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
59473
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
59422
59474
  * state said the way every navi control says it: the dialog opens and closes
59423
59475
  * to match the signal, and writes into it whenever it opens or closes on its
59424
59476
  * own (Escape, backdrop, a --navi-close command) — one binding to both drive
59425
59477
  * the dialog and know where it is, and the state stays where the app put it.
59426
59478
  * Excludes `open`; `onOpen`/`onClose` still fire. A signal holding `true` at
59427
59479
  * mount behaves like `defaultOpen`: the dialog was already open, no entrance
59428
- * plays.
59480
+ * plays. A signal holding an id (one dialog showing whichever card
59481
+ * the address names) reads as open, and the dialog leaves the id alone.
59429
59482
  * @param {any} [props.value] - What `signal` holds while THIS dialog is
59430
59483
  * open, for several of them sharing one signal that says which is open
59431
59484
  * (`?seat=<gameId>` over a list of cards): open while `signal.value` is this
@@ -61033,14 +61086,15 @@ const css$D = /* css */`
61033
61086
  * the focus only leaves it for something that asked by name (`autoFocus` on
61034
61087
  * that element, which outranks whatever the popover says).
61035
61088
  * @param {boolean} [props.open] - Controlled open state.
61036
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
61089
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
61037
61090
  * state said the way every navi control says it: the popover opens and closes
61038
61091
  * to match the signal, and writes into it whenever it opens or closes on its
61039
61092
  * own (Escape, light dismiss, a --navi-close command) — one binding to both
61040
61093
  * drive the popover and know where it is, and the state stays where the app
61041
61094
  * put it. Excludes `open`; `onOpen`/`onClose` still fire. A signal holding
61042
61095
  * `true` at mount behaves like `defaultOpen`: the popover was already open,
61043
- * no entrance plays.
61096
+ * no entrance plays. A signal holding an id (one popover showing whichever card
61097
+ * the address names) reads as open, and the popover leaves the id alone.
61044
61098
  * @param {any} [props.value] - What `signal` holds while THIS popover is
61045
61099
  * open, for several of them sharing one signal that says which is open
61046
61100
  * (`?seat=<gameId>` over a list of cards): open while `signal.value` is this
@@ -64688,45 +64742,6 @@ const ListSelectable = props => {
64688
64742
  focusGroupDirection,
64689
64743
  focusGroupWrap
64690
64744
  } = props;
64691
- // `kept` is what the list holds as it is asked, which is not the same as what
64692
- // its rows say: a list draws the rows it needs and no more, so the selected
64693
- // one may be scrolled out of the window or filtered out of the view, and a
64694
- // row that is not there cannot say it is not selected. Reading it off the
64695
- // group rather than remembering it here is what makes a value put ON the list
64696
- // (a `value` prop, a signal, a reopened popup) replace the whole selection —
64697
- // a private memory of its own would go on holding the rows it could not see
64698
- // being unselected.
64699
- //
64700
- // `fallbackState` is the empty of the shape the list declared below
64701
- // (`stateType`): `[]` for a multiple list, nothing for a single one. Taking
64702
- // it is what lets an emptied list say "empty" — `undefined` is the word for
64703
- // "unset", and a bound stateSignal reads that as "nothing decided here, go
64704
- // back to the default" (see docs/control_value.md), which is how a list
64705
- // emptied down to its last row puts that row back on reload.
64706
- const aggregateChildStates = (children, fallbackState, kept) => {
64707
- if (multiple) {
64708
- const drawnValues = new Set(children.map(child => child.props.value));
64709
- const stillSelected = Array.isArray(kept) ? kept.filter(value => !drawnValues.has(value)) : [];
64710
- for (const child of children) {
64711
- if (child.uiState !== undefined) {
64712
- stillSelected.push(child.uiState);
64713
- }
64714
- }
64715
- return stillSelected.length === 0 ? fallbackState : stillSelected;
64716
- }
64717
- for (const child of children) {
64718
- if (child.uiState !== undefined) {
64719
- return child.uiState;
64720
- }
64721
- }
64722
- // No drawn row claims it. If the row that held it IS drawn, it was really
64723
- // deselected; if it is not, the list keeps what it holds.
64724
- const keptIsDrawn = children.some(child => child.props.value === kept);
64725
- if (keptIsDrawn) {
64726
- return undefined;
64727
- }
64728
- return kept;
64729
- };
64730
64745
  const [listControlRootProps, listControlProps, childrenWrapperProps] = useControlgroupProps({
64731
64746
  // The defaults of the group this IS (see CheckboxGroup / RadioGroup):
64732
64747
  // what a selectable list shows is a claim about what was accepted, so
@@ -64738,9 +64753,15 @@ const ListSelectable = props => {
64738
64753
  resetOnError: true,
64739
64754
  ...props
64740
64755
  }, {
64756
+ // A list draws the rows it needs and no more, so a selected row may be
64757
+ // scrolled out of the window or filtered out of the view: the default
64758
+ // aggregate of these two groups keeps a value whose row is not drawn
64759
+ // (see GROUP_DEFAULTS in ui_state_controller.js). `stateType` is what
64760
+ // an emptied multiple list says — `[]`, not `undefined`, which a bound
64761
+ // stateSignal reads as "nothing decided here, go back to the default"
64762
+ // (see docs/control_value.md).
64741
64763
  stateType: multiple ? "array" : "",
64742
- controlType: multiple ? "checkbox_group" : "radio_group",
64743
- aggregateChildStates
64764
+ controlType: multiple ? "checkbox_group" : "radio_group"
64744
64765
  });
64745
64766
  const uiGroupStateController = getUIStateControllerById(listControlProps.id);
64746
64767
  useFocusGroup(ref, {
@@ -65942,6 +65963,7 @@ const ListUI = props => {
65942
65963
  ref,
65943
65964
  tracker,
65944
65965
  renderBudget,
65966
+ renderBudgetSteady: renderBudgetAfterPaint,
65945
65967
  virtualItemSize,
65946
65968
  virtual,
65947
65969
  scrolled,
@@ -66269,6 +66291,7 @@ const useListScrollSync = ({
66269
66291
  ref,
66270
66292
  tracker,
66271
66293
  renderBudget,
66294
+ renderBudgetSteady,
66272
66295
  virtualItemSize,
66273
66296
  virtual,
66274
66297
  scrolled,
@@ -67019,31 +67042,36 @@ const useListScrollSync = ({
67019
67042
  }
67020
67043
  });
67021
67044
 
67022
- // Scroll listener — slides the window as the user scrolls.
67023
- useLayoutEffect(() => {
67024
- const listContainerEl = ref.current;
67025
- if (!listContainerEl) {
67026
- return undefined;
67045
+ // The window the visible band asks for, decided from geometry: the band's
67046
+ // two edges in row units, from where the scroller's viewport cuts the list
67047
+ // and the row size. What has to be drawn is everything between the edges,
67048
+ // plus what the scroll is about to bring — so the window is judged on the
67049
+ // rows it keeps AHEAD of the band, in the direction the user goes, and moves
67050
+ // once those fall under half a screen. Re-framed, it puts three quarters of
67051
+ // its spare rows ahead and one quarter behind: a window centred on the band
67052
+ // is due to move again as soon as the band has crossed the few rows it kept
67053
+ // ahead, and each move is rows drawn while the user waits for them.
67054
+ // Without a row size there is no geometry to reason on; the row under a
67055
+ // probe stands in for the band, framed the old way.
67056
+ const scrollDirectionRef = useRef(1);
67057
+ const windowSlidRef = useRef(false);
67058
+ useRef(false);
67059
+ const evaluateWindow = reason => {
67060
+ const total = virtual.totalSignal.peek();
67061
+ if (total <= renderBudget) {
67062
+ return;
67027
67063
  }
67028
67064
  const scrollerEl = getScroller();
67029
67065
  const listEl = getListEl();
67030
- const onScroll = () => {
67031
- updateCurrentScroll();
67032
- // Where the user is now is where things must be held from now on.
67033
- anchorRef.current = null;
67034
- if (scrolledByListRef.current) {
67035
- // The window stays where it is — the position it would be re-derived
67036
- // from was chosen to keep the rows still — but where the list is has
67037
- // genuinely changed, and whoever keeps that position must hear it.
67038
- scrolledByListRef.current = false;
67039
- reportPosition();
67040
- return;
67041
- }
67042
- reportPosition();
67043
- const total = virtual.totalSignal.peek();
67044
- if (total <= renderBudget) {
67045
- return;
67046
- }
67066
+ if (!scrollerEl || !listEl) {
67067
+ return;
67068
+ }
67069
+ const {
67070
+ start,
67071
+ end
67072
+ } = renderWindowRef.current;
67073
+ const virtualItemSize = virtualItemSizeSignal.peek();
67074
+ if (virtualItemSize === 0) {
67047
67075
  const scrollInfo = getScrollInfo({
67048
67076
  scrollValues: {
67049
67077
  left: scrollerEl.scrollLeft,
@@ -67060,17 +67088,8 @@ const useListScrollSync = ({
67060
67088
  return;
67061
67089
  }
67062
67090
  const {
67063
- index,
67064
- reason
67091
+ index
67065
67092
  } = scrollInfo;
67066
- // Recentering on every row crossed would rebuild the whole window a few
67067
- // times a second, and a window rebuilt is every row of it rendered
67068
- // again. It only moves once what is on screen comes near one of its
67069
- // edges — until then, it already holds what has to be drawn.
67070
- const {
67071
- start,
67072
- end
67073
- } = renderWindowRef.current;
67074
67093
  const margin = Math.floor(renderBudget / 4);
67075
67094
  const farFromStart = index - start >= margin || start === 0;
67076
67095
  const farFromEnd = end - index > margin || end === total;
@@ -67078,12 +67097,113 @@ const useListScrollSync = ({
67078
67097
  return;
67079
67098
  }
67080
67099
  const half = Math.floor(renderBudget / 2);
67081
- let newStart = Math.max(0, index - half);
67082
- let newEnd = Math.min(total, newStart + renderBudget);
67083
- if (newEnd === total) {
67084
- newStart = Math.max(0, total - renderBudget);
67100
+ let newStart = index - half < 0 ? 0 : index - half;
67101
+ let newEnd = newStart + renderBudget;
67102
+ if (newEnd > total) {
67103
+ newEnd = total;
67104
+ newStart = total - renderBudget < 0 ? 0 : total - renderBudget;
67105
+ }
67106
+ windowSlidRef.current = true;
67107
+ updateRenderWindow(newStart, newEnd, `${reason}: ${scrollInfo.reason}`);
67108
+ return;
67109
+ }
67110
+ const viewportRect = getScrollerViewportRect(scrollerEl);
67111
+ const listRect = listEl.getBoundingClientRect();
67112
+ const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
67113
+ const viewportTo = horizontal ? viewportRect.right : viewportRect.bottom;
67114
+ const listFrom = horizontal ? listRect.left : listRect.top;
67115
+ let bandStart = Math.floor((viewportFrom - listFrom) / virtualItemSize);
67116
+ // Exclusive, like the window's end.
67117
+ let bandEnd = Math.ceil((viewportTo - listFrom) / virtualItemSize);
67118
+ if (bandStart < 0) {
67119
+ bandStart = 0;
67120
+ }
67121
+ if (bandEnd > total) {
67122
+ bandEnd = total;
67123
+ }
67124
+ if (bandEnd <= bandStart) {
67125
+ return;
67126
+ }
67127
+ const visibleCount = bandEnd - bandStart;
67128
+ const spare = renderBudget - visibleCount;
67129
+ const behind = spare > 0 ? Math.floor(spare / 4) : 0;
67130
+ const ahead = spare > 0 ? spare - behind : 0;
67131
+ const halfScreen = Math.ceil(visibleCount / 2);
67132
+ const aheadNeeded = halfScreen < ahead / 2 ? halfScreen : Math.floor(ahead / 2);
67133
+ const behindNeeded = halfScreen < behind / 2 ? halfScreen : Math.floor(behind / 2);
67134
+ const forward = scrollDirectionRef.current > 0;
67135
+ const rowsAheadOfBand = forward ? end - bandEnd : bandStart - start;
67136
+ const rowsBehindBand = forward ? bandStart - start : end - bandEnd;
67137
+ const atEdgeAhead = forward ? end === total : start === 0;
67138
+ const atEdgeBehind = forward ? start === 0 : end === total;
67139
+ const coversBand = start <= bandStart && end >= bandEnd;
67140
+ const coversAhead = atEdgeAhead || rowsAheadOfBand >= aheadNeeded;
67141
+ const coversBehind = atEdgeBehind || rowsBehindBand >= behindNeeded;
67142
+ if (coversBand && coversAhead && coversBehind) {
67143
+ return;
67144
+ }
67145
+ let newStart = forward ? bandStart - behind : bandEnd + behind - renderBudget;
67146
+ if (newStart < 0) {
67147
+ newStart = 0;
67148
+ }
67149
+ let newEnd = newStart + renderBudget;
67150
+ if (newEnd > total) {
67151
+ newEnd = total;
67152
+ newStart = total - renderBudget < 0 ? 0 : total - renderBudget;
67153
+ }
67154
+ windowSlidRef.current = true;
67155
+ updateRenderWindow(newStart, newEnd);
67156
+ };
67157
+ // A slide is judged again once its rows are laid out: the scroll had moved
67158
+ // on while the rows were being drawn, or the anchoring of this very commit
67159
+ // moved it, and a window that stops short of the screen's edge with no
67160
+ // scroll event to come would stay short for good. On the next frame, after
67161
+ // the layout this commit's anchoring settles on.
67162
+ useLayoutEffect(() => {
67163
+ if (!windowSlidRef.current) {
67164
+ return undefined;
67165
+ }
67166
+ windowSlidRef.current = false;
67167
+ const frameId = requestAnimationFrame(() => {
67168
+ evaluateWindow("after slide");
67169
+ });
67170
+ return () => {
67171
+ cancelAnimationFrame(frameId);
67172
+ };
67173
+ }, [renderWindow]);
67174
+
67175
+ // Scroll listener — slides the window as the user scrolls.
67176
+ useLayoutEffect(() => {
67177
+ const listContainerEl = ref.current;
67178
+ if (!listContainerEl) {
67179
+ return undefined;
67180
+ }
67181
+ const scrollerEl = getScroller();
67182
+ const onScroll = () => {
67183
+ // Which way the user is going, read before the position is stored: what
67184
+ // the window keeps ahead is ahead of this.
67185
+ const previousScroll = currentScrollRef.current;
67186
+ if (previousScroll) {
67187
+ const delta = horizontal ? scrollerEl.scrollLeft - previousScroll.left : scrollerEl.scrollTop - previousScroll.top;
67188
+ if (delta > 0) {
67189
+ scrollDirectionRef.current = 1;
67190
+ } else if (delta < 0) {
67191
+ scrollDirectionRef.current = -1;
67192
+ }
67193
+ }
67194
+ updateCurrentScroll();
67195
+ // Where the user is now is where things must be held from now on.
67196
+ anchorRef.current = null;
67197
+ if (scrolledByListRef.current) {
67198
+ // The window stays where it is — the position it would be re-derived
67199
+ // from was chosen to keep the rows still — but where the list is has
67200
+ // genuinely changed, and whoever keeps that position must hear it.
67201
+ scrolledByListRef.current = false;
67202
+ reportPosition();
67203
+ return;
67085
67204
  }
67086
- updateRenderWindow(newStart, newEnd);
67205
+ reportPosition();
67206
+ evaluateWindow("scroll");
67087
67207
  };
67088
67208
  // A page-level scroller does not emit "scroll" on the element itself
67089
67209
  // (document.scrollingElement); the document does.
@@ -70017,8 +70137,11 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
70017
70137
  * window, which slides as the user scrolls while fillers hold the room of
70018
70138
  * the rows outside it. Rows declared one by one as `<List.Item>` children
70019
70139
  * are all drawn, whatever this says — a list with more than a few dozen rows
70020
- * gives them to a run (see docs/scroll.md, "Many rows"). Below 30 the list
70021
- * warns: a window shorter than a tall screen shows blank fillers.
70140
+ * gives them to a run (see docs/scroll.md, "Many rows"). The window keeps
70141
+ * most of its spare rows ahead of the scroll, and moves once those fall
70142
+ * under half a screen; it has to be larger than what the scroller shows at
70143
+ * once, with room for that lookahead — the list warns below 30, and when a
70144
+ * budget leaves fewer than two rows beyond the screen.
70022
70145
  *
70023
70146
  * `{ initial, after }` for a list drawn in the click that opens it (a popup):
70024
70147
  * `initial` rows in the commit the browser paints first — about what a phone
@@ -84052,7 +84175,7 @@ const css = /* css */`.navi_side_panel {
84052
84175
  * @param {object} props
84053
84176
  * @param {boolean} [props.open] - Controlled open state, forwarded as-is to
84054
84177
  * `Popup`'s own `open`.
84055
- * @param {import("@preact/signals").Signal<boolean>} [props.signal] - The open
84178
+ * @param {import("@preact/signals").Signal<any>} [props.signal] - The open
84056
84179
  * state said the way every navi control says it: the panel opens and closes
84057
84180
  * to match the signal, and writes into it whenever it opens or closes on its
84058
84181
  * own (Escape, swipe, a --navi-close command) — one binding to both drive