staffa 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +169 -125
  2. package/dist/components/autocomplete.js +3 -2
  3. package/dist/components/box.js +2 -2
  4. package/dist/components/button.d.ts +11 -2
  5. package/dist/components/button.js +36 -8
  6. package/dist/components/buttonChooser.d.ts +4 -5
  7. package/dist/components/buttonChooser.js +2 -2
  8. package/dist/components/buttonGroup.js +0 -3
  9. package/dist/components/field.js +0 -3
  10. package/dist/components/main.d.ts +34 -9
  11. package/dist/components/main.js +187 -49
  12. package/dist/components/menu.d.ts +118 -0
  13. package/dist/components/menu.js +218 -0
  14. package/dist/components/tabs.d.ts +0 -2
  15. package/dist/components/tabs.js +6 -19
  16. package/dist/components/toast.d.ts +37 -0
  17. package/dist/components/toast.js +79 -0
  18. package/dist/components/tooltip.d.ts +32 -0
  19. package/dist/components/tooltip.js +130 -0
  20. package/dist/core.d.ts +1 -1
  21. package/dist/icons-helpers.d.ts +46 -0
  22. package/dist/icons-helpers.js +44 -0
  23. package/dist/icons.d.ts +1960 -0
  24. package/dist/icons.js +1972 -0
  25. package/dist/index.d.ts +11 -0
  26. package/dist/index.js +8 -0
  27. package/dist/staffa.esm.js +1 -1
  28. package/dist/theme.d.ts +1 -1
  29. package/dist/theme.js +114 -13
  30. package/package.json +10 -4
  31. package/src/components/autocomplete.ts +3 -2
  32. package/src/components/box.ts +2 -2
  33. package/src/components/button.ts +42 -10
  34. package/src/components/buttonChooser.ts +6 -7
  35. package/src/components/buttonGroup.ts +0 -3
  36. package/src/components/field.ts +0 -3
  37. package/src/components/main.ts +201 -45
  38. package/src/components/menu.ts +288 -0
  39. package/src/components/tabs.ts +6 -20
  40. package/src/components/toast.ts +115 -0
  41. package/src/components/tooltip.ts +139 -0
  42. package/src/core.ts +1 -1
  43. package/src/icons-helpers.ts +90 -0
  44. package/src/icons.ts +1977 -0
  45. package/src/index.ts +11 -0
  46. package/src/theme.ts +128 -18
  47. package/dist/components/modal.d.ts +0 -2
  48. package/dist/components/modal.js +0 -2
  49. package/dist/skye.esm.js +0 -1
@@ -11,8 +11,6 @@ export interface ButtonOptions {
11
11
  icon?: Slot;
12
12
  /** Click handler. */
13
13
  click?: (event: Event) => void;
14
- /** Size. Defaults to `"md"`. */
15
- size?: "sm" | "md" | "lg";
16
14
  /** Disables the button. */
17
15
  disabled?: boolean;
18
16
  /** Native button behaviour. Defaults to `"button"`. */
@@ -25,6 +23,10 @@ export interface ButtonOptions {
25
23
  * Aberdeen attr/style string applied to the button. A button is a surface, so
26
24
  * pass surface modifier classes here to restyle it, e.g. `".danger"`,
27
25
  * `".neutral .outlined"`. Defaults to a filled `.primary` surface.
26
+ *
27
+ * Size is set here too, with `.small` or `.large` (medium is the default and
28
+ * needs no class), e.g. `".danger .small"`. A `.small`/`.large` parent (such
29
+ * as a {@link buttonGroup}) also sizes its buttons, so you can set it once.
28
30
  */
29
31
  attrs?: Attributes;
30
32
  }
@@ -38,16 +40,37 @@ A.insertGlobalCss({
38
40
  "display:inline-flex align-items:center justify-content:center gap:$2 " +
39
41
  "font-weight:600 line-height:1.2 white-space:nowrap cursor:pointer text-decoration:none " +
40
42
  "border: 1px solid $s-border; r: $s-radius; padding: 0.5em 1em; " +
41
- "transition: background 0.15s, border-color 0.15s, filter 0.15s, box-shadow 0.15s;",
43
+ "transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, box-shadow 0.15s, transform 0.08s;",
42
44
  "&:focus-visible": "outline:none box-shadow: 0 0 0 3px $s-focus;",
43
45
  "&:disabled, &[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none filter:saturate(0.6)",
44
- "&:hover": "filter: brightness(1.08)",
46
+ // Every button lifts a little toward the cursor on hover (the transform is in
47
+ // the transition list above). The filled `.gradient` CTA below layers a deeper
48
+ // shadow on top of the same lift, so it still reads as the signature action.
49
+ "&:hover": "filter: brightness(1.08); transform: translateY(-1px)",
45
50
  "&.tonal:hover, &.outlined:hover": "background: color-mix(in srgb, $s-b 26%, transparent);",
46
- "&.s-sm": "padding: 0.32em 0.7em; font-size:0.85em",
47
- "&.s-lg": "padding: 0.66em 1.3em; font-size:1.1em",
51
+ // A filled `.gradient` button (the default) is the app's signature call to
52
+ // action: a borderless gradient with a soft glow that lifts on hover. The
53
+ // gradient fill itself comes from the `.s-s.gradient` surface rule in theme.ts.
54
+ // No border: a filled gradient reads as one solid shape. Dropping the border
55
+ // (rather than making it transparent) also sidesteps a Chromium artifact where
56
+ // a gradient clipped to a transparent rounded border fringes the edge with the
57
+ // gradient's far colour.
58
+ "&.gradient:not(.tonal):not(.outlined)": "border:0 box-shadow: $s-glow;",
59
+ "&.gradient:not(.tonal):not(.outlined):hover":
60
+ "filter: brightness(1.06); box-shadow: 0 10px 28px color-mix(in srgb, $s-primary 42%, transparent); transform: translateY(-1px);",
61
+ // Subtle press feedback.
62
+ "&:active:not(:disabled):not([aria-disabled=true])": "transform: translateY(1px)",
63
+ // Size: set on the button itself, or inherited from a `.small`/`.large`
64
+ // parent (e.g. a buttonGroup), so a container can size all its buttons at once.
65
+ "&.small, .small > &": "padding: 0.32em 0.7em; font-size:0.85em",
66
+ "&.large, .large > &": "padding: 0.66em 1.3em; font-size:1.1em",
48
67
  },
49
68
  });
50
69
 
70
+ // Surface-role classes a caller may pass in `attrs`. When one is present we skip
71
+ // the default `.gradient` base so the two roles don't stack on one element.
72
+ const ROLE_CLASS = /\.(gradient|primary|secondary|neutral|danger|success|warning|base|panel|raised)(\.|\s|$)/;
73
+
51
74
  /**
52
75
  * A button. Always carries at least a visible border so its affordance is
53
76
  * obvious at a glance.
@@ -55,6 +78,13 @@ A.insertGlobalCss({
55
78
  * Shortcut: pass a string to use it as the label, or a function for custom
56
79
  * content.
57
80
  *
81
+ * **Tip:** pair `href` with Aberdeen's `interceptLinks()` (called once at app
82
+ * startup) for SPA-style navigation without manual click handlers:
83
+ * ```ts
84
+ * interceptLinks(); // once at root
85
+ * S.button({ href: "/dashboard", text: "Dashboard" }); // navigates via router
86
+ * ```
87
+ *
58
88
  * @example
59
89
  * ```ts
60
90
  * S.button({ text: "Save", click: save });
@@ -67,11 +97,13 @@ export function button(opts: ButtonOptions | string | Content = {}): void {
67
97
  const o: ButtonOptions = typeof opts === "string" ? { text: opts } : typeof opts === "function" ? { content: opts } : opts;
68
98
 
69
99
  const tag = o.href != null ? "a" : "button";
70
- const sizeCls = o.size != null ? `.s-${o.size}` : "";
71
100
 
72
- // A filled `.primary` surface by default; `attrs` (applied after) can override
73
- // the role/variant with e.g. `.danger`, `.neutral .outlined`.
74
- A(`${tag}.s-btn.s-s.primary${sizeCls}`, o.attrs, () => {
101
+ // A filled `.gradient` surface by default the signature CTA. If the caller's
102
+ // `attrs` already names a surface role we omit the default, so `.danger`,
103
+ // `.neutral .outlined`, etc. fully take over (rather than stacking two roles).
104
+ // A bare variant/size (`.outlined`, `.small`) keeps the gradient base.
105
+ const role = o.attrs && ROLE_CLASS.test(o.attrs) ? "" : ".gradient";
106
+ A(`${tag}.s-btn.s-s${role}`, o.attrs, () => {
75
107
  if (o.href != null) {
76
108
  A(`href=${o.href} role=button`);
77
109
  if (o.disabled) A("aria-disabled=true");
@@ -1,5 +1,5 @@
1
1
  import A from "aberdeen";
2
- import { type Bindable, type Attributes } from "../core.js";
2
+ import { type Bindable, type Attributes, type Slot } from "../core.js";
3
3
  import { buttonGroup } from "./buttonGroup.js";
4
4
 
5
5
  /** Options for {@link buttonChooser}. */
@@ -8,9 +8,10 @@ export interface ButtonChooserOptions {
8
8
  attrs?: Attributes;
9
9
  /**
10
10
  * The options to display, as a plain object mapping id → display label.
11
- * Buttons appear in insertion order.
11
+ * Buttons appear in insertion order. A label may be a plain (rich-text)
12
+ * string, or a draw-function for custom content such as an icon.
12
13
  */
13
- options: Record<string, string>;
14
+ options: Record<string, Slot>;
14
15
  /**
15
16
  * Two-way binding for the selected id, or `null` when nothing is selected.
16
17
  * Use an `A.proxy` or `A.ref`.
@@ -21,8 +22,6 @@ export interface ButtonChooserOptions {
21
22
  * `bind.value` to `null`. Useful for "none / auto" states.
22
23
  */
23
24
  allowDeselect?: boolean;
24
- /** Button size. Defaults to `"md"`. */
25
- size?: "sm" | "md" | "lg";
26
25
  /** Name attribute for the hidden `<input>`, enabling form submission. */
27
26
  name?: string;
28
27
  }
@@ -49,8 +48,8 @@ export function buttonChooser(opts: ButtonChooserOptions): void {
49
48
  buttonGroup({
50
49
  attrs: opts.attrs,
51
50
  buttons: Object.entries(opts.options).map(([id, label]) => ({
52
- text: label,
53
- size: opts.size,
51
+ text: typeof label === "string" ? label : undefined,
52
+ content: typeof label === "function" ? label : undefined,
54
53
  attrs: selected === id ? ".primary" : ".neutral .outlined",
55
54
  click: () => {
56
55
  opts.bind.value = (opts.allowDeselect && selected === id) ? null : id;
@@ -24,8 +24,6 @@ A.insertGlobalCss({
24
24
  "&.s-spaced": "gap:$2 flex-wrap:wrap",
25
25
  "&.s-vertical": "flex-direction:column",
26
26
  "&.s-attached": "gap:0",
27
- // When attached, collapse the shared border and square off the touching
28
- // corners, keeping only the outer ends of the group rounded.
29
27
  "&.s-attached:not(.s-vertical) > .s-btn:not(:first-child)": "margin-left:-1px",
30
28
  "&.s-attached:not(.s-vertical) > .s-btn:not(:first-child):not(:last-child)": "r:0",
31
29
  "&.s-attached:not(.s-vertical) > .s-btn:first-child:not(:last-child)": "border-top-right-radius:0 border-bottom-right-radius:0",
@@ -34,7 +32,6 @@ A.insertGlobalCss({
34
32
  "&.s-attached.s-vertical > .s-btn:not(:first-child):not(:last-child)": "r:0",
35
33
  "&.s-attached.s-vertical > .s-btn:first-child:not(:last-child)": "border-bottom-left-radius:0 border-bottom-right-radius:0",
36
34
  "&.s-attached.s-vertical > .s-btn:last-child:not(:first-child)": "border-top-left-radius:0 border-top-right-radius:0",
37
- // Keep the hovered/focused button's border above its neighbours.
38
35
  "&.s-attached > .s-btn:hover, &.s-attached > .s-btn:focus-visible": "z-index:1",
39
36
  },
40
37
  });
@@ -38,12 +38,9 @@ A.insertGlobalCss({
38
38
  "&": "display:flex flex-direction:column gap:$1",
39
39
  "> label": "font-weight:600 font-size:0.9em fg:$s-fg user-select:none",
40
40
  },
41
- // Shared, reusable bits (also used by checkbox & autocomplete).
42
41
  ".s-req": "fg:$s-danger margin-left:2px",
43
42
  ".s-help": "font-size:0.82em fg:$s-fg-muted",
44
43
  ".s-error": "font-size:0.82em fg:$s-danger",
45
- // Shared look for text-like controls: a panel fill with a contextual border,
46
- // the brand accent for focus, and the semantic danger ink when invalid.
47
44
  ".s-input": {
48
45
  "&": "w:100% bg:$s-panel fg:$s-ink border: 1px solid $s-border; r:$s-radius padding: 0.55em 0.7em; transition: border-color 0.15s, box-shadow 0.15s;",
49
46
  "&:hover:not(:disabled)": "border-color:$s-border-strong",
@@ -1,5 +1,6 @@
1
1
  import A from "aberdeen";
2
2
  import { type Content, type Slot, type Attributes, drawSlot } from "../core.js";
3
+ import { type MenuOptions, menuButton, drawMenu } from "./menu.js";
3
4
 
4
5
  /** Options for {@link main}. */
5
6
  export interface MainOptions {
@@ -18,97 +19,252 @@ export interface MainOptions {
18
19
  /** Footer content, pinned below the scroll area. */
19
20
  footer?: Slot;
20
21
  /**
21
- * Max content width. When set, the content is centered in a "sheet" with a
22
- * drop shadow and a distinct surface, against the darker page background.
23
- * e.g. `"60rem"`.
22
+ * Max width for the page's *content*, e.g. `"60rem"`. The header and footer
23
+ * backgrounds still span the full shell width, but their contents and the
24
+ * sidebar + separator + content trio (or just the content when there's no
25
+ * sidebar) — cap to this width and centre horizontally. When unset, everything
26
+ * fills the available width. Either way the content shares the page surface —
27
+ * it is not boxed.
24
28
  */
25
29
  maxWidth?: string;
26
- /** Aberdeen attr/style string applied to the content sheet. */
30
+ /** Aberdeen attr/style string applied to the content area. */
27
31
  contentAttrs?: Attributes;
28
32
  /** Aberdeen attr/style string applied to the top bar. */
29
33
  topbarAttrs?: Attributes;
34
+ /**
35
+ * Navigation menu. When provided, renders a sidebar (in `"left"` / `"right"`
36
+ * mode) or a button+dropdown (in `"button"` mode). The sidebar automatically
37
+ * collapses to button mode when the shell is too narrow.
38
+ */
39
+ nav?: MenuOptions;
40
+ /**
41
+ * Where to render the nav. Defaults to `"left"`.
42
+ * - `"left"` / `"right"`: sidebar next to the content area; collapses to a
43
+ * button+dropdown in the top bar when the shell width drops below 640 px.
44
+ * - `"button"`: always a button+dropdown, never a sidebar.
45
+ */
46
+ navPosition?: "left" | "right" | "button";
47
+ /** Aberdeen attr/style string applied to the sidebar nav panel. */
48
+ navAttrs?: Attributes;
30
49
  }
31
50
 
32
51
  A.insertGlobalCss({
33
52
  ".s-main": {
34
- // Colours come from the surface classes added in main(): the shell is a
35
- // filled base, the bars are raised, the framed sheet is a panel.
36
- "&": "display:flex flex-direction:column min-height:100vh max-height:100vh",
37
- "> header": "display:flex align-items:center gap:$3 padding: $2 $3; border-bottom: 1px solid $s-border; position:sticky top:0 z-index:10",
38
- "> header .s-icon": "display:flex align-items:center font-size:1.4em",
53
+ // container-type so @container queries below can respond to shell width.
54
+ "&": "display:flex flex-direction:column min-height:100vh max-height:100vh container-type:inline-size",
55
+ // Header/footer stretch their background the full shell width; their inner
56
+ // `.s-bar` caps to maxWidth and centres, so chrome aligns with the content.
57
+ "> header": "border-bottom: 1px solid $s-border; position:sticky top:0 z-index:10",
58
+ "> footer": "border-top: 1px solid $s-border; fg:$s-fg-muted",
59
+ "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
60
+ "> header .s-header-icon": "display:flex align-items:center font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
39
61
  "> header .s-titles": "display:flex flex-direction:column min-width:0 flex:1",
40
- "> header .s-title": "font-weight:700 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap",
62
+ "> header .s-title": "font-weight:800 font-size:1.1em line-height:1.2 overflow:hidden text-overflow:ellipsis white-space:nowrap letter-spacing:-0.01em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent; width:fit-content max-width:100%",
41
63
  "> header .s-subtitle": "fg:$s-fg-muted font-size:0.85em overflow:hidden text-overflow:ellipsis white-space:nowrap",
42
64
  "> header .s-menu": "display:flex align-items:center gap:$2",
43
- "> main": "flex:1 overflow-y:auto display:flex flex-direction:column",
44
- "> main > .s-content": "width:100% flex:1",
45
- "> main > .s-content.s-framed": "margin: $3 auto; border: 1px solid $s-border; r:$s-radius-lg box-shadow:$s-shadow p:$4",
46
- "> main > .s-content.s-plain": "p:$3",
47
- "> footer": "display:flex align-items:center gap:$2 padding: $2 $3; border-top: 1px solid $s-border; fg:$s-fg-muted",
65
+ // Body holds sidebar + separator + <main> side by side (only used in sidebar
66
+ // nav mode). It centres `.s-body-inner`, which caps the trio to maxWidth.
67
+ ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center",
68
+ ".s-body-inner": "flex:1 display:flex flex-direction:row min-height:0",
69
+ // Put the sidebar on the right (content fills the left) for right-hand navs.
70
+ "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
71
+ // A vertical hairline between sidebar and content, fading out at both ends —
72
+ // the vertical sibling of the menu's `hr.s-menu-sep`.
73
+ ".s-nav-sep": "width:1px flex-shrink:0 align-self:stretch margin: 0.6rem 0; border:0 background: linear-gradient(to bottom, transparent, $s-border-strong 18%, $s-border-strong 82%, transparent);",
74
+ // Without a sidebar, <main> is a direct child; with one it lives in .s-body.
75
+ "> main, .s-body main": "flex:1 overflow-y:auto display:flex flex-direction:column",
76
+ // The content area fills the scroll region with comfortable padding. Without a
77
+ // sidebar it caps its own width to maxWidth and centres (applied inline in
78
+ // drawMainContent); with one, `.s-body-inner` does the capping for the trio.
79
+ // It is deliberately NOT a boxed "sheet" — content brings its own boxes.
80
+ "> main > .s-content, .s-body main > .s-content": "width:100% flex:1 p:$3",
81
+ // When <main> actually shows a vertical scrollbar (the `.s-scroll-y` class is
82
+ // toggled from JS by watchVerticalOverflow), inset it from the shell edge by
83
+ // $3 so the bar's right edge lines up with the header/footer content (which
84
+ // sits $3 inside the edge via `.s-bar` padding). The $3 gap between the content
85
+ // and the bar already comes from `.s-content`'s padding. Without a scrollbar
86
+ // there's no margin, so the content keeps its single $3 edge — not 2×$3.
87
+ "> main.s-scroll-y, .s-body main.s-scroll-y": "margin-right:$3",
88
+ },
89
+ // Sidebar nav panel. Items reuse the shared `.s-menu-item[-link]` /
90
+ // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
91
+ // dropdown stay visually identical.
92
+ // Borderless and transparent so the page's aurora shows through — an airy,
93
+ // floating sidebar whose only chrome is the active item's gradient pill.
94
+ ".s-nav-panel": {
95
+ // Extra horizontal padding leaves room for the active pill's glow, which the
96
+ // vertical scroll (overflow-y:auto, which also clips overflow-x) would
97
+ // otherwise cut off at the panel edges.
98
+ "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1 background:transparent",
99
+ },
100
+ // In button-only mode (or always-button navPosition), hide the sidebar and
101
+ // show the trigger. In sidebar mode, show the panel and hide the trigger.
102
+ // CSS @container queries handle the responsive collapse automatically.
103
+ ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:none",
104
+ ".s-main.s-nav-btn-only .s-nav-panel": "display:none",
105
+ ".s-main.s-nav-btn-only .s-nav-trigger": "display:flex",
106
+ // Collapse sidebar → button when shell is narrow.
107
+ "@container (max-width: 640px)": {
108
+ ".s-main.s-nav-left .s-nav-panel, .s-main.s-nav-right .s-nav-panel, .s-main .s-nav-sep": "display:none",
109
+ ".s-main.s-nav-left .s-nav-trigger, .s-main.s-nav-right .s-nav-trigger": "display:flex",
110
+ // On phones a top-level content box becomes a full-bleed block: pull it out
111
+ // to negate the content padding and drop the rounded corners.
112
+ ".s-content > .s-box": "margin-inline: calc(-1 * $3); r:0 border-inline:0",
48
113
  },
49
114
  });
50
115
 
51
116
  /**
52
117
  * An application shell that wires up the things almost every app needs: a sticky
53
118
  * top bar (icon, title, subtitle, action menu), a scrollable content area, and a
54
- * footer. With {@link MainOptions.maxWidth} the content becomes a centered,
55
- * shadowed "sheet" the common dashboard/document look while staying fully
56
- * customisable via the various draw-function options and styling strings.
119
+ * footer. With {@link MainOptions.maxWidth} the content area is centred and its
120
+ * width capped. Add a `nav` to get a responsive sidebar (auto-collapses to a
121
+ * menu button below 640 px, or always a button with `navPosition: "button"`).
57
122
  *
58
123
  * @example
59
124
  * ```ts
60
125
  * S.main({
61
126
  * icon: "✦",
62
127
  * title: "Staffa Demo",
63
- * subtitle: "Component playground",
64
128
  * maxWidth: "56rem",
65
- * menu: () => S.button({ text: "New", size: "sm" }),
129
+ * nav: {
130
+ * items: [
131
+ * { label: "Home", icon: () => A("#🏠"), href: "/" },
132
+ * { label: "Settings", href: "/settings" },
133
+ * ],
134
+ * },
135
+ * navPosition: "left",
136
+ * menu: () => S.button({ text: "New", attrs: ".small" }),
66
137
  * content: () => drawPage(),
67
138
  * footer: "© 2026",
68
139
  * });
69
140
  * ```
70
141
  */
71
142
  export function main(opts: MainOptions = {}): void {
72
- A("div.s-main.s-s.base", opts.attrs, () => {
73
- // Top bar only rendered when there's something to show in it.
143
+ const nav = opts.nav;
144
+ const navPos = opts.navPosition ?? "left";
145
+ const hasNav = nav != null && nav.items.length > 0;
146
+ const navCls = hasNav ? (navPos === "button" ? ".s-nav-btn-only" : `.s-nav-${navPos}`) : "";
147
+
148
+ A(`div.s-main.s-s.base${navCls}`, opts.attrs, () => {
149
+ // Top bar.
74
150
  A(() => {
75
- const hasBar = opts.title != null || opts.subtitle != null || opts.icon != null || opts.menu != null;
151
+ const hasBar =
152
+ opts.title != null ||
153
+ opts.subtitle != null ||
154
+ opts.icon != null ||
155
+ opts.menu != null ||
156
+ hasNav;
76
157
  if (!hasBar) return;
77
158
  A("header.s-s.raised", opts.topbarAttrs, () => {
78
- A(() => {
79
- if (opts.icon != null) A("div.s-icon", () => drawSlot(opts.icon));
80
- });
81
- A("div.s-titles", () => {
159
+ A("div.s-bar", () => {
160
+ // Cap the bar's content to maxWidth and centre it within the full-width header.
82
161
  A(() => {
83
- if (opts.title != null) A("div.s-title", () => drawSlot(opts.title));
162
+ if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
84
163
  });
164
+ // Nav trigger button — visible when sidebar is hidden (button mode or narrow viewport).
85
165
  A(() => {
86
- if (opts.subtitle != null) A("div.s-subtitle", () => drawSlot(opts.subtitle));
166
+ if (!hasNav) return;
167
+ // .s-nav-trigger: CSS toggles display based on sidebar visibility.
168
+ A("div.s-nav-trigger", () => {
169
+ menuButton({
170
+ ...nav,
171
+ button: {
172
+ icon: () => A("span aria-hidden=true #☰"),
173
+ ariaLabel: "Open navigation",
174
+ attrs: ".neutral .outlined .small",
175
+ ...nav.button,
176
+ },
177
+ });
178
+ });
179
+ });
180
+
181
+ A(() => {
182
+ if (opts.icon != null) A("div.s-header-icon", () => drawSlot(opts.icon));
183
+ });
184
+ A("div.s-titles", () => {
185
+ A(() => {
186
+ if (opts.title != null) A("div.s-title", () => drawSlot(opts.title));
187
+ });
188
+ A(() => {
189
+ if (opts.subtitle != null) A("div.s-subtitle", () => drawSlot(opts.subtitle));
190
+ });
191
+ });
192
+ A(() => {
193
+ if (opts.menu) A("div.s-menu", () => opts.menu?.());
87
194
  });
88
- });
89
- A(() => {
90
- if (opts.menu) A("div.s-menu", () => opts.menu?.());
91
195
  });
92
196
  });
93
197
  });
94
198
 
95
- // Scrollable main region with the (optionally framed) content sheet.
96
- A("main", () => {
97
- A("div.s-content", opts.contentAttrs, () => {
98
- // Framing applied in its own scope so changing maxWidth doesn't
99
- // recreate the content (which holds the whole page).
100
- A(() => {
101
- const max = opts.maxWidth;
102
- if (max != null) A(".s-framed.s-s.panel max-width:", max);
103
- else A(".s-plain");
199
+ // Body — wraps sidebar + separator + main when nav is in sidebar mode. The
200
+ // trio together caps to maxWidth (via .s-body-inner); main fills the rest.
201
+ if (hasNav && navPos !== "button") {
202
+ A("div.s-body", () => {
203
+ A("div.s-body-inner", () => {
204
+ A(() => {
205
+ if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
206
+ });
207
+ A(`nav.s-nav-panel.s-s.raised.s-nav-${navPos}`, opts.navAttrs, () => {
208
+ drawMenu(nav.items);
209
+ });
210
+ A("div.s-nav-sep aria-hidden=true");
211
+ drawMainContent(opts, false);
104
212
  });
105
- if (opts.content) opts.content();
106
213
  });
107
- });
214
+ } else {
215
+ drawMainContent(opts, true);
216
+ }
108
217
 
109
- // Footer.
218
+ // Footer — full-width background, content centred to maxWidth via .s-bar.
110
219
  A(() => {
111
- if (opts.footer != null) A("footer", () => drawSlot(opts.footer));
220
+ if (opts.footer != null) {
221
+ A("footer", () => {
222
+ A("div.s-bar", () => {
223
+ A(() => {
224
+ if (opts.maxWidth != null) A("max-width:", opts.maxWidth);
225
+ });
226
+ drawSlot(opts.footer);
227
+ });
228
+ });
229
+ }
112
230
  });
113
231
  });
114
232
  }
233
+
234
+ /**
235
+ * Draw the scrollable `<main>` + content area. When `capWidth` is true (no
236
+ * sidebar), the content caps its own width to maxWidth and centres; in sidebar
237
+ * mode the surrounding `.s-body-inner` already caps the sidebar+content trio.
238
+ */
239
+ function drawMainContent(opts: MainOptions, capWidth: boolean): void {
240
+ const mainEl = A("main", () => {
241
+ A("div.s-content", opts.contentAttrs, () => {
242
+ if (capWidth) {
243
+ A(() => {
244
+ if (opts.maxWidth != null) A("margin-inline:auto max-width:", opts.maxWidth);
245
+ });
246
+ }
247
+ if (opts.content) opts.content();
248
+ });
249
+ }) as HTMLElement;
250
+ watchVerticalOverflow(mainEl);
251
+ }
252
+
253
+ /**
254
+ * Toggle the `.s-scroll-y` class on `el` whenever a vertical scrollbar is eating
255
+ * into its width, so CSS can inset the bar from the shell edge (see the
256
+ * `.s-scroll-y` rule above). We key on `offsetWidth > clientWidth` — a
257
+ * *space-consuming* scrollbar — rather than on content overflow, so overlay
258
+ * scrollbars (mobile, macOS) that take no layout width don't trigger the margin.
259
+ * A `ResizeObserver` watches both the viewport and its content, so the class
260
+ * tracks live content/layout changes; it's disconnected when the scope tears down.
261
+ */
262
+ function watchVerticalOverflow(el: HTMLElement): void {
263
+ if (typeof ResizeObserver === "undefined") return; // No-op outside the browser.
264
+ const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
265
+ const ro = new ResizeObserver(update);
266
+ ro.observe(el);
267
+ if (el.firstElementChild) ro.observe(el.firstElementChild);
268
+ update();
269
+ A.clean(() => ro.disconnect());
270
+ }