@apliteni/apliteni-ui 0.128.0 → 0.130.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.130.0",
4
4
  "workspaces": [
5
5
  "react"
6
6
  ],
package/react/README.md CHANGED
@@ -585,6 +585,13 @@ or neutral. Notices disappear after five seconds, pausing while hovered or focus
585
585
  Danger notices are announced assertively; other tones are polite. An `action` with a `label` and
586
586
  `onClick` stays until selected or dismissed. New notices appear at the bottom.
587
587
 
588
+ Pass `compact: true` to show only the title, or `dismissible: false` to hide the
589
+ close button. Hiding the close button keeps the five-second timer for notices
590
+ without actions; notices with actions remain until their action is selected, so a
591
+ notice with an action and no close button has no other dismissal affordance.
592
+ Pass `timer: 12` to dismiss a notice after that many seconds. Pass `timer: false` or `timer: 0`
593
+ to keep a notice until it is dismissed.
594
+
588
595
  ```tsx
589
596
  function SaveButton() {
590
597
  const push = useToast();
@@ -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
  }
@@ -778,6 +778,9 @@ declare const SearchField: react.ForwardRefExoticComponent<Omit<InputHTMLAttribu
778
778
 
779
779
  type ToastNotice = {
780
780
  tone?: 'success' | 'danger' | 'warn' | 'info' | 'neutral';
781
+ compact?: boolean;
782
+ dismissible?: boolean;
783
+ timer?: boolean | number;
781
784
  title: string;
782
785
  text?: string;
783
786
  action?: {
@@ -3432,11 +3432,13 @@ function Notice({ notice, remove }) {
3432
3432
  const root = useRef18(null);
3433
3433
  const [leaving2, setLeaving] = useState12(false);
3434
3434
  const dismissed = useRef18(false);
3435
- const remaining = useRef18(5e3);
3436
3435
  const [hovered, setHovered] = useState12(false);
3437
3436
  const [focused, setFocused] = useState12(false);
3438
3437
  const paused = hovered || focused;
3439
3438
  const tone = notice.tone ?? "success";
3439
+ const timed = !notice.action && notice.timer !== false && notice.timer !== 0;
3440
+ const duration = typeof notice.timer === "number" ? notice.timer * 1e3 : 5e3;
3441
+ const remaining = useRef18(duration);
3440
3442
  const dismiss = useCallback5(() => {
3441
3443
  if (dismissed.current) return;
3442
3444
  dismissed.current = true;
@@ -3458,19 +3460,19 @@ function Notice({ notice, remove }) {
3458
3460
  }
3459
3461
  }, [leaving2, remove]);
3460
3462
  useEffect12(() => {
3461
- if (leaving2 || notice.action || paused) return;
3463
+ if (leaving2 || !timed || paused) return;
3462
3464
  const started = Date.now();
3463
3465
  const timer = setTimeout(dismiss, remaining.current);
3464
3466
  return () => {
3465
3467
  clearTimeout(timer);
3466
3468
  remaining.current = Math.max(0, remaining.current - (Date.now() - started));
3467
3469
  };
3468
- }, [dismiss, leaving2, notice.action, paused]);
3470
+ }, [dismiss, leaving2, paused, timed]);
3469
3471
  return /* @__PURE__ */ jsxs27(
3470
3472
  "div",
3471
3473
  {
3472
3474
  ref: root,
3473
- className: `ui-toast ui-toast--${tone} ui-toast--soft${leaving2 ? " is-leaving" : ""}`,
3475
+ className: `ui-toast ui-toast--${tone} ui-toast--soft${notice.compact ? " ui-toast--compact" : ""}${leaving2 ? " is-leaving" : ""}`,
3474
3476
  role: tone === "danger" ? "alert" : "status",
3475
3477
  "aria-live": tone === "danger" ? "assertive" : "polite",
3476
3478
  onMouseEnter: () => setHovered(true),
@@ -3483,7 +3485,7 @@ function Notice({ notice, remove }) {
3483
3485
  /* @__PURE__ */ jsx32("span", { className: "ui-toast__icon", children: /* @__PURE__ */ jsx32(Icon, { name: glyphs[tone] }) }),
3484
3486
  /* @__PURE__ */ jsxs27("div", { className: "ui-toast__body", children: [
3485
3487
  /* @__PURE__ */ jsx32("div", { className: "ui-toast__title", children: notice.title }),
3486
- notice.text && /* @__PURE__ */ jsx32("div", { className: "ui-toast__text", children: notice.text })
3488
+ !notice.compact && notice.text && /* @__PURE__ */ jsx32("div", { className: "ui-toast__text", children: notice.text })
3487
3489
  ] }),
3488
3490
  notice.action && /* @__PURE__ */ jsx32(
3489
3491
  "button",
@@ -3500,13 +3502,13 @@ function Notice({ notice, remove }) {
3500
3502
  children: notice.action.label
3501
3503
  }
3502
3504
  ),
3503
- /* @__PURE__ */ jsx32("button", { type: "button", className: "ui-toast__close", "aria-label": "Dismiss", disabled: leaving2, onClick: dismiss, children: /* @__PURE__ */ jsx32(Icon, { name: "x" }) }),
3504
- !notice.action && /* @__PURE__ */ jsx32(
3505
+ notice.dismissible !== false && /* @__PURE__ */ jsx32("button", { type: "button", className: "ui-toast__close", "aria-label": "Dismiss", disabled: leaving2, onClick: dismiss, children: /* @__PURE__ */ jsx32(Icon, { name: "x" }) }),
3506
+ timed && /* @__PURE__ */ jsx32(
3505
3507
  "span",
3506
3508
  {
3507
3509
  className: "ui-toast__timer is-running",
3508
3510
  "aria-hidden": "true",
3509
- style: { animationPlayState: paused ? "paused" : "running" }
3511
+ style: { animationDuration: `${duration}ms`, animationPlayState: paused ? "paused" : "running" }
3510
3512
  }
3511
3513
  )
3512
3514
  ]
@@ -3553,7 +3555,7 @@ function Toast({ children }) {
3553
3555
  }
3554
3556
 
3555
3557
  // src/AppShell.tsx
3556
- import { useEffect as useEffect14, useId as useId12, useState as useState14 } from "react";
3558
+ import { useEffect as useEffect14, useId as useId12, useRef as useRef19, useState as useState14 } from "react";
3557
3559
  import { paletteHotkey, prism, railCollapsed } from "@apliteni/apliteni-ui";
3558
3560
 
3559
3561
  // src/SidebarNav.tsx
@@ -3751,16 +3753,26 @@ function AppShell({
3751
3753
  const uid = useId12();
3752
3754
  const [collapsed, setCollapsed] = useState14(defaultCollapsed);
3753
3755
  const [search, setSearch] = useState14(false);
3754
- const [more, setMore] = useState14(false);
3756
+ const [menu, setMenu] = useState14(false);
3757
+ const menuButton = useRef19(null);
3755
3758
  const current = path(pathname);
3756
3759
  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
3760
  useEffect14(() => {
3758
3761
  setCollapsed(railCollapsed() ?? defaultCollapsed);
3759
3762
  }, [defaultCollapsed]);
3760
3763
  useEffect14(() => {
3761
- setMore(false);
3764
+ setMenu(false);
3762
3765
  setSearch(false);
3763
3766
  }, [pathname]);
3767
+ useEffect14(() => {
3768
+ if (!menu) return;
3769
+ const grown = () => {
3770
+ const button = menuButton.current;
3771
+ if (button && getComputedStyle(button).display === "none") setMenu(false);
3772
+ };
3773
+ window.addEventListener("resize", grown);
3774
+ return () => window.removeEventListener("resize", grown);
3775
+ }, [menu]);
3764
3776
  useEffect14(() => {
3765
3777
  const hotkey = (event) => {
3766
3778
  if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return;
@@ -3780,36 +3792,17 @@ function AppShell({
3780
3792
  } catch {
3781
3793
  }
3782
3794
  };
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(
3795
+ const sidebar = (label) => /* @__PURE__ */ jsx35(
3803
3796
  SidebarNav,
3804
3797
  {
3805
3798
  "aria-label": label,
3806
- items: items.map((section) => ({ ...section, id: section.href, badge: section.count })),
3799
+ items: sections.map((section) => ({ ...section, id: section.href, badge: section.count })),
3807
3800
  active: active?.href,
3808
3801
  activeIs: back ? "section" : "page",
3809
3802
  renderLink: (item, props) => {
3810
- const section = items.find((section2) => section2.href === item.id);
3803
+ const section = sections.find((section2) => section2.href === item.id);
3811
3804
  const linkProps = { ...props, onClick: (event) => {
3812
- if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) setMore(false);
3805
+ if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) setMenu(false);
3813
3806
  } };
3814
3807
  return renderLink ? renderLink(section, linkProps) : /* @__PURE__ */ jsx35("a", { ...linkProps });
3815
3808
  }
@@ -3817,8 +3810,6 @@ function AppShell({
3817
3810
  );
3818
3811
  const foldLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
3819
3812
  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
3813
  const initials = account.name.trim().split(/\s+/).map((part) => part[0]).slice(0, 2).join("");
3823
3814
  const lockup = /* @__PURE__ */ jsxs30("a", { className: "ui-app__brand", href: brandHref, "aria-label": word, children: [
3824
3815
  brand ?? /* @__PURE__ */ jsx35("i", { "aria-hidden": "true", className: "ui-react-app__mark", dangerouslySetInnerHTML: { __html: prism(uid, 24) } }),
@@ -3827,7 +3818,7 @@ function AppShell({
3827
3818
  return /* @__PURE__ */ jsxs30("div", { className: `ui-app ui-app--topbar ui-react-app${collapsed ? " is-collapsed" : ""}`, children: [
3828
3819
  navigable && /* @__PURE__ */ jsxs30("div", { className: "ui-app__rail", role: "region", "aria-label": "Sidebar", children: [
3829
3820
  /* @__PURE__ */ jsx35("div", { className: "ui-app__head", children: lockup }),
3830
- sidebar(sections, "Sections"),
3821
+ sidebar("Sections"),
3831
3822
  /* @__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
3823
  /* @__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
3824
  /* @__PURE__ */ jsx35("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }),
@@ -3839,6 +3830,23 @@ function AppShell({
3839
3830
  ] }),
3840
3831
  /* @__PURE__ */ jsxs30("div", { className: "ui-app__well", children: [
3841
3832
  /* @__PURE__ */ jsxs30("header", { className: "ui-app__bar", children: [
3833
+ navigable && /* @__PURE__ */ jsxs30(Fragment12, { children: [
3834
+ /* @__PURE__ */ jsx35(
3835
+ Button,
3836
+ {
3837
+ ref: menuButton,
3838
+ variant: "ghost",
3839
+ icon: "menu",
3840
+ iconOnly: true,
3841
+ "aria-label": "Menu",
3842
+ className: "ui-app__menu",
3843
+ "aria-haspopup": "dialog",
3844
+ "aria-expanded": menu,
3845
+ onClick: () => setMenu(true)
3846
+ }
3847
+ ),
3848
+ /* @__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) } }) })
3849
+ ] }),
3842
3850
  !navigable && lockup,
3843
3851
  /* @__PURE__ */ jsxs30("button", { type: "button", className: "ui-app__search", "aria-haspopup": "dialog", onClick: () => setSearch(true), children: [
3844
3852
  /* @__PURE__ */ jsx35("span", { className: "ui-app__search-ic", children: /* @__PURE__ */ jsx35(Icon, { name: "search" }) }),
@@ -3847,7 +3855,7 @@ function AppShell({
3847
3855
  /* @__PURE__ */ jsx35("kbd", { className: "ui-cmdk__key", children: paletteHotkey() })
3848
3856
  ] }),
3849
3857
  /* @__PURE__ */ jsx35("span", { className: "ui-app__bar-gap" }),
3850
- bandControl,
3858
+ bandControl && /* @__PURE__ */ jsx35("span", { className: "ui-react-app__band-control", children: bandControl }),
3851
3859
  /* @__PURE__ */ jsx35(ThemeToggle, {}),
3852
3860
  /* @__PURE__ */ jsx35("div", { className: "ui-app__user ui-app__user--bar", children: /* @__PURE__ */ jsx35(
3853
3861
  Dropdown,
@@ -3876,24 +3884,7 @@ function AppShell({
3876
3884
  /* @__PURE__ */ jsx35("div", { className: "ui-app__body", children })
3877
3885
  ] })
3878
3886
  ] }),
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") }),
3887
+ 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
3888
  /* @__PURE__ */ jsx35(CommandPalette, { ...palette ?? { groups: [{ items: sections.map((section) => ({ id: section.href, ...section })) }] }, open: search, onClose: () => setSearch(false) })
3898
3889
  ] });
3899
3890
  }
@@ -3921,7 +3912,7 @@ function EmptyState({ variant = "first-run", title, sub, icon: icon7, art, actio
3921
3912
  }
3922
3913
 
3923
3914
  // src/Success.tsx
3924
- import { forwardRef as forwardRef9, useEffect as useEffect15, useRef as useRef19, useState as useState15 } from "react";
3915
+ import { forwardRef as forwardRef9, useEffect as useEffect15, useRef as useRef20, useState as useState15 } from "react";
3925
3916
  import { jsx as jsx37, jsxs as jsxs32 } from "react/jsx-runtime";
3926
3917
  var SuccessCheck = forwardRef9(function SuccessCheck2({ variant = "line", className, ...rest }, ref) {
3927
3918
  const circled = variant === "circled";
@@ -3969,7 +3960,7 @@ var pieces = [
3969
3960
  ];
3970
3961
  function Countdown({ seconds, label = "Redirecting", onDone }) {
3971
3962
  const [remaining, setRemaining] = useState15(seconds);
3972
- const callback = useRef19(onDone);
3963
+ const callback = useRef20(onDone);
3973
3964
  useEffect15(() => {
3974
3965
  callback.current = onDone;
3975
3966
  }, [onDone]);
@@ -4050,7 +4041,7 @@ import {
4050
4041
  useId as useId13,
4051
4042
  useLayoutEffect as useLayoutEffect5,
4052
4043
  useMemo as useMemo4,
4053
- useRef as useRef20,
4044
+ useRef as useRef21,
4054
4045
  useState as useState16
4055
4046
  } from "react";
4056
4047
  import { CHART_FLOOR, bridgeWalk, chartScale, pointTarget } from "@apliteni/apliteni-ui";
@@ -4216,16 +4207,16 @@ function Chart(props) {
4216
4207
  const plotHeight = height ?? HEIGHT[variant];
4217
4208
  const showTable = table ?? !spark;
4218
4209
  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);
4210
+ const frameEl = useRef21(null);
4211
+ const scroller = useRef21(null);
4212
+ const plot = useRef21(null);
4213
+ const tipEl = useRef21(null);
4214
+ const svgEl = useRef21(null);
4215
+ const ignoreMouseUntil = useRef21(0);
4216
+ const touchMoved = useRef21(false);
4217
+ const dismissed = useRef21(null);
4218
+ const opening = useRef21(false);
4219
+ const pointerFocus = useRef21(false);
4229
4220
  const floor = useTapFloor();
4230
4221
  const [width, setWidth] = useState16(0);
4231
4222
  const [openId, setOpenId] = useState16(null);
@@ -4765,7 +4756,7 @@ var AccentPicker = forwardRef10(function AccentPicker2({
4765
4756
  });
4766
4757
 
4767
4758
  // src/NavTabs.tsx
4768
- import { Fragment as Fragment14, forwardRef as forwardRef11, useEffect as useEffect17, useRef as useRef21, useImperativeHandle as useImperativeHandle2 } from "react";
4759
+ import { Fragment as Fragment14, forwardRef as forwardRef11, useEffect as useEffect17, useRef as useRef22, useImperativeHandle as useImperativeHandle2 } from "react";
4769
4760
  import { safeUrl, observeNavFit, revealCurrentNav, revealFocusedNav } from "@apliteni/apliteni-ui";
4770
4761
  import { Fragment as Fragment15, jsx as jsx40, jsxs as jsxs34 } from "react/jsx-runtime";
4771
4762
  var NavTabs = forwardRef11(function NavTabs2({
@@ -4778,7 +4769,7 @@ var NavTabs = forwardRef11(function NavTabs2({
4778
4769
  "aria-label": ariaLabel = "Tabs",
4779
4770
  ...rest
4780
4771
  }, ref) {
4781
- const navRef = useRef21(null);
4772
+ const navRef = useRef22(null);
4782
4773
  useImperativeHandle2(ref, () => navRef.current, []);
4783
4774
  const signature = items.map((item) => item.id).join("\0");
4784
4775
  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));
@@ -16,6 +16,9 @@ import { toast } from './index.js';
16
16
  const reduceMotion = () =>
17
17
  typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
18
18
 
19
+ const resolve = (container) =>
20
+ (typeof container === 'string' ? document.querySelector(container) : container) || null;
21
+
19
22
  // Slide-and-fade a toast out, then remove it. Idempotent.
20
23
  export function dismissToast(el) {
21
24
  if (!el || el.dataset.leaving) return;
@@ -36,20 +39,41 @@ function wireToast(el) {
36
39
  el.querySelector('.ui-toast__close')?.addEventListener('click', () => dismissToast(el));
37
40
 
38
41
  const bar = el.querySelector('[data-toast-timer]');
39
- if (bar && !reduceMotion()) {
42
+ if (bar) {
40
43
  bar.classList.add('is-running');
41
44
  const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
42
- let timer = setTimeout(() => dismissToast(el), dur * 1000);
43
- el.addEventListener('mouseenter', () => { clearTimeout(timer); bar.style.animationPlayState = 'paused'; });
44
- el.addEventListener('mouseleave', () => {
45
- bar.style.animationPlayState = 'running';
46
- const left = (parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7)) || 1);
47
- timer = setTimeout(() => dismissToast(el), dur * 1000 * left);
48
- });
49
- } else if (bar && reduceMotion()) {
50
- // No animated bar under reduced motion, but still auto-dismiss on time.
51
- const dur = parseFloat(getComputedStyle(el).getPropertyValue('--toast-dur')) || 5;
52
- setTimeout(() => dismissToast(el), dur * 1000);
45
+ let remaining = dur * 1000;
46
+ let started = Date.now();
47
+ let timer = setTimeout(() => dismissToast(el), remaining);
48
+ // The line's own pause lives in the sheet, on :hover and :focus-within, so
49
+ // it covers a keyboard reader too. What is left here is the countdown that
50
+ // has to stop with it. This runs even under reduced motion, where the line
51
+ // is hidden but the reader still gets the same hold.
52
+ let held = false;
53
+ const hold = () => {
54
+ if (held) return;
55
+ held = true;
56
+ clearTimeout(timer);
57
+ const visualLeft = !reduceMotion()
58
+ ? parseFloat(getComputedStyle(bar).transform.split(',')[0].slice(7))
59
+ : NaN;
60
+ remaining = Number.isFinite(visualLeft)
61
+ ? dur * 1000 * visualLeft
62
+ : Math.max(0, remaining - (Date.now() - started));
63
+ };
64
+ const release = () => {
65
+ if (!held || el.matches(':hover, :focus-within')) return;
66
+ held = false;
67
+ started = Date.now();
68
+ timer = setTimeout(() => dismissToast(el), remaining);
69
+ };
70
+ el.addEventListener('mouseenter', hold);
71
+ el.addEventListener('mouseleave', release);
72
+ el.addEventListener('focusin', hold);
73
+ // At focusout the old target has already blurred and the new one has not
74
+ // focused yet, so :focus-within reads false even for a move from the close
75
+ // button to the action. relatedTarget is the only thing that knows.
76
+ el.addEventListener('focusout', (e) => { if (!el.contains(e.relatedTarget)) release(); });
53
77
  }
54
78
 
55
79
  // Swipe-to-dismiss (ignore drags that start on a button).
@@ -77,7 +101,7 @@ function wireToast(el) {
77
101
 
78
102
  // Wire every toast currently inside a container (string selector or element).
79
103
  export function wireToastStack(container) {
80
- const root = typeof container === 'string' ? document.querySelector(container) : container;
104
+ const root = resolve(container);
81
105
  if (!root) return null;
82
106
  root.querySelectorAll('.ui-toast').forEach(wireToast);
83
107
  return root;
@@ -85,7 +109,7 @@ export function wireToastStack(container) {
85
109
 
86
110
  // Build a toast from opts, prepend it to a live stack (newest on top), wire it.
87
111
  export function pushToast(container, opts = {}) {
88
- const root = typeof container === 'string' ? document.querySelector(container) : container;
112
+ const root = resolve(container);
89
113
  if (!root) return null;
90
114
  root.insertAdjacentHTML('afterbegin', toast(opts));
91
115
  const el = root.firstElementChild;
@@ -93,9 +93,16 @@
93
93
  .ui-toast__close:hover { color: var(--text); }
94
94
  .ui-toast__close:focus-visible { outline: var(--ring); outline-offset: var(--ring-offset); border-radius: var(--radius-xs); }
95
95
 
96
- /* auto-dismiss progress bar — .is-running is added by wireToastStack() */
96
+ /* auto-dismiss progress line — .is-running is added by wireToastStack() */
97
97
  .ui-toast__timer { position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; background: var(--toast-accent); opacity: 0.85; transform-origin: left; }
98
- .ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the bar spends the toast's dismiss timer, which the caller sets */ }
98
+ .ui-toast__timer.is-running { animation: ui-toast-timer var(--toast-dur, 5s) linear forwards; /* motion: ambient — the line spends the toast's dismiss timer, which the caller sets */ }
99
+ /* The line spends the notice's own timer, so it stops exactly when the timer
100
+ does: under the pointer, and while focus is anywhere inside. Holding both
101
+ pauses here rather than in each face's event wiring keeps the line and the
102
+ countdown from drifting apart, and gives a keyboard reader the same reprieve
103
+ a pointer reader gets. */
104
+ .ui-toast:hover > .ui-toast__timer.is-running,
105
+ .ui-toast:focus-within > .ui-toast__timer.is-running { animation-play-state: paused; }
99
106
 
100
107
  /* Legacy style classes share the neutral card. */
101
108
  .ui-toast--soft, .ui-toast--solid, .ui-toast--outline { background: var(--surface); color: var(--text); }
@@ -110,7 +117,10 @@
110
117
 
111
118
  @media (prefers-reduced-motion: reduce) {
112
119
  .ui-toast, .ui-toast.is-leaving { animation: none; }
113
- .ui-toast__timer.is-running { animation: none; }
120
+ /* A line that cannot move sits full for the whole five seconds and reads as
121
+ "no time spent yet" — the opposite of what it is there to say. The notice
122
+ still expires on time; only the false readout goes. */
123
+ .ui-toast__timer { display: none; }
114
124
  }
115
125
 
116
126
  /* stack container for queued toasts */
@@ -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. */