@apliteni/apliteni-ui 0.128.0 → 0.129.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": "@apliteni/apliteni-ui",
3
- "version": "0.128.0",
3
+ "version": "0.129.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
@@ -852,7 +852,7 @@
852
852
  .rx-toast-stack {
853
853
  position: fixed;
854
854
  right: var(--space-4);
855
- bottom: calc(var(--space-4) + var(--ui-app-bottom-clearance, 0px));
855
+ bottom: var(--space-4);
856
856
  max-width: calc(100% - 2 * var(--space-4));
857
857
  z-index: var(--z-toast);
858
858
  pointer-events: none;
@@ -869,9 +869,6 @@
869
869
  }
870
870
 
871
871
  /* src/AppShell.css */
872
- .ui-react-app__bottom {
873
- display: none;
874
- }
875
872
  .ui-react-app__actions {
876
873
  display: flex;
877
874
  flex-wrap: wrap;
@@ -882,66 +879,71 @@
882
879
  display: inline-flex;
883
880
  opacity: 1;
884
881
  }
885
- .ui-react-app__bottom .ui-nav__item {
886
- min-height: 48px;
887
- }
888
882
  .ui-react-app:not(:has(> .ui-app__rail)) {
889
883
  grid-template-columns: minmax(0, 1fr);
890
884
  }
891
- .ui-react-app .ui-app__bar > .ui-app__brand {
885
+ .ui-react-app .ui-app__bar > .ui-app__brand:not(.ui-app__brand--band) {
892
886
  flex: none;
893
887
  margin-inline-end: var(--space-2);
894
888
  }
889
+ .ui-react-app__menu .ui-nav--side,
890
+ .ui-react-app__menu .ui-nav--side > * {
891
+ width: 100%;
892
+ }
893
+ .ui-react-app__menu .ui-nav__item {
894
+ min-height: 44px;
895
+ }
896
+ :where(:root[data-theme=light]) .ui-react-app__menu .ui-nav__item:not(.is-danger):not(.is-active):hover {
897
+ background: color-mix(in srgb, var(--surface-3) 50%, transparent);
898
+ }
899
+ :where(:root[data-theme=light]) .ui-react-app__menu .ui-nav__item.is-active {
900
+ background: var(--surface-3);
901
+ }
902
+ .ui-react-app__band-control {
903
+ display: contents;
904
+ }
895
905
  @media (max-width: 560px) {
896
- :root:has(.ui-react-app__bottom) {
897
- --ui-app-bottom-clearance: calc(96px + env(safe-area-inset-bottom));
898
- }
899
- :root:has(.ui-react-app) {
900
- scroll-padding-bottom: calc(var(--space-5) + var(--ui-app-bottom-clearance, 0px));
901
- }
902
- .ui-react-app {
903
- display: block;
906
+ .ui-react-app .ui-app__bar {
907
+ gap: var(--space-1);
908
+ padding-inline: var(--space-2);
904
909
  }
905
- .ui-react-app > .ui-app__rail {
910
+ .ui-react-app .ui-app__bar-gap {
906
911
  display: none;
907
912
  }
908
- :root:has(.ui-react-app__bottom) .ui-react-app .ui-app__main {
909
- padding-bottom: var(--ui-app-bottom-clearance, 0px);
910
- }
911
- .ui-react-app .ui-app__bar {
912
- padding-inline: var(--space-4);
913
- }
914
- .ui-react-app .ui-app__bar > .ui-app__brand {
913
+ .ui-react-app .ui-app__search-ic {
915
914
  display: none;
916
915
  }
917
- .ui-react-app__bottom {
918
- display: block;
919
- position: fixed;
920
- inset: auto 0 0;
921
- z-index: var(--z-sticky);
922
- padding: var(--space-2) var(--space-2) calc(var(--space-2) + env(safe-area-inset-bottom));
923
- background: var(--bg);
924
- border-top: 1px solid var(--border);
925
- }
926
- .ui-react-app__bottom .ui-nav__list {
916
+ .ui-react-app .ui-app__search {
917
+ flex: 1 1 auto;
918
+ min-width: 56px;
919
+ padding-inline: var(--space-2);
920
+ }
921
+ .ui-react-app__band-control {
927
922
  display: flex;
928
- flex-direction: row;
923
+ flex: 0 1 auto;
924
+ min-width: 56px;
925
+ max-width: min(90px, calc(100vw - 261px));
929
926
  }
930
- .ui-react-app__bottom li {
931
- flex: 1;
927
+ .ui-react-app__band-control > .ui-btn {
932
928
  min-width: 0;
929
+ min-height: 34px;
930
+ max-width: 100%;
933
931
  }
934
- .ui-react-app__bottom .ui-nav__item {
935
- justify-content: center;
936
- flex-direction: column;
937
- gap: var(--space-1);
938
- padding-inline: var(--space-1);
932
+ .ui-react-app .ui-app__bar .toggle {
933
+ margin-inline-start: 1px;
934
+ }
935
+ .ui-react-app .ui-app__user--bar {
936
+ margin-inline-start: 2px;
937
+ }
938
+ .ui-react-app .ui-app__user-trigger {
939
+ min-width: 44px;
939
940
  }
940
- .ui-react-app__bottom .ui-nav__label {
941
- flex: none;
942
- font-size: var(--text-xs);
941
+ .ui-react-app__band-control > .ui-btn .ui-btn__label {
942
+ overflow: hidden;
943
+ text-overflow: ellipsis;
944
+ white-space: nowrap;
943
945
  }
944
- .ui-react-app__bottom .ui-nav__badge {
946
+ .ui-react-app .ui-app__bar > .ui-app__brand:not(.ui-app__brand--band) {
945
947
  display: none;
946
948
  }
947
949
  }
@@ -3553,7 +3553,7 @@ function Toast({ children }) {
3553
3553
  }
3554
3554
 
3555
3555
  // src/AppShell.tsx
3556
- import { useEffect as useEffect14, useId as useId12, useState as useState14 } from "react";
3556
+ import { useEffect as useEffect14, useId as useId12, useRef as useRef19, useState as useState14 } from "react";
3557
3557
  import { paletteHotkey, prism, railCollapsed } from "@apliteni/apliteni-ui";
3558
3558
 
3559
3559
  // src/SidebarNav.tsx
@@ -3751,16 +3751,26 @@ function AppShell({
3751
3751
  const uid = useId12();
3752
3752
  const [collapsed, setCollapsed] = useState14(defaultCollapsed);
3753
3753
  const [search, setSearch] = useState14(false);
3754
- const [more, setMore] = useState14(false);
3754
+ const [menu, setMenu] = useState14(false);
3755
+ const menuButton = useRef19(null);
3755
3756
  const current = path(pathname);
3756
3757
  const active = sections.filter((section) => current === path(section.href) || path(section.href) !== "/" && current.startsWith(`${path(section.href)}/`)).sort((a, b) => path(b.href).length - path(a.href).length)[0];
3757
3758
  useEffect14(() => {
3758
3759
  setCollapsed(railCollapsed() ?? defaultCollapsed);
3759
3760
  }, [defaultCollapsed]);
3760
3761
  useEffect14(() => {
3761
- setMore(false);
3762
+ setMenu(false);
3762
3763
  setSearch(false);
3763
3764
  }, [pathname]);
3765
+ useEffect14(() => {
3766
+ if (!menu) return;
3767
+ const grown = () => {
3768
+ const button = menuButton.current;
3769
+ if (button && getComputedStyle(button).display === "none") setMenu(false);
3770
+ };
3771
+ window.addEventListener("resize", grown);
3772
+ return () => window.removeEventListener("resize", grown);
3773
+ }, [menu]);
3764
3774
  useEffect14(() => {
3765
3775
  const hotkey = (event) => {
3766
3776
  if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
@@ -3780,36 +3790,17 @@ function AppShell({
3780
3790
  } catch {
3781
3791
  }
3782
3792
  };
3783
- const links = (items) => items.map((section) => {
3784
- const linkProps = {
3785
- href: section.href,
3786
- className: `ui-nav__item${section === active ? " is-active" : ""}`,
3787
- // guidelines/going-back.md, section-lit: the same row, so the same reading as
3788
- // the rail's. "page" here would announce the list as the page the reader is on.
3789
- "aria-current": section === active ? back ? "true" : "page" : void 0,
3790
- "aria-label": `${section.label}${section.count == null ? "" : ` ${section.count}`}`,
3791
- onClick: (event) => {
3792
- if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) setMore(false);
3793
- },
3794
- children: /* @__PURE__ */ jsxs30(Fragment12, { children: [
3795
- /* @__PURE__ */ jsx35("span", { className: "ui-nav__ic", children: /* @__PURE__ */ jsx35(Icon, { name: section.icon }) }),
3796
- /* @__PURE__ */ jsx35("span", { className: "ui-nav__label", children: section.label }),
3797
- section.count != null && /* @__PURE__ */ jsx35("span", { className: "ui-nav__badge is-neutral", children: section.count })
3798
- ] })
3799
- };
3800
- return /* @__PURE__ */ jsx35("li", { children: renderLink ? renderLink(section, linkProps) : /* @__PURE__ */ jsx35("a", { ...linkProps }) }, section.href);
3801
- });
3802
- const sidebar = (items, label) => /* @__PURE__ */ jsx35(
3793
+ const sidebar = (label) => /* @__PURE__ */ jsx35(
3803
3794
  SidebarNav,
3804
3795
  {
3805
3796
  "aria-label": label,
3806
- items: items.map((section) => ({ ...section, id: section.href, badge: section.count })),
3797
+ items: sections.map((section) => ({ ...section, id: section.href, badge: section.count })),
3807
3798
  active: active?.href,
3808
3799
  activeIs: back ? "section" : "page",
3809
3800
  renderLink: (item, props) => {
3810
- const section = items.find((section2) => section2.href === item.id);
3801
+ const section = sections.find((section2) => section2.href === item.id);
3811
3802
  const linkProps = { ...props, onClick: (event) => {
3812
- if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) setMore(false);
3803
+ if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) setMenu(false);
3813
3804
  } };
3814
3805
  return renderLink ? renderLink(section, linkProps) : /* @__PURE__ */ jsx35("a", { ...linkProps });
3815
3806
  }
@@ -3817,8 +3808,6 @@ function AppShell({
3817
3808
  );
3818
3809
  const foldLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
3819
3810
  const navigable = sections.length > 1 || Boolean(back);
3820
- const visible = sections.length > 4 ? sections.slice(0, 3) : sections;
3821
- const remaining = sections.slice(visible.length);
3822
3811
  const initials = account.name.trim().split(/\s+/).map((part) => part[0]).slice(0, 2).join("");
3823
3812
  const lockup = /* @__PURE__ */ jsxs30("a", { className: "ui-app__brand", href: brandHref, "aria-label": word, children: [
3824
3813
  brand ?? /* @__PURE__ */ jsx35("i", { "aria-hidden": "true", className: "ui-react-app__mark", dangerouslySetInnerHTML: { __html: prism(uid, 24) } }),
@@ -3827,7 +3816,7 @@ function AppShell({
3827
3816
  return /* @__PURE__ */ jsxs30("div", { className: `ui-app ui-app--topbar ui-react-app${collapsed ? " is-collapsed" : ""}`, children: [
3828
3817
  navigable && /* @__PURE__ */ jsxs30("div", { className: "ui-app__rail", role: "region", "aria-label": "Sidebar", children: [
3829
3818
  /* @__PURE__ */ jsx35("div", { className: "ui-app__head", children: lockup }),
3830
- sidebar(sections, "Sections"),
3819
+ sidebar("Sections"),
3831
3820
  /* @__PURE__ */ jsx35("div", { className: "ui-app__foot", children: /* @__PURE__ */ jsx35("div", { className: "ui-app__fold-row", children: /* @__PURE__ */ jsxs30("button", { type: "button", className: "ui-nav__item ui-app__fold", "aria-expanded": !collapsed, "aria-label": foldLabel, onClick: fold, children: [
3832
3821
  /* @__PURE__ */ jsx35("span", { className: "ui-nav__ic", children: /* @__PURE__ */ jsxs30("svg", { className: "ui-icon", viewBox: "0 0 24 24", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
3833
3822
  /* @__PURE__ */ jsx35("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }),
@@ -3839,6 +3828,23 @@ function AppShell({
3839
3828
  ] }),
3840
3829
  /* @__PURE__ */ jsxs30("div", { className: "ui-app__well", children: [
3841
3830
  /* @__PURE__ */ jsxs30("header", { className: "ui-app__bar", children: [
3831
+ navigable && /* @__PURE__ */ jsxs30(Fragment12, { children: [
3832
+ /* @__PURE__ */ jsx35(
3833
+ Button,
3834
+ {
3835
+ ref: menuButton,
3836
+ variant: "ghost",
3837
+ icon: "menu",
3838
+ iconOnly: true,
3839
+ "aria-label": "Menu",
3840
+ className: "ui-app__menu",
3841
+ "aria-haspopup": "dialog",
3842
+ "aria-expanded": menu,
3843
+ onClick: () => setMenu(true)
3844
+ }
3845
+ ),
3846
+ /* @__PURE__ */ jsx35("a", { className: "ui-app__brand ui-app__brand--band", href: brandHref, "aria-label": word, children: brand ?? /* @__PURE__ */ jsx35("i", { "aria-hidden": "true", className: "ui-react-app__mark", dangerouslySetInnerHTML: { __html: prism(`${uid}-band`, 24) } }) })
3847
+ ] }),
3842
3848
  !navigable && lockup,
3843
3849
  /* @__PURE__ */ jsxs30("button", { type: "button", className: "ui-app__search", "aria-haspopup": "dialog", onClick: () => setSearch(true), children: [
3844
3850
  /* @__PURE__ */ jsx35("span", { className: "ui-app__search-ic", children: /* @__PURE__ */ jsx35(Icon, { name: "search" }) }),
@@ -3847,7 +3853,7 @@ function AppShell({
3847
3853
  /* @__PURE__ */ jsx35("kbd", { className: "ui-cmdk__key", children: paletteHotkey() })
3848
3854
  ] }),
3849
3855
  /* @__PURE__ */ jsx35("span", { className: "ui-app__bar-gap" }),
3850
- bandControl,
3856
+ bandControl && /* @__PURE__ */ jsx35("span", { className: "ui-react-app__band-control", children: bandControl }),
3851
3857
  /* @__PURE__ */ jsx35(ThemeToggle, {}),
3852
3858
  /* @__PURE__ */ jsx35("div", { className: "ui-app__user ui-app__user--bar", children: /* @__PURE__ */ jsx35(
3853
3859
  Dropdown,
@@ -3876,24 +3882,7 @@ function AppShell({
3876
3882
  /* @__PURE__ */ jsx35("div", { className: "ui-app__body", children })
3877
3883
  ] })
3878
3884
  ] }),
3879
- navigable && /* @__PURE__ */ jsx35("nav", { className: "ui-nav ui-react-app__bottom", "aria-label": "Sections on mobile", children: /* @__PURE__ */ jsxs30("ul", { className: "ui-nav__list", children: [
3880
- links(visible),
3881
- remaining.length > 0 && /* @__PURE__ */ jsx35("li", { children: /* @__PURE__ */ jsxs30(
3882
- "button",
3883
- {
3884
- type: "button",
3885
- className: `ui-nav__item${active && remaining.includes(active) ? " is-active" : ""}`,
3886
- "aria-haspopup": "dialog",
3887
- "aria-expanded": more,
3888
- onClick: () => setMore(true),
3889
- children: [
3890
- /* @__PURE__ */ jsx35("span", { className: "ui-nav__ic", children: /* @__PURE__ */ jsx35(Icon, { name: "moreHorizontal" }) }),
3891
- /* @__PURE__ */ jsx35("span", { className: "ui-nav__label", children: "More" })
3892
- ]
3893
- }
3894
- ) })
3895
- ] }) }),
3896
- /* @__PURE__ */ jsx35(Drawer, { open: more, onClose: () => setMore(false), title: "More sections", side: "bottom", children: sidebar(remaining, "More sections") }),
3885
+ navigable && /* @__PURE__ */ jsx35(Drawer, { open: menu, onClose: () => setMenu(false), title: word, side: "left", size: "sm", children: /* @__PURE__ */ jsx35("div", { className: "ui-react-app__menu", children: sidebar("Menu sections") }) }),
3897
3886
  /* @__PURE__ */ jsx35(CommandPalette, { ...palette ?? { groups: [{ items: sections.map((section) => ({ id: section.href, ...section })) }] }, open: search, onClose: () => setSearch(false) })
3898
3887
  ] });
3899
3888
  }
@@ -3921,7 +3910,7 @@ function EmptyState({ variant = "first-run", title, sub, icon: icon7, art, actio
3921
3910
  }
3922
3911
 
3923
3912
  // src/Success.tsx
3924
- import { forwardRef as forwardRef9, useEffect as useEffect15, useRef as useRef19, useState as useState15 } from "react";
3913
+ import { forwardRef as forwardRef9, useEffect as useEffect15, useRef as useRef20, useState as useState15 } from "react";
3925
3914
  import { jsx as jsx37, jsxs as jsxs32 } from "react/jsx-runtime";
3926
3915
  var SuccessCheck = forwardRef9(function SuccessCheck2({ variant = "line", className, ...rest }, ref) {
3927
3916
  const circled = variant === "circled";
@@ -3969,7 +3958,7 @@ var pieces = [
3969
3958
  ];
3970
3959
  function Countdown({ seconds, label = "Redirecting", onDone }) {
3971
3960
  const [remaining, setRemaining] = useState15(seconds);
3972
- const callback = useRef19(onDone);
3961
+ const callback = useRef20(onDone);
3973
3962
  useEffect15(() => {
3974
3963
  callback.current = onDone;
3975
3964
  }, [onDone]);
@@ -4050,7 +4039,7 @@ import {
4050
4039
  useId as useId13,
4051
4040
  useLayoutEffect as useLayoutEffect5,
4052
4041
  useMemo as useMemo4,
4053
- useRef as useRef20,
4042
+ useRef as useRef21,
4054
4043
  useState as useState16
4055
4044
  } from "react";
4056
4045
  import { CHART_FLOOR, bridgeWalk, chartScale, pointTarget } from "@apliteni/apliteni-ui";
@@ -4216,16 +4205,16 @@ function Chart(props) {
4216
4205
  const plotHeight = height ?? HEIGHT[variant];
4217
4206
  const showTable = table ?? !spark;
4218
4207
  const uid = useId13().replace(/[^\w-]/g, "");
4219
- const frameEl = useRef20(null);
4220
- const scroller = useRef20(null);
4221
- const plot = useRef20(null);
4222
- const tipEl = useRef20(null);
4223
- const svgEl = useRef20(null);
4224
- const ignoreMouseUntil = useRef20(0);
4225
- const touchMoved = useRef20(false);
4226
- const dismissed = useRef20(null);
4227
- const opening = useRef20(false);
4228
- const pointerFocus = useRef20(false);
4208
+ const frameEl = useRef21(null);
4209
+ const scroller = useRef21(null);
4210
+ const plot = useRef21(null);
4211
+ const tipEl = useRef21(null);
4212
+ const svgEl = useRef21(null);
4213
+ const ignoreMouseUntil = useRef21(0);
4214
+ const touchMoved = useRef21(false);
4215
+ const dismissed = useRef21(null);
4216
+ const opening = useRef21(false);
4217
+ const pointerFocus = useRef21(false);
4229
4218
  const floor = useTapFloor();
4230
4219
  const [width, setWidth] = useState16(0);
4231
4220
  const [openId, setOpenId] = useState16(null);
@@ -4765,7 +4754,7 @@ var AccentPicker = forwardRef10(function AccentPicker2({
4765
4754
  });
4766
4755
 
4767
4756
  // src/NavTabs.tsx
4768
- import { Fragment as Fragment14, forwardRef as forwardRef11, useEffect as useEffect17, useRef as useRef21, useImperativeHandle as useImperativeHandle2 } from "react";
4757
+ import { Fragment as Fragment14, forwardRef as forwardRef11, useEffect as useEffect17, useRef as useRef22, useImperativeHandle as useImperativeHandle2 } from "react";
4769
4758
  import { safeUrl, observeNavFit, revealCurrentNav, revealFocusedNav } from "@apliteni/apliteni-ui";
4770
4759
  import { Fragment as Fragment15, jsx as jsx40, jsxs as jsxs34 } from "react/jsx-runtime";
4771
4760
  var NavTabs = forwardRef11(function NavTabs2({
@@ -4778,7 +4767,7 @@ var NavTabs = forwardRef11(function NavTabs2({
4778
4767
  "aria-label": ariaLabel = "Tabs",
4779
4768
  ...rest
4780
4769
  }, ref) {
4781
- const navRef = useRef21(null);
4770
+ const navRef = useRef22(null);
4782
4771
  useImperativeHandle2(ref, () => navRef.current, []);
4783
4772
  const signature = items.map((item) => item.id).join("\0");
4784
4773
  useEffect17(() => observeNavFit(navRef.current), []);
@@ -180,6 +180,10 @@ export const iconOnlyAllowed = {
180
180
  // is the glyph a reader has already met doing this everywhere else. The entry
181
181
  // is the decision — review did not infer it from the glyph looking obvious.
182
182
  download: 'export or download the rows',
183
+ // The phone shell's menu control, decided in #592: below 720px the icon strip goes away
184
+ // and one button opens the full navigation in a drawer. The three-bar glyph is the
185
+ // reader's own mark for this action.
186
+ menu: 'open the navigation drawer',
183
187
  };
184
188
 
185
189
  // The word a wordless control's name may open with. The list above says which
@@ -200,6 +204,7 @@ export const iconOnlyNames = {
200
204
  // depicts it, so a consumer naming it that way is not borrowing the tray for
201
205
  // something else.
202
206
  download: ['export', 'download'],
207
+ menu: ['menu'],
203
208
  };
204
209
 
205
210
  // What a glyph means when a component picks it for the reader rather than a
@@ -153,10 +153,13 @@ export function wireDrawer(root = document) {
153
153
  doc.__drawerGlobalWired = true;
154
154
  // Any [data-drawer-open="ID"] trigger opens the matching drawer.
155
155
  doc.addEventListener('click', (e) => {
156
- const opener = e.target.closest?.('[data-drawer-open]');
156
+ // composedPath() and not target: a trigger inside an open shadow root is retargeted
157
+ // to its host here, and its drawer is found in the tree the trigger is in.
158
+ const opener = e.composedPath().find((n) => n.nodeType === 1 && n.hasAttribute('data-drawer-open'));
157
159
  if (!opener) return;
158
160
  e.preventDefault();
159
- const target = doc.getElementById(opener.getAttribute('data-drawer-open'));
161
+ const id = opener.getAttribute('data-drawer-open');
162
+ const target = (opener.getRootNode().getElementById?.(id)) || doc.getElementById(id);
160
163
  if (target) openDrawer(target, opener);
161
164
  });
162
165
  }
@@ -51,20 +51,24 @@ export function focusablesIn(panel) {
51
51
  return Array.from(panel.querySelectorAll(FOCUSABLE)).filter(reachable);
52
52
  }
53
53
 
54
- // Hide everything *outside* `root` from AT + the tab order: walk root→body and mark each
54
+ // Hide everything *outside* `root` from AT + the tab order: walk through a shadow
55
+ // host when present, then to body, and mark each
55
56
  // ancestor's other children. Scrim and panel live inside the root so they stay
56
57
  // interactive; a drawer under an aria-modal alertdialog is outside it and must not be.
57
58
  function mark(page, doc, root) {
58
59
  let node = root;
59
- while (node && node.parentElement && node !== doc.body) {
60
- for (const sib of node.parentElement.children) {
60
+ while (node && node !== doc.body) {
61
+ const tree = node.getRootNode();
62
+ const parent = node.parentElement || (tree.host ? tree : null);
63
+ if (!parent) break;
64
+ for (const sib of parent.children) {
61
65
  if (sib === node) continue;
62
66
  page.marked.push([sib, sib.getAttribute('aria-hidden'), sib.hasAttribute('inert')]);
63
67
  sib.setAttribute('aria-hidden', 'true');
64
68
  sib.setAttribute('inert', '');
65
69
  sib.inert = true;
66
70
  }
67
- node = node.parentElement;
71
+ node = parent.host || parent;
68
72
  }
69
73
  }
70
74
 
@@ -248,7 +252,7 @@ export function trapTab(panel, e) {
248
252
  if (!items.length) { e.preventDefault(); panel.focus(); return; }
249
253
  const first = items[0];
250
254
  const last = items[items.length - 1];
251
- const active = (panel.ownerDocument || document).activeElement;
255
+ const active = panel.getRootNode().activeElement || panel.ownerDocument.activeElement;
252
256
  if (!panel.contains(active)) {
253
257
  e.preventDefault(); (e.shiftKey ? last : first).focus();
254
258
  } else if (e.shiftKey && (active === first || active === panel)) {
@@ -3,9 +3,10 @@
3
3
  // wireShell() once after mounting wires the fold, the nav's groups and the reader's menu.
4
4
  // why: docs/components.md#the-page-shell
5
5
  import { topbar as productTopbar } from './topbar.js';
6
- import { esc, icon } from './index.js';
6
+ import { esc, icon, button } from './index.js';
7
7
  import { sidebarNav, breadcrumbs, wireNav } from './nav.js';
8
8
  import { dropdown, wireDropdown } from './dropdown.js';
9
+ import { drawer, wireDrawer, closeDrawer } from './drawer.js';
9
10
  import { backLink } from './back.js';
10
11
  import { prism } from '../assets/brand.js';
11
12
  import { ACCOUNT_NAV, toMenuTuple, initials } from './account-nav.js';
@@ -207,7 +208,7 @@ const readerFace = (name, email, named, markOnly = false) =>
207
208
  //
208
209
  // One block, two places: only the direction it opens is the layout's answer.
209
210
  // `portal: true` either way — the rail and the band are both sticky.
210
- function readerBlock({ name, email }, signOutHref, { band = false } = {}) {
211
+ function readerBlock({ name, email }, signOutHref, { band = false, portal = true } = {}) {
211
212
  if (!name && !email) return '';
212
213
  const cls = `ui-app__user${band ? ' ui-app__user--bar' : ''}`;
213
214
  const who = [name, email].filter(Boolean).join(', ');
@@ -225,7 +226,7 @@ function readerBlock({ name, email }, signOutHref, { band = false } = {}) {
225
226
  + `</div>`;
226
227
  return `<div class="${cls}">${dropdown({
227
228
  variant: 'menu',
228
- portal: true,
229
+ portal,
229
230
  direction: band ? 'down' : 'up',
230
231
  align: band ? 'end' : 'start',
231
232
  chevron: !band,
@@ -253,13 +254,26 @@ const searchField = ({ palette, placeholder, shortLabel }) =>
253
254
 
254
255
  // The band: search at its start, the reader at its end, beside the rail rather than
255
256
  // across the top of both. why: docs/components.md#the-page-shell
256
- const shellBar = (search, account, signOutHref) =>
257
+ const shellBar = (search, account, signOutHref, lead = '') =>
257
258
  `<header class="ui-app__bar">`
259
+ + lead
258
260
  + (search ? searchField(search) : '')
259
261
  + `<span class="ui-app__bar-gap"></span>`
260
262
  + readerBlock(account, signOutHref, { band: true })
261
263
  + `</header>`;
262
264
 
265
+ // Below 720px the rail is not drawn. A band holds the menu control and the brand's mark,
266
+ // and the control opens the same rail in the kit's left drawer. Each part carries
267
+ // `ui-app__phone`, so layout.css draws it at that width only.
268
+ // why: docs/components.md#the-page-shell
269
+ const menuControl = (id) => button({ label: 'Menu', variant: 'ghost', icon: 'menu', iconOnly: true })
270
+ .replace('<button ', `<button data-drawer-open="${id}" aria-haspopup="dialog" `)
271
+ .replace('class="', 'class="ui-app__menu ');
272
+
273
+ const bandBrand = (word, href, uid) =>
274
+ `<a class="ui-app__brand ui-app__brand--band" href="${esc(safeUrl(href))}" aria-label="${esc(word)}">`
275
+ + `${prism(`appb-${uid}`, 24)}</a>`;
276
+
263
277
  // Unique-per-render suffix for the brand mark's clip id — the same reason
264
278
  // nav.js keeps a module counter. Two shells on one page must not collide.
265
279
  let _shellUid = 0;
@@ -291,6 +305,7 @@ export function appShell(options = {}) {
291
305
  // The layout's one branch, read once. Everything below asks this and not the string.
292
306
  const banded = layout === 'topbar';
293
307
  const up = back ? backLink(back) : '';
308
+ const menuId = `ui-app-menu-${++_shellUid}`;
294
309
  // No footer slot: the nav is places to go, and the one thing that was in it —
295
310
  // sign out — is in the reader's menu at the rail's foot.
296
311
  const rail = sidebarNav({
@@ -331,6 +346,27 @@ export function appShell(options = {}) {
331
346
  const foot = banded
332
347
  ? (fold ? `<div class="ui-app__foot">${fold}</div>` : '')
333
348
  : readerBlock(account, signOutHref);
349
+ // The phone's way to the same rail: the menu control, and the drawer it opens. The
350
+ // drawer holds a second copy of the nav and the reader, because a drawer is a dialog
351
+ // and the rail is a landmark; layout.css draws one or the other. The banded layout
352
+ // keeps its reader on the band, so its drawer has no foot.
353
+ const menu = menuControl(menuId);
354
+ const mark = bandBrand(word, brandHref, ++_shellUid);
355
+ const phone = `<div class="ui-app__drawer">${drawer({
356
+ side: 'left',
357
+ size: 'sm',
358
+ title: word,
359
+ id: menuId,
360
+ body: sidebarNav({
361
+ sections: [{ label: navLabel, items: nav }],
362
+ active,
363
+ activeIs: up ? 'section' : 'page',
364
+ ariaLabel: navLabel,
365
+ }),
366
+ // Not portalled: a panel lifted to the page would sit outside the open drawer, inert
367
+ // and under it, so Sign out could not be reached.
368
+ footer: banded ? '' : readerBlock(account, signOutHref, { portal: false }),
369
+ })}</div>`;
334
370
  const main = `<main class="ui-app__main${width === 'wide' ? ' ui-app__main--wide' : ''}"${maxWidth ? ` style="--ui-app-main: ${maxWidth}"` : ''}>
335
371
  ${up || (crumbs.length ? breadcrumbs({ items: crumbs }) : '')}
336
372
  ${title ? `<h1>${title}</h1>` : ''}
@@ -339,8 +375,8 @@ export function appShell(options = {}) {
339
375
  </main>`;
340
376
  // The second column is a box of its own: the band and the page are two rows of it.
341
377
  const well = banded
342
- ? `<div class="ui-app__well">${shellBar(search, account, signOutHref)}${main}</div>`
343
- : main;
378
+ ? `<div class="ui-app__well">${shellBar(search, account, signOutHref, menu + mark)}${main}</div>`
379
+ : `${compat ? '' : `<header class="ui-app__bar ui-app__bar--phone">${menu}${mark}</header>`}${main}`;
344
380
  const grid = `<div class="ui-app${banded ? ' ui-app--topbar' : ''}${folded ? ' is-collapsed' : ''}"${auto}>
345
381
  <div class="ui-app__rail" role="region" aria-label="Sidebar">
346
382
  ${head}
@@ -348,8 +384,11 @@ export function appShell(options = {}) {
348
384
  ${foot}
349
385
  </div>
350
386
  ${well}
387
+ ${phone}
351
388
  </div>`;
352
- return compat ? `<div class="ui-app-page">${productTopbar(compat)}${grid}</div>` : grid;
389
+ return compat
390
+ ? `<div class="ui-app-page">${productTopbar(compat).replace('<div class="topbar__in">', `<div class="topbar__in">${menu}`)}${grid}</div>`
391
+ : grid;
353
392
  }
354
393
 
355
394
  // ---- Behaviour -----------------------------------------------------------
@@ -358,6 +397,9 @@ export function appShell(options = {}) {
358
397
  // keeps every shell under root out of the cookie, shells drawn there later
359
398
  // included; only `persist: true` on that root turns it back on.
360
399
  const _wiredDocs = new WeakSet();
400
+ // The roots wireShell() was handed, per document: a resize has to find a drawer open
401
+ // inside a shadow root, which the document's own querySelectorAll does not enter.
402
+ const _wiredRoots = new WeakMap();
361
403
  const _unpersisted = new WeakSet();
362
404
 
363
405
  // The toggle, addressed from the rail that owns it: a fold row of a shell's own rail
@@ -369,6 +411,8 @@ const _unpersisted = new WeakSet();
369
411
  const FOLD_PATH = '.ui-app__fold-row > [data-rail-toggle]';
370
412
  const RAIL_FOLD = `.ui-app__rail ${FOLD_PATH}`;
371
413
 
414
+ const DRAWER_LINK = '.ui-app__drawer a.ui-nav__item[href]';
415
+
372
416
  // Under an opted-out root? Steps out of a shadow root through its host.
373
417
  const optedOut = (node) => {
374
418
  for (let n = node; n; n = n.parentNode || n.host) if (_unpersisted.has(n)) return true;
@@ -390,13 +434,31 @@ function setRail(app, collapsed) {
390
434
  function listen(doc) {
391
435
  if (_wiredDocs.has(doc)) return;
392
436
  _wiredDocs.add(doc);
437
+ // A drawer left open while the window grows past the phone width would be hidden and
438
+ // still hold the page inert, so it closes there. Its wrapper is not drawn above that
439
+ // width, which is how the stylesheet's breakpoint is read without a second copy of it.
440
+ const view = doc.defaultView;
441
+ if (view) {
442
+ view.addEventListener('resize', () => {
443
+ for (const r of _wiredRoots.get(doc) || []) {
444
+ r.querySelectorAll('.ui-app__drawer .ui-drawer.is-open').forEach((d) => {
445
+ if (view.getComputedStyle(d.parentElement).display === 'none') closeDrawer(d);
446
+ });
447
+ }
448
+ });
449
+ }
393
450
  doc.addEventListener('click', (e) => {
394
451
  // A shell's own toggle only, in the head of its own rail: a stray
395
452
  // [data-rail-toggle] in the page body folds nothing. composedPath() rather
396
453
  // than target, so a shell inside an open shadow root is found. closest()
397
454
  // rather than a chain of parents, which the head band made one link longer
398
455
  // and which said nothing about what it was walking through.
399
- const btn = e.composedPath().find((n) => n.nodeType === 1 && n.matches(RAIL_FOLD));
456
+ const path = e.composedPath();
457
+ // A press on a place in the phone drawer closes it. A group's toggle is a button
458
+ // and goes nowhere, so only a link does. why: docs/components.md#the-page-shell
459
+ const row = path.find((n) => n.nodeType === 1 && n.matches(DRAWER_LINK));
460
+ if (row) closeDrawer(row.closest('.ui-drawer'));
461
+ const btn = path.find((n) => n.nodeType === 1 && n.matches(RAIL_FOLD));
400
462
  const app = btn && btn.closest('.ui-app');
401
463
  if (!app) return;
402
464
  const next = !app.classList.contains('is-collapsed');
@@ -408,7 +470,6 @@ function listen(doc) {
408
470
  } catch (err) { /* a sandboxed frame: the fold works, nothing is kept */ }
409
471
  }
410
472
  // A document with no window (DOMParser, createHTMLDocument) has no CustomEvent to send.
411
- const view = doc.defaultView;
412
473
  if (view) {
413
474
  app.dispatchEvent(new view.CustomEvent('ui-rail', { bubbles: true, composed: true, detail: { collapsed: next } }));
414
475
  }
@@ -420,6 +481,7 @@ export function wireShell(root = document, { persist } = {}) {
420
481
  // The reader's menu is a dropdown() like any other, so it is wired like any
421
482
  // other. Idempotent, and a shell with no account draws none to find.
422
483
  wireDropdown(root);
484
+ wireDrawer(root);
423
485
  // A server cannot know which key the reader holds, so the browser corrects it here —
424
486
  // off the ROOT's window, not the global one. why: docs/components.md#the-page-shell
425
487
  const here = root.nodeType === 9 ? root : root.ownerDocument;
@@ -428,6 +490,10 @@ export function wireShell(root = document, { persist } = {}) {
428
490
  for (const kbd of root.querySelectorAll('[data-palette-hotkey]')) kbd.textContent = key;
429
491
  const doc = root.nodeType === 9 ? root : root.ownerDocument;
430
492
  listen(doc);
493
+ if (!_wiredRoots.has(doc)) _wiredRoots.set(doc, new Set());
494
+ const roots = _wiredRoots.get(doc);
495
+ for (const r of roots) if (r !== doc && !(r.host || r).isConnected) roots.delete(r);
496
+ roots.add(root);
431
497
  if (persist === false) _unpersisted.add(root);
432
498
  else if (persist === true) _unpersisted.delete(root);
433
499
  const saved = railCollapsed(cookieOf(doc));
@@ -119,7 +119,7 @@
119
119
  resting step. */
120
120
  /* Re-tuned at #295, which took the light rail down a step with the page. Below 720px
121
121
  the glyph is the whole row, so it is held at text grade rather than at 1.4.11's bar —
122
- stories/apps/shell-states.test.js:1708 `a resting glyph is dimmer but still legible`. */
122
+ stories/apps/shell-states.test.js:1535 `a resting glyph is dimmer but still legible`. */
123
123
  .ui-app__rail .ui-nav__ic svg { opacity: 0.66; }
124
124
 
125
125
  /* A destructive row of the rail is a navigation row and rests like one; the danger
@@ -139,6 +139,8 @@
139
139
  padding-top: var(--space-5);
140
140
  border-top: 1px solid var(--border);
141
141
  }
142
+ /* A drawer's footer carries no rule above it, so the block keeps the rail's rule out. */
143
+ .ui-app__drawer .ui-app__user { padding-top: 0; border-top: 0; }
142
144
  /* The other thing that can close a rail: where the reader's block is in the band,
143
145
  the fold's control takes its place under the same rule.
144
146
  why: docs/components.md#the-page-shell */
@@ -254,8 +256,7 @@
254
256
  .ui-app.is-collapsed .ui-app__fold .ui-app__fold-arrow { transform: scaleX(-1); }
255
257
 
256
258
  /* -- The collapsed rail --------------------------------------------------- */
257
- /* Each rule here has its twin in the 720px block below; nothing lays a row out
258
- again. why: docs/components.md#the-page-shell */
259
+ /* The reader's own fold, at the widths that have a rail. why: docs/components.md#the-page-shell */
259
260
  .ui-app.is-collapsed { --ui-rail-w: calc(var(--ui-nav-strip) + 2 * var(--space-4) + var(--ui-rail-rule)); }
260
261
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__cap,
261
262
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__label,
@@ -263,20 +264,17 @@
263
264
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__caret,
264
265
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-app__who,
265
266
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-dropdown__chevron,
266
- :where(.ui-app.is-collapsed) .ui-app__brand span { opacity: 0; pointer-events: none; }
267
+ :where(.ui-app.is-collapsed) .ui-app__rail .ui-app__brand span { opacity: 0; pointer-events: none; }
267
268
  /* The toggle rides onto the glyph column — the two widths' own difference — and the
268
- lockup goes whole, since that column is where its mark stood. The mark may only go
269
- where the control arrives: below 720px the toggle is not drawn, so the 720px block
270
- gives the lockup back rather than leaving an empty band with a hairline under it.
271
- why: docs/components.md#the-page-shell */
269
+ lockup goes whole, since that column is where its mark stood. Below 720px there is
270
+ no rail, so none of this is drawn. why: docs/components.md#the-page-shell */
272
271
  :where(.ui-app.is-collapsed) .ui-app__head > .ui-app__fold-row { inset-inline-start: calc(var(--ui-nav-strip) - var(--ui-nav-col)); }
273
272
  /* …and only in the layout where something arrives on the mark's column. In the
274
273
  topbar layout the toggle is at the rail's foot, nothing lands where the mark
275
274
  stands, and a lockup faded out there leaves a band with nothing in it and its
276
- hairline still under it — the defect the 720px block below exists to avoid. So
277
- that layout keeps the mark and lets the shared rule above fold away its words.
275
+ hairline still under it. So that layout keeps the mark and lets the shared rule above fold away its words.
278
276
  why: docs/components.md#the-page-shell */
279
- :where(.ui-app:not(.ui-app--topbar).is-collapsed) .ui-app__brand { opacity: 0; visibility: hidden; pointer-events: none; }
277
+ :where(.ui-app:not(.ui-app--topbar).is-collapsed) .ui-app__rail .ui-app__brand { opacity: 0; visibility: hidden; pointer-events: none; }
280
278
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__sub { margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0; }
281
279
  :where(.ui-app.is-collapsed) .ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
282
280
  /* The current row's plate on the glyph column, and beside it the one thing a bare rail
@@ -502,61 +500,48 @@
502
500
  .ui-toolbar--split > :last-child { margin-inline-start: 0; align-self: start; }
503
501
  }
504
502
 
505
- /* -- The narrow rail ------------------------------------------------------ */
506
- /* Below this width the rail folds to the icon strip instead of disappearing, and
507
- it is the fold the reader's own press makes — every rule here has its twin
508
- above. Scoped to the shell's own rail; a sidebarNav() elsewhere keeps its
509
- labels. Nothing here resizes a glyph: an icon-sizing rule inside a media query
510
- is one src/styles/icon-size.test.js cannot measure, and it says so. */
503
+ /* -- The phone shell ------------------------------------------------------ */
504
+ /* Below this width there is no rail and no strip. A band at the top holds the menu
505
+ control and the brand's mark, the control opens the same rail in the kit's left
506
+ drawer, and the reading column takes the whole width. The band, the control and the
507
+ drawer exist at every width and are not drawn above it, so the rail's rows above
508
+ 720px are the rows they were. why: docs/components.md#the-page-shell */
509
+ .ui-app__bar--phone { display: none; }
510
+ .ui-btn.ui-app__menu { display: none; }
511
+ .ui-app__brand--band { display: none; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; /* a finger's box around a 24px mark */ }
512
+ .ui-app__drawer { display: none; }
513
+ /* The drawer panel is --bg-elevated, which --surface matches exactly in light, so nav.css's
514
+ plate measures 1.00:1 there. The current row and the hover wash take the rung the rail
515
+ takes (--surface-3), and the count keeps its --surface pill on that plate. Dark already
516
+ has a step and keeps it. why: docs/components.md#the-page-shell */
517
+ :where(:root[data-theme="light"]) .ui-app__drawer .ui-nav__item:not(.is-danger):not(.is-active):hover { background: color-mix(in srgb, var(--surface-3) 50%, transparent); /* code-bg: inherit — The wash keeps the containing ground. */ }
518
+ :where(:root[data-theme="light"]) .ui-app__drawer .ui-nav__item.is-active { background: var(--surface-3); /* code-bg: inherit — The control keeps the containing ground. */ }
511
519
  @media (max-width: 720px) {
512
- .ui-app { --ui-rail-w: calc(var(--ui-nav-strip) + 2 * var(--space-4) + var(--ui-rail-rule)); }
513
- /* The strip is the only layout this width has, so there is nothing to fold. */
514
- .ui-app__rail .ui-app__fold-row { display: none; }
515
- /* …and the foot goes with it, for the reason the head band does below: in the
516
- topbar layout that box holds the toggle and nothing else, so at this width it is
517
- padding and a hairline drawn over nothing. Scoped to the shell's own rail, like
518
- every other rule in this block. why: docs/components.md#the-page-shell */
519
- .ui-app__rail .ui-app__foot { display: none; }
520
- /* …and with it the band, when the band has nothing else in it: a head with no
521
- wordmark is the toggle alone, so at this width it is padding and a hairline
522
- drawn over nothing. The reader's fold cannot take this rule — there the toggle
523
- IS drawn. why: docs/components.md#the-page-shell */
524
- .ui-app__head:not(:has(> .ui-app__brand)) { display: none; }
525
- /* And where the band does hold the mark, the mark stays — at this width the press
526
- that folded the rail on a desktop still reads out of the cookie, and the control
527
- that would undo it is the one above. A head band faded to nothing here is an empty
528
- 41px box over a rule, with no way home. The reader's fold cannot take this rule —
529
- there the toggle lands on the column the mark gives up.
530
- why: docs/components.md#the-page-shell */
531
- .ui-app__brand { opacity: 1; visibility: visible; pointer-events: auto; }
532
- .ui-app__rail .ui-nav__cap,
533
- .ui-app__rail .ui-nav__label,
534
- .ui-app__rail .ui-nav__badge,
535
- .ui-app__rail .ui-nav__caret,
536
- .ui-app__rail .ui-app__who,
537
- .ui-app__rail .ui-dropdown__chevron,
538
- .ui-app__brand span { opacity: 0; pointer-events: none; }
539
- .ui-app__rail .ui-nav__sub { margin-inline-start: 0; padding-inline-start: 0; border-inline-start-width: 0; }
540
- .ui-app__rail .ui-nav__item:not(:has(.ui-nav__ic))::after { opacity: 0.62; }
541
- .ui-app__rail .ui-nav__item.is-active { isolation: isolate; background: none; }
542
- .ui-app__rail .ui-nav__item.is-active::before { content: ""; position: absolute; left: 0; top: 50%; width: var(--ui-nav-strip); height: 100%; border-radius: 10px; transform: translateY(-50%); /* motion: still — a mark on the current row, whose own highlight already transitions; this centres it, it does not travel */ background: var(--surface-3); /* code-bg: inherit — The plate contains no focus target. */ z-index: -1; }
543
- .ui-app__rail .ui-nav__item:not(.is-danger):not(.is-active):hover { background: none; }
544
- /* The one line of this block that is deliberately NOT the reader's fold: a finger
545
- is the only pointer this width has, so a row and the account block are held to
546
- 44px. Neither floor can spread to the press, where growing the box is the one
547
- thing the travel promises nothing does. why: docs/components.md#the-page-shell */
548
- .ui-app__rail .ui-nav__item,
549
- .ui-app__rail .ui-app__user-trigger { min-height: 44px; }
550
- .ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
551
- .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who { position: fixed; left: calc(var(--ui-rail-w) + var(--space-2)); z-index: var(--z-dropdown); width: max-content; max-width: 15rem; margin-block: -7px; padding: 6px 10px; border: 1px solid var(--float-edge); box-shadow: inset 0 0 0 1px var(--elev-edge, var(--float-edge-inner)), var(--elev-drop); border-radius: var(--radius-xs); background: var(--surface); /* code-bg: inherit — decorative or text-only paint; nothing nested reads a ground from it. */ color: var(--strong); opacity: 1; pointer-events: none; }
552
- @supports (anchor-name: --a) and (anchor-scope: --a) {
553
- .ui-app { anchor-scope: --ui-rail; }
554
- .ui-app__rail { anchor-name: --ui-rail; }
555
- .ui-app__rail .ui-nav__item,
556
- .ui-app__rail .ui-app__user-trigger { anchor-name: --ui-rail-row; anchor-scope: --ui-rail-row; }
557
- .ui-app__rail .ui-nav__item:is(:hover, :focus-visible) > .ui-nav__label,
558
- .ui-app__rail .ui-app__user-trigger:is(:hover, :focus-visible) > .ui-app__who { position-anchor: --ui-rail-row; left: calc(anchor(--ui-rail right) + var(--space-2)); top: anchor(top); bottom: anchor(bottom); align-self: center; margin-block: 0; }
559
- }
520
+ /* A column, not a grid: the band sticks to the top of the shell, and a sticky grid
521
+ item sticks only inside its own row. */
522
+ .ui-app { display: flex; flex-direction: column; }
523
+ .ui-app > .ui-app__rail { display: none; }
524
+ .ui-app__well { flex: 1 1 auto; }
525
+ .ui-app > .ui-app__main { flex: 1 1 auto; }
526
+ .ui-app > .ui-app__bar--phone { display: flex; }
527
+ .ui-btn.ui-app__menu { display: inline-flex; }
528
+ .ui-app__brand--band { display: flex; }
529
+ /* The menu control stands at the band's start with the band's inset beside it. */
530
+ .ui-app__bar { padding-inline: var(--space-2) var(--space-4); }
531
+ /* The drawer paints itself and sits out of the flow, so its wrapper takes no height. */
532
+ .ui-app__drawer { display: block; }
533
+ /* The rail's rows are the drawer's rows: the column fills the panel, and the foot
534
+ starts at the panel's edge like the rows above it. */
535
+ .ui-app__drawer .ui-nav--side,
536
+ .ui-app__drawer .ui-nav--side > * { width: 100%; }
537
+ /* The panel's title names the list, so the section's own caption stays out. */
538
+ .ui-app__drawer .ui-nav__cap { display: none; }
539
+ .ui-app__drawer .ui-drawer__footer { justify-content: flex-start; }
540
+ .ui-app__drawer .ui-app__user { width: 100%; }
541
+ /* A finger is the only pointer this width has, so a row and the account block are
542
+ held to 44px. why: docs/components.md#the-page-shell */
543
+ .ui-app__drawer .ui-nav__item,
544
+ .ui-app__drawer .ui-app__user-trigger { min-height: 44px; }
560
545
  .ui-app__main { padding: 28px var(--space-4) 56px; }
561
546
  .ui-app__main h1 { font-size: calc(var(--text-xl) + 3px); }
562
547
  /* A settings row is label-beside-control down to the width where the control
@@ -733,16 +718,3 @@
733
718
  forced-color-adjust: auto; background: Canvas; color: CanvasText; border-color: CanvasText;
734
719
  }
735
720
  }
736
- @media (forced-colors: active) and (max-width: 720px) {
737
- .ui-app .ui-app__rail .ui-nav__item:is(.is-active, .is-current) { isolation: isolate; background: none !important; }
738
- .ui-app .ui-app__rail .ui-nav__item.is-current:not(.is-active)::before {
739
- content: ""; position: absolute; left: 0; top: 50%; width: var(--ui-nav-strip); height: 100%; border-radius: 10px;
740
- transform: translateY(-50%);
741
- z-index: -1;
742
- }
743
- .ui-app .ui-app__rail .ui-nav__item.is-current:not(.is-active):not(.is-disabled)::before { background: Highlight; }
744
- .ui-app .ui-app__rail .ui-nav__item.is-current.is-disabled:not(.is-active)::before { background: GrayText; }
745
- .ui-app .ui-app__rail .ui-nav__item:is(.is-active, .is-current):is(:hover, :focus-visible) > .ui-nav__label {
746
- forced-color-adjust: auto; background: Canvas; color: CanvasText; border-color: CanvasText;
747
- }
748
- }
@@ -252,6 +252,15 @@
252
252
  padding: var(--space-1) var(--space-3) 3px;
253
253
  }
254
254
  .topbar__in > .brand { grid-area: 1 / 1 / 2 / 3; justify-self: start; }
255
+ /* With the shell's menu control on the band it takes the first column and the brand
256
+ the next, so the switch row below spans both. */
257
+ .topbar__in:has(> .ui-app__menu) { grid-template-columns: max-content max-content minmax(0, 1fr) auto auto; }
258
+ .topbar__in > .ui-app__menu { grid-area: 1 / 1; margin-inline-end: var(--space-2); }
259
+ .topbar__in:has(> .ui-app__menu) > .brand { grid-area: 1 / 2 / 2 / 4; }
260
+ .topbar__in:has(> .ui-app__menu) > .toggle { grid-area: 1 / 4; }
261
+ .topbar__in:has(> .ui-app__menu) > .acct { grid-area: 1 / 5; }
262
+ .topbar__in:has(> .ui-app__menu) > .dtsw { grid-area: 2 / 1 / 3 / 3; }
263
+ .topbar__in:has(> .ui-app__menu) > .vsw { grid-area: 2 / 3 / 3 / -1; }
255
264
  .topbar__in > .toggle { grid-area: 1 / 3; justify-self: end; }
256
265
  /* The 8px beside the account is its own margin, so a hidden account leaves no gap and
257
266
  the theme switch ends on the inset. */