@gjermundgaraba-designs/pixel-paper 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba-designs/pixel-paper",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -5630,6 +5630,54 @@ dialog.veil:focus {
5630
5630
  opacity: var(--fade-dragged);
5631
5631
  }
5632
5632
 
5633
+ /* under tabs, the reel is its tab list over the scroller, which takes the height left. The
5634
+ list sits a gutter in from the reel's edges, as the windows do */
5635
+ .reel.tabbed {
5636
+ display: flex;
5637
+ flex-direction: column;
5638
+ }
5639
+
5640
+ .reel.tabbed > .reel-view {
5641
+ flex: 1 1 0;
5642
+ height: auto;
5643
+ min-height: 0;
5644
+ }
5645
+
5646
+ .reel-tabs {
5647
+ flex: none;
5648
+ max-width: calc(100% - 2 * var(--reel-gutter));
5649
+ margin: var(--reel-gutter) var(--reel-gutter) 0;
5650
+ }
5651
+
5652
+ /* a tab holds a kind glyph before its words, and gives up width before the list overflows:
5653
+ its words end in an ellipsis */
5654
+ .reel-tabs button {
5655
+ display: inline-flex;
5656
+ align-items: center;
5657
+ gap: 6px;
5658
+ min-width: 0;
5659
+ }
5660
+
5661
+ .reel-tab-words {
5662
+ min-width: 0;
5663
+ overflow: hidden;
5664
+ text-overflow: ellipsis;
5665
+ }
5666
+
5667
+ /* the one slot under tabs: the view less a gutter at every edge, every pane pinned to it */
5668
+ .reel-stage {
5669
+ flex: 1 1 0;
5670
+ height: calc(100cqh - 2 * var(--reel-gutter));
5671
+ min-height: 0;
5672
+ margin-inline: var(--reel-gutter);
5673
+ }
5674
+
5675
+ /* a pane behind the selected tab keeps its place and its size, so what sizes itself by its
5676
+ box (a terminal) is not resized as the tabs change; it is only not drawn */
5677
+ .reel-pane.behind {
5678
+ visibility: hidden;
5679
+ }
5680
+
5633
5681
  /* only the grips lift: a pane's text selects and its terminal takes the pointer */
5634
5682
  [data-reel-grip] {
5635
5683
  cursor: grab;
package/src/reel/index.ts CHANGED
@@ -2,7 +2,9 @@ export { Layout, OutMessage, Pane, apply } from "./layout.ts";
2
2
  export {
3
3
  Action,
4
4
  AnnounceReel,
5
+ Arrangement,
5
6
  FocusPane,
7
+ FocusTab,
6
8
  Message,
7
9
  Model,
8
10
  RestoreFocus,
package/src/reel/reel.ts CHANGED
@@ -86,6 +86,7 @@ const COPY = {
86
86
  widthLabel: "Width of column {column}",
87
87
  heightLabel: "Height of {pane}",
88
88
  focused: "{pane}, {where}.",
89
+ focusedTab: "{pane}, tab {tab} of {tabs}.",
89
90
  moved: "{subject} moved to {where}.",
90
91
  widened: "Column {column} takes {width}.",
91
92
  pickedUp: "Picked up {subject}.",
@@ -143,6 +144,12 @@ export const Model = Schema.Struct({
143
144
  });
144
145
  export type Model = typeof Model.Type;
145
146
 
147
+ /** How a reel shows its panes: side by side in their columns (`Strip`), or one at a time at
148
+ * the reel's full size under a tab per pane (`Tabs`). The application holds it and passes it
149
+ * in; the layout is the same in both. */
150
+ export const Arrangement = Schema.Literals(["Strip", "Tabs"]);
151
+ export type Arrangement = typeof Arrangement.Type;
152
+
146
153
  // MESSAGE
147
154
 
148
155
  const PointerAt = Schema.Struct({
@@ -169,6 +176,8 @@ export const Message = defineMessageUnion({
169
176
  LeftGrip: {},
170
177
  PressedEscape: {},
171
178
  FocusedPane: { paneId: Schema.String },
179
+ PressedTab: { paneId: Schema.String },
180
+ SteppedTab: { paneId: Schema.String },
172
181
  PressedSplitter: {
173
182
  splitter: Splitter,
174
183
  pointerId: Schema.Number,
@@ -181,6 +190,7 @@ export const Message = defineMessageUnion({
181
190
  PressedSplitterKey: { splitter: Splitter, share: Schema.Number },
182
191
  CompletedAnnounceReel: {},
183
192
  CompletedFocusPane: {},
193
+ CompletedFocusTab: {},
184
194
  CompletedRestoreFocus: {},
185
195
  CompletedScrollIntoView: {},
186
196
  CompletedSizeLiftedPane: {},
@@ -238,6 +248,8 @@ const paneHook = (model: Model, paneId: string): string => `${model.id}/pane/${p
238
248
 
239
249
  const columnHook = (model: Model, columnId: string): string => `${model.id}/column/${columnId}`;
240
250
 
251
+ const tabHook = (model: Model, paneId: string): string => `${model.id}/tab/${paneId}`;
252
+
241
253
  const scrollerOf = (reelHook: string): Option.Option<HTMLElement> =>
242
254
  Option.fromNullishOr(
243
255
  document.querySelector<HTMLElement>(`[data-reel="${CSS.escape(reelHook)}"] > .reel-view`),
@@ -286,6 +298,21 @@ export const FocusPane = Command.define("FocusPane", {
286
298
  }),
287
299
  });
288
300
 
301
+ /** Focuses the tab `tabHook` names, for a tab the arrows step to: the keyboard stays in the
302
+ * tab list until the reader goes into the pane. */
303
+ export const FocusTab = Command.define("FocusTab", {
304
+ args: { tabHook: Schema.String },
305
+ messages: [Message.CompletedFocusTab],
306
+ execute: ({ tabHook: hook }) =>
307
+ Effect.gen(function* () {
308
+ yield* Render.afterCommit;
309
+ yield* Effect.sync(() => {
310
+ document.querySelector<HTMLElement>(`[data-reel-tab="${CSS.escape(hook)}"]`)?.focus();
311
+ });
312
+ return Message.CompletedFocusTab();
313
+ }),
314
+ });
315
+
289
316
  /** Focuses the pane `paneHook` names only where focus was lost (it left with the pane that
290
317
  * held it), so focus the reader put somewhere else stays there. */
291
318
  export const RestoreFocus = Command.define("RestoreFocus", {
@@ -327,8 +354,9 @@ export const ScrollIntoView = Command.define("ScrollIntoView", {
327
354
  // UPDATE
328
355
 
329
356
  /** What the reel reads from its parent as each Message arrives: the layout as the
330
- * application holds it now. */
331
- export type UpdateContext = Readonly<{ layout: Layout }>;
357
+ * application holds it now, and how the view shows it (`Strip` when not given), which
358
+ * `perform` reads. */
359
+ export type UpdateContext = Readonly<{ layout: Layout; arrangement?: Arrangement }>;
332
360
 
333
361
  type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>;
334
362
 
@@ -497,6 +525,19 @@ const resizeSplitter = (
497
525
  },
498
526
  });
499
527
 
528
+ /** Shows the pane `paneId` under its tab and moves the keyboard as `focus` says: into the
529
+ * pane for a press, onto the tab for an arrow. */
530
+ const selectTab = (
531
+ model: Model,
532
+ layout: Layout,
533
+ paneId: string,
534
+ focus: Command.Command<Message>,
535
+ ): UpdateReturn =>
536
+ Option.match(placeOf(layout, paneId), {
537
+ onNone: () => ({ model }),
538
+ onSome: (place) => ({ model: focusOn(model, place), commands: [focus] }),
539
+ });
540
+
500
541
  /** Processes a reel Message against the layout as the application holds it now, and returns
501
542
  * the next Model, Commands and OutMessage. */
502
543
  export const update = (model: Model, message: Message, context: UpdateContext): UpdateReturn => {
@@ -542,6 +583,10 @@ export const update = (model: Model, message: Message, context: UpdateContext):
542
583
  Option.isSome(model.maybeResizing)
543
584
  ? { model: withResizing(model, Option.none()) }
544
585
  : whileHeld(model, (held) => cancel(model, layout, held)),
586
+ PressedTab: ({ paneId }) =>
587
+ selectTab(model, layout, paneId, FocusPane({ paneHook: paneHook(model, paneId) })),
588
+ SteppedTab: ({ paneId }) =>
589
+ selectTab(model, layout, paneId, FocusTab({ tabHook: tabHook(model, paneId) })),
545
590
  FocusedPane: ({ paneId }) =>
546
591
  Option.match(
547
592
  Option.filter(
@@ -578,6 +623,7 @@ export const update = (model: Model, message: Message, context: UpdateContext):
578
623
  }),
579
624
  CompletedAnnounceReel: () => ({ model }),
580
625
  CompletedFocusPane: () => ({ model }),
626
+ CompletedFocusTab: () => ({ model }),
581
627
  CompletedRestoreFocus: () => ({ model }),
582
628
  CompletedScrollIntoView: () => ({ model }),
583
629
  CompletedSizeLiftedPane: () => ({ model }),
@@ -700,6 +746,51 @@ const requestStep = (
700
746
  },
701
747
  );
702
748
 
749
+ /** The panes in the order their tabs print: column by column, and down each column. */
750
+ const readingOrder = (layout: Layout): ReadonlyArray<Pane> =>
751
+ Array.flatMap(layout, (column) => column.panes);
752
+
753
+ /** How far each focus key steps through the tabs: left and up to the one before, right and
754
+ * down to the one after. */
755
+ const TAB_STEPS: Readonly<Partial<Record<Action, number>>> = {
756
+ FocusLeft: -1,
757
+ FocusUp: -1,
758
+ FocusRight: 1,
759
+ FocusDown: 1,
760
+ };
761
+
762
+ /** `perform` while the panes print as tabs: a focus key moves to the tab beside the focused
763
+ * pane's, past neither end; there is no column to move or size. */
764
+ const performTabbed = (model: Model, action: Action, layout: Layout): UpdateReturn => {
765
+ const panes = readingOrder(layout);
766
+ const maybeNext = Option.flatMap(
767
+ Option.all({
768
+ step: Option.fromUndefinedOr(TAB_STEPS[action]),
769
+ index: Option.flatMap(focusedPaneId(model, layout), (paneId) =>
770
+ Array.findFirstIndex(panes, (pane) => pane.id === paneId),
771
+ ),
772
+ }),
773
+ ({ step, index }) =>
774
+ Option.flatMap(Array.get(panes, index + step), (pane) =>
775
+ Option.map(placeOf(layout, pane.id), (place) => ({ place, tab: index + step + 1 })),
776
+ ),
777
+ );
778
+
779
+ return Option.match(
780
+ Option.filter(maybeNext, () => !isHolding(model)),
781
+ {
782
+ onNone: () => ({ model }),
783
+ onSome: ({ place, tab }) => ({
784
+ model: focusOn(model, place),
785
+ commands: [
786
+ FocusPane({ paneHook: paneHook(model, place.pane.id) }),
787
+ announce(fill(COPY.focusedTab, { pane: place.pane.label, tab, tabs: panes.length })),
788
+ ],
789
+ }),
790
+ },
791
+ );
792
+ };
793
+
703
794
  const columnIdAt = (layout: Layout, index: number): Option.Option<string> =>
704
795
  Option.map(Array.get(layout, index), ({ id }) => id);
705
796
 
@@ -708,10 +799,15 @@ const columnIdAt = (layout: Layout, index: number): Option.Option<string> =>
708
799
  * layout as the application holds it: focus moves focus the pane and scroll the reel to it;
709
800
  * moves report their request as the OutMessage, which the application applies, with focus
710
801
  * kept on the pane. Each is said through the live region. Nothing happens while something is
711
- * held, or where there is nowhere to go.
802
+ * held, or where there is nowhere to go. Under tabs, only the focus keys act, stepping from tab
803
+ * to tab.
712
804
  */
713
805
  export const perform = (model: Model, action: Action, context: UpdateContext): UpdateReturn => {
714
- const { layout } = context;
806
+ const { layout, arrangement = "Strip" } = context;
807
+
808
+ if (arrangement === "Tabs") {
809
+ return performTabbed(model, action, layout);
810
+ }
715
811
 
716
812
  return Option.match(
717
813
  Option.filter(focusedPlace(model, layout), () => !isHolding(model)),
@@ -1157,11 +1253,12 @@ export const SizeLiftedPane = Mount.define("SizeLiftedPane", {
1157
1253
 
1158
1254
  // VIEW
1159
1255
 
1160
- /** What the reel hands `toPaneView` for each pane: put `grip` in the Window's `lead` and give
1256
+ /** What the reel hands `toPaneView` for each pane: put `lead` in the Window's `lead` and give
1161
1257
  * it `id`. */
1162
1258
  export type PaneRender = Readonly<{
1163
- /** The pane's grip, for its title bar's `lead`: a quiet glyph button named "Move <label>". */
1164
- grip: Html;
1259
+ /** The title bar's `lead`: the pane's grip, a quiet glyph button named "Move <label>", or
1260
+ * nothing under tabs, where a pane does not move. */
1261
+ lead: ReadonlyArray<Html>;
1165
1262
  /** The window's id, unique in the document. */
1166
1263
  id: string;
1167
1264
  }>;
@@ -1173,15 +1270,21 @@ export type ViewInputs = Readonly<{
1173
1270
  layout: Layout;
1174
1271
  /** Names the reel for screen readers. */
1175
1272
  label: string;
1176
- /** Renders one pane, a `Window.view` with `render.grip` as its `lead` and `render.id` as its
1273
+ /** Renders one pane, a `Window.view` with `render.lead` as its `lead` and `render.id` as its
1177
1274
  * `id`. Mark the control focus should land on in it (a terminal's input) with
1178
1275
  * `data-reel-focus`. */
1179
1276
  toPaneView: (pane: Pane, render: PaneRender) => Html;
1277
+ /** How the panes print: in their columns (`Strip`, when not given), or one at a time under
1278
+ * their tabs (`Tabs`). Pass the same to `perform`. */
1279
+ arrangement?: Arrangement;
1280
+ /** What a pane's tab prints before its label: a kind glyph. Nothing when not given. */
1281
+ toTabLeadView?: (pane: Pane) => ReadonlyArray<Html>;
1180
1282
  }>;
1181
1283
 
1182
1284
  type ReelInfo = Readonly<{
1183
1285
  model: Model;
1184
1286
  layout: Layout;
1287
+ isTabbed: boolean;
1185
1288
  toPaneView: (pane: Pane, render: PaneRender) => Html;
1186
1289
  maybeFocusedId: Option.Option<string>;
1187
1290
  maybeMark: Option.Option<Target>;
@@ -1202,6 +1305,9 @@ const HELD_STEPS: Readonly<Record<string, Step>> = {
1202
1305
 
1203
1306
  const isDropKey = (key: string): boolean => key === " " || key === "Enter";
1204
1307
 
1308
+ /** The anchor every pane is pinned to under tabs: the one slot, the reel's full size. */
1309
+ const STAGE_ANCHOR = "--reel-stage";
1310
+
1205
1311
  /** The anchor that pins a pane to its slot. Pane ids are the application's, so the name is
1206
1312
  * the pane's place in the id order, which the slot and the pane read from one list. */
1207
1313
  const anchorName = (index: number): string => `--reel-${index}`;
@@ -1406,26 +1512,32 @@ const paneLayerView = (
1406
1512
  ): Html => {
1407
1513
  const { model } = info;
1408
1514
 
1409
- // NOTE: the panes keep one order in the page, by id, whatever the layout: a move changes
1410
- // only where each is anchored, so no pane's element is taken out and put back, which would
1411
- // drop focus, a terminal's scroll and whatever a Mount holds inside it.
1515
+ // NOTE: the panes keep one order in the page, by id, whatever the layout or the arrangement:
1516
+ // a move or a change to tabs changes only where each is anchored, so no pane's element is
1517
+ // taken out and put back, which would drop focus, a terminal's scroll and whatever a Mount
1518
+ // holds inside it. Under tabs the panes behind the focused one are hidden, not removed, and
1519
+ // keep their size, so a terminal sized by its box is not resized as the tabs change.
1412
1520
  return h.div(
1413
1521
  [h.Class("reel-panes")],
1414
1522
  Array.map(panes, (pane) => {
1415
1523
  const isFocused = Option.contains(info.maybeFocusedId, pane.id);
1416
1524
  const isHeld = Option.contains(info.maybeCarriedId, pane.id);
1525
+ const isBehind = info.isTabbed && !isFocused;
1417
1526
  return h.keyed("div")(
1418
1527
  pane.id,
1419
1528
  [
1420
- h.Class(`reel-pane${isFocused ? " focused" : ""}${isHeld ? " drag-dim" : ""}`),
1529
+ h.Class(
1530
+ `reel-pane${isFocused ? " focused" : ""}${isHeld ? " drag-dim" : ""}${isBehind ? " behind" : ""}`,
1531
+ ),
1421
1532
  h.DataAttribute("reel-pane", paneHook(model, pane.id)),
1422
1533
  h.Style({ "--reel-anchor": anchors.get(pane.id) ?? "none" }),
1423
1534
  h.Tabindex(-1),
1535
+ ...(isBehind ? [h.Inert(true)] : []),
1424
1536
  h.OnFocusEnter(Message.FocusedPane({ paneId: pane.id })),
1425
1537
  ],
1426
1538
  [
1427
1539
  info.toPaneView(pane, {
1428
- grip: gripView(model, pane, h),
1540
+ lead: info.isTabbed ? [] : [gripView(model, pane, h)],
1429
1541
  id: `${model.id}-${pane.id}`,
1430
1542
  }),
1431
1543
  ],
@@ -1484,6 +1596,57 @@ const liftedCopyView = (info: ReelInfo, h: HtmlBuilder<Message>): ReadonlyArray<
1484
1596
  );
1485
1597
  };
1486
1598
 
1599
+ /** The tab list under tabs: a tab per pane in reading order, the focused pane's selected. A
1600
+ * press shows its pane and puts the keyboard in it; the side arrows (wrapping), Home and End
1601
+ * select as they move and keep the keyboard on the tabs, only the selected tab being in the
1602
+ * Tab order. A modified arrow is the application's. */
1603
+ const tabListView = (
1604
+ info: ReelInfo,
1605
+ label: string,
1606
+ toTabLeadView: (pane: Pane) => ReadonlyArray<Html>,
1607
+ h: HtmlBuilder<Message>,
1608
+ ): Html => {
1609
+ const { model, layout } = info;
1610
+ const panes = readingOrder(layout);
1611
+ const stepTo = (index: number): Option.Option<Message> =>
1612
+ Option.map(Array.get(panes, (index + panes.length) % panes.length), ({ id }) =>
1613
+ Message.SteppedTab({ paneId: id }),
1614
+ );
1615
+ const keyOf = (index: number, key: string): Option.Option<Message> =>
1616
+ Match.value(key).pipe(
1617
+ Match.when("ArrowLeft", () => stepTo(index - 1)),
1618
+ Match.when("ArrowRight", () => stepTo(index + 1)),
1619
+ Match.when("Home", () => stepTo(0)),
1620
+ Match.when("End", () => stepTo(panes.length - 1)),
1621
+ Match.orElse(() => Option.none()),
1622
+ );
1623
+
1624
+ return h.div(
1625
+ [h.Class("nav tabs reel-tabs"), h.Role("tablist"), h.AriaLabel(label)],
1626
+ Array.map(panes, (pane, index) => {
1627
+ const isSelected = Option.contains(info.maybeFocusedId, pane.id);
1628
+ return h.keyed("button")(
1629
+ pane.id,
1630
+ [
1631
+ h.Type("button"),
1632
+ h.Role("tab"),
1633
+ h.AriaSelected(isSelected),
1634
+ h.AriaControls(`${model.id}-${pane.id}`),
1635
+ h.Tabindex(isSelected ? 0 : -1),
1636
+ h.DataAttribute("reel-tab", tabHook(model, pane.id)),
1637
+ h.OnClick(Message.PressedTab({ paneId: pane.id })),
1638
+ h.OnKeyDownPreventDefault((key, modifiers) =>
1639
+ isPlain(modifiers) ? keyOf(index, key) : Option.none(),
1640
+ ),
1641
+ ],
1642
+ [...toTabLeadView(pane), h.span([h.Class("reel-tab-words")], [pane.label])],
1643
+ );
1644
+ }),
1645
+ );
1646
+ };
1647
+
1648
+ const NO_TAB_LEAD = (): ReadonlyArray<Html> => [];
1649
+
1487
1650
  /** The layout as it prints, with a dragged splitter's resize shown before it is asked for. */
1488
1651
  const previewed = (model: Model, layout: Layout): Layout =>
1489
1652
  Option.match(
@@ -1504,48 +1667,75 @@ const previewed = (model: Model, layout: Layout): Layout =>
1504
1667
  * panes), Space drops it, and Escape or Tab puts it back. A drop reports what it asks
1505
1668
  * of the layout as the OutMessage. Lift `subscriptions(keyPrefix)` into the application's, or
1506
1669
  * nothing follows the pointer, and call `reconcile` whenever the layout changes.
1670
+ *
1671
+ * Under tabs (`arrangement: "Tabs"`) the reel is `div.reel.tabbed`: a `div.nav.tabs.reel-tabs`
1672
+ * tab list over the scroller, whose track holds one `div.reel-slot.reel-stage` at the reel's
1673
+ * full size where the columns were. Every pane is pinned to it, the focused one shown and the
1674
+ * others `.behind`, hidden and inert; there are no grips and no splitters.
1507
1675
  */
1508
1676
  export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h): Html => {
1509
- const { label, toPaneView } = viewInputs;
1677
+ const { label, toPaneView, arrangement = "Strip", toTabLeadView = NO_TAB_LEAD } = viewInputs;
1678
+ const isTabbed = arrangement === "Tabs";
1510
1679
  const layout = previewed(model, viewInputs.layout);
1511
1680
  const panes = Array.sort(
1512
1681
  Array.flatMap(layout, (column) => column.panes),
1513
1682
  byId,
1514
1683
  );
1515
- const anchors = new Map(Array.map(panes, (pane, index) => [pane.id, anchorName(index)] as const));
1684
+ const anchors = new Map(
1685
+ Array.map(
1686
+ panes,
1687
+ (pane, index) => [pane.id, isTabbed ? STAGE_ANCHOR : anchorName(index)] as const,
1688
+ ),
1689
+ );
1516
1690
  const info: ReelInfo = {
1517
1691
  model,
1518
1692
  layout,
1693
+ isTabbed,
1519
1694
  toPaneView,
1520
1695
  maybeFocusedId: focusedPaneId(model, layout),
1521
1696
  maybeMark: markOf(model, layout),
1522
1697
  maybeCarriedId: carriedIdOf(model),
1523
1698
  };
1524
1699
 
1700
+ const strip = (): ReadonlyArray<Html> => [
1701
+ h.keyed("div")("gap:start", [
1702
+ h.Class(
1703
+ `reel-gap${
1704
+ isGapMarked(
1705
+ info,
1706
+ Option.map(Array.head(layout), ({ id }) => id),
1707
+ )
1708
+ ? " drop-here"
1709
+ : ""
1710
+ }`,
1711
+ ),
1712
+ ]),
1713
+ ...Array.flatMap(layout, (column, index) => columnView(info, anchors, column, index, h)),
1714
+ ];
1715
+
1525
1716
  return h.div(
1526
- [h.Class("reel"), h.Role("group"), h.AriaLabel(label), h.DataAttribute("reel", model.id)],
1527
1717
  [
1718
+ h.Class(isTabbed ? "reel tabbed" : "reel"),
1719
+ h.Role("group"),
1720
+ h.AriaLabel(label),
1721
+ h.DataAttribute("reel", model.id),
1722
+ ],
1723
+ [
1724
+ ...(isTabbed ? [tabListView(info, label, toTabLeadView, h)] : []),
1528
1725
  h.div(
1529
1726
  [h.Class("reel-view")],
1530
1727
  [
1531
1728
  h.div(
1532
1729
  [h.Class("reel-track")],
1533
1730
  [
1534
- h.keyed("div")("gap:start", [
1535
- h.Class(
1536
- `reel-gap${
1537
- isGapMarked(
1538
- info,
1539
- Option.map(Array.head(layout), ({ id }) => id),
1540
- )
1541
- ? " drop-here"
1542
- : ""
1543
- }`,
1544
- ),
1545
- ]),
1546
- ...Array.flatMap(layout, (column, index) =>
1547
- columnView(info, anchors, column, index, h),
1548
- ),
1731
+ ...(isTabbed
1732
+ ? [
1733
+ h.div([
1734
+ h.Class("reel-slot reel-stage"),
1735
+ h.Style({ "--reel-anchor": STAGE_ANCHOR }),
1736
+ ]),
1737
+ ]
1738
+ : strip()),
1549
1739
  paneLayerView(info, panes, anchors, h),
1550
1740
  ],
1551
1741
  ),