flexdesk 0.2.0 → 0.3.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.
Files changed (56) hide show
  1. package/css/base.css +1484 -181
  2. package/css/flexdesk.css +1311 -18
  3. package/css/overrides.css +44 -0
  4. package/css/tokens.css +45 -0
  5. package/dist/charts.js +5 -3
  6. package/dist/charts.js.map +1 -1
  7. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  8. package/dist/chunk-ELXVW542.js.map +7 -0
  9. package/dist/chunk-LH5TSOZW.js +1237 -0
  10. package/dist/chunk-LH5TSOZW.js.map +7 -0
  11. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  12. package/dist/chunk-O5OHMWBB.js.map +7 -0
  13. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  14. package/dist/chunk-QIU5S2RU.js.map +7 -0
  15. package/dist/chunk-QNQHQ24V.js +408 -0
  16. package/dist/chunk-QNQHQ24V.js.map +7 -0
  17. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  18. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  19. package/dist/editor.js +3 -380
  20. package/dist/editor.js.map +3 -3
  21. package/dist/flexdesk.css +1311 -18
  22. package/dist/tiles.js +168 -41
  23. package/dist/tiles.js.map +2 -2
  24. package/dist/tokens.css +45 -0
  25. package/dist/widgets.js +44 -14
  26. package/dist/widgets.js.map +2 -2
  27. package/dist/wm.js +2983 -142
  28. package/dist/wm.js.map +4 -4
  29. package/package.json +3 -2
  30. package/src/charts/chart_types.js +167 -0
  31. package/src/charts/plotly_wrapper.js +178 -10
  32. package/src/editor/notebook_tab_bar.js +39 -3
  33. package/src/tiles/tile_base.js +143 -35
  34. package/src/tiles/tile_grid.js +52 -1
  35. package/src/tiling/command_palette.js +71 -18
  36. package/src/tiling/desktops.js +36 -12
  37. package/src/tiling/keymap.js +24 -4
  38. package/src/tiling/shell.js +135 -24
  39. package/src/tiling/tab_strip.js +184 -0
  40. package/src/tiling/tile_breadcrumb.js +34 -2
  41. package/src/tiling/tile_renderer.js +1386 -21
  42. package/src/tiling/tile_tab_menu.js +101 -0
  43. package/src/tiling/tile_tree.js +82 -0
  44. package/src/tiling/wm.js +2352 -74
  45. package/src/ui/components/action_dropdown.js +34 -3
  46. package/src/ui/components/autocomplete_field.js +65 -13
  47. package/src/ui/components/context_menu.js +79 -8
  48. package/src/ui/components/data_table.js +508 -84
  49. package/src/ui/components/managed_window.js +928 -36
  50. package/src/ui/components/modal.js +214 -8
  51. package/dist/chunk-CT4YXXLP.js.map +0 -7
  52. package/dist/chunk-DRYCDMEG.js.map +0 -7
  53. package/dist/chunk-DVU44T77.js.map +0 -7
  54. package/dist/chunk-TLZUUFOE.js.map +0 -7
  55. package/dist/chunk-UCJ2WD4D.js +0 -625
  56. package/dist/chunk-UCJ2WD4D.js.map +0 -7
package/css/flexdesk.css CHANGED
@@ -587,6 +587,25 @@ body.twm-dt-col-resizing {
587
587
  .twm-preview-table:not(.twm-preview-table--readonly) tr:hover td {
588
588
  background: rgba(255, 255, 255, 0.04);
589
589
  }
590
+ /* Selected rows. `_updateRowSelection()` (src/ui/components/data_table.js:2226)
591
+ has always toggled `.selected` on the row, and no stylesheet has ever
592
+ defined it — so `selectable: true`, the component's headline affordance,
593
+ has selected INVISIBLY in every consumer since the class was written.
594
+ Painted on `td` rather than `tr` to match the hover rule above: the zebra
595
+ stripe sits on `tr`, and a `tr` background would fight it at equal
596
+ specificity instead of covering it.
597
+
598
+ AND IT HAS TO OUT-SPECIFY HOVER, not merely follow it. Source order only
599
+ decides a TIE, and there is none here: the hover rule above is (0,3,2) —
600
+ `:not()` contributes the specificity of its argument — against (0,2,3) for
601
+ the plain form. Written that way, pointing at the row you had chosen
602
+ un-highlights it, which reads as the selection being lost on mouse-over. The
603
+ first selector below carries the same `:not()` and lands at (0,3,3); the
604
+ second keeps the readonly tables, which hover never claimed, working. */
605
+ .twm-preview-table:not(.twm-preview-table--readonly) tbody tr.selected td,
606
+ .twm-preview-table tbody tr.selected td {
607
+ background: var(--state-selected, rgba(0, 122, 204, 0.15));
608
+ }
590
609
  /* Wizard mode: uniform column widths, no selection */
591
610
  .twm-preview-table-wrap--wizard .twm-preview-table {
592
611
  table-layout: auto;
@@ -882,7 +901,14 @@ body.twm-dt-col-resizing {
882
901
  background: transparent;
883
902
  border: none;
884
903
  color: #7f7f7f;
885
- padding: var(--space-xs) 6px;
904
+ padding: 0;
905
+ /* A BOX, not "whatever the glyph turned out to be". The rule had padding
906
+ * and no dimensions, so the button's size was the icon's size — and the
907
+ * icon's size was nobody's decision at all (see below). In a 35px top bar
908
+ * that produced a ~32px control that visibly outweighed everything beside
909
+ * it. */
910
+ width: 22px;
911
+ height: 22px;
886
912
  border-radius: var(--radius-sm);
887
913
  cursor: pointer;
888
914
  display: flex;
@@ -890,6 +916,23 @@ body.twm-dt-col-resizing {
890
916
  justify-content: center;
891
917
  transition: background-color 0.2s ease, color 0.2s ease;
892
918
  }
919
+ /* THE GLYPH SIZE CAME ACROSS AS NOTHING.
920
+ *
921
+ * EcoAgent sets it — `main_new.css:4700`, `.panel-toggle-btn
922
+ * .material-symbols-outlined { font-size: var(--font-size-xl) }` — and the
923
+ * extraction that produced this stylesheet took the button rule and left that
924
+ * one behind. Material Symbols then falls back to its own default of 24px, so
925
+ * every consumer of the library has been drawing this control half again as
926
+ * large as its author intended. Same family as the `--ea-*` variables that
927
+ * arrived without their definitions, and as `.twm-collapsible-content.visible`:
928
+ * an extraction that split a rule from the rule it depended on.
929
+ *
930
+ * `font-size` IS the glyph size for an icon font. `width`/`height` on the
931
+ * button does not constrain it — it overflows instead. */
932
+ .twm-panel-toggle-btn .material-symbols-outlined {
933
+ font-size: var(--font-size-xl);
934
+ line-height: 1;
935
+ }
893
936
  .twm-panel-toggle-btn:hover {
894
937
  background-color: #3f4040;
895
938
  }
@@ -1139,6 +1182,13 @@ body.twm-dt-col-resizing {
1139
1182
  .twm-collapsible-content {
1140
1183
  display: none;
1141
1184
  }
1185
+ /* C8. The rule `toggleCategory()` has always assumed and no stylesheet has ever
1186
+ * provided. Without it the collapsible primitive is non-functional as shipped:
1187
+ * the toggle adds `.visible` and nothing acts on the class, so a category that
1188
+ * "opens" stays `display: none`. One rule; it was simply missing. */
1189
+ .twm-collapsible-content.visible {
1190
+ display: block;
1191
+ }
1142
1192
  /* ============================================
1143
1193
  Autocomplete Field Component (legacy-variable style)
1144
1194
  ============================================ */
@@ -1259,6 +1309,27 @@ body.twm-dt-col-resizing {
1259
1309
  display: none;
1260
1310
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
1261
1311
  }
1312
+ /* `#open()` adds `visible` and `#close()` takes it off again
1313
+ (`ui/components/autocomplete_field.js`), and until now NEITHER stylesheet
1314
+ said what the class meant — while the dropdown above is `display: none`. So
1315
+ the suggestion list has never been shown to anybody, in any consumer, for as
1316
+ long as the component has existed: the provider ran, the items rendered, and
1317
+ the element stayed hidden. This is the same shape as
1318
+ `.twm-action-dropdown-menu.visible`, which is the pattern the component was
1319
+ written against. */
1320
+ .twm-autocomplete-field__dropdown.visible {
1321
+ display: block;
1322
+ }
1323
+ /* `#renderDropdown()` marks the keyboard cursor's row `active` and
1324
+ `#moveSelection()` moves it — with no rule to draw it. Arrowing down the list
1325
+ therefore moved something invisible, and Enter picked a row the user had no
1326
+ way to see. `:hover` above is the mouse's answer to the same question; this
1327
+ is the keyboard's, and it is deliberately the stronger of the two so that
1328
+ moving the pointer across the list does not hide where the cursor is. */
1329
+ .twm-autocomplete-field__item.active {
1330
+ background-color: var(--state-selected, rgba(0, 122, 204, 0.15));
1331
+ box-shadow: inset 2px 0 0 var(--accent-bright, #007acc);
1332
+ }
1262
1333
  .twm-autocomplete-field__item {
1263
1334
  display: flex;
1264
1335
  align-items: center;
@@ -1356,7 +1427,17 @@ body.twm-dt-col-resizing {
1356
1427
  padding: var(--space-sm) 0;
1357
1428
  backdrop-filter: saturate(120%) blur(2px);
1358
1429
  }
1430
+ /* A `<button role="menuitem">`, not a div — that is what makes the menu
1431
+ * operable by keyboard without re-implementing Enter, Space and focus. The
1432
+ * first five declarations are the button reset that buys it: without them the
1433
+ * UA paints its own background, border and centred text over everything below. */
1359
1434
  .twm-context-menu-item {
1435
+ appearance: none;
1436
+ background: none;
1437
+ border: 0;
1438
+ width: 100%;
1439
+ text-align: left;
1440
+ color: inherit;
1360
1441
  padding: var(--space-md) 14px;
1361
1442
  cursor: pointer;
1362
1443
  font-family: Segoe UI, Arial, sans-serif;
@@ -1366,9 +1447,25 @@ body.twm-dt-col-resizing {
1366
1447
  align-items: center;
1367
1448
  gap: 10px;
1368
1449
  }
1369
- .twm-context-menu-item:hover {
1450
+ .twm-context-menu-item:hover:not(.disabled),
1451
+ .twm-context-menu-item:focus-visible {
1370
1452
  background: #2a2a2a;
1453
+ outline: none;
1454
+ }
1455
+ /* A DISABLED ITEM MUST LOOK DISABLED.
1456
+ *
1457
+ * `showContextMenu` has always added `.disabled` and bound no click listener to
1458
+ * it — the behaviour was right and there was no rule for the class anywhere, so
1459
+ * a disabled item rendered at full opacity, kept `cursor: pointer`, still
1460
+ * highlighted on hover, and did nothing when clicked. That is the exact shape of
1461
+ * the `.twm-collapsible-content.visible` bug: a class the JS sets, a stylesheet
1462
+ * that has never heard of it, and a feature that looks live and is not. */
1463
+ .twm-context-menu-item.disabled {
1464
+ opacity: 0.4;
1465
+ cursor: default;
1466
+ color: var(--text-muted);
1371
1467
  }
1468
+ .twm-context-menu-item.disabled.twm-delete-node { color: var(--text-muted); }
1372
1469
  .twm-context-menu-item.twm-delete-node {
1373
1470
  color: #ff6b6b;
1374
1471
  }
@@ -2338,20 +2435,536 @@ body.twm-window-maximized .twm-resize-handle {
2338
2435
  border-radius: var(--radius-sm);
2339
2436
  padding: var(--space-xs) 12px;
2340
2437
  font-size: var(--font-size-sm);
2341
- cursor: pointer;
2342
- transition: border-color var(--transition-normal), color var(--transition-normal);
2343
- }
2344
- .twm-run-progress__stop-btn:hover:not(:disabled) {
2345
- border-color: #f44336;
2346
- color: #f44336;
2438
+ cursor: pointer;
2439
+ transition: border-color var(--transition-normal), color var(--transition-normal);
2440
+ }
2441
+ .twm-run-progress__stop-btn:hover:not(:disabled) {
2442
+ border-color: #f44336;
2443
+ color: #f44336;
2444
+ }
2445
+ .twm-run-progress__stop-btn:disabled {
2446
+ opacity: 0.5;
2447
+ cursor: default;
2448
+ }
2449
+
2450
+ /* ═══════════════════════════════════════════════════════════════════════
2451
+ @flexdesk/tiles — THE TILE GRID'S CHROME
2452
+
2453
+ THIS STYLESHEET HAD NEVER EXISTED. `src/tiles/` was extracted from
2454
+ EcoAgent's `ui/js/results/` with its JavaScript and without its CSS, so
2455
+ every consumer of `@flexdesk/tiles` got a `TileGrid` that ran, dragged,
2456
+ resized, saved layouts — and drew a column of unstyled `<div>`s.
2457
+ `TileGrid._init()` sets `--grid-columns`, `--grid-row-height` and
2458
+ `--grid-gap` on the grid element (`src/tiles/tile_grid.js`), and until
2459
+ these rules landed NOTHING IN ANY STYLESHEET READ THEM: no
2460
+ `display: grid`, no `grid-template-columns`, no `grid-auto-rows`. That is
2461
+ the CSS-side half of this repository's signature bug — not a class the JS
2462
+ sets and no rule defines, but the reverse, three custom properties written
2463
+ by the JS into a void.
2464
+
2465
+ THE CLASS NAMES ARE UNPREFIXED, deliberately and unusually. Everything
2466
+ else in this file is `twm-`; these are not, because the names are compiled
2467
+ into the JavaScript that was extracted (`className = 'tile-grid'`,
2468
+ `'tile-header'`, `classList.add('dragging')` …) and renaming them upstream
2469
+ would be a breaking change to every consumer for a cosmetic gain. The one
2470
+ place the prefix DOES appear — `.twm-tile-error`, below — is the exception
2471
+ that proves it: `showError()` emits the prefixed spelling, so the donor's
2472
+ `.tile-error` rule is NOT ported here. Porting it would have written a
2473
+ dead rule directly beside a live class, which is the mistake this comment
2474
+ exists to stop the next reader from repeating.
2475
+
2476
+ Values come from `tokens.css`, so swapping the tokens restyles the tiles
2477
+ with everything else. Two colour literals survive the port and each says at
2478
+ its own rule why: a light accent, for which there is no token (the nearest,
2479
+ `--accent-bright`, is a border colour on this background rather than a text
2480
+ one), and the error red already shipped in `.twm-tile-error`.
2481
+ Ported from EcoAgent `ui/css/results_widgets.css` (the donor still owns its
2482
+ own copy — it does not load this file).
2483
+ ═══════════════════════════════════════════════════════════════════════ */
2484
+
2485
+ /* ── the grid container ──────────────────────────────────────────────── */
2486
+ /* The three custom properties are set by `TileGrid._init()` as inline style
2487
+ on this element; the fallbacks are the constructor's defaults, so a grid
2488
+ element built by hand still lays out. */
2489
+ .tile-grid {
2490
+ display: grid;
2491
+ grid-template-columns: repeat(var(--grid-columns, 12), 1fr);
2492
+ grid-auto-rows: var(--grid-row-height, 80px);
2493
+ gap: var(--grid-gap, 16px);
2494
+ padding: var(--grid-gap, 16px) 0 0 0;
2495
+ /* A floor rather than a height: the rows are what size the grid. It
2496
+ matters only when the board is empty, and an empty board that is zero
2497
+ pixels tall is a board you cannot drop the first tile onto. */
2498
+ min-height: 400px;
2499
+ position: relative;
2500
+ }
2501
+
2502
+ /* ── the built-in toolbar ────────────────────────────────────────────── */
2503
+ /* `_buildToolbar()` emits `tile-grid-toolbar tile-grid-toolbar--bottom` and no
2504
+ other variant, so the divider lives on the modifier: a rule that put a
2505
+ `border-bottom` on the base class — as the donor's did, when the toolbar sat
2506
+ above the grid — draws a line along the wrong edge here. A consumer that
2507
+ passes `showToolbar: false` and builds its own toolbar gets none of this. */
2508
+ .tile-grid-toolbar {
2509
+ display: flex;
2510
+ justify-content: space-between;
2511
+ align-items: center;
2512
+ gap: var(--space-md);
2513
+ padding: var(--space-lg) var(--space-xl);
2514
+ background: rgba(255, 255, 255, 0.02);
2515
+ }
2516
+
2517
+ .tile-grid-toolbar--bottom {
2518
+ border-top: 1px solid var(--border-subtle);
2519
+ }
2520
+
2521
+ /* `toolbar-group`, `toolbar-btn`, `btn-text` and `dropdown-arrow` are names
2522
+ generic enough that a consumer almost certainly has its own. They are
2523
+ scoped under the toolbar rather than claimed globally — a library that
2524
+ defines a bare `.toolbar-btn` restyles buttons it has never met. */
2525
+ .tile-grid-toolbar .toolbar-group {
2526
+ display: flex;
2527
+ align-items: center;
2528
+ gap: var(--space-md);
2529
+ }
2530
+
2531
+ .tile-grid-toolbar .add-widget-container {
2532
+ position: relative;
2533
+ }
2534
+
2535
+ .tile-grid-toolbar .toolbar-btn {
2536
+ display: inline-flex;
2537
+ align-items: center;
2538
+ gap: var(--space-xs);
2539
+ padding: var(--space-xs) var(--space-lg);
2540
+ background: rgba(255, 255, 255, 0.04);
2541
+ border: 1px solid var(--border-default);
2542
+ border-radius: var(--radius-sm);
2543
+ color: var(--text-body);
2544
+ font-family: inherit;
2545
+ font-size: var(--font-size-sm);
2546
+ cursor: pointer;
2547
+ transition: background var(--transition-normal), border-color var(--transition-normal);
2548
+ }
2549
+
2550
+ .tile-grid-toolbar .toolbar-btn:hover {
2551
+ background: var(--state-hover);
2552
+ border-color: var(--border-strong);
2553
+ }
2554
+
2555
+ .tile-grid-toolbar .toolbar-btn .material-symbols-outlined {
2556
+ font-size: var(--font-size-xl);
2557
+ }
2558
+
2559
+ .tile-grid-toolbar .btn-text {
2560
+ white-space: nowrap;
2561
+ }
2562
+
2563
+ .tile-grid-toolbar .dropdown-arrow {
2564
+ opacity: 0.6;
2565
+ }
2566
+
2567
+ /* `--main` is the ONLY group `_buildToolbar()` emits, and all three buttons
2568
+ sit inside it, so it takes the strip's whole width — `space-between` on the
2569
+ toolbar has nothing to space until a consumer adds a second group. */
2570
+ .tile-grid-toolbar .toolbar-group--main {
2571
+ flex: 1;
2572
+ }
2573
+
2574
+ /* Add Widget is the toolbar's one primary verb, so it is the one that carries
2575
+ the accent. */
2576
+ .add-widget-btn {
2577
+ background: rgba(0, 122, 204, 0.15);
2578
+ border-color: rgba(0, 122, 204, 0.3);
2579
+ color: var(--text-bright);
2580
+ }
2581
+
2582
+ .add-widget-btn:hover {
2583
+ background: rgba(0, 122, 204, 0.25);
2584
+ border-color: rgba(0, 122, 204, 0.5);
2585
+ }
2586
+
2587
+ /* The other two are secondary — Export is an aside and Reset Layout throws the
2588
+ board away, and neither should read as the thing to press. */
2589
+ .export-btn,
2590
+ .reset-layout-btn {
2591
+ color: var(--text-muted);
2592
+ }
2593
+
2594
+ .export-btn:hover,
2595
+ .reset-layout-btn:hover {
2596
+ color: var(--text-body);
2597
+ }
2598
+
2599
+ /* ── the tile ────────────────────────────────────────────────────────── */
2600
+ /* Flat, one 1 px border, no radius and no shadow: dense expert-tool chrome,
2601
+ the register of a trading terminal rather than a card wall. The donor also
2602
+ carried `margin: -1px 0 0 -1px` so that adjacent borders collapsed into one
2603
+ line — which is only true at `gap: 0`, and this grid's default gap is 16 px,
2604
+ where the same rule just shifts every tile a pixel up and left. Dropped on
2605
+ purpose; a consumer running gap-0 can add it back in one rule. */
2606
+ .tile {
2607
+ background: var(--surface-1);
2608
+ border: 1px solid var(--border-default);
2609
+ border-radius: 0;
2610
+ display: flex;
2611
+ flex-direction: column;
2612
+ position: relative;
2613
+ overflow: hidden;
2614
+ height: 100%;
2615
+ min-height: 0;
2616
+ transition: border-color var(--transition-normal);
2617
+ }
2618
+
2619
+ .tile:hover {
2620
+ border-color: var(--border-strong);
2621
+ box-shadow: none;
2622
+ }
2623
+
2624
+ .tile.dragging {
2625
+ opacity: 0.5;
2626
+ z-index: var(--z-dropdown);
2627
+ box-shadow: var(--shadow-lg);
2628
+ }
2629
+
2630
+ .tile.resizing {
2631
+ z-index: var(--z-dropdown);
2632
+ border-color: var(--accent-bright);
2633
+ box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.3);
2634
+ }
2635
+
2636
+ /* Selected — set by `_onConfigClick()` while the config dialog is open, and
2637
+ cleared from every sibling by the same handler. #78b9ff is a literal
2638
+ because there is no light-accent token: `--accent-bright` is #007acc, which
2639
+ is a border colour on this background and not a text one. */
2640
+ .tile.tile--selected {
2641
+ border-color: #78b9ff;
2642
+ box-shadow: 0 0 0 2px rgba(120, 185, 255, 0.3), var(--shadow-md);
2643
+ }
2644
+
2645
+ .tile.tile--selected .tile-header {
2646
+ background: rgba(120, 185, 255, 0.1);
2647
+ }
2648
+
2649
+ .tile.tile--selected .tile-title {
2650
+ color: #78b9ff;
2651
+ }
2652
+
2653
+ /* Read-only. `_buildChrome()`'s read-only branch emits no drag handle, no
2654
+ cogwheel, no ⋯ menu, no remove button and no resize handle, so there is
2655
+ nothing here to hide — what is left is to stop the tile advertising the
2656
+ affordances it no longer has. */
2657
+ .tile--readonly .tile-header {
2658
+ cursor: default;
2659
+ }
2660
+
2661
+ .tile--readonly:hover {
2662
+ border-color: var(--border-default);
2663
+ }
2664
+
2665
+ /* ── the header strip ────────────────────────────────────────────────── */
2666
+ .tile-header {
2667
+ display: flex;
2668
+ align-items: center;
2669
+ gap: var(--space-xs);
2670
+ padding: 0 var(--space-sm);
2671
+ background: var(--surface-0);
2672
+ border-bottom: 1px solid var(--border-default);
2673
+ min-height: 22px;
2674
+ flex-shrink: 0;
2675
+ }
2676
+
2677
+ .tile-drag-handle {
2678
+ display: flex;
2679
+ align-items: center;
2680
+ justify-content: center;
2681
+ width: 16px;
2682
+ height: 18px;
2683
+ color: rgba(255, 255, 255, 0.3);
2684
+ cursor: grab;
2685
+ transition: color var(--transition-normal);
2686
+ }
2687
+
2688
+ .tile-drag-handle:hover {
2689
+ color: rgba(255, 255, 255, 0.6);
2690
+ }
2691
+
2692
+ .tile-drag-handle:active {
2693
+ cursor: grabbing;
2694
+ }
2695
+
2696
+ .tile-drag-handle .material-symbols-outlined {
2697
+ font-size: var(--font-size-lg);
2698
+ }
2699
+
2700
+ /* `cursor: default`, where the donor said `pointer`. In EcoAgent the title
2701
+ opened the config panel; in this library that moved to the cogwheel alone
2702
+ ("Config opens ONLY via the explicit cogwheel button" — `_buildChrome`), so
2703
+ a pointer cursor here promises a click that does nothing. */
2704
+ .tile-title {
2705
+ flex: 1;
2706
+ min-width: 0;
2707
+ font-size: var(--font-size-xs);
2708
+ font-weight: var(--font-weight-semibold);
2709
+ color: var(--text-body);
2710
+ text-transform: uppercase;
2711
+ letter-spacing: 0.04em;
2712
+ white-space: nowrap;
2713
+ overflow: hidden;
2714
+ text-overflow: ellipsis;
2715
+ cursor: default;
2716
+ padding: 0;
2717
+ border-radius: 0;
2718
+ line-height: 1;
2719
+ }
2720
+
2721
+ .tile-controls {
2722
+ display: flex;
2723
+ align-items: center;
2724
+ gap: var(--space-xs);
2725
+ margin-left: var(--space-md);
2726
+ flex-shrink: 0;
2727
+ }
2728
+
2729
+ /* One geometry for every icon button in the strip. They differ only in what
2730
+ they turn when you hover them, which is the next block. */
2731
+ .tile-config-btn,
2732
+ .tile-menu-btn,
2733
+ .tile-expand-btn,
2734
+ .tile-remove-btn {
2735
+ display: flex;
2736
+ align-items: center;
2737
+ justify-content: center;
2738
+ width: 20px;
2739
+ height: 20px;
2740
+ padding: 0;
2741
+ background: transparent;
2742
+ border: none;
2743
+ border-radius: var(--radius-sm);
2744
+ color: rgba(255, 255, 255, 0.4);
2745
+ cursor: pointer;
2746
+ transition: background var(--transition-normal), color var(--transition-normal);
2747
+ }
2748
+
2749
+ .tile-config-btn:hover,
2750
+ .tile-menu-btn:hover,
2751
+ .tile-expand-btn:hover {
2752
+ background: var(--state-hover);
2753
+ color: #78b9ff;
2754
+ }
2755
+
2756
+ .tile-remove-btn:hover {
2757
+ background: var(--state-hover);
2758
+ color: #ff6b6b;
2759
+ }
2760
+
2761
+ .tile-config-btn .material-symbols-outlined,
2762
+ .tile-menu-btn .material-symbols-outlined,
2763
+ .tile-expand-btn .material-symbols-outlined,
2764
+ .tile-remove-btn .material-symbols-outlined {
2765
+ font-size: var(--font-size-xl);
2766
+ }
2767
+
2768
+ /* The ⓘ button carries its own inline `style` from `_buildChrome`, and inline
2769
+ style beats any rule in this file — background, border, padding and colour
2770
+ are already decided there and CANNOT be changed from here. So this rule adds
2771
+ only what the inline style leaves open. A hover background written here
2772
+ would be a rule that never applies: exactly the dead rule the header comment
2773
+ warns about, arrived at from the other direction. */
2774
+ .tile-info-btn {
2775
+ width: 20px;
2776
+ height: 20px;
2777
+ border-radius: var(--radius-sm);
2778
+ flex-shrink: 0;
2779
+ }
2780
+
2781
+ /* ── the ⋯ menu ──────────────────────────────────────────────────────── */
2782
+ /* Rendered from `TileBase.menuItems()`; an empty list emits no wrapper at all,
2783
+ so none of this draws for a consumer that overrides the hook to `[]`. */
2784
+ .tile-menu-wrapper {
2785
+ position: relative;
2786
+ }
2787
+
2788
+ .tile-menu-dropdown {
2789
+ position: absolute;
2790
+ top: 100%;
2791
+ right: 0;
2792
+ z-index: var(--z-dropdown);
2793
+ min-width: 180px;
2794
+ padding: var(--space-xs) 0;
2795
+ background: var(--surface-2);
2796
+ border: 1px solid rgba(255, 255, 255, 0.1);
2797
+ border-radius: var(--radius-md);
2798
+ box-shadow: var(--shadow-md);
2799
+ }
2800
+
2801
+ /* `!important`, matching `.twm-action-dropdown-menu[hidden]` below and for the
2802
+ same reason: `hidden` is the JS's only way of closing this menu
2803
+ (`menuDropdown.hidden = !menuDropdown.hidden`), and the UA implements the
2804
+ attribute as the weakest possible rule — any consumer selector that sets a
2805
+ display on the dropdown would leave it open with nothing to close it. */
2806
+ .tile-menu-dropdown[hidden] {
2807
+ display: none !important;
2808
+ }
2809
+
2810
+ .tile-menu-item {
2811
+ display: flex;
2812
+ align-items: center;
2813
+ gap: var(--space-md);
2814
+ width: 100%;
2815
+ padding: var(--space-sm) var(--space-lg);
2816
+ background: none;
2817
+ border: none;
2818
+ color: var(--text-secondary);
2819
+ font-family: inherit;
2820
+ font-size: var(--font-size-sm);
2821
+ text-align: left;
2822
+ cursor: pointer;
2823
+ white-space: nowrap;
2824
+ transition: background var(--transition-fast);
2825
+ }
2826
+
2827
+ .tile-menu-item:hover {
2828
+ background: var(--state-active);
2829
+ color: var(--text-bright);
2830
+ }
2831
+
2832
+ .tile-menu-item .material-symbols-outlined {
2833
+ font-size: var(--font-size-xl);
2834
+ color: rgba(255, 255, 255, 0.5);
2835
+ }
2836
+
2837
+ .tile-menu-item:hover .material-symbols-outlined {
2838
+ color: #78b9ff;
2839
+ }
2840
+
2841
+ /* ── the content area ────────────────────────────────────────────────── */
2842
+ /* `min-height: 0` is the load-bearing declaration, not the padding: a flex
2843
+ child's default `min-height: auto` refuses to shrink below its content, so
2844
+ without it a tall table or a chart pushes the tile past its grid rows
2845
+ instead of scrolling inside them. */
2846
+ .tile-content {
2847
+ flex: 1;
2848
+ padding: var(--space-lg);
2849
+ overflow: hidden;
2850
+ position: relative;
2851
+ min-height: 0;
2852
+ display: flex;
2853
+ flex-direction: column;
2854
+ }
2855
+
2856
+ /* A chart library that measures its container on draw gets a container with a
2857
+ real size. The `!important` is the only lever there is: Plotly writes width
2858
+ and height INLINE onto `.js-plotly-plot` when it draws, an inline
2859
+ declaration outranks any selector, and `!important` is what outranks it —
2860
+ without which a chart keeps the size it had when the tile was half as wide. */
2861
+ .tile-content > [class*="-container"] {
2862
+ flex: 1;
2863
+ min-height: 0;
2864
+ width: 100%;
2865
+ position: relative;
2866
+ }
2867
+
2868
+ .tile-content .js-plotly-plot,
2869
+ .tile-content .plot-container.plotly,
2870
+ .tile-content .svg-container {
2871
+ width: 100% !important;
2872
+ height: 100% !important;
2873
+ }
2874
+
2875
+ /* ── resize handle and drag ghost ────────────────────────────────────── */
2876
+ .tile-resize-handle {
2877
+ position: absolute;
2878
+ background: transparent;
2879
+ z-index: var(--z-sticky);
2880
+ }
2881
+
2882
+ .tile-resize-handle.se {
2883
+ bottom: 0;
2884
+ right: 0;
2885
+ width: 24px;
2886
+ height: 24px;
2887
+ cursor: se-resize;
2888
+ display: flex;
2889
+ align-items: center;
2890
+ justify-content: center;
2891
+ color: rgba(255, 255, 255, 0.2);
2892
+ transition: color var(--transition-normal);
2893
+ }
2894
+
2895
+ .tile-resize-handle.se:hover {
2896
+ color: rgba(255, 255, 255, 0.5);
2897
+ }
2898
+
2899
+ .tile-resize-handle .material-symbols-outlined {
2900
+ font-size: var(--font-size-xl);
2901
+ transform: rotate(-45deg);
2902
+ }
2903
+
2904
+ /* The drop preview, positioned by `_updateGhost()` in grid coordinates. */
2905
+ .tile-ghost {
2906
+ position: absolute;
2907
+ background: rgba(0, 122, 204, 0.15);
2908
+ border: 2px dashed rgba(0, 122, 204, 0.5);
2909
+ border-radius: var(--radius-sm);
2910
+ pointer-events: none;
2911
+ z-index: 50;
2912
+ transition: transform 0.1s ease;
2913
+ }
2914
+
2915
+ /* ── the three content states ────────────────────────────────────────── */
2916
+ /* `showLoading()`, `showEmpty()` and `showError()` each replace the whole of
2917
+ `.tile-content`, so all three centre themselves in it. */
2918
+ .tile-loading {
2919
+ display: flex;
2920
+ flex-direction: column;
2921
+ align-items: center;
2922
+ justify-content: center;
2923
+ height: 100%;
2924
+ gap: var(--space-md);
2925
+ color: rgba(255, 255, 255, 0.5);
2926
+ font-size: var(--font-size-sm);
2927
+ }
2928
+
2929
+ .tile-loading .material-symbols-outlined {
2930
+ font-size: 24px;
2931
+ }
2932
+
2933
+ /* Scoped, because `.spinning` on its own is a name a consumer is entitled to
2934
+ use for its own spinner. `spin` is not scopeable — `@keyframes` names are
2935
+ global — and a consumer that defines its own `spin` later in the cascade
2936
+ simply spins this icon its way, which is harmless. */
2937
+ .tile-loading .spinning {
2938
+ animation: spin 1s linear infinite;
2939
+ }
2940
+
2941
+ @keyframes spin {
2942
+ from { transform: rotate(0deg); }
2943
+ to { transform: rotate(360deg); }
2944
+ }
2945
+
2946
+ .tile-empty {
2947
+ display: flex;
2948
+ flex-direction: column;
2949
+ align-items: center;
2950
+ justify-content: center;
2951
+ height: 100%;
2952
+ gap: var(--space-md);
2953
+ color: rgba(255, 255, 255, 0.4);
2954
+ font-size: var(--font-size-sm);
2955
+ text-align: center;
2347
2956
  }
2348
- .twm-run-progress__stop-btn:disabled {
2957
+
2958
+ .tile-empty .material-symbols-outlined {
2959
+ font-size: 32px;
2349
2960
  opacity: 0.5;
2350
- cursor: default;
2351
2961
  }
2352
2962
 
2353
- /* ── from results_widgets.css ───────────────────────── */
2354
- /* Error State */
2963
+ /* THE ERROR STATE IS PREFIXED AND THE OTHER TWO ARE NOT. `showError()` emits
2964
+ `.twm-tile-error` where `showEmpty()` emits `.tile-empty` — an asymmetry
2965
+ that is now a fixed fact of the wire, because this rule shipped before the
2966
+ rest of the block did. The donor's unprefixed `.tile-error` is deliberately
2967
+ NOT ported: it would sit here looking correct and matching nothing. */
2355
2968
  .twm-tile-error {
2356
2969
  display: flex;
2357
2970
  flex-direction: column;
@@ -2361,6 +2974,11 @@ body.twm-window-maximized .twm-resize-handle {
2361
2974
  gap: var(--space-md);
2362
2975
  color: #ff6b6b;
2363
2976
  font-size: var(--font-size-sm);
2977
+ text-align: center;
2978
+ }
2979
+
2980
+ .twm-tile-error .material-symbols-outlined {
2981
+ font-size: 32px;
2364
2982
  }
2365
2983
  /* ===================================================================
2366
2984
  REUSABLE ACTION DROPDOWN COMPONENT
@@ -2384,6 +3002,29 @@ body.twm-window-maximized .twm-resize-handle {
2384
3002
  .twm-action-dropdown-menu[hidden] {
2385
3003
  display: none !important;
2386
3004
  }
3005
+ /* THE OPEN STATE. `ActionDropdown.open()` un-hides the menu and then adds
3006
+ `.visible` on the next frame to fade it in (action_dropdown.js:172-180) —
3007
+ and no rule for that class existed, so the menu unhid itself into
3008
+ `opacity: 0; pointer-events: none` and stayed there. Every ActionDropdown in
3009
+ the library, "Add Widget" included, opened invisible and un-clickable.
3010
+ The same defect `.twm-collapsible-content.visible` had, in the same shape:
3011
+ the JS toggles a class the stylesheet never grew. */
3012
+ .twm-action-dropdown-menu.visible {
3013
+ opacity: 1;
3014
+ pointer-events: auto;
3015
+ }
3016
+ /* The option's text column. `_renderOptions` wraps the label and the
3017
+ description in `<span class="option-text">` (action_dropdown.js:125-128) and
3018
+ both children are inline, so a described option rendered as
3019
+ "Add Widget Put a widget on this desktop" on one run-together line. The
3020
+ description is a SECOND LINE by construction; this is what makes it one.
3021
+ `min-width: 0` so the ellipsis on the two children has something to bite on
3022
+ inside the flex row. */
3023
+ .twm-action-dropdown-option .option-text {
3024
+ display: flex;
3025
+ flex-direction: column;
3026
+ min-width: 0;
3027
+ }
2387
3028
  /* Dropdown options */
2388
3029
  .twm-action-dropdown-option {
2389
3030
  display: flex;
@@ -2556,6 +3197,98 @@ body.twm-window-maximized .twm-resize-handle {
2556
3197
  /* Enable transitions for minimize/maximize animations */
2557
3198
  transition: transform 0.2s ease-out, opacity 0.2s ease-out, box-shadow var(--transition-normal);
2558
3199
  }
3200
+ /* C3. Containment. `--contained` swaps `position: fixed` for `absolute` so the
3201
+ * window is placed relative to its container, which must be a containing block
3202
+ * established with `position: relative`/`absolute` and NOTHING ELSE.
3203
+ *
3204
+ * This is the most dangerous rule in the containment work. `transform`,
3205
+ * `filter`, `contain` and `will-change` on the container ALSO create a
3206
+ * containing block — and they additionally capture `position: fixed`
3207
+ * descendants. DataTable's filter dropdown (`position: fixed; z-index: 10001`)
3208
+ * and the autocomplete dropdown (`z-index: 10000000`) deliberately escape their
3209
+ * tile to the viewport; under a transformed ancestor they become
3210
+ * container-relative and are clipped by the container's `overflow: hidden`.
3211
+ * The symptom is "the filter dropdown is cut in half" and the cause is three
3212
+ * files away. */
3213
+ /* C11. Aero snap — the preview shown while a drag is over an edge zone.
3214
+ * Absolutely positioned in the SAME coordinate space the window uses, so it is
3215
+ * the container when contained and the page otherwise. Below the window's own
3216
+ * z-index and above everything else in the container, so it reads as a target
3217
+ * behind the thing being dragged. */
3218
+ .twm-snap-preview {
3219
+ position: absolute;
3220
+ z-index: 5999;
3221
+ pointer-events: none;
3222
+ background: rgba(23, 119, 179, 0.18);
3223
+ border: 1px solid var(--accent-bright);
3224
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
3225
+ transition: left 0.09s ease, top 0.09s ease, width 0.09s ease, height 0.09s ease;
3226
+ }
3227
+ /* C15. A CONTROLLER'S preview is positioned in VIEWPORT pixels, because its
3228
+ * rectangle came from hit-testing somebody else's DOM (`getBoundingClientRect`)
3229
+ * rather than from `_bounds()`. `position: fixed` is what makes those numbers
3230
+ * mean what they say; leaving it `absolute` under `document.body` would be
3231
+ * right only until the page scrolls. */
3232
+ .twm-snap-preview--viewport {
3233
+ position: fixed;
3234
+ }
3235
+ /* A window whose position came from a snap rather than from a drag. Nothing
3236
+ * depends on it visually; it is there so a consumer can tell the two apart. */
3237
+ .twm-managed-window--snapped { }
3238
+ /* R3. HALF TRANSPARENT THE MOMENT THE DRAG LEAVES THE TILE IT CAME FROM.
3239
+ *
3240
+ * The one signal that separates "I am moving this window around on its own
3241
+ * pane" from "letting go now will put this window into the tree somewhere".
3242
+ * Inside its origin pane the window is fully opaque and nothing is armed; the
3243
+ * moment the pointer crosses out of it the window goes translucent and the drop
3244
+ * preview starts drawing what a release would produce.
3245
+ *
3246
+ * No transition of its own: `.twm-managed-window` already transitions `opacity`
3247
+ * over 0.2s for the minimize animation, so the fade comes for free — and adding
3248
+ * a second, faster one here would make the same property animate at two speeds
3249
+ * depending on which class won. Translucent, not ghostly: the window is still
3250
+ * the thing being aimed, and at 0.5 the pane underneath reads through it while
3251
+ * its title bar stays legible. */
3252
+ .twm-managed-window--detached {
3253
+ opacity: 0.5;
3254
+ }
3255
+ /* A title-bar button the WM has suppressed for the window's current state — the
3256
+ * minimize button on a maximised window it promoted out of a tile. `!important`
3257
+ * because `button.twm-managed-window__btn` sets `display: flex` and an author
3258
+ * declaration of `display` defeats the `hidden` attribute outright; a class
3259
+ * that loses the specificity tie is a control that stays on screen. */
3260
+ .twm-managed-window__btn--suppressed { display: none !important; }
3261
+
3262
+ .twm-managed-window--contained {
3263
+ position: absolute;
3264
+ }
3265
+ .twm-managed-window__backdrop--contained {
3266
+ position: absolute;
3267
+ top: 0;
3268
+ left: 0;
3269
+ right: 0;
3270
+ bottom: 0;
3271
+ }
3272
+ /* The layer a contained window is mounted into. `position: relative` and
3273
+ * `overflow: hidden` ONLY — see the warning above. */
3274
+ .tbl-window-layer {
3275
+ position: absolute;
3276
+ inset: 0;
3277
+ overflow: hidden;
3278
+ }
3279
+ /* C6. An extra titlebar button (the table cogwheel), sized like the built-ins.
3280
+ * R7. And the maximize button, when a consumer has given it a glyph instead of
3281
+ * the built-in square — the WM does, because for a window it lifted out of a
3282
+ * tile that button means "back to tile". A Material Symbol renders at its
3283
+ * `font-size` and Google's own sheet says 24px, which is three times the height
3284
+ * of a 20px title-bar button: the rule has to be a DESCENDANT selector like
3285
+ * this one to out-specify it, and a class on the glyph element itself would
3286
+ * quietly lose. */
3287
+ .twm-managed-window__btn--custom .material-symbols-outlined,
3288
+ .twm-managed-window__btn--maximize .material-symbols-outlined {
3289
+ font-size: 13px;
3290
+ line-height: 1;
3291
+ }
2559
3292
  .twm-managed-window:focus-within {
2560
3293
  border-color: #505050;
2561
3294
  }
@@ -2569,6 +3302,26 @@ body.twm-window-maximized .twm-resize-handle {
2569
3302
  .twm-managed-window--restoring {
2570
3303
  animation: windowRestore 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
2571
3304
  }
3305
+ /* C27. THE KEYFRAMES THE RULE ABOVE HAS ALWAYS NAMED AND NOTHING EVER DEFINED.
3306
+ `animation: windowRestore ...` referred to an `@keyframes` block that does
3307
+ not exist anywhere in either stylesheet, which CSS treats as no animation at
3308
+ all — so the restore was an instant pop while its mirror image, the minimise,
3309
+ played for 200ms. The fifth instance of this codebase's signature defect: one
3310
+ side names a thing the other side has never heard of, nothing throws, and no
3311
+ check in any of the three consumers can see it. Deliberately the exact
3312
+ inverse of `--minimizing`, from the same three custom properties, so the two
3313
+ halves of the gesture cannot describe different geometry. */
3314
+ @keyframes windowRestore {
3315
+ from {
3316
+ transform: translate(var(--mw-target-x, 0px), var(--mw-target-y, 100vh))
3317
+ scale(var(--mw-target-scale, 0.1));
3318
+ opacity: 0;
3319
+ }
3320
+ to {
3321
+ transform: none;
3322
+ opacity: 1;
3323
+ }
3324
+ }
2572
3325
  /* ========== Backdrop (for modal windows) ========== */
2573
3326
 
2574
3327
  .twm-managed-window__backdrop {
@@ -2694,6 +3447,29 @@ button.twm-managed-window__btn svg rect {
2694
3447
  position: absolute;
2695
3448
  z-index: var(--z-sticky);
2696
3449
  }
3450
+ /* The eight handles' geometry. Without these the elements exist, are
3451
+ * `position: absolute`, and have NO width, height or offset — so every window
3452
+ * in every consumer is unresizable and nothing says why. Edges are 6px, corners
3453
+ * 12px and sit above the edges, because a corner that loses to an edge makes
3454
+ * diagonal resize a matter of luck. */
3455
+ .twm-managed-window__resize--n,
3456
+ .twm-managed-window__resize--s { left: 0; right: 0; height: 6px; cursor: ns-resize; }
3457
+ .twm-managed-window__resize--n { top: -3px; }
3458
+ .twm-managed-window__resize--s { bottom: -3px; }
3459
+ .twm-managed-window__resize--e,
3460
+ .twm-managed-window__resize--w { top: 0; bottom: 0; width: 6px; cursor: ew-resize; }
3461
+ .twm-managed-window__resize--e { right: -3px; }
3462
+ .twm-managed-window__resize--w { left: -3px; }
3463
+ .twm-managed-window__resize--ne,
3464
+ .twm-managed-window__resize--nw,
3465
+ .twm-managed-window__resize--se,
3466
+ .twm-managed-window__resize--sw { width: 12px; height: 12px; z-index: calc(var(--z-sticky) + 1); }
3467
+ .twm-managed-window__resize--ne { top: -3px; right: -3px; cursor: nesw-resize; }
3468
+ .twm-managed-window__resize--nw { top: -3px; left: -3px; cursor: nwse-resize; }
3469
+ .twm-managed-window__resize--se { bottom: -3px; right: -3px; cursor: nwse-resize; }
3470
+ .twm-managed-window__resize--sw { bottom: -3px; left: -3px; cursor: nesw-resize; }
3471
+ /* A maximised or snapped window is not resized by dragging its edges. */
3472
+ .twm-managed-window--maximized .twm-managed-window__resize { display: none; }
2697
3473
  /* ========== Taskbar in Bottom Bar ========== */
2698
3474
 
2699
3475
  .twm-bar-windows {
@@ -3572,6 +4348,24 @@ button.twm-about-dialog__btn--close:focus {
3572
4348
  opacity: 0.4;
3573
4349
  cursor: not-allowed;
3574
4350
  }
4351
+
4352
+ /* C26. A LEADING GLYPH ON AN ACTION BUTTON — `openModal({actions:[{icon}]})`.
4353
+ *
4354
+ * Material Symbols is a FONT. It renders at `font-size`, which defaults to
4355
+ * 24px, and the button's `min-height` does not constrain it — so without this
4356
+ * the glyph overflows a 22px action button and the row reads as ragged. The
4357
+ * declaration is written TWICE, once as a single class and once compounded
4358
+ * with `.material-symbols-outlined`, because a consumer that loads Google's
4359
+ * hosted stylesheet loads it AFTER this one: `.material-symbols-outlined
4360
+ * {font-size:24px}` is a single class too, so it ties on specificity and wins
4361
+ * on source order. The compound selector is what actually holds. */
4362
+ .twm-btn__glyph,
4363
+ .twm-btn__glyph.material-symbols-outlined {
4364
+ font-size: var(--font-size-xl, 16px);
4365
+ line-height: 1;
4366
+ flex: 0 0 auto;
4367
+ }
4368
+ .twm-btn__label { line-height: 1; }
3575
4369
  .twm-btn--primary {
3576
4370
  background: var(--ea-accent);
3577
4371
  border-color: var(--ea-accent);
@@ -4366,6 +5160,18 @@ button.twm-about-dialog__btn--close:focus {
4366
5160
  position: relative;
4367
5161
  }
4368
5162
  .twm-root {
5163
+ /* R1. THE CONTAINING BLOCK A DRAGGED WINDOW ESCAPES INTO. A window promoted
5164
+ * in place is contained to its leaf wrap (C21), which clips it; for the
5165
+ * length of a drag the WM re-parents it here instead, so it can be carried
5166
+ * across tile boundaries. Its `left`/`top` then resolve against this box,
5167
+ * and without a `position` they would resolve against whatever positioned
5168
+ * ancestor the embedder happens to have — usually `.twm-shell__main`, which
5169
+ * is the same rectangle, and occasionally the page, which is not.
5170
+ *
5171
+ * `relative` and nothing else, for the reason `.twm-leaf` gives at length:
5172
+ * `transform`, `filter` and `contain` would also capture the
5173
+ * `position: fixed` dropdowns that deliberately escape their tile. */
5174
+ position: relative;
4369
5175
  display: flex;
4370
5176
  height: 100%;
4371
5177
  width: 100%;
@@ -4412,6 +5218,15 @@ body.twm-dragging .twm-splitter { background: var(--accent-bright); opacity: 0.5
4412
5218
  body.twm-dragging * { user-select: none; }
4413
5219
  /* Leaf — chrome + body. */
4414
5220
  .twm-leaf {
5221
+ /* C21. THE CONTAINING BLOCK for a window promoted in place. `overflow:
5222
+ * hidden` below is what actually confines it; this is what makes its
5223
+ * coordinates mean the pane rather than the page.
5224
+ *
5225
+ * `position: relative` and NOTHING ELSE — `transform`, `filter`, `contain`
5226
+ * and `will-change` also create a containing block AND capture
5227
+ * `position: fixed` descendants, which would clip the dropdowns DataTable
5228
+ * opens on purpose. */
5229
+ position: relative;
4415
5230
  display: flex;
4416
5231
  flex-direction: column;
4417
5232
  flex: 1 1 auto;
@@ -4453,15 +5268,67 @@ body.twm-dragging * { user-select: none; }
4453
5268
  background: var(--surface-2);
4454
5269
  color: var(--text-primary);
4455
5270
  }
5271
+ /* NO `text-transform` HERE, and the absence is the decision. This element used
5272
+ * to be lower-cased, which reads as a house style until you notice whose text
5273
+ * it is: the embedder's. `tile_renderer` puts a content factory's `title` in
5274
+ * here, and in a data application that is a record's display name — typed by a
5275
+ * user who capitalised "Customer Orders" on purpose. A framework may style its
5276
+ * own chrome however it likes; it may not rewrite its embedder's data on the
5277
+ * way to the screen. `.twm-leaf__tab-label` shows the same strings and lost the
5278
+ * same rule for the same reason. */
4456
5279
  .twm-leaf__title {
4457
5280
  color: var(--text-secondary);
4458
5281
  white-space: nowrap;
4459
5282
  text-overflow: ellipsis;
4460
5283
  overflow: hidden;
4461
- text-transform: lowercase;
4462
5284
  letter-spacing: 0.02em;
4463
5285
  }
4464
- .twm-leaf__actions { display: inline-flex; gap: 2px; }
5286
+ /* C19. The tile's own glyph, left of the title, so a pane and the floating
5287
+ * window it promotes into look like the same thing — `.twm-managed-window__icon`
5288
+ * has always drawn one. Sized to the BUTTONS at the other end of the strip
5289
+ * rather than to the title, because those are the other icons in this bar.
5290
+ *
5291
+ * AND THE GLYPH HAS TO BE TOLD, exactly as on `.twm-leaf__btn` below: Material
5292
+ * Symbols is a font and renders at `font-size`, which defaults to 24px. Leaving
5293
+ * this off does not give a slightly large icon — it gives one half again as
5294
+ * tall as the 22px bar it sits in, which pushes the chrome open. */
5295
+ /* THE COMPOUND SELECTOR IS THE POINT.
5296
+ *
5297
+ * The element is `<span class="twm-leaf__icon material-symbols-outlined">`, and
5298
+ * Google's own stylesheet — which every consumer loads, AFTER this one — sets
5299
+ * `.material-symbols-outlined { font-size: 24px }`. Two single-class selectors
5300
+ * tie on specificity, so the LAST one wins, and the last one is theirs. A rule
5301
+ * written `.twm-leaf__icon { font-size: 14px }` therefore exists, is valid,
5302
+ * looks correct in review, and does nothing.
5303
+ *
5304
+ * `.twm-leaf__icon.material-symbols-outlined` is two classes and wins outright.
5305
+ * The same tie is why `.twm-panel-toggle-btn .material-symbols-outlined` works
5306
+ * — a descendant selector is also two — and it is the fourth outing of the same
5307
+ * defect family in this codebase: a rule for a class the other side already
5308
+ * claims. */
5309
+ .twm-leaf__icon,
5310
+ .twm-leaf__icon.material-symbols-outlined {
5311
+ flex: 0 0 auto;
5312
+ font-size: 14px;
5313
+ line-height: 1;
5314
+ color: var(--text-muted);
5315
+ font-variation-settings: 'opsz' 20, 'wght' 400;
5316
+ }
5317
+ /* Hidden, not empty, for a kind that has no glyph — the framework's own
5318
+ * `panel:*` tiles are in nobody's taxonomy. The chrome is a flex row with a
5319
+ * gap, and an empty span still takes its gap: the title would sit a glyph's
5320
+ * width off the left margin with nothing in front of it. */
5321
+ .twm-leaf__icon[hidden] { display: none; }
5322
+ .twm-leaf__actions { display: inline-flex; gap: 2px; align-items: center; }
5323
+ /* C18. The embedder's own buttons, to the LEFT of the structural ones and
5324
+ * separated from them — these act on the CONTENT, those act on the TILE. */
5325
+ .twm-leaf__actions--content {
5326
+ margin-left: auto;
5327
+ padding-right: var(--space-sm);
5328
+ margin-right: var(--space-sm);
5329
+ border-right: 1px solid var(--border-subtle);
5330
+ }
5331
+ .twm-leaf__actions--content[hidden] { display: none; }
4465
5332
  .twm-leaf__btn {
4466
5333
  display: inline-flex;
4467
5334
  align-items: center;
@@ -4475,7 +5342,27 @@ body.twm-dragging * { user-select: none; }
4475
5342
  cursor: pointer;
4476
5343
  border-radius: 0;
4477
5344
  }
4478
- .twm-leaf__btn:hover { background: var(--state-hover); color: var(--text-primary); }
5345
+ /* THE GLYPH HAS TO BE TOLD. Material Symbols ships at `font-size: 24px`, and
5346
+ * the button is 18px square — so every icon overflowed its own button, the
5347
+ * strip read as oversized next to a 22px chrome bar, and the buttons visually
5348
+ * collided. `font-size` IS the glyph size for an icon font; `width`/`height` on
5349
+ * the button does not constrain it. */
5350
+ .twm-leaf__btn .material-symbols-outlined {
5351
+ font-size: 14px;
5352
+ line-height: 1;
5353
+ /* The variable-font axes the family exposes. At 14px the default optical
5354
+ * size (24) draws strokes too heavy to read as a small control. */
5355
+ font-variation-settings: 'opsz' 20, 'wght' 400;
5356
+ }
5357
+ .twm-leaf__btn:hover:not(:disabled) { background: var(--state-hover); color: var(--text-primary); }
5358
+ /* C20. A verb that applies to this kind of pane but not right now — greyed and
5359
+ * explained by its tooltip, rather than removed. `:disabled` alone would still
5360
+ * take the hover highlight above, which reads as clickable. */
5361
+ .twm-leaf__btn--disabled,
5362
+ .twm-leaf__btn:disabled {
5363
+ opacity: 0.35;
5364
+ cursor: default;
5365
+ }
4479
5366
  .twm-leaf__body {
4480
5367
  flex: 1 1 auto;
4481
5368
  overflow: auto;
@@ -4489,7 +5376,15 @@ body.twm-dragging * { user-select: none; }
4489
5376
  *
4490
5377
  * The hamburger button on the left opens the per-tile "browse this
4491
5378
  * page's content" menu (`wm.ctx.onTileTabMenu`). */
4492
- .twm-leaf__tabbar {
5379
+ /* R8. THE SAME STRIP, IN A WINDOW. A pane floated into a managed window takes
5380
+ * its tabs with it — *"to window includes the tab-strip"* — and the strip in
5381
+ * the window is the strip from the pane: same component, same vocabulary, same
5382
+ * height. So it is the same rule, with a second selector, rather than a second
5383
+ * block that would drift from this one the first time either was touched.
5384
+ * `.twm-window-tabbar` is always the `--top` variant (a window's chrome is its
5385
+ * title bar, and the tabs go under it), so it appears in that block too. */
5386
+ .twm-leaf__tabbar,
5387
+ .twm-window-tabbar {
4493
5388
  flex: 0 0 auto;
4494
5389
  display: flex;
4495
5390
  align-items: stretch;
@@ -4501,7 +5396,51 @@ body.twm-dragging * { user-select: none; }
4501
5396
  user-select: none;
4502
5397
  font-size: var(--font-size-xs);
4503
5398
  }
4504
- .twm-leaf__tabbar--hidden { display: none; }
5399
+ .twm-leaf__tabbar--hidden,
5400
+ .twm-window-tabbar--hidden { display: none; }
5401
+
5402
+ /* ══════════════════════════════════════════════════════════════════════
5403
+ * C33. A TAB DRAGGED INTO A TILE
5404
+ *
5405
+ * Mirrored by hand from `css/overrides.css`, where the source lives with the
5406
+ * rest of the `.twm-leaf*` rules. This sheet is the only one `build.mjs`
5407
+ * copies into `dist/`, and its generator (`scripts/css_extract.py`) is not in
5408
+ * this repository — so a rule written into overrides.css and not repeated
5409
+ * here does not exist for a consumer.
5410
+ *
5411
+ * THE ZONE RECTANGLE IS NOT STYLED HERE. It reuses `.twm-snap-preview
5412
+ * .twm-snap-preview--viewport` above, which is the window drop's own preview
5413
+ * element, so a tab drop and a window drop cannot come to disagree about what
5414
+ * a drop looks like.
5415
+ *
5416
+ * AND THERE IS NO `cursor` RULE, which is not an omission. For the length of a
5417
+ * native drag the browser draws the pointer from `dataTransfer.dropEffect`; a
5418
+ * `cursor` on the drop target is ignored throughout.
5419
+ * ══════════════════════════════════════════════════════════════════════ */
5420
+
5421
+ /* THE PANE UNDER THE POINTER. The preview rectangle says what a release would
5422
+ * PRODUCE; this says which pane is being asked. An inset OUTLINE rather than a
5423
+ * border, because a border changes the box and every tile in the row would
5424
+ * reflow by a pixel as the pointer crossed between them. */
5425
+ .twm-leaf--drop-target {
5426
+ outline: 2px solid var(--accent-bright, #1683c4);
5427
+ outline-offset: -2px;
5428
+ }
5429
+
5430
+ /* A STRIP ARMED TO RECEIVE — the drop that adds the tab at the end of this
5431
+ * tile's list rather than into a zone of its body. */
5432
+ .twm-leaf__tabbar--drop-target {
5433
+ background: color-mix(in srgb, var(--accent-bright, #1683c4) 18%, transparent);
5434
+ box-shadow: inset 0 0 0 1px var(--accent-bright, #1683c4);
5435
+ }
5436
+
5437
+ /* THE TAB BEING CARRIED. `DragReorder` has put `.dragging` on the TOP strip's
5438
+ * tab since it was written and `.notebook-tabs > .tab.dragging` below dims it;
5439
+ * the bottom strip's hand-rolled reorder set no class at all, so a drag there
5440
+ * looked exactly like a click that did nothing. One declaration for both
5441
+ * strips. */
5442
+ .twm-leaf__tab.dragging { opacity: 0.4; }
5443
+
4505
5444
  .twm-leaf__tab-hamburger {
4506
5445
  flex: 0 0 auto;
4507
5446
  display: inline-flex;
@@ -4576,11 +5515,12 @@ body.twm-dragging * { user-select: none; }
4576
5515
  border-top: 1px solid var(--accent-bright, #1683c4);
4577
5516
  clip-path: polygon(8px 0, 100% 0, calc(100% - 8px) 100%, 0 100%);
4578
5517
  }
5518
+ /* Lower-casing removed here too — a tab label is the same embedder-supplied
5519
+ * title as `.twm-leaf__title`, just in the strip at the bottom of the leaf. */
4579
5520
  .twm-leaf__tab-label {
4580
5521
  overflow: hidden;
4581
5522
  text-overflow: ellipsis;
4582
5523
  max-width: 160px;
4583
- text-transform: lowercase;
4584
5524
  letter-spacing: 0.02em;
4585
5525
  }
4586
5526
  .twm-leaf__tab-close {
@@ -4602,6 +5542,293 @@ body.twm-dragging * { user-select: none; }
4602
5542
  opacity: 1;
4603
5543
  color: var(--text-bright);
4604
5544
  }
5545
+
5546
+ /* ══ C22. THE TOP TAB LAYOUT ═════════════════════════════════════════
5547
+ *
5548
+ * `tabLayout: 'top'` puts the strip BETWEEN the chrome and the body and draws
5549
+ * it with `NotebookTabBar` — the editor's tab bar — instead of the spreadsheet
5550
+ * strip above. The bar element is the same `.twm-leaf__tabbar`, so everything
5551
+ * it already carries (the fixed height, `--hidden`, `flex: 0 0 auto`) still
5552
+ * applies; this modifier only turns the strip the right way up.
5553
+ *
5554
+ * The border moves with it. A strip at the bottom of a tile is separated from
5555
+ * the body above it by a top border; a strip at the top is separated from the
5556
+ * body BELOW it by a bottom one, and keeping the top border as well would draw
5557
+ * a second line a pixel under the chrome's own. */
5558
+ .twm-leaf__tabbar--top,
5559
+ .twm-window-tabbar {
5560
+ border-top: none;
5561
+ border-bottom: 1px solid var(--border-default);
5562
+ /* The editor tabs are a touch taller than a 22px spreadsheet tab: they
5563
+ * carry a glyph, a label and a close button on one line, and 22px leaves
5564
+ * the close button touching the label's descenders. */
5565
+ height: 26px;
5566
+ padding: 0;
5567
+ /* `overflow: hidden` on the bottom strip clips the horizontal scroll the
5568
+ * tab list wants. The list scrolls itself (below); the BAR must not. */
5569
+ overflow: visible;
5570
+ }
5571
+ /* `NotebookTabBar.mount()` assigns `className = 'tabs notebook-tabs'` to
5572
+ * whatever container it is handed (`notebook_tab_bar.js:61`) — it does not add
5573
+ * to it — which is why the renderer mounts it into a child of the bar rather
5574
+ * than into the bar. The child is that container, and it fills the bar. */
5575
+ .twm-leaf__tabbar--top > .notebook-tabs,
5576
+ .twm-window-tabbar > .notebook-tabs { flex: 1 1 auto; min-width: 0; }
5577
+ /* A window's body is what the content mounts into, below the strip. It is
5578
+ * the box `.twm-managed-window__content` used to be for a window with no
5579
+ * tabs, so it carries the same three declarations — without them a grid
5580
+ * inside a floated pane sizes to its rows rather than to the window and the
5581
+ * strip is pushed off the bottom edge. */
5582
+ .twm-window-body { flex: 1 1 auto; min-height: 0; min-width: 0; }
5583
+
5584
+ /* ══ NotebookTabBar ══════════════════════════════════════════════════
5585
+ *
5586
+ * A CLASS ONE SIDE KNOWS AND THE OTHER HAD NEVER HEARD OF — the fifth in this
5587
+ * codebase and the first one found before it shipped. `NotebookTabBar` has
5588
+ * rendered `.tabs` / `.tab` / `.tab-icon` / `.twm-tab-title` / `.tab-dirty` /
5589
+ * `.twm-tab-close` / `.nb-context-menu` since it was extracted, and NOT ONE of
5590
+ * those selectors existed in any FlexDesk stylesheet: the component imports
5591
+ * cleanly, mounts without error, is fully interactive, and draws as a row of
5592
+ * unstyled inline text with a `×` after each label. Nothing throws and no test
5593
+ * can see it, which is the signature of the whole family.
5594
+ *
5595
+ * The visual language is the tile chrome's, not an editor's: `--surface-1` bar,
5596
+ * `--surface-0` active tab so it reads as continuous with the body under it,
5597
+ * square corners, `--font-size-xs`. A tab bar drawn in one house style with the
5598
+ * chrome an inch above it in another is how a pane stops looking like one
5599
+ * object. */
5600
+ .tabs.notebook-tabs {
5601
+ display: flex;
5602
+ align-items: stretch;
5603
+ height: 100%;
5604
+ min-width: 0;
5605
+ background: var(--surface-1);
5606
+ user-select: none;
5607
+ font-size: var(--font-size-xs);
5608
+ /* SCROLL, DO NOT WRAP OR SHRINK. The tabs keep their width and the strip
5609
+ * scrolls under them — a tab that shrinks to nothing as you open the tenth
5610
+ * one is a tab you cannot read or aim at. The scrollbar itself is hidden:
5611
+ * it is a 26px strip, and a horizontal bar in it would eat a third of the
5612
+ * label. Wheel and drag still scroll it, and DragReorder auto-scrolls the
5613
+ * edges while a tab is being dragged (`drag_reorder.js:_autoScrollEdge`). */
5614
+ overflow-x: auto;
5615
+ overflow-y: hidden;
5616
+ scrollbar-width: none;
5617
+ }
5618
+ .tabs.notebook-tabs::-webkit-scrollbar { display: none; }
5619
+ .notebook-tabs > .tab {
5620
+ position: relative;
5621
+ flex: 0 0 auto;
5622
+ display: inline-flex;
5623
+ align-items: center;
5624
+ gap: var(--space-xs);
5625
+ max-width: 200px;
5626
+ padding: 0 var(--space-sm);
5627
+ background: var(--surface-1);
5628
+ color: var(--text-muted);
5629
+ border: none;
5630
+ border-right: 1px solid var(--border-subtle);
5631
+ cursor: pointer;
5632
+ line-height: 1;
5633
+ white-space: nowrap;
5634
+ }
5635
+ .notebook-tabs > .tab:hover { background: var(--surface-2); color: var(--text-primary); }
5636
+ /* The active tab is the body's colour and loses the line under it, so the tab
5637
+ * and the pane it shows read as one surface. The accent sits on TOP — the edge
5638
+ * furthest from the body — for the same reason a browser puts it there: it
5639
+ * marks the tab, not the boundary. */
5640
+ .notebook-tabs > .tab.active {
5641
+ background: var(--surface-0);
5642
+ color: var(--text-bright);
5643
+ }
5644
+ .notebook-tabs > .tab.active::before {
5645
+ content: '';
5646
+ position: absolute;
5647
+ inset: 0 0 auto 0;
5648
+ height: 2px;
5649
+ pointer-events: none;
5650
+ background: var(--accent-bright, #1683c4);
5651
+ }
5652
+ /* A PREVIEW tab — opened by a single click and replaced by the next one.
5653
+ * Italic is the convention every editor uses for "this one is not staying". */
5654
+ .notebook-tabs > .tab.tab--preview { font-style: italic; }
5655
+ /* THE GLYPH HAS TO BE TOLD, and it has to be told in TWO selectors.
5656
+ *
5657
+ * `<span class="material-symbols-outlined tab-icon">` carries two classes, and
5658
+ * Google's stylesheet — loaded by every consumer, AFTER this one — sets
5659
+ * `.material-symbols-outlined { font-size: 24px }`. A single-class rule here
5660
+ * ties on specificity and loses to whichever came last, which is theirs. The
5661
+ * compound selector is two classes and wins outright. Fourth outing of this
5662
+ * exact defect; it is why `.twm-leaf__icon` is written the same way. */
5663
+ .notebook-tabs .tab-icon,
5664
+ .notebook-tabs .tab-icon.material-symbols-outlined {
5665
+ flex: 0 0 auto;
5666
+ font-size: 14px;
5667
+ line-height: 1;
5668
+ font-variation-settings: 'opsz' 20, 'wght' 400;
5669
+ }
5670
+ .notebook-tabs .tab-icon[hidden] { display: none; }
5671
+ /* The label. NOT lower-cased and not capitalised: it is the embedder's own
5672
+ * string — a record's display name, typed by a user who capitalised it on
5673
+ * purpose — for the same reason `.twm-leaf__title` lost that rule.
5674
+ *
5675
+ * AND IT NEEDS ITS OWN `line-height`, WHICH IS THE FIX FOR CLIPPED DESCENDERS.
5676
+ * `.tab` sets `line-height: 1` so the row's boxes stack predictably in a 26px
5677
+ * strip; this span sets `overflow: hidden` for the ellipsis, which makes it a
5678
+ * scroll container whose content box is exactly `1 × font-size` tall. Anything
5679
+ * drawn below the baseline — the tail of a `g`, a `y`, a `p` — falls outside
5680
+ * that box and is CLIPPED. It reads as a tab label with its bottom shaved off,
5681
+ * which is precisely how it was reported ("fix the cut-off text in the tabs -
5682
+ * cut off at bottom"), and it is invisible on any label that happens to have no
5683
+ * descender in it.
5684
+ *
5685
+ * 1.45 is the smallest value that clears the descender at every size this strip
5686
+ * is used at; the flex row still centres the span, so the tab's own height and
5687
+ * every other box in it are unchanged. */
5688
+ .notebook-tabs .twm-tab-title {
5689
+ overflow: hidden;
5690
+ text-overflow: ellipsis;
5691
+ letter-spacing: 0.02em;
5692
+ line-height: 1.45;
5693
+ }
5694
+ /* Unsaved work. A dot rather than a `*`, and it takes the close button's slot
5695
+ * rather than adding to the row — in most editors the two swap on hover, but a
5696
+ * dot that MOVES the close button as it appears makes the button a target that
5697
+ * shifts under the pointer. */
5698
+ .notebook-tabs .tab-dirty {
5699
+ flex: 0 0 auto;
5700
+ font-size: 9px;
5701
+ line-height: 1;
5702
+ color: var(--accent-bright, #1683c4);
5703
+ }
5704
+ .notebook-tabs .twm-tab-close {
5705
+ flex: 0 0 auto;
5706
+ display: inline-flex;
5707
+ align-items: center;
5708
+ justify-content: center;
5709
+ width: 14px;
5710
+ height: 14px;
5711
+ padding: 0;
5712
+ background: transparent;
5713
+ border: none;
5714
+ border-radius: var(--radius-sm);
5715
+ color: inherit;
5716
+ /* A `×` CHARACTER, not a Material Symbol — the component writes the glyph
5717
+ * itself (`notebook_tab_bar.js:168`), so this one is sized by `font-size`
5718
+ * on the button and there is no icon font to out-specify. */
5719
+ font-size: 13px;
5720
+ line-height: 1;
5721
+ opacity: 0.55;
5722
+ cursor: pointer;
5723
+ }
5724
+ .notebook-tabs .twm-tab-close:hover {
5725
+ opacity: 1;
5726
+ background: var(--state-hover);
5727
+ color: var(--text-bright);
5728
+ }
5729
+ /* ── Reordering ──────────────────────────────────────────────────────
5730
+ * `DragReorder` puts `.dragging` on the tab under the pointer and inserts a
5731
+ * `.twm-tab-drop-indicator` element at the position it would land
5732
+ * (`drag_reorder.js:_start`, `_ensureIndicator`). Neither had a rule, so a
5733
+ * drag looked exactly like a click that did nothing until the tab arrived
5734
+ * somewhere else. */
5735
+ .notebook-tabs > .tab.dragging { opacity: 0.4; }
5736
+ .twm-tab-drop-indicator {
5737
+ flex: 0 0 auto;
5738
+ align-self: stretch;
5739
+ width: 2px;
5740
+ background: var(--accent-bright, #1683c4);
5741
+ pointer-events: none;
5742
+ }
5743
+
5744
+ /* ── C33. TWO MORE OF THE CLASS-ONE-SIDE-KNOWS FAMILY ────────────────
5745
+ *
5746
+ * Mirrored by hand from `css/base.css`. Both have been set by JavaScript
5747
+ * since the day they were written and defined by NO stylesheet in this
5748
+ * repository, so a cross-pane drop has never drawn anything anywhere:
5749
+ *
5750
+ * `.notebook-tabs--drop-target` `notebook_tab_bar.js`,
5751
+ * `#onStripDragOver`
5752
+ * `.notebook-editor-container--drop-target` `editor/editor_pane.js`,
5753
+ * its own `dragover`
5754
+ *
5755
+ * Fixed alongside C33 rather than left alone, because C33 adds a THIRD
5756
+ * `--drop-target` affordance beside them and shipping a live one next to two
5757
+ * dead ones is how the family keeps growing. Same declarations as
5758
+ * `.twm-leaf__tabbar--drop-target` above, deliberately: three surfaces that
5759
+ * mean "let go here and this list takes it" should look like one thing. */
5760
+ .notebook-tabs--drop-target,
5761
+ .notebook-editor-container--drop-target {
5762
+ background: color-mix(in srgb, var(--accent-bright, #1683c4) 18%, transparent);
5763
+ box-shadow: inset 0 0 0 1px var(--accent-bright, #1683c4);
5764
+ }
5765
+ /* ── The tab bar's own context menu ──────────────────────────────────
5766
+ * Reached only by a consumer that lets the component open it — the tile
5767
+ * renderer suppresses it in favour of the WM's single tab menu, so these rules
5768
+ * are for `EditorPane` and anyone else mounting a `NotebookTabBar` directly.
5769
+ * Same geometry and the same tokens as `.twm-context-menu`, because two
5770
+ * context menus in one application that disagree about their own padding is
5771
+ * how a UI stops feeling assembled. */
5772
+ .nb-context-menu {
5773
+ position: fixed;
5774
+ z-index: 10050;
5775
+ min-width: 180px;
5776
+ padding: var(--space-xxs) 0;
5777
+ background: var(--surface-2);
5778
+ border: 1px solid var(--border-strong);
5779
+ border-radius: var(--radius-sm);
5780
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
5781
+ font-size: var(--font-size-xs);
5782
+ /* Opened hidden and revealed on the next frame (`#showContextMenu` adds
5783
+ * `.visible` in a rAF) so it can fade rather than snap — the same
5784
+ * open-then-reveal the collapsible primitive uses, and the same reason its
5785
+ * `.visible` rule had to exist before anything worked. */
5786
+ opacity: 0;
5787
+ transition: opacity 90ms ease-out;
5788
+ }
5789
+ .nb-context-menu.visible { opacity: 1; }
5790
+ .nb-context-menu__item {
5791
+ display: flex;
5792
+ align-items: center;
5793
+ gap: var(--space-sm);
5794
+ width: 100%;
5795
+ padding: var(--space-xs) var(--space-md);
5796
+ background: transparent;
5797
+ border: none;
5798
+ color: var(--text-primary);
5799
+ text-align: left;
5800
+ cursor: pointer;
5801
+ white-space: nowrap;
5802
+ }
5803
+ .nb-context-menu__item:hover { background: var(--state-hover); }
5804
+ .nb-context-menu__item .material-symbols-outlined {
5805
+ font-size: 14px;
5806
+ line-height: 1;
5807
+ color: var(--text-muted);
5808
+ font-variation-settings: 'opsz' 20, 'wght' 400;
5809
+ }
5810
+ .nb-context-menu__item--danger { color: var(--color-danger, #c74e4e); }
5811
+ .nb-context-menu__item--danger .material-symbols-outlined { color: inherit; }
5812
+ /* DISABLED MEANS DISABLED. `.twm-context-menu-item.disabled` was the third
5813
+ * instance of this family — the JS had always set the class, no rule existed,
5814
+ * and a disabled item rendered at full opacity with a pointer cursor and did
5815
+ * nothing when clicked. `#showContextMenu` binds no listener on a disabled
5816
+ * item, so `pointer-events: none` is belt to that braces. */
5817
+ .nb-context-menu__item--disabled {
5818
+ opacity: 0.4;
5819
+ cursor: default;
5820
+ pointer-events: none;
5821
+ }
5822
+ .nb-context-menu__shortcut {
5823
+ margin-left: auto;
5824
+ padding-left: var(--space-lg);
5825
+ color: var(--text-dim);
5826
+ }
5827
+ .nb-context-menu__separator {
5828
+ height: 1px;
5829
+ margin: var(--space-xxs) 0;
5830
+ background: var(--border-subtle);
5831
+ }
4605
5832
  /* ── Tile tab menu (hamburger pop-out) ────────────────────────────
4606
5833
  * Anchored above the hamburger button. Lists every entity that the
4607
5834
  * tile's home page would show, grouped by source, with search +
@@ -4752,6 +5979,72 @@ body.twm-dragging * { user-select: none; }
4752
5979
  cursor: not-allowed;
4753
5980
  }
4754
5981
 
5982
+ /* Tab switcher — the leaf hamburger's open-tabs list. A compact menu
5983
+ * anchored above the hamburger for quick switching between the tile's
5984
+ * open tabs (distinct from the content browser above). */
5985
+ .twm-tile-tabswitch-overlay {
5986
+ position: fixed;
5987
+ inset: 0;
5988
+ z-index: var(--z-modal);
5989
+ pointer-events: none;
5990
+ }
5991
+ .twm-tile-tabswitch {
5992
+ pointer-events: auto;
5993
+ width: 260px;
5994
+ display: flex;
5995
+ flex-direction: column;
5996
+ background: var(--surface-1);
5997
+ border: 1px solid var(--border-default);
5998
+ box-shadow: var(--shadow-lg);
5999
+ overflow: hidden;
6000
+ }
6001
+ .twm-tile-tabswitch__head {
6002
+ display: flex;
6003
+ align-items: center;
6004
+ gap: var(--space-sm);
6005
+ padding: var(--space-sm) var(--space-md);
6006
+ background: var(--surface-2);
6007
+ border-bottom: 1px solid var(--border-subtle);
6008
+ font-size: var(--font-size-xs);
6009
+ text-transform: uppercase;
6010
+ letter-spacing: 0.04em;
6011
+ color: var(--text-secondary);
6012
+ }
6013
+ .twm-tile-tabswitch__head .material-symbols-outlined { font-size: 14px; }
6014
+ .twm-tile-tabswitch__head-label { flex: 1 1 auto; }
6015
+ .twm-tile-tabswitch__list {
6016
+ list-style: none;
6017
+ margin: 0;
6018
+ padding: 4px 0;
6019
+ overflow-y: auto;
6020
+ }
6021
+ .twm-tile-tabswitch__item {
6022
+ display: grid;
6023
+ grid-template-columns: 18px 1fr;
6024
+ align-items: center;
6025
+ gap: var(--space-xs);
6026
+ padding: 4px 12px;
6027
+ cursor: pointer;
6028
+ color: var(--text-secondary);
6029
+ font-size: var(--font-size-sm);
6030
+ }
6031
+ .twm-tile-tabswitch__item:hover,
6032
+ .twm-tile-tabswitch__item--cursor {
6033
+ background: var(--state-selected);
6034
+ color: var(--text-bright);
6035
+ }
6036
+ .twm-tile-tabswitch__item--cursor {
6037
+ outline: 1px solid var(--accent-bright, #1683c4);
6038
+ outline-offset: -1px;
6039
+ }
6040
+ .twm-tile-tabswitch__item--on { color: var(--text-bright); }
6041
+ .twm-tile-tabswitch__check { font-size: 14px; color: var(--accent-bright, #1683c4); }
6042
+ .twm-tile-tabswitch__label {
6043
+ overflow: hidden;
6044
+ text-overflow: ellipsis;
6045
+ white-space: nowrap;
6046
+ }
6047
+
4755
6048
  /* ── from tiling/command_palette.css ───────────────────────── */
4756
6049
  /* command_palette.css — Ctrl+K popup. */
4757
6050