@stealthscale/component-screen 0.0.1 → 0.2.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.
@@ -1,4 +1,5 @@
1
- import { CONTROL_INSET_END, CONTROL_INSET_START, axis, below, cornerVariants, defineSlotRecipe, dense, divider, interactive, lookVariants, motion, onSlot, onSlots, paletteVariants, sizeVariants, surface, truncate } from "@stealthscale/theme/authoring";
1
+ import { i as NARROW$1, n as FOLDING } from "./recipe-DRMsitrw.js";
2
+ import { CONTROL_INSET_END, CONTROL_INSET_START, axis, below, cornerVariants, defineSlotRecipe, dense, divider, interactive, motion, onSlot, onSlots, paletteVariants, sizeVariants, surface, truncate } from "@stealthscale/theme/authoring";
2
3
  //#region src/action-bar/recipe.ts
3
4
  /**
4
5
  * Styles the action bar, a panel fixed to the bottom of the window with the actions for what a
@@ -9,8 +10,9 @@ import { CONTROL_INSET_END, CONTROL_INSET_START, axis, below, cornerVariants, de
9
10
  * recipe passes its variants from an ancestor. The positioner spans the window inside the inset
10
11
  * and takes no pointer events, so the page around the bar remains pressable. The toolbar inside
11
12
  * the bar is as wide as the bar, which is at least `sizes.2xl`, 672px. The toolbar folds when it
12
- * is narrower than the `sm` breakpoint, 640px. The minimum width makes it fold on the width of
13
- * the window and not on the width of its folded content. The recipe has no `palette` axis,
13
+ * is narrower than the `sm` breakpoint, 640px, and while its controls need more room than the
14
+ * window leaves the bar. The minimum width makes it fold on the width of the window and not on
15
+ * the width of its folded content. The recipe has no `palette` axis,
14
16
  * because the bar reads the neutral `bg.panel`, and no `effect` axis, because the bar is not a
15
17
  * control.
16
18
  */
@@ -37,7 +39,7 @@ const ALIGNED = {
37
39
  /**
38
40
  * Defines the action bar recipe, which places the bar in the middle of the bottom edge by default.
39
41
  */
40
- const recipe$8 = defineSlotRecipe({
42
+ const recipe$7 = defineSlotRecipe({
41
43
  base: {
42
44
  closeTrigger: { flexShrink: "0" },
43
45
  content: {
@@ -85,6 +87,98 @@ const recipe$8 = defineSlotRecipe({
85
87
  placement: onSlot("positioner", axis(PLACEMENTS, (placement) => ({ justifyContent: ALIGNED[placement] }))()) }
86
88
  });
87
89
  //#endregion
90
+ //#region src/resize/widths.ts
91
+ /**
92
+ * Attribute an element has while a drag inside it resizes a panel or a column.
93
+ */
94
+ const RESIZING = "data-resizing";
95
+ /**
96
+ * Returns the CSS length of a width a person resized an element to: the width, and never wider than
97
+ * its limit, so a width stored on a wide screen fits a narrow one.
98
+ *
99
+ * @param width - The width, in pixels.
100
+ * @param max - The widest width, in pixels. Half the window where left out.
101
+ * @returns The length, such as `min(320px, 50vw)`.
102
+ */
103
+ function lengthOf(width, max) {
104
+ const limit = max === void 0 ? "50vw" : `${String(max)}px`;
105
+ return `min(${String(width)}px, ${limit})`;
106
+ }
107
+ /**
108
+ * Returns a width held between the bounds.
109
+ *
110
+ * @param width - The width asked for.
111
+ * @param bounds - The narrowest and the widest width.
112
+ * @returns The width, raised to the narrowest or lowered to the widest.
113
+ */
114
+ function clamped(width, { max, min }) {
115
+ return Math.min(Math.max(width, min), max);
116
+ }
117
+ /**
118
+ * Returns 1 where the pointer moving to the physical right widens the element, and -1 where it
119
+ * narrows it.
120
+ *
121
+ * @param edge - Side of the page the element is on.
122
+ * @param rtl - Whether the page reads right to left.
123
+ */
124
+ function growthOf(edge, rtl) {
125
+ return edge === "start" === !rtl ? 1 : -1;
126
+ }
127
+ /**
128
+ * Returns the width a key moves the element to.
129
+ *
130
+ * @param key - The key pressed, as `KeyboardEvent.key` spells it.
131
+ * @param shift - Whether Shift is held.
132
+ * @param width - The element's width now.
133
+ * @param scale - The narrowest and the widest width, and the direction the width grows in.
134
+ * @returns The new width, or undefined for a key that resizes nothing.
135
+ */
136
+ function keyedWidth(key, shift, width, scale) {
137
+ const step = (shift ? 50 : 10) * scale.growth;
138
+ if (key === "ArrowRight") return clamped(width + step, scale);
139
+ if (key === "ArrowLeft") return clamped(width - step, scale);
140
+ if (key === "Home") return scale.min;
141
+ if (key === "End") return scale.max;
142
+ }
143
+ //#endregion
144
+ //#region src/resize/strip.ts
145
+ /**
146
+ * Styles the strip on the edge of a panel or a column that a person presses, drags or focuses.
147
+ *
148
+ * @remarks
149
+ * The strip is 24px wide and transparent, with a line down its middle under the pointer, on
150
+ * keyboard focus and while a drag inside its holder runs. A separator, the strip of an element a
151
+ * person resizes, takes the column resize cursor. The strip is hidden under a coarse pointer,
152
+ * where a finger presses the menu button instead. The module imports no React, because the theme
153
+ * plugin evaluates the recipes that read it in Node.
154
+ */
155
+ /**
156
+ * Styles the strip, with its placement left to the recipe that reads it.
157
+ */
158
+ const STRIP$3 = {
159
+ ...interactive(),
160
+ _after: {
161
+ background: "transparent",
162
+ content: "\"\"",
163
+ inlineSize: "{borderWidths.indicator}",
164
+ insetBlock: "0",
165
+ insetInlineStart: "calc(50% - {borderWidths.indicator} / 2)",
166
+ position: "absolute"
167
+ },
168
+ _focusVisible: { _after: { background: "colorPalette.focusRing" } },
169
+ _hover: { _after: { background: "border.emphasized" } },
170
+ _touch: { display: "none" },
171
+ "&[role=separator]": { cursor: "resizeColumn" },
172
+ [`[${RESIZING}] &`]: { _after: { background: "border.emphasized" } },
173
+ appearance: "none",
174
+ background: "transparent",
175
+ borderWidth: "0",
176
+ colorPalette: "neutral",
177
+ inlineSize: "6",
178
+ padding: "0",
179
+ zIndex: "docked"
180
+ };
181
+ //#endregion
88
182
  //#region src/app-shell/metrics.ts
89
183
  /**
90
184
  * Names the custom properties and attributes the app shell reads, and defines the style objects
@@ -115,10 +209,24 @@ const PANEL_RAIL = "--app-shell-panel-rail";
115
209
  */
116
210
  const STICKY_OFFSET$1 = "--app-shell-sticky-offset";
117
211
  /**
118
- * Custom property on the root with the height of every pinned bar.
212
+ * Custom property on the root with the height of every pinned bar, the bars in a pane included,
213
+ * which a page's sticky bands stick under.
119
214
  */
120
215
  const STICKY_TOP$1 = "--app-shell-sticky-top";
121
216
  /**
217
+ * Custom property on the root with the height of the pinned bars above the body, which a panel
218
+ * that sticks under them reads.
219
+ *
220
+ * @remarks
221
+ * A bar in a pane is beside the panels and not above them, so a panel sticks under the root's
222
+ * own bars alone.
223
+ */
224
+ const PANEL_TOP = "--app-shell-panel-top";
225
+ /**
226
+ * Custom property on the root with the height of the pinned bars in a pane.
227
+ */
228
+ const PANE_TOP = "--app-shell-pane-top";
229
+ /**
122
230
  * Custom property an ancestor sets to the height of the shell's window.
123
231
  *
124
232
  * @remarks
@@ -140,14 +248,15 @@ const WINDOW = `var(${WINDOW_HEIGHT}, 100dvh)`;
140
248
  */
141
249
  const SETTLED = "data-settled";
142
250
  /**
143
- * Styles the transition of a moving part, with no motion under reduced motion or before the root
144
- * has settled.
251
+ * Styles the transition of a moving part, with no motion under reduced motion, before the root has
252
+ * settled, or while a person drags a panel's width.
145
253
  */
146
254
  const MOVING = {
147
255
  _motionReduce: { transitionDuration: "0s" },
148
256
  transitionDuration: "move",
149
257
  transitionTimingFunction: "move",
150
- [`.${CLASS$5}__root:not([${SETTLED}]) &`]: { transitionDuration: "0s" }
258
+ [`.${CLASS$5}__root:not([${SETTLED}]) &`]: { transitionDuration: "0s" },
259
+ [`.${CLASS$5}__root[${RESIZING}] &`]: { transitionDuration: "0s" }
151
260
  };
152
261
  /**
153
262
  * Width of a panel over the page: its open width, and never closer than a rail's width to the far
@@ -236,16 +345,16 @@ const PINNED = {
236
345
  zIndex: "sticky"
237
346
  };
238
347
  /**
239
- * Styles a panel in the body while the window scrolls: stuck under the pinned bars and as tall as
240
- * the height they leave.
348
+ * Styles a panel in the body while the window scrolls: stuck under the root's pinned bars and as
349
+ * tall as the height they leave.
241
350
  *
242
351
  * @remarks
243
352
  * The rule selects a panel beside the page only, because the window-scroll look is a variant and
244
353
  * applies over the base, where a sheet is fixed and a panel under the page is static.
245
354
  */
246
355
  const STUCK$1 = { ["&:not([data-overlaid], [data-stacked])"]: {
247
- blockSize: `calc(${WINDOW} - var(${STICKY_TOP$1}, 0px))`,
248
- insetBlockStart: `var(${STICKY_TOP$1}, 0px)`,
356
+ blockSize: `calc(${WINDOW} - var(${PANEL_TOP}, 0px))`,
357
+ insetBlockStart: `var(${PANEL_TOP}, 0px)`,
249
358
  position: "sticky"
250
359
  } };
251
360
  /**
@@ -294,38 +403,18 @@ const COLUMN = {
294
403
  }
295
404
  };
296
405
  /**
297
- * Styles the rail: a 24px strip centred on the edge between a panel and the page, with a line down
298
- * its middle under the pointer and on keyboard focus.
406
+ * Styles the rail: the 24px strip centred on the edge between a panel and the page.
299
407
  *
300
408
  * @remarks
301
409
  * The negative margins take the strip's width back from the row, so the strip overlaps the panel
302
- * and the page by 12px each and moves neither. The strip is hidden under a coarse pointer, where
303
- * a finger presses the menu button instead.
410
+ * and the page by 12px each and moves neither.
304
411
  */
305
412
  const RAIL = {
306
- ...interactive(),
307
- _after: {
308
- background: "transparent",
309
- content: "\"\"",
310
- inlineSize: "{borderWidths.indicator}",
311
- insetBlock: "0",
312
- insetInlineStart: "calc(50% - {borderWidths.indicator} / 2)",
313
- position: "absolute"
314
- },
315
- _focusVisible: { _after: { background: "colorPalette.focusRing" } },
316
- _hover: { _after: { background: "border.emphasized" } },
317
- _touch: { display: "none" },
413
+ ...STRIP$3,
318
414
  alignSelf: "stretch",
319
- appearance: "none",
320
- background: "transparent",
321
- borderWidth: "0",
322
- colorPalette: "neutral",
323
415
  flexShrink: "0",
324
- inlineSize: "6",
325
416
  marginInline: "calc({sizes.6} / -2)",
326
- padding: "0",
327
- position: "relative",
328
- zIndex: "docked"
417
+ position: "relative"
329
418
  };
330
419
  //#endregion
331
420
  //#region src/app-shell/recipe.ts
@@ -334,29 +423,36 @@ const RAIL = {
334
423
  * and a panel on either side of the page in the body.
335
424
  *
336
425
  * @remarks
337
- * The recipe has eighteen slots. The body, the main region, a panel's content and a band that
426
+ * The recipe has nineteen slots. The body, the main region, a panel's content and a band that
338
427
  * scrolls are the primitives package's scroll areas, so every bar in the shell is the theme's:
339
428
  * `bodyViewport` and `row` are the body's viewport and content, `mainViewport` is the `main`
340
429
  * element, `column` is the content of the main region and of a panel, and `scroller` is the root
341
- * of a band that scrolls. A part that is left out takes no room. Every part is a container: the
342
- * recipe sets the layout, the scrolling, the motion, the hairlines between regions and the
343
- * grounds, and the application styles the rest through a theme. The plain look fills no region,
344
- * so the shell shows the ground it is placed on. The backdrop, a sheet and a pinned bar are
345
- * filled, because the page is visible under each. A sheet is raised by a shadow. A panel in the
346
- * body is a track whose width moves between its open and closed widths, and its content keeps the
347
- * open width, so the track clips the content instead of reflowing it. A panel over the page is
348
- * fixed to the window with a backdrop behind it. The open and closed widths are the theme's
349
- * `sizes.sidebar`, `sizes.aside` and `sizes.rail` unless a panel states its own. The window's
350
- * height is `100dvh` unless an ancestor sets `WINDOW_HEIGHT`, so a shell staged in a box of a
351
- * fixed height fills the box. The header and the footer pad their content by the middle gap, the
352
- * status bar pads its row of entries by the smallest gap above and below and the middle gap at
353
- * the sides, and a section pads by the large gap. The recipe has no `palette` and no `effect`
354
- * axis, because the parts are containers and the components inside them offer their own.
430
+ * of a band that scrolls. A pane is a column in the row between the panels, with bars above and
431
+ * below the main region, so the panels run the full height beside the bars. A part that is left
432
+ * out takes no room. Every part is a container: the recipe sets the layout, the scrolling, the
433
+ * motion, the hairlines between regions and the grounds, and the application styles the rest
434
+ * through a theme. The plain look fills no region, so the shell shows the ground it is placed
435
+ * on. The backdrop, a sheet and a pinned bar are filled, because the page is visible under each.
436
+ * A sheet is raised by a shadow. A panel in the body is a track whose width moves between its
437
+ * open and closed widths, and its content keeps the open width, so the track clips the content
438
+ * instead of reflowing it. A panel over the page is fixed to the window with a backdrop behind
439
+ * it. The open and closed widths are the theme's `sizes.sidebar`, `sizes.aside` and `sizes.rail`
440
+ * unless a panel states its own. The window's height is `100dvh` unless an ancestor sets
441
+ * `WINDOW_HEIGHT`, so a shell staged in a box of a fixed height fills the box. The header and
442
+ * the footer pad their content by the middle gap, the status bar pads its row of entries by the
443
+ * smallest gap above and below and the middle gap at the sides, and takes no room while every
444
+ * entry renders nothing. A section pads by the large gap. The recipe has no `palette` and no
445
+ * `effect` axis, because the parts are containers and the components inside them offer their
446
+ * own.
355
447
  */
356
448
  /**
357
449
  * Styles a plain, divided shell that scrolls its page.
450
+ *
451
+ * @remarks
452
+ * `staticCss` lists every value of `content`, `scroll` and `variant`. A product's frame states
453
+ * them as data, and the compiler emits a variant only where a source states it as a literal.
358
454
  */
359
- const recipe$7 = defineSlotRecipe({
455
+ const recipe$6 = defineSlotRecipe({
360
456
  base: {
361
457
  aside: {
362
458
  ...PANEL,
@@ -412,10 +508,12 @@ const recipe$7 = defineSlotRecipe({
412
508
  "&[data-sticky]": {
413
509
  ...PINNED,
414
510
  insetBlockStart: `var(${STICKY_OFFSET$1}, 0px)`
415
- }
511
+ },
512
+ [`.${CLASS$5}__pane > &`]: { "&[data-sticky]": { insetBlockStart: `calc(var(${PANEL_TOP}, 0px) + var(${STICKY_OFFSET$1}, 0px))` } }
416
513
  },
417
514
  main: {
418
515
  flex: "1",
516
+ minBlockSize: "0",
419
517
  minInlineSize: "0"
420
518
  },
421
519
  navbar: {
@@ -431,16 +529,28 @@ const recipe$7 = defineSlotRecipe({
431
529
  insetInlineStart: "0"
432
530
  }
433
531
  },
532
+ pane: {
533
+ display: "flex",
534
+ flex: "1",
535
+ flexDirection: "column",
536
+ minBlockSize: "0",
537
+ minInlineSize: "0"
538
+ },
434
539
  rail: RAIL,
435
540
  root: {
436
541
  display: "flex",
437
542
  flexDirection: "column",
438
- inlineSize: "100%"
543
+ inlineSize: "100%",
544
+ [STICKY_TOP$1]: `calc(var(${PANEL_TOP}, 0px) + var(${PANE_TOP}, 0px))`,
545
+ [`&[${RESIZING}]`]: {
546
+ cursor: "resizeColumn",
547
+ userSelect: "none"
548
+ }
439
549
  },
440
550
  row: {
441
551
  display: "flex",
442
552
  "&:has(> [data-stacked])": { flexWrap: "wrap" },
443
- [`&:has(> [data-stacked]) > .${CLASS$5}__main`]: { flexBasis: "100%" }
553
+ [`&:has(> [data-stacked]) > :is(.${CLASS$5}__main, .${CLASS$5}__pane)`]: { flexBasis: "100%" }
444
554
  },
445
555
  scroller: SCROLLER,
446
556
  section: SECTION,
@@ -452,12 +562,14 @@ const recipe$7 = defineSlotRecipe({
452
562
  flexWrap: "wrap",
453
563
  paddingBlock: dense("{spacing.gap.xs}"),
454
564
  paddingInline: dense("{spacing.gap.md}"),
455
- rowGap: dense("{spacing.gap.xs}")
565
+ rowGap: dense("{spacing.gap.xs}"),
566
+ "&:empty": { display: "none" }
456
567
  },
457
568
  trigger: { flexShrink: "0" }
458
569
  },
459
570
  className: CLASS$5,
460
571
  defaultVariants: {
572
+ content: "fluid",
461
573
  divided: true,
462
574
  scroll: "page",
463
575
  variant: "plain"
@@ -470,6 +582,7 @@ const recipe$7 = defineSlotRecipe({
470
582
  "bodyViewport",
471
583
  "row",
472
584
  "navbar",
585
+ "pane",
473
586
  "main",
474
587
  "mainViewport",
475
588
  "aside",
@@ -483,7 +596,27 @@ const recipe$7 = defineSlotRecipe({
483
596
  "rail",
484
597
  "backdrop"
485
598
  ],
599
+ staticCss: [{
600
+ content: ["constrained", "fluid"],
601
+ scroll: ["page", "window"],
602
+ variant: [
603
+ "floating",
604
+ "inset",
605
+ "plain"
606
+ ]
607
+ }],
486
608
  variants: {
609
+ /**
610
+ * Width of the page in the main region: the whole region, or at most the theme's wide page
611
+ * measure, centred in the region.
612
+ */
613
+ content: {
614
+ constrained: { column: { [`.${CLASS$5}__mainViewport > &`]: {
615
+ marginInline: "auto",
616
+ maxInlineSize: "{sizes.page.wide}"
617
+ } } },
618
+ fluid: { column: { [`.${CLASS$5}__mainViewport > &`]: { maxInlineSize: "none" } } }
619
+ },
487
620
  /**
488
621
  * Whether a hairline separates each bar and each panel from the page.
489
622
  *
@@ -529,7 +662,7 @@ const recipe$7 = defineSlotRecipe({
529
662
  [`&:has(> .${CLASS$5}__row > [data-stacked])`]: { overflow: "auto" },
530
663
  overflow: "visible"
531
664
  },
532
- mainViewport: { [`.${CLASS$5}__row:has(> [data-stacked]) > .${CLASS$5}__main > &`]: { overflow: "visible" } },
665
+ mainViewport: { [`.${CLASS$5}__row:has(> [data-stacked]) > :is(.${CLASS$5}__main, .${CLASS$5}__pane > .${CLASS$5}__main) > &`]: { overflow: "visible" } },
533
666
  root: { blockSize: WINDOW },
534
667
  row: {
535
668
  "&:has(> [data-stacked])": { blockSize: "auto" },
@@ -552,7 +685,9 @@ const recipe$7 = defineSlotRecipe({
552
685
  * `plain` fills nothing, so every region shows the ground the shell is placed on: the page's
553
686
  * `bg` in an application, or a staging box's own fill. A sidebar in a panel paints its own
554
687
  * ground. `inset` gives the main region `surface()`, the panel ground with a hairline and a
555
- * shadow, over a `bg.subtle` root. `floating` gives each panel's content `surface()` instead.
688
+ * shadow, over a `bg.subtle` root, and gives it to the pane instead where the main region is
689
+ * in one, so the bars in the pane are on the card too. `floating` gives each panel's content
690
+ * `surface()` instead.
556
691
  */
557
692
  variant: {
558
693
  floating: {
@@ -565,7 +700,11 @@ const recipe$7 = defineSlotRecipe({
565
700
  root: { background: "bg.subtle" }
566
701
  },
567
702
  inset: {
568
- main: {
703
+ main: { [`&:not(.${CLASS$5}__pane > *)`]: {
704
+ ...surface(),
705
+ margin: dense("{spacing.gap.xs}")
706
+ } },
707
+ pane: {
569
708
  ...surface(),
570
709
  margin: dense("{spacing.gap.xs}")
571
710
  },
@@ -604,7 +743,7 @@ const CLASS$4 = "floating-panel";
604
743
  /**
605
744
  * Width of a resize trigger's strip, and the side of a corner's square.
606
745
  */
607
- const STRIP$1 = "{sizes.2}";
746
+ const STRIP$2 = "{sizes.2}";
608
747
  /**
609
748
  * Stacking level of the positioner: the banner level of the z-index scale plus the panel's place in
610
749
  * the stack of open panels, which the machine writes as `--z-index`.
@@ -617,15 +756,16 @@ const RING_OFFSET = "--scroll-area-ring-offset";
617
756
  /**
618
757
  * Defines the floating panel recipe.
619
758
  */
620
- const recipe$6 = defineSlotRecipe({
759
+ const recipe$5 = defineSlotRecipe({
621
760
  base: {
622
761
  body: {
623
- padding: dense("{spacing.inset.sm}"),
624
- textStyle: "body.sm"
762
+ textStyle: "body.sm",
763
+ padding: dense("{spacing.inset.sm}")
625
764
  },
626
765
  closeTrigger: { flexShrink: "0" },
627
766
  content: {
628
767
  ...motion("scale-fade.in", "scale-fade.out"),
768
+ focusVisibleRing: "outside",
629
769
  background: "bg.panel",
630
770
  borderColor: "transparent",
631
771
  borderRadius: "l3",
@@ -636,7 +776,6 @@ const recipe$6 = defineSlotRecipe({
636
776
  display: "flex",
637
777
  flexDirection: "column",
638
778
  focusRingColor: "colorPalette.focusRing",
639
- focusVisibleRing: "outside",
640
779
  position: "relative"
641
780
  },
642
781
  control: {
@@ -677,11 +816,11 @@ const recipe$6 = defineSlotRecipe({
677
816
  },
678
817
  positioner: { zIndex: LAYERED },
679
818
  resizeTrigger: {
680
- "&:is([data-axis=e], [data-axis=w])": { inlineSize: STRIP$1 },
681
- "&:is([data-axis=n], [data-axis=s])": { blockSize: STRIP$1 },
819
+ "&:is([data-axis=e], [data-axis=w])": { inlineSize: STRIP$2 },
820
+ "&:is([data-axis=n], [data-axis=s])": { blockSize: STRIP$2 },
682
821
  "&:is([data-axis=ne], [data-axis=nw], [data-axis=se], [data-axis=sw])": {
683
- blockSize: STRIP$1,
684
- inlineSize: STRIP$1
822
+ blockSize: STRIP$2,
823
+ inlineSize: STRIP$2
685
824
  },
686
825
  "&[data-disabled]": { display: "none" },
687
826
  zIndex: "docked"
@@ -690,8 +829,8 @@ const recipe$6 = defineSlotRecipe({
690
829
  scroller: {
691
830
  "&[hidden]": { display: "none" },
692
831
  flex: "1",
693
- marginBlockEnd: STRIP$1,
694
- marginInlineEnd: STRIP$1,
832
+ marginBlockEnd: STRIP$2,
833
+ marginInlineEnd: STRIP$2,
695
834
  minBlockSize: "0",
696
835
  [RING_OFFSET]: "calc({borderWidths.ring} * -1)"
697
836
  },
@@ -701,8 +840,8 @@ const recipe$6 = defineSlotRecipe({
701
840
  },
702
841
  title: {
703
842
  ...truncate(),
704
- fontWeight: "semibold",
705
- textStyle: "label.md"
843
+ textStyle: "label.md",
844
+ fontWeight: "semibold"
706
845
  },
707
846
  trigger: {
708
847
  ...interactive(),
@@ -730,58 +869,11 @@ const recipe$6 = defineSlotRecipe({
730
869
  ]
731
870
  });
732
871
  //#endregion
733
- //#region src/folding/folding.ts
734
- /**
735
- * Styles an action that folds to its icon while its row is narrow, for the page, the toolbar and
736
- * the section.
737
- *
738
- * @remarks
739
- * A secondary action shows its icon alone, and its words remain for a screen reader. A tertiary
740
- * action renders nothing on a narrow row and runs from the row's menu, which the action registers
741
- * with. The row measures its own width, so a row beside an open sidebar folds on its own width
742
- * and the caller does not write a breakpoint.
743
- */
744
- /**
745
- * Attribute an action sets on itself while its own row is narrow.
746
- *
747
- * @remarks
748
- * The action reads the state from its row's context, so a narrow ancestor folds no action of a
749
- * wider row inside it. A bar fixed to the window is wider than the page around it in the
750
- * document.
751
- */
752
- const NARROW$2 = "data-narrow";
753
- /**
754
- * Attribute an action sets to its priority.
755
- *
756
- * @remarks
757
- * The priority is an attribute and not an axis, because a slot recipe resolves its variants once,
758
- * at the root, and each action in a row folds on its own priority.
759
- */
760
- const PRIORITY = "data-priority";
761
- /**
762
- * Styles an action by its priority while its row is narrow.
763
- *
764
- * @remarks
765
- * Every action keeps its words on one line at every width, because a wrapped control would make
766
- * the row taller. A secondary action hides its element children other than its icon with
767
- * `srOnly`, so it keeps its accessible name, and renders as a square around its icon. Its words
768
- * must be in an element, because a CSS selector cannot select a bare text node. A primary action
769
- * is unchanged.
770
- */
771
- const FOLDING = {
772
- [`&[${PRIORITY}=secondary]`]: { [`&[${NARROW$2}]`]: {
773
- "& > :not(svg)": { srOnly: true },
774
- aspectRatio: "square",
775
- justifyContent: "center",
776
- paddingInline: "0"
777
- } },
778
- whiteSpace: "nowrap"
779
- };
780
- //#endregion
781
872
  //#region src/page/metrics.ts
782
873
  /**
783
- * Computes the rules the page recipe writes for the tabs in the navigation band, the panel the tab
784
- * picker opens and the title's heading role.
874
+ * Computes the rules the page recipe writes at each of its sizes: the padding and gaps of the
875
+ * bands, the tabs in the navigation band, the panel the tab picker opens and the title's heading
876
+ * role.
785
877
  *
786
878
  * @remarks
787
879
  * The module is separate from the recipe so the recipe is under the 300-line limit. It imports no
@@ -804,14 +896,35 @@ const TABS = "tabs";
804
896
  */
805
897
  const COMMAND = "command";
806
898
  /**
807
- * Selects the root of the tabs in the navigation band.
899
+ * Selects the strip of tabs in the navigation band: the tabs' root, or the `Page.Tabs` slot where
900
+ * the tabs' root contains the panels too and the band contains the list alone.
808
901
  */
809
- const TABS_IN_NAV = `& > .${TABS}__root`;
902
+ const STRIP$1 = `:is(.${TABS}__root, .${CLASS$3}__tabs)`;
903
+ /**
904
+ * Selects the strip of tabs as a child of the navigation band.
905
+ */
906
+ const TABS_IN_NAV = `& > ${STRIP$1}`;
810
907
  /**
811
908
  * Selects every tab of a strip in the navigation band.
812
909
  */
813
910
  const TAB_IN_NAV = `& .${TABS}__trigger`;
814
911
  /**
912
+ * Sizes the page offers, which a section in the page reads too.
913
+ */
914
+ const STEPS = [
915
+ "sm",
916
+ "md",
917
+ "lg"
918
+ ];
919
+ /**
920
+ * Selects a band followed by the navigation, which then has the hairline.
921
+ */
922
+ const BEFORE_NAV = `&:has(+ .${CLASS$3}__nav)`;
923
+ /**
924
+ * Selects a navigation band with a strip of tabs, which is flush with the band's hairline.
925
+ */
926
+ const WITH_TABS = `&:has(> ${STRIP$1})`;
927
+ /**
815
928
  * Tab height and inline inset per page size: a medium or large page's tabs are 48px tall and inset
816
929
  * by the `md` inset, a small page's 44px and the `sm` inset.
817
930
  */
@@ -877,18 +990,65 @@ const NARROW_TITLE = `.${CLASS$3}__root[data-narrow] > .${CLASS$3}__header > &`;
877
990
  */
878
991
  const TITLES = {
879
992
  lg: {
880
- [NARROW_TITLE]: { textStyle: "heading.lg" },
881
- textStyle: "heading.xl"
993
+ textStyle: "heading.xl",
994
+ [NARROW_TITLE]: { textStyle: "heading.lg" }
882
995
  },
883
996
  md: {
884
- [NARROW_TITLE]: { textStyle: "heading.md" },
885
- textStyle: "heading.lg"
997
+ textStyle: "heading.lg",
998
+ [NARROW_TITLE]: { textStyle: "heading.md" }
886
999
  },
887
1000
  sm: {
888
- [NARROW_TITLE]: { textStyle: "heading.sm" },
889
- textStyle: "heading.md"
1001
+ textStyle: "heading.md",
1002
+ [NARROW_TITLE]: { textStyle: "heading.sm" }
890
1003
  }
891
1004
  };
1005
+ /**
1006
+ * Rules the recipe's `size` axis writes on each slot it sizes, at the page's three sizes.
1007
+ */
1008
+ const SIZES = onSlots({
1009
+ actions: sizeVariants((size) => ({ gap: dense(`{spacing.gap.${size}}`) }), STEPS),
1010
+ aside: sizeVariants((size) => ({ paddingBlock: dense(`{spacing.inset.${size}}`) }), STEPS),
1011
+ banner: sizeVariants((size) => ({ paddingBlock: dense(`{spacing.inset.${size}}`) }), STEPS),
1012
+ body: sizeVariants((size) => ({ paddingBlock: dense(`{spacing.inset.${size}}`) }), STEPS),
1013
+ context: sizeVariants((size) => ({
1014
+ textStyle: `body.${below(size)}`,
1015
+ gap: dense(`{spacing.gap.${size}}`),
1016
+ marginBlockEnd: size === "lg" ? "{spacing.1}" : "{spacing.0.5}"
1017
+ }), STEPS),
1018
+ description: {
1019
+ lg: { textStyle: "body.md" },
1020
+ md: { textStyle: "body.md" },
1021
+ sm: { textStyle: "body.sm" }
1022
+ },
1023
+ footer: sizeVariants((size) => ({
1024
+ gap: dense(`{spacing.gap.${size}}`),
1025
+ paddingBlock: dense(`{spacing.inset.${size}}`)
1026
+ }), STEPS),
1027
+ header: sizeVariants((size) => ({
1028
+ [BEFORE_NAV]: { paddingBlockEnd: dense(`{spacing.gap.${size}}`) },
1029
+ paddingBlock: dense(`{spacing.inset.${size}}`)
1030
+ }), STEPS),
1031
+ meta: sizeVariants((size) => ({
1032
+ gap: dense(`{spacing.gap.${size}}`),
1033
+ marginInlineStart: dense(`{spacing.gap.${size}}`)
1034
+ }), STEPS),
1035
+ nav: sizeVariants((size) => ({
1036
+ ...tabbed(size),
1037
+ paddingBlock: dense(`{spacing.gap.${size}}`),
1038
+ [WITH_TABS]: { paddingBlock: "0" }
1039
+ }), STEPS),
1040
+ palette: sizeVariants(() => FLAT_PALETTE, STEPS),
1041
+ sidebar: sizeVariants((size) => ({ paddingBlock: dense(`{spacing.inset.${size}}`) }), STEPS),
1042
+ title: TITLES,
1043
+ toolbar: sizeVariants((size) => ({
1044
+ gap: dense(`{spacing.gap.${size}}`),
1045
+ paddingBlock: dense(`{spacing.gap.${size}}`)
1046
+ }), STEPS),
1047
+ trail: sizeVariants((size) => ({
1048
+ textStyle: `body.${below(size)}`,
1049
+ gap: dense(`{spacing.gap.${below(size)}}`)
1050
+ }), STEPS)
1051
+ });
892
1052
  //#endregion
893
1053
  //#region src/page/recipe.ts
894
1054
  /**
@@ -906,9 +1066,12 @@ const TITLES = {
906
1066
  * `folded` below the `sm` breakpoint. From the `lg` breakpoint of the window, a page with an
907
1067
  * aside becomes a grid with the aside beside the body, because a rail beside the text needs the
908
1068
  * window's width. Below it the aside stacks under the body, or leaves the page with
909
- * `folds="hide"`. A sticky aside keeps to the top of its row, under the shell's sticky bars. The
910
- * recipe has no `palette` axis, because a page is layout on the page's surface, and no `effect`
911
- * axis, because a page is not a control.
1069
+ * `folds="hide"`. A page at least as wide as the `md` breakpoint, by its own measurement, becomes
1070
+ * a grid with its sidebar at the start of the body's row, and a narrower page renders the
1071
+ * sidebar's picker as a band above the body. A sticky aside keeps to the top of its row, under
1072
+ * the shell's sticky bars, and so does a sticky sidebar. The recipe has no `palette` axis,
1073
+ * because a page is layout on the page's surface, and no `effect` axis, because a page is not a
1074
+ * control.
912
1075
  */
913
1076
  /**
914
1077
  * Custom property the root sets to the room at the page's inline edges.
@@ -949,10 +1112,15 @@ const STICKY_OFFSET = "--page-sticky-offset";
949
1112
  */
950
1113
  const STICKY_TOP = "--page-sticky-top";
951
1114
  /**
952
- * Custom property a sticky aside takes, set to the height of the area that scrolls the page.
1115
+ * Custom property a sticky aside or sidebar takes, set to the height of the area that scrolls the
1116
+ * page.
953
1117
  */
954
1118
  const SCROLLPORT = "--page-scrollport";
955
1119
  /**
1120
+ * Custom property the sidebar sets to the width a person resized it to.
1121
+ */
1122
+ const SIDEBAR_SIZE = "--page-sidebar-size";
1123
+ /**
956
1124
  * Room a focus ring takes outside an element: its offset and its width.
957
1125
  */
958
1126
  const RING_ROOM = "calc(var(--focus-ring-offset, 0px) + var(--focus-ring-width, 0px))";
@@ -973,23 +1141,41 @@ const STUCK = {
973
1141
  zIndex: "sticky"
974
1142
  };
975
1143
  /**
1144
+ * Styles a column that sticks to the top of its row, under the shell's sticky bars and the page's
1145
+ * sticky bands, a gap below them.
1146
+ */
1147
+ const STICKY_COLUMN = {
1148
+ alignSelf: "start",
1149
+ insetBlockStart: `calc(var(${SHELL_TOP}, 0px) + var(${STICKY_TOP}, 0px) + {spacing.gap.xl})`,
1150
+ position: "sticky"
1151
+ };
1152
+ /**
1153
+ * Caps a sticky column at the height of the area that scrolls the page, less the sticky bars and
1154
+ * bands above it and a gap at each end, as a flex column that its scroll area fills.
1155
+ */
1156
+ const CAPPED_COLUMN = {
1157
+ display: "flex",
1158
+ flexDirection: "column",
1159
+ maxBlockSize: `calc(var(${SCROLLPORT}, 100dvh) - var(${SHELL_TOP}, 0px) - var(${STICKY_TOP}, 0px) - {spacing.gap.xl} * 2)`
1160
+ };
1161
+ /**
976
1162
  * Styles a row that centres its items, wraps and shrinks.
977
1163
  */
978
- const ROW$2 = {
1164
+ const ROW$1 = {
979
1165
  alignItems: "center",
980
1166
  display: "flex",
981
1167
  flexWrap: "wrap",
982
1168
  minInlineSize: "0"
983
1169
  };
984
1170
  /**
985
- * Selects a band followed by the navigation, which then has the hairline.
986
- */
987
- const BEFORE_NAV = `&:has(+ .${CLASS$3}__nav)`;
988
- /**
989
1171
  * Selects a root with an aside, which lays its bands out as a grid from the `lg` breakpoint.
990
1172
  */
991
1173
  const WITH_ASIDE = `&:has(> .${CLASS$3}__aside)`;
992
1174
  /**
1175
+ * Selects a root with a sidebar beside its body, which lays its bands out as a grid.
1176
+ */
1177
+ const WITH_SIDEBAR = `&:has(> .${CLASS$3}__sidebar[data-beside])`;
1178
+ /**
993
1179
  * Selects a toolbar in the navigation band.
994
1180
  */
995
1181
  const TOOLBAR_IN_NAV = `& > .toolbar__root`;
@@ -1004,38 +1190,47 @@ const STRIP_IN_NAV = `& .${CLASS$3}__tabs[data-orientation]`;
1004
1190
  * @remarks
1005
1191
  * The body's row takes the remaining height and every other row takes its content's height,
1006
1192
  * because the root is at least as tall as the shell's main region and a grid shares spare height
1007
- * between its `auto` rows.
1193
+ * between its `auto` rows. The sidebar's row holds the picker a narrow page renders in the
1194
+ * sidebar's place, and takes no height without one.
1008
1195
  */
1009
1196
  const BESIDE = {
1010
1197
  columnGap: dense("{spacing.gap.xl}"),
1011
1198
  display: "grid",
1012
- gridTemplateAreas: "\"banner banner\" \"header header\" \"nav nav\" \"toolbar toolbar\" \"body aside\" \"footer footer\"",
1199
+ gridTemplateAreas: "\"banner banner\" \"header header\" \"nav nav\" \"toolbar toolbar\" \"sidebar sidebar\" \"body aside\" \"footer footer\"",
1013
1200
  gridTemplateColumns: "minmax(0, 1fr) auto",
1014
- gridTemplateRows: "auto auto auto auto 1fr auto"
1201
+ gridTemplateRows: "auto auto auto auto auto 1fr auto"
1015
1202
  };
1016
1203
  /**
1017
- * Sizes the page offers, which a section in the page reads too.
1204
+ * Styles the grid of a page with a sidebar beside its body: every band across, the sidebar at the
1205
+ * start, and an aside under the body.
1018
1206
  */
1019
- const STEPS = [
1020
- "sm",
1021
- "md",
1022
- "lg"
1023
- ];
1207
+ const SIDEBAR_BESIDE = {
1208
+ columnGap: dense("{spacing.gap.xl}"),
1209
+ display: "grid",
1210
+ gridTemplateAreas: "\"banner banner\" \"header header\" \"nav nav\" \"toolbar toolbar\" \"sidebar body\" \"sidebar aside\" \"footer footer\"",
1211
+ gridTemplateColumns: "auto minmax(0, 1fr)",
1212
+ gridTemplateRows: "auto auto auto auto 1fr auto auto"
1213
+ };
1024
1214
  /**
1025
- * Selects a navigation band with a strip of tabs, which is flush with the band's hairline.
1215
+ * Styles the grid of a page with a sidebar beside its body and an aside beside it from the `lg`
1216
+ * breakpoint: the sidebar, the body and the aside in one row.
1026
1217
  */
1027
- const WITH_TABS = `&:has(> .${TABS}__root)`;
1218
+ const SIDEBAR_AND_ASIDE = {
1219
+ gridTemplateAreas: "\"banner banner banner\" \"header header header\" \"nav nav nav\" \"toolbar toolbar toolbar\" \"sidebar body aside\" \"footer footer footer\"",
1220
+ gridTemplateColumns: "auto minmax(0, 1fr) auto",
1221
+ gridTemplateRows: "auto auto auto auto 1fr auto"
1222
+ };
1028
1223
  /**
1029
1224
  * Defines the page recipe: a full-width page at size `md` by default.
1030
1225
  */
1031
- const recipe$5 = defineSlotRecipe({
1226
+ const recipe$4 = defineSlotRecipe({
1032
1227
  base: {
1033
1228
  action: {
1034
1229
  ...FOLDING,
1035
1230
  flexShrink: "0"
1036
1231
  },
1037
1232
  actions: {
1038
- ...ROW$2,
1233
+ ...ROW$1,
1039
1234
  flexWrap: "nowrap",
1040
1235
  gridArea: "actions",
1041
1236
  justifySelf: "end",
@@ -1052,14 +1247,8 @@ const recipe$5 = defineSlotRecipe({
1052
1247
  aside: {
1053
1248
  "&[data-folds=hide]": { lgDown: { display: "none" } },
1054
1249
  "&[data-sticky]": {
1055
- alignSelf: "start",
1056
- insetBlockStart: `calc(var(${SHELL_TOP}, 0px) + var(${STICKY_TOP}, 0px) + {spacing.gap.xl})`,
1057
- lg: {
1058
- display: "flex",
1059
- flexDirection: "column",
1060
- maxBlockSize: `calc(var(${SCROLLPORT}, 100dvh) - var(${SHELL_TOP}, 0px) - var(${STICKY_TOP}, 0px) - {spacing.gap.xl} * 2)`
1061
- },
1062
- position: "sticky"
1250
+ ...STICKY_COLUMN,
1251
+ lg: CAPPED_COLUMN
1063
1252
  },
1064
1253
  gridArea: "aside",
1065
1254
  lg: { paddingInlineStart: "0" },
@@ -1089,7 +1278,7 @@ const recipe$5 = defineSlotRecipe({
1089
1278
  minBlockSize: "0"
1090
1279
  },
1091
1280
  context: {
1092
- ...ROW$2,
1281
+ ...ROW$1,
1093
1282
  gridArea: "context",
1094
1283
  minInlineSize: "0"
1095
1284
  },
@@ -1107,7 +1296,7 @@ const recipe$5 = defineSlotRecipe({
1107
1296
  */
1108
1297
  footer: {
1109
1298
  ...BAND$1,
1110
- ...ROW$2,
1299
+ ...ROW$1,
1111
1300
  "&[data-sticky]": {
1112
1301
  ...STUCK,
1113
1302
  insetBlockEnd: "0",
@@ -1139,7 +1328,7 @@ const recipe$5 = defineSlotRecipe({
1139
1328
  marginInlineEnd: dense("{spacing.gap.lg}")
1140
1329
  },
1141
1330
  meta: {
1142
- ...ROW$2,
1331
+ ...ROW$1,
1143
1332
  gridArea: "meta"
1144
1333
  },
1145
1334
  nav: {
@@ -1172,14 +1361,64 @@ const recipe$5 = defineSlotRecipe({
1172
1361
  minInlineSize: "0"
1173
1362
  },
1174
1363
  root: {
1364
+ [`&[${RESIZING}]`]: {
1365
+ cursor: "resizeColumn",
1366
+ userSelect: "none"
1367
+ },
1175
1368
  display: "flex",
1176
1369
  flexDirection: "column",
1177
1370
  flexGrow: "1",
1178
1371
  inlineSize: "100%",
1179
1372
  minBlockSize: "100%",
1180
1373
  minInlineSize: "0",
1181
- [WITH_ASIDE]: { lg: BESIDE }
1374
+ [WITH_ASIDE]: { lg: BESIDE },
1375
+ [WITH_SIDEBAR]: {
1376
+ ...SIDEBAR_BESIDE,
1377
+ [WITH_ASIDE]: { lg: SIDEBAR_AND_ASIDE }
1378
+ }
1379
+ },
1380
+ /**
1381
+ * The sidebar: a column beside the body on a page as wide as the `md` breakpoint, and a band
1382
+ * with the picker on a narrower page.
1383
+ *
1384
+ * @remarks
1385
+ * Beside the body the column starts at the gutter, so the fills of its rows start where the
1386
+ * title starts. The column is as wide as the theme's sidebar unless a person resized it. A
1387
+ * sticky sidebar keeps to the top of its row and is at most as tall as the room under the
1388
+ * sticky bands, as a sticky aside is. The handle is centred on the column's end edge, in the
1389
+ * gap before the body.
1390
+ */
1391
+ sidebar: {
1392
+ ...BAND$1,
1393
+ "&[data-beside]": {
1394
+ "&[data-sticky]": {
1395
+ ...STICKY_COLUMN,
1396
+ ...CAPPED_COLUMN
1397
+ },
1398
+ inlineSize: `var(${SIDEBAR_SIZE}, {sizes.sidebar})`,
1399
+ paddingInlineEnd: "0",
1400
+ position: "relative"
1401
+ },
1402
+ gridArea: "sidebar"
1403
+ },
1404
+ /**
1405
+ * The padding of a sticky sidebar's scroll area, the room a focus ring takes at its edges.
1406
+ */
1407
+ sidebarContent: { padding: RING_ROOM },
1408
+ /**
1409
+ * The strip on a resizable sidebar's end edge, centred on the edge.
1410
+ */
1411
+ sidebarHandle: {
1412
+ ...STRIP$3,
1413
+ insetBlock: "0",
1414
+ insetInlineEnd: "calc({sizes.6} / -2)",
1415
+ position: "absolute"
1182
1416
  },
1417
+ /**
1418
+ * A sticky sidebar's scroll area, pulled out on every side by the room its content's padding
1419
+ * takes, so the content keeps its place.
1420
+ */
1421
+ sidebarScroller: { margin: `calc(${RING_ROOM} * -1)` },
1183
1422
  title: {
1184
1423
  gridArea: "title",
1185
1424
  minInlineSize: "0",
@@ -1187,7 +1426,7 @@ const recipe$5 = defineSlotRecipe({
1187
1426
  },
1188
1427
  toolbar: {
1189
1428
  ...BAND$1,
1190
- ...ROW$2,
1429
+ ...ROW$1,
1191
1430
  "&[data-sticky]": STUCK,
1192
1431
  gridArea: "toolbar"
1193
1432
  },
@@ -1250,6 +1489,10 @@ const recipe$5 = defineSlotRecipe({
1250
1489
  "picker",
1251
1490
  "palette",
1252
1491
  "toolbar",
1492
+ "sidebar",
1493
+ "sidebarScroller",
1494
+ "sidebarContent",
1495
+ "sidebarHandle",
1253
1496
  "body",
1254
1497
  "aside",
1255
1498
  "asideScroller",
@@ -1333,49 +1576,7 @@ const recipe$5 = defineSlotRecipe({
1333
1576
  * The page offers three sizes, because larger steps belong to a display heading. A section in
1334
1577
  * the page reads this size too.
1335
1578
  */
1336
- size: onSlots({
1337
- actions: sizeVariants((size) => ({ gap: dense(`{spacing.gap.${size}}`) }), STEPS),
1338
- aside: sizeVariants((size) => ({ paddingBlock: dense(`{spacing.inset.${size}}`) }), STEPS),
1339
- banner: sizeVariants((size) => ({ paddingBlock: dense(`{spacing.inset.${size}}`) }), STEPS),
1340
- body: sizeVariants((size) => ({ paddingBlock: dense(`{spacing.inset.${size}}`) }), STEPS),
1341
- context: sizeVariants((size) => ({
1342
- gap: dense(`{spacing.gap.${size}}`),
1343
- marginBlockEnd: size === "lg" ? "{spacing.1}" : "{spacing.0.5}",
1344
- textStyle: `body.${below(size)}`
1345
- }), STEPS),
1346
- description: {
1347
- lg: { textStyle: "body.md" },
1348
- md: { textStyle: "body.md" },
1349
- sm: { textStyle: "body.sm" }
1350
- },
1351
- footer: sizeVariants((size) => ({
1352
- gap: dense(`{spacing.gap.${size}}`),
1353
- paddingBlock: dense(`{spacing.inset.${size}}`)
1354
- }), STEPS),
1355
- header: sizeVariants((size) => ({
1356
- [BEFORE_NAV]: { paddingBlockEnd: dense(`{spacing.gap.${size}}`) },
1357
- paddingBlock: dense(`{spacing.inset.${size}}`)
1358
- }), STEPS),
1359
- meta: sizeVariants((size) => ({
1360
- gap: dense(`{spacing.gap.${size}}`),
1361
- marginInlineStart: dense(`{spacing.gap.${size}}`)
1362
- }), STEPS),
1363
- nav: sizeVariants((size) => ({
1364
- ...tabbed(size),
1365
- paddingBlock: dense(`{spacing.gap.${size}}`),
1366
- [WITH_TABS]: { paddingBlock: "0" }
1367
- }), STEPS),
1368
- palette: sizeVariants(() => FLAT_PALETTE, STEPS),
1369
- title: TITLES,
1370
- toolbar: sizeVariants((size) => ({
1371
- gap: dense(`{spacing.gap.${size}}`),
1372
- paddingBlock: dense(`{spacing.gap.${size}}`)
1373
- }), STEPS),
1374
- trail: sizeVariants((size) => ({
1375
- gap: dense(`{spacing.gap.${below(size)}}`),
1376
- textStyle: `body.${below(size)}`
1377
- }), STEPS)
1378
- })
1579
+ size: SIZES
1379
1580
  }
1380
1581
  });
1381
1582
  //#endregion
@@ -1413,7 +1614,7 @@ const AFTER = `.${CLASS$2}__root + &`;
1413
1614
  /**
1414
1615
  * Selects a band of a narrow section.
1415
1616
  */
1416
- const NARROW$1 = `.${CLASS$2}__root[data-narrow] > &`;
1617
+ const NARROW = `.${CLASS$2}__root[data-narrow] > &`;
1417
1618
  /**
1418
1619
  * Selects the actions of a narrow section.
1419
1620
  */
@@ -1457,7 +1658,7 @@ const SPACED = {
1457
1658
  /**
1458
1659
  * Styles a row that centres its items, wraps and shrinks.
1459
1660
  */
1460
- const ROW$1 = {
1661
+ const ROW = {
1461
1662
  alignItems: "center",
1462
1663
  display: "flex",
1463
1664
  flexWrap: "wrap",
@@ -1466,14 +1667,14 @@ const ROW$1 = {
1466
1667
  /**
1467
1668
  * Defines the section recipe: a plain section at size `md` by default.
1468
1669
  */
1469
- const recipe$4 = defineSlotRecipe({
1670
+ const recipe$3 = defineSlotRecipe({
1470
1671
  base: {
1471
1672
  action: {
1472
1673
  ...FOLDING,
1473
1674
  flexShrink: "0"
1474
1675
  },
1475
1676
  actions: {
1476
- ...ROW$1,
1677
+ ...ROW,
1477
1678
  flexWrap: "nowrap",
1478
1679
  gridArea: "actions",
1479
1680
  justifySelf: "end",
@@ -1488,7 +1689,7 @@ const recipe$4 = defineSlotRecipe({
1488
1689
  minInlineSize: "0"
1489
1690
  },
1490
1691
  footer: {
1491
- ...ROW$1,
1692
+ ...ROW,
1492
1693
  "& > :last-child:not(:first-child)": { marginInlineStart: "auto" },
1493
1694
  justifyContent: "space-between"
1494
1695
  },
@@ -1599,7 +1800,7 @@ const recipe$4 = defineSlotRecipe({
1599
1800
  }
1600
1801
  },
1601
1802
  header: {
1602
- [NARROW$1]: { marginBlockEnd: `var(${BANDS})` },
1803
+ [NARROW]: { marginBlockEnd: `var(${BANDS})` },
1603
1804
  padding: "0"
1604
1805
  },
1605
1806
  root: {
@@ -1644,8 +1845,8 @@ const recipe$4 = defineSlotRecipe({
1644
1845
  "lg"
1645
1846
  ]),
1646
1847
  description: sizeVariants((size) => ({
1647
- marginBlockStart: size === "lg" ? "{spacing.1}" : "{spacing.0.5}",
1648
- textStyle: `body.${below(size)}`
1848
+ textStyle: `body.${below(size)}`,
1849
+ marginBlockStart: size === "lg" ? "{spacing.1}" : "{spacing.0.5}"
1649
1850
  }), [
1650
1851
  "sm",
1651
1852
  "md",
@@ -1722,7 +1923,7 @@ const recipe$4 = defineSlotRecipe({
1722
1923
  /**
1723
1924
  * Selects a part inside a sidebar collapsed to a rail.
1724
1925
  */
1725
- const ICONIC$1 = "[data-iconic] &";
1926
+ const ICONIC = "[data-iconic] &";
1726
1927
  /**
1727
1928
  * Selects the children of a band that fold to an icon by themselves: a search, a nav block, a
1728
1929
  * navigation list and a switcher.
@@ -1750,7 +1951,7 @@ const BANDED = {
1750
1951
  display: "flex",
1751
1952
  flexDirection: "column",
1752
1953
  flexShrink: "0",
1753
- [ICONIC$1]: {
1954
+ [ICONIC]: {
1754
1955
  alignItems: "center",
1755
1956
  [WORDS]: { srOnly: true }
1756
1957
  },
@@ -1810,16 +2011,21 @@ function squared(size) {
1810
2011
  */
1811
2012
  function banded(size) {
1812
2013
  return {
2014
+ textStyle: `label.${below(size)}`,
1813
2015
  gap: dense(`{spacing.gap.${below(size)}}`),
1814
2016
  [LINES]: { marginInline: marked(size) },
1815
- padding: dense(`{spacing.gap.${size}}`),
1816
- textStyle: `label.${below(size)}`
2017
+ padding: dense(`{spacing.gap.${size}}`)
1817
2018
  };
1818
2019
  }
1819
2020
  /**
1820
2021
  * Styles a plain sidebar at the middle size.
2022
+ *
2023
+ * @remarks
2024
+ * `staticCss` lists every size and every look. A sidebar in a panel over the page renders at `lg`
2025
+ * whatever its `size`, a product's frame states the size and the look as data, and the compiler
2026
+ * emits a variant only where a source states it as a literal.
1821
2027
  */
1822
- const recipe$3 = defineSlotRecipe({
2028
+ const recipe$2 = defineSlotRecipe({
1823
2029
  base: {
1824
2030
  content: {
1825
2031
  display: "flex",
@@ -1827,7 +2033,7 @@ const recipe$3 = defineSlotRecipe({
1827
2033
  },
1828
2034
  empty: {
1829
2035
  color: "fg.muted",
1830
- [ICONIC$1]: { display: "none" },
2036
+ [ICONIC]: { display: "none" },
1831
2037
  textAlign: "center"
1832
2038
  },
1833
2039
  footer: BANDED,
@@ -1855,7 +2061,7 @@ const recipe$3 = defineSlotRecipe({
1855
2061
  display: "flex",
1856
2062
  flexShrink: "0",
1857
2063
  gridColumn: "2 / 3",
1858
- [ICONIC$1]: { display: "none" },
2064
+ [ICONIC]: { display: "none" },
1859
2065
  justifyContent: "center",
1860
2066
  padding: "0"
1861
2067
  },
@@ -1866,18 +2072,18 @@ const recipe$3 = defineSlotRecipe({
1866
2072
  display: "flex",
1867
2073
  fontWeight: "medium",
1868
2074
  gridColumn: "1 / 2",
1869
- [ICONIC$1]: { srOnly: true },
2075
+ [ICONIC]: { srOnly: true },
1870
2076
  textTransform: "uppercase"
1871
2077
  },
1872
2078
  navLabel: {
1873
2079
  ...truncate(),
2080
+ textStyle: "label.xs",
1874
2081
  alignItems: "center",
1875
2082
  color: "fg.subtle",
1876
2083
  display: "flex",
1877
2084
  fontWeight: "medium",
1878
2085
  gridColumn: "1 / 2",
1879
- [ICONIC$1]: { srOnly: true },
1880
- textStyle: "label.xs",
2086
+ [ICONIC]: { srOnly: true },
1881
2087
  textTransform: "uppercase"
1882
2088
  },
1883
2089
  root: {
@@ -1892,7 +2098,7 @@ const recipe$3 = defineSlotRecipe({
1892
2098
  minBlockSize: "0"
1893
2099
  },
1894
2100
  search: {
1895
- [ICONIC$1]: {
2101
+ [ICONIC]: {
1896
2102
  display: "flex",
1897
2103
  justifyContent: "center"
1898
2104
  },
@@ -1921,6 +2127,19 @@ const recipe$3 = defineSlotRecipe({
1921
2127
  "empty",
1922
2128
  "separator"
1923
2129
  ],
2130
+ staticCss: [{
2131
+ size: [
2132
+ "sm",
2133
+ "md",
2134
+ "lg"
2135
+ ],
2136
+ variant: [
2137
+ "outline",
2138
+ "plain",
2139
+ "subtle",
2140
+ "surface"
2141
+ ]
2142
+ }],
1924
2143
  variants: {
1925
2144
  size: onSlots({
1926
2145
  /**
@@ -1938,8 +2157,8 @@ const recipe$3 = defineSlotRecipe({
1938
2157
  "lg"
1939
2158
  ]),
1940
2159
  empty: sizeVariants((size) => ({
1941
- padding: dense(`{spacing.inset.${size}}`),
1942
- textStyle: `body.${size}`
2160
+ textStyle: `body.${size}`,
2161
+ padding: dense(`{spacing.inset.${size}}`)
1943
2162
  }), [
1944
2163
  "sm",
1945
2164
  "md",
@@ -1978,10 +2197,10 @@ const recipe$3 = defineSlotRecipe({
1978
2197
  "lg"
1979
2198
  ]),
1980
2199
  navHeading: sizeVariants((size) => ({
2200
+ textStyle: "label.xs",
1981
2201
  marginBlockEnd: `calc(${dense(`{spacing.gap.${size}}`)} * -0.5)`,
1982
2202
  paddingBlock: dense("{spacing.gap.xs}"),
1983
- paddingInline: marked(size),
1984
- textStyle: "label.xs"
2203
+ paddingInline: marked(size)
1985
2204
  }), [
1986
2205
  "sm",
1987
2206
  "md",
@@ -2095,7 +2314,7 @@ const PILLED = `&:has(> .${CLASS$1}__resizeTriggerIndicator)`;
2095
2314
  /**
2096
2315
  * Defines the splitter recipe, in the primary palette by default.
2097
2316
  */
2098
- const recipe$2 = defineSlotRecipe({
2317
+ const recipe$1 = defineSlotRecipe({
2099
2318
  base: {
2100
2319
  panel: {
2101
2320
  display: "flex",
@@ -2104,6 +2323,7 @@ const recipe$2 = defineSlotRecipe({
2104
2323
  minInlineSize: "0"
2105
2324
  },
2106
2325
  resizeTrigger: {
2326
+ focusVisibleRing: "outside",
2107
2327
  _touch: {
2108
2328
  _before: {
2109
2329
  content: "\"\"",
@@ -2129,7 +2349,6 @@ const recipe$2 = defineSlotRecipe({
2129
2349
  marginBlock: `calc(${STRIP} * -0.5)`
2130
2350
  },
2131
2351
  focusRingColor: "colorPalette.focusRing",
2132
- focusVisibleRing: "outside",
2133
2352
  justifyItems: "center",
2134
2353
  [PILLED]: { _focusVisible: { outlineStyle: "none" } },
2135
2354
  position: "relative",
@@ -2228,224 +2447,6 @@ const recipe$2 = defineSlotRecipe({
2228
2447
  palette: onSlot("root", paletteVariants()) }
2229
2448
  });
2230
2449
  //#endregion
2231
- //#region src/switcher/recipe.ts
2232
- /**
2233
- * Declares the switcher's slot recipe, which styles the control that shows the current workspace,
2234
- * project or environment and opens the menu of the others.
2235
- *
2236
- * @remarks
2237
- * The recipe has six slots. The root slot is the trigger, and the mark, the label, the name, the
2238
- * detail and the indicator are inside it. `Switcher.Root` is the disclosure package's menu, so
2239
- * the menu's recipe styles the panel and its rows. The trigger takes the button's looks and
2240
- * palettes from the same layer styles, so an outlined switcher has the button's light edge. The
2241
- * mark is a square in the palette's muted fill for an initial, an icon or an avatar. The name and
2242
- * the detail truncate to keep the control at one height. A pair of chevrons marks a control that
2243
- * switches and not a direction, so the indicator does not rotate while the menu is open. In a
2244
- * sidebar closed to icons the control is its mark alone, square, and in a narrow toolbar a
2245
- * switcher with a mark hides its name. The words remain in the accessible name in both.
2246
- */
2247
- /**
2248
- * Selects a part of a switcher drawn as its mark alone, in a sidebar closed to icons.
2249
- */
2250
- const ICONIC = ".switcher__root[data-iconic] &";
2251
- /**
2252
- * Selects a part of a switcher with a mark in a toolbar too narrow for the name. A switcher
2253
- * without a mark keeps its name, because the name is the only content that identifies it.
2254
- */
2255
- const NARROW = ".switcher__root[data-narrow]:has(.switcher__mark) &";
2256
- /**
2257
- * Custom property the size axis sets to the height of a button at the switcher's size.
2258
- */
2259
- const HEIGHT = "--switcher-height";
2260
- /**
2261
- * Styles a control that is one line as wide as its words: in a toolbar and on its own.
2262
- *
2263
- * @remarks
2264
- * `inlineSize: fit` keeps the control as wide as its words, because a flex container fills its
2265
- * line otherwise. The detail is hidden, because the control is one line tall. The control is at
2266
- * least as tall as a button of its size, so it lines up with the buttons in a toolbar: 36px at
2267
- * `sm` and 40px at `md`.
2268
- */
2269
- const INLINE = {
2270
- detail: { display: "none" },
2271
- root: {
2272
- inlineSize: "fit",
2273
- minBlockSize: `var(${HEIGHT})`
2274
- }
2275
- };
2276
- /**
2277
- * Styles the control in a sidebar: a row as wide as the column, and the mark alone on a rail.
2278
- *
2279
- * @remarks
2280
- * On a rail the control has no padding and no edge, so it is the mark's square, as tall as the
2281
- * rows under it: 32px at `md`.
2282
- */
2283
- const ROW = { root: {
2284
- "&[data-iconic]": {
2285
- alignSelf: "center",
2286
- aspectRatio: "square",
2287
- borderWidth: "0",
2288
- inlineSize: "auto",
2289
- justifyContent: "center",
2290
- padding: "0"
2291
- },
2292
- inlineSize: "full"
2293
- } };
2294
- /**
2295
- * Styles a ghost switcher at the middle size, alone.
2296
- */
2297
- const recipe$1 = defineSlotRecipe({
2298
- base: {
2299
- detail: {
2300
- ...truncate(),
2301
- color: "fg.subtle",
2302
- textStyle: "caption"
2303
- },
2304
- indicator: {
2305
- _open: { rotate: "0deg" },
2306
- alignItems: "center",
2307
- color: "fg.subtle",
2308
- display: "inline-flex",
2309
- flexShrink: "0",
2310
- [ICONIC]: { display: "none" },
2311
- justifyContent: "center",
2312
- marginInlineStart: "auto",
2313
- transitionDuration: "press",
2314
- transitionProperty: "common",
2315
- transitionTimingFunction: "press"
2316
- },
2317
- label: {
2318
- display: "flex",
2319
- flex: "1",
2320
- flexDirection: "column",
2321
- [ICONIC]: { srOnly: true },
2322
- minInlineSize: "0",
2323
- [NARROW]: { srOnly: true },
2324
- textAlign: "start"
2325
- },
2326
- mark: {
2327
- alignItems: "center",
2328
- background: "colorPalette.muted",
2329
- borderRadius: "l1",
2330
- color: "colorPalette.fg",
2331
- display: "inline-flex",
2332
- flexShrink: "0",
2333
- fontWeight: "semibold",
2334
- justifyContent: "center",
2335
- lineHeight: "tight",
2336
- overflow: "clip"
2337
- },
2338
- name: {
2339
- ...truncate(),
2340
- fontWeight: "medium"
2341
- },
2342
- root: {
2343
- ...interactive(),
2344
- alignItems: "center",
2345
- appearance: "none",
2346
- borderColor: "transparent",
2347
- borderWidth: "control",
2348
- colorPalette: "neutral",
2349
- display: "flex",
2350
- minInlineSize: "0"
2351
- }
2352
- },
2353
- className: "switcher",
2354
- compoundVariants: [{
2355
- css: { mark: {
2356
- boxSize: dense("{sizes.icon.lg}"),
2357
- fontSize: "xs"
2358
- } },
2359
- name: "marked",
2360
- placement: ["alone", "toolbar"]
2361
- }, (
2362
- /**
2363
- * Sets the detail and the indicator in the trigger's own ink on the solid look, where the
2364
- * page's subtle ink is not read against the fill.
2365
- */
2366
- {
2367
- css: {
2368
- detail: { color: "inherit" },
2369
- indicator: { color: "inherit" }
2370
- },
2371
- name: "inked",
2372
- variant: "solid"
2373
- })],
2374
- defaultVariants: {
2375
- placement: "alone",
2376
- size: "md",
2377
- variant: "ghost"
2378
- },
2379
- jsx: [/^Switcher(\.\w+)?$/u],
2380
- slots: [
2381
- "root",
2382
- "mark",
2383
- "label",
2384
- "name",
2385
- "detail",
2386
- "indicator"
2387
- ],
2388
- variants: {
2389
- /**
2390
- * The palette of the look and the mark.
2391
- */
2392
- palette: onSlot("root", paletteVariants()),
2393
- /**
2394
- * Where the control is placed, which sets its width and its lines.
2395
- *
2396
- * @remarks
2397
- * `Switcher.Root` reads the placement from the sidebar or the toolbar around it unless a
2398
- * caller states one. The `marked` compound sizes the mark to `icon.lg` on one line, because
2399
- * the size axis sizes it for a sidebar and a compound applies over an axis.
2400
- */
2401
- placement: {
2402
- alone: INLINE,
2403
- sidebar: ROW,
2404
- toolbar: INLINE
2405
- },
2406
- size: onSlots({
2407
- indicator: sizeVariants((size) => ({ boxSize: dense(`{sizes.icon.${below(size)}}`) }), [
2408
- "sm",
2409
- "md",
2410
- "lg"
2411
- ]),
2412
- mark: sizeVariants((size) => ({
2413
- boxSize: dense(`{sizes.control.${below(below(size))}}`),
2414
- fontSize: below(size)
2415
- }), [
2416
- "sm",
2417
- "md",
2418
- "lg"
2419
- ]),
2420
- name: sizeVariants((size) => ({ textStyle: `body.${below(size)}` }), [
2421
- "sm",
2422
- "md",
2423
- "lg"
2424
- ]),
2425
- root: sizeVariants((size) => ({
2426
- borderRadius: "l2",
2427
- gap: dense(`{spacing.gap.${size}}`),
2428
- [HEIGHT]: dense(`{sizes.control.${size}}`),
2429
- paddingBlock: dense(`{spacing.gap.${below(size)}}`),
2430
- paddingInline: dense(`{spacing.gap.${size}}`),
2431
- textStyle: `body.${below(size)}`
2432
- }), [
2433
- "sm",
2434
- "md",
2435
- "lg"
2436
- ])
2437
- }),
2438
- /**
2439
- * The button's six looks, each one of the theme's layer styles.
2440
- *
2441
- * @remarks
2442
- * `ghost`, the default, has no fill at rest, so a switcher at the head of a sidebar shows the
2443
- * sidebar's own ground and fills under a pointer.
2444
- */
2445
- variant: onSlot("root", lookVariants())
2446
- }
2447
- });
2448
- //#endregion
2449
2450
  //#region src/toolbar/recipe.ts
2450
2451
  /**
2451
2452
  * Recipe for a toolbar: a row of controls with a start, a centre and an end band, and a search.
@@ -2460,9 +2461,11 @@ const recipe$1 = defineSlotRecipe({
2460
2461
  * percentage width gives a band no content width, so the search states an inline size. An opened
2461
2462
  * search covers the whole row, the padding and the edge of an outline or surface row included,
2462
2463
  * and the root hides its other children while it is open, so the row needs no fill of its own on
2463
- * any surface. The recipe has no `palette`
2464
- * axis, because the controls in the row set their own palettes, and no `effect` axis, because the
2465
- * row is not a control.
2464
+ * any surface. The title is semibold and leaves twice the gap after it, so the control after it
2465
+ * reads as the first of the row's controls and not as part of the title. A narrow row folds
2466
+ * those controls, and its title leaves the row's gap alone. The recipe has no
2467
+ * `palette` axis, because the controls in the row set their own palettes, and no `effect` axis,
2468
+ * because the row is not a control.
2466
2469
  */
2467
2470
  /**
2468
2471
  * Custom property the root sets to the gap between controls, which every band reads.
@@ -2548,6 +2551,13 @@ const recipe = defineSlotRecipe({
2548
2551
  start: {
2549
2552
  ...BAND,
2550
2553
  flexShrink: "0"
2554
+ },
2555
+ title: {
2556
+ ...BAND,
2557
+ [`&[${NARROW$1}]`]: { marginInlineEnd: "0" },
2558
+ flexShrink: "0",
2559
+ fontWeight: "semibold",
2560
+ marginInlineEnd: `calc(var(${GAP}) * 2)`
2551
2561
  }
2552
2562
  },
2553
2563
  className: CLASS,
@@ -2565,7 +2575,8 @@ const recipe = defineSlotRecipe({
2565
2575
  "action",
2566
2576
  "group",
2567
2577
  "separator",
2568
- "search"
2578
+ "search",
2579
+ "title"
2569
2580
  ],
2570
2581
  variants: {
2571
2582
  /**
@@ -2609,4 +2620,4 @@ const recipe = defineSlotRecipe({
2609
2620
  }
2610
2621
  });
2611
2622
  //#endregion
2612
- export { STICKY_OFFSET$1 as _, recipe$4 as a, recipe$8 as b, STICKY_TOP as c, PRIORITY as d, recipe$6 as f, SETTLED as g, PANEL_SIZE as h, recipe$3 as i, recipe$5 as l, PANEL_RAIL as m, recipe$1 as n, SCROLLPORT as o, recipe$7 as p, recipe$2 as r, STICKY_OFFSET as s, recipe as t, NARROW$2 as u, STICKY_TOP$1 as v, WINDOW_HEIGHT as y };
2623
+ export { lengthOf as C, keyedWidth as S, STICKY_OFFSET$1 as _, SCROLLPORT as a, clamped as b, STICKY_TOP as c, recipe$6 as d, PANEL_RAIL as f, SETTLED as g, PANE_TOP as h, recipe$3 as i, recipe$4 as l, PANEL_TOP as m, recipe$1 as n, SIDEBAR_SIZE as o, PANEL_SIZE as p, recipe$2 as r, STICKY_OFFSET as s, recipe as t, recipe$5 as u, WINDOW_HEIGHT as v, recipe$7 as w, growthOf as x, RESIZING as y };