@stacksjs/components 0.2.165 → 0.2.167

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.
@@ -639,10 +639,12 @@ defineExpose({ collapsed, toggle: toggleCollapse, syncRouteSelection })
639
639
  /* Width is listed here because this rule outranks the pane's
640
640
  `transition-[width]` utility class and would otherwise drop it. */
641
641
  transition:
642
- --stx-space-from 420ms ease,
643
- --stx-space-to 420ms ease,
644
- --stx-space-ink 420ms ease,
645
- --stx-space-accent 420ms ease,
642
+ --stx-space-from var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
643
+ --stx-space-to var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
644
+ --stx-space-ink var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
645
+ --stx-space-accent var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
646
+ /* Collapsing the pane is a different intent from changing space, so it
647
+ keeps its own timing. */
646
648
  width 300ms ease-out;
647
649
  }
648
650
 
@@ -16,6 +16,19 @@ export const clear = $props.clear || null
16
16
  export const theme = $props.theme || 'arc'
17
17
  export const active = $props.active ?? false
18
18
 
19
+ // Render the shell without its contents.
20
+ //
21
+ // Every space's rows are rendered on the server even though the Arc design
22
+ // shows one at a time, and that cost is linear in the number of spaces: a host
23
+ // with fourteen projects pays for fourteen panels on every page load to display
24
+ // one. `deferred` keeps the panel — the track's geometry, the tab role and the
25
+ // swipe all depend on it existing — and drops what is inside it.
26
+ //
27
+ // The space still announces itself as a tabpanel, so the switcher and keyboard
28
+ // navigation are unchanged; `data-space-deferred` tells the controller this one
29
+ // has nothing yet, and `spaceChange` is where a host fills it or navigates.
30
+ export const deferred = $props.deferred ?? false
31
+
19
32
  const themeStyle = resolveSidebarTheme(theme)
20
33
  export const scrollAreaClasses = themeStyle.scrollArea
21
34
 
@@ -32,7 +45,7 @@ export const actionClasses = [
32
45
  'flex w-full items-center gap-[8px]',
33
46
  'h-[30px] rounded-[8px] px-[6px]',
34
47
  'text-[13px] leading-[16px]',
35
- 'opacity-55 transition-[background-color,opacity] duration-150 ease-out',
48
+ 'opacity-55 stx-space-timed',
36
49
  'hover:bg-white/45 hover:opacity-100 dark:hover:bg-white/8',
37
50
  'cursor-default',
38
51
  ].join(' ')
@@ -40,7 +53,7 @@ export const actionClasses = [
40
53
  export const clearClasses = [
41
54
  'flex items-center gap-[4px] rounded-[6px] px-[5px] py-[2px]',
42
55
  'text-[11px] leading-[14px]',
43
- 'opacity-45 transition-opacity duration-150',
56
+ 'opacity-45 stx-space-timed',
44
57
  'hover:opacity-90',
45
58
  'cursor-default',
46
59
  ].join(' ')
@@ -64,6 +77,7 @@ function onAction(event) {
64
77
  data-space
65
78
  data-space-id="{{ id }}"
66
79
  data-space-active="{{ active ? 'true' : 'false' }}"
80
+ @if(deferred)data-space-deferred@endif
67
81
  @if(!active)inert@endif
68
82
  class="flex min-w-0 flex-1 flex-col"
69
83
  role="tabpanel"
@@ -71,7 +85,9 @@ function onAction(event) {
71
85
  @click="onAction($event)"
72
86
  >
73
87
  <div class="{{ scrollAreaClasses }} stx-sidebar-scroll">
74
- <SidebarPinned :items="pinned" />
88
+ @if(!deferred)
89
+ <SidebarPinned :items="pinned" />
90
+ @endif
75
91
 
76
92
  @if(label)
77
93
  <div class="{{ titleClasses }}">
@@ -82,6 +98,12 @@ function onAction(event) {
82
98
  </div>
83
99
  @endif
84
100
 
101
+ <!--
102
+ Rows, and the actions that belong with them, are what a deferred panel
103
+ drops. The title above stays: it is a single line, and it is what tells
104
+ you which space you are swiping into.
105
+ -->
106
+ @if(!deferred)
85
107
  @foreach(sections as section)
86
108
  <SidebarSection
87
109
  :id="section.id"
@@ -123,10 +145,22 @@ function onAction(event) {
123
145
  <span class="truncate">{{ action.label }}</span>
124
146
  </button>
125
147
  @endif
148
+ @endif
126
149
  </div>
127
150
  </section>
128
151
 
129
152
  <style scoped>
153
+ /* Anything that changes with the space shares one duration and one curve, so a
154
+ swipe is a single motion. See `--stx-space-settle` in SidebarSpaces. */
155
+ .stx-space-timed {
156
+ transition:
157
+ background-color var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
158
+ opacity var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
159
+ color var(--stx-space-settle, 420ms) var(--stx-space-ease, ease);
160
+ }
161
+ /* Hover is a separate intent and stays quick. */
162
+ .stx-space-timed:hover { transition-duration: 120ms; }
163
+
130
164
  /* The title icon rides the space accent. Published as a custom property by
131
165
  SidebarSpaces so it crossfades along with the panel instead of cutting. */
132
166
  .stx-space-title-icon {
@@ -0,0 +1,189 @@
1
+ <script server>
2
+ // The indicator along the bottom of an Arc-style sidebar: one dot per space,
3
+ // plus an optional trailing "+".
4
+ //
5
+ // Dots rather than one icon per space, which is what Dia does and what this
6
+ // used to get wrong. An icon rail costs ~28px of width each, so it fits about
7
+ // eight spaces in a 280px sidebar and then overflows — and with every space
8
+ // showing the same default folder glyph, thirteen of them read as a broken
9
+ // progress bar rather than as a switcher. A dot is 6px, says only "which of
10
+ // how many", and leaves the identifying work to the space title and the tint,
11
+ // which is where it belongs.
12
+ //
13
+ // The buttons are markup only. `<SidebarSpaces>` owns the single click handler
14
+ // and flips `data-space-current`, so the rail never holds its own copy of
15
+ // which space is active — there is exactly one source of truth.
16
+ export const spaces = ($props.spaces || []).map(space => ({
17
+ id: space.id,
18
+ label: space.label || space.id,
19
+ }))
20
+
21
+ export const active = $props.active || (spaces[0] && spaces[0].id) || ''
22
+ export const showAdd = $props.showAdd ?? false
23
+ export const addLabel = $props.addLabel || 'New space'
24
+
25
+ // A single space has nothing to indicate. Dia hides the row entirely rather
26
+ // than showing one lonely dot, and so does this — unless "+" needs a home.
27
+ export const showDots = spaces.length > 1
28
+ </script>
29
+
30
+ <div
31
+ data-space-switcher
32
+ class="stx-space-rail"
33
+ role="tablist"
34
+ aria-label="Spaces"
35
+ aria-orientation="horizontal"
36
+ >
37
+ <!--
38
+ A spacer of the same width as the trailing button, so the dots are centred
39
+ on the sidebar rather than on the space left over beside "+".
40
+ -->
41
+ <span class="stx-space-rail-gutter" aria-hidden="true"></span>
42
+
43
+ <div class="stx-space-dots">
44
+ @if(showDots)
45
+ @foreach(spaces as space)
46
+ <button
47
+ type="button"
48
+ data-space-target="{{ space.id }}"
49
+ data-space-current="{{ space.id === active ? 'true' : 'false' }}"
50
+ class="stx-space-dot"
51
+ role="tab"
52
+ aria-selected="{{ space.id === active ? 'true' : 'false' }}"
53
+ aria-label="{{ space.label }}"
54
+ title="{{ space.label }}"
55
+ tabindex="{{ space.id === active ? '0' : '-1' }}"
56
+ >
57
+ <!--
58
+ The dot is a child rather than the button itself: the button stays a
59
+ comfortable target while the mark stays small.
60
+ -->
61
+ <span class="stx-space-dot-mark" aria-hidden="true"></span>
62
+ </button>
63
+ @endforeach
64
+ @endif
65
+ </div>
66
+
67
+ <span class="stx-space-rail-gutter">
68
+ @if(showAdd)
69
+ <button
70
+ type="button"
71
+ data-space-add
72
+ class="stx-space-add"
73
+ aria-label="{{ addLabel }}"
74
+ title="{{ addLabel }}"
75
+ >
76
+ <span class="i-f7-plus h-[11px] w-[11px]" aria-hidden="true"></span>
77
+ </button>
78
+ @endif
79
+ </span>
80
+ </div>
81
+
82
+ <style scoped>
83
+ .stx-space-rail {
84
+ display: flex;
85
+ align-items: center;
86
+ flex-shrink: 0;
87
+ height: 34px;
88
+ padding: 0 8px 6px;
89
+ }
90
+
91
+ .stx-space-rail-gutter {
92
+ display: grid;
93
+ place-items: center;
94
+ width: 22px;
95
+ flex-shrink: 0;
96
+ }
97
+
98
+ .stx-space-dots {
99
+ display: flex;
100
+ flex: 1;
101
+ align-items: center;
102
+ justify-content: center;
103
+ gap: 2px;
104
+ min-width: 0;
105
+ /* Many spaces scroll rather than squeeze: dots that shrink to fit stop
106
+ reading as dots, and a sidebar cannot get wider. The bar is hidden because
107
+ the dots are the affordance. */
108
+ overflow-x: auto;
109
+ scrollbar-width: none;
110
+ }
111
+ .stx-space-dots::-webkit-scrollbar { display: none; }
112
+
113
+ .stx-space-dot {
114
+ display: grid;
115
+ place-items: center;
116
+ width: 14px;
117
+ height: 18px;
118
+ flex-shrink: 0;
119
+ background: transparent;
120
+ border: 0;
121
+ padding: 0;
122
+ cursor: default;
123
+ }
124
+
125
+ .stx-space-dot-mark {
126
+ width: 6px;
127
+ height: 6px;
128
+ border-radius: 9999px;
129
+ background: currentColor;
130
+ opacity: 0.25;
131
+ /* One duration and one curve for everything that moves when the space
132
+ changes — see `--stx-space-settle` in SidebarSpaces. Independent timings
133
+ are what made a swipe read as several things happening at once. */
134
+ transition:
135
+ opacity var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
136
+ background-color var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
137
+ transform var(--stx-space-settle, 420ms) var(--stx-space-ease, ease);
138
+ }
139
+
140
+ /* Hover is not part of a space change, so it stays quick. */
141
+ .stx-space-dot:hover .stx-space-dot-mark {
142
+ opacity: 0.5;
143
+ transition-duration: 120ms;
144
+ }
145
+
146
+ .stx-space-dot[data-space-current="true"] .stx-space-dot-mark {
147
+ opacity: 1;
148
+ background: var(--stx-space-accent, currentColor);
149
+ /* Barely larger. Enough to find at a glance, not enough to jump. */
150
+ transform: scale(1.15);
151
+ }
152
+
153
+ .stx-space-dot:focus-visible {
154
+ outline: 2px solid var(--stx-space-accent, currentColor);
155
+ outline-offset: 2px;
156
+ border-radius: 9999px;
157
+ }
158
+
159
+ .stx-space-add {
160
+ display: grid;
161
+ place-items: center;
162
+ width: 20px;
163
+ height: 20px;
164
+ border-radius: 6px;
165
+ color: currentColor;
166
+ opacity: 0.35;
167
+ background: transparent;
168
+ border: 0;
169
+ padding: 0;
170
+ cursor: default;
171
+ transition: opacity 120ms ease, background-color 120ms ease;
172
+ }
173
+ .stx-space-add:hover {
174
+ opacity: 0.8;
175
+ background: rgba(255, 255, 255, 0.4);
176
+ }
177
+ .stx-space-add:focus-visible {
178
+ outline: 2px solid var(--stx-space-accent, currentColor);
179
+ outline-offset: 1px;
180
+ }
181
+
182
+ @media (prefers-color-scheme: dark) {
183
+ .stx-space-add:hover { background: rgba(255, 255, 255, 0.1); }
184
+ }
185
+
186
+ @media (prefers-reduced-motion: reduce) {
187
+ .stx-space-dot-mark { transition: none; }
188
+ }
189
+ </style>
@@ -106,6 +106,20 @@ function tintTarget() {
106
106
  return root.closest('[data-stx-sidebar]') || root
107
107
  }
108
108
 
109
+ /**
110
+ * Publish the settle timing so CSS and the track animation cannot drift.
111
+ *
112
+ * `SETTLE_MS` drives the track's transform from JS; everything else that moves
113
+ * during a space change reads these properties. Written once at mount rather
114
+ * than duplicated as literals in four stylesheets, which is how they came to
115
+ * disagree — 420ms on one curve, 420ms on another, 300ms, and 150ms.
116
+ */
117
+ function publishTiming() {
118
+ const style = tintTarget().style
119
+ style.setProperty('--stx-space-settle', `${SETTLE_MS}ms`)
120
+ style.setProperty('--stx-space-ease', SETTLE_EASE)
121
+ }
122
+
109
123
  function applyTint(position) {
110
124
  const tint = tints[position]
111
125
  if (!tint) return
@@ -452,6 +466,10 @@ onMount(() => {
452
466
  tints = spaces.map(space => space.tint)
453
467
  if (ids.length === 0) return
454
468
 
469
+ // Before the first paint, so nothing animates on the default timing once and
470
+ // the shared one afterwards.
471
+ publishTiming()
472
+
455
473
  const startIndex = clampIndex(config.index || 0, ids.length)
456
474
  const saved = restored()
457
475
  const initial = saved === -1 ? startIndex : saved
@@ -514,6 +532,7 @@ defineExpose({
514
532
  :action="space.action"
515
533
  :clear="space.clear"
516
534
  :active="space.id === activeId"
535
+ :deferred="space.deferred === true"
517
536
  :theme="theme"
518
537
  />
519
538
  @endforeach
@@ -559,11 +578,19 @@ defineExpose({
559
578
  linear-gradient(180deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 32%),
560
579
  linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
561
580
  color: var(--stx-space-ink);
581
+ /* One duration and one curve for everything a space change moves.
582
+ This used to be four timings and three easings across the track, the
583
+ palette, the switcher and the space's own buttons — 420ms on one curve,
584
+ 420ms on another, 300ms, 150ms — so a swipe read as several things
585
+ happening near each other rather than as one motion. Published as custom
586
+ properties so the pieces cannot drift apart again. */
587
+ --stx-space-settle: 420ms;
588
+ --stx-space-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
562
589
  transition:
563
- --stx-space-from 420ms ease,
564
- --stx-space-to 420ms ease,
565
- --stx-space-ink 420ms ease,
566
- --stx-space-accent 420ms ease;
590
+ --stx-space-from var(--stx-space-settle) var(--stx-space-ease),
591
+ --stx-space-to var(--stx-space-settle) var(--stx-space-ease),
592
+ --stx-space-ink var(--stx-space-settle) var(--stx-space-ease),
593
+ --stx-space-accent var(--stx-space-settle) var(--stx-space-ease);
567
594
  }
568
595
 
569
596
  @media (prefers-color-scheme: dark) {
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // @bun
2
- // ../../../../../../../tmp/stx-components-build-S1H8s5/stubs/stub-0.js
2
+ // ../../../../../../../tmp/stx-components-build-RdlquU/stubs/stub-0.js
3
3
  var stub_0_default = { __stx: true, src: "./components/CodeBlock.stx" };
4
- // ../../../../../../../tmp/stx-components-build-S1H8s5/stubs/stub-1.js
4
+ // ../../../../../../../tmp/stx-components-build-RdlquU/stubs/stub-1.js
5
5
  var stub_1_default = { __stx: true, src: "./components/Footer.stx" };
6
- // ../../../../../../../tmp/stx-components-build-S1H8s5/stubs/stub-2.js
6
+ // ../../../../../../../tmp/stx-components-build-RdlquU/stubs/stub-2.js
7
7
  var stub_2_default = { __stx: true, src: "./components/Hero.stx" };
8
- // ../../../../../../../tmp/stx-components-build-S1H8s5/stubs/stub-3.js
8
+ // ../../../../../../../tmp/stx-components-build-RdlquU/stubs/stub-3.js
9
9
  var stub_3_default = { __stx: true, src: "./components/Installation.stx" };
10
10
  // src/composables/useCopyCode.ts
11
11
  function useCopyCode(options) {
@@ -1132,7 +1132,7 @@ var RadioGroupOption_default = "./RadioGroupOption-s1d6r221.stx";
1132
1132
  // src/ui/select/Select.stx
1133
1133
  var Select_default = "./Select-1vnd0brz.stx";
1134
1134
  // src/ui/sidebar/Sidebar.stx
1135
- var Sidebar_default = "./Sidebar-56px4jha.stx";
1135
+ var Sidebar_default = "./Sidebar-v7ecx5gs.stx";
1136
1136
  // src/ui/sidebar/SidebarFooter.stx
1137
1137
  var SidebarFooter_default = "./SidebarFooter-s7skj92y.stx";
1138
1138
  // src/ui/sidebar/SidebarHeader.stx
@@ -1144,11 +1144,11 @@ var SidebarPinned_default = "./SidebarPinned-tvj28bm0.stx";
1144
1144
  // src/ui/sidebar/SidebarSection.stx
1145
1145
  var SidebarSection_default = "./SidebarSection-zd9z35ch.stx";
1146
1146
  // src/ui/sidebar/SidebarSpace.stx
1147
- var SidebarSpace_default = "./SidebarSpace-h50n47va.stx";
1147
+ var SidebarSpace_default = "./SidebarSpace-r062weq2.stx";
1148
1148
  // src/ui/sidebar/SidebarSpaces.stx
1149
- var SidebarSpaces_default = "./SidebarSpaces-sy8dy0gb.stx";
1149
+ var SidebarSpaces_default = "./SidebarSpaces-fgxhn4ac.stx";
1150
1150
  // src/ui/sidebar/SidebarSpaceSwitcher.stx
1151
- var SidebarSpaceSwitcher_default = "./SidebarSpaceSwitcher-882njcxq.stx";
1151
+ var SidebarSpaceSwitcher_default = "./SidebarSpaceSwitcher-az9wqm87.stx";
1152
1152
  // src/ui/sidebar/themes.ts
1153
1153
  var macosColors = {
1154
1154
  blue: "#0088ff",
@@ -1427,7 +1427,8 @@ function normalizeSpace(space) {
1427
1427
  action: normalizeAction(space.action, space.id, "action", "i-f7-plus"),
1428
1428
  clear: normalizeAction(space.clear, space.id, "clear", "i-f7-arrow-down"),
1429
1429
  tint,
1430
- style: spaceTintVars(tint)
1430
+ style: spaceTintVars(tint),
1431
+ deferred: space.deferred === true
1431
1432
  };
1432
1433
  }
1433
1434
  function normalizeSpaces(spaces) {
@@ -81,6 +81,7 @@ export declare interface SidebarSpaceData {
81
81
  icon?: string
82
82
  tint?: string | SidebarSpaceTint
83
83
  pinned?: SidebarPinnedItemData[]
84
+ deferred?: boolean
84
85
  sections?: SidebarSectionData[]
85
86
  action?: SidebarSpaceActionData
86
87
  clear?: SidebarSpaceActionData
@@ -102,4 +103,5 @@ export declare interface NormalizedSidebarSpace {
102
103
  clear: SidebarSpaceActionData | null
103
104
  tint: SidebarSpaceTint
104
105
  style: string
106
+ deferred: boolean
105
107
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/components",
3
3
  "type": "module",
4
- "version": "0.2.165",
4
+ "version": "0.2.167",
5
5
  "description": "A modern component library built with STX, featuring headwind utility classes and TypeScript syntax highlighting.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -64,8 +64,8 @@
64
64
  "dependencies": {
65
65
  "@iconify-json/f7": "^1.2.2",
66
66
  "@cwcss/crosswind": "^0.2.14",
67
- "@stacksjs/stx": "0.2.165",
68
- "bun-plugin-stx": "0.2.165",
67
+ "@stacksjs/stx": "0.2.167",
68
+ "bun-plugin-stx": "0.2.167",
69
69
  "ts-syntax-highlighter": "^0.2.1"
70
70
  },
71
71
  "devDependencies": {
@@ -639,10 +639,12 @@ defineExpose({ collapsed, toggle: toggleCollapse, syncRouteSelection })
639
639
  /* Width is listed here because this rule outranks the pane's
640
640
  `transition-[width]` utility class and would otherwise drop it. */
641
641
  transition:
642
- --stx-space-from 420ms ease,
643
- --stx-space-to 420ms ease,
644
- --stx-space-ink 420ms ease,
645
- --stx-space-accent 420ms ease,
642
+ --stx-space-from var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
643
+ --stx-space-to var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
644
+ --stx-space-ink var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
645
+ --stx-space-accent var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
646
+ /* Collapsing the pane is a different intent from changing space, so it
647
+ keeps its own timing. */
646
648
  width 300ms ease-out;
647
649
  }
648
650
 
@@ -16,6 +16,19 @@ export const clear = $props.clear || null
16
16
  export const theme = $props.theme || 'arc'
17
17
  export const active = $props.active ?? false
18
18
 
19
+ // Render the shell without its contents.
20
+ //
21
+ // Every space's rows are rendered on the server even though the Arc design
22
+ // shows one at a time, and that cost is linear in the number of spaces: a host
23
+ // with fourteen projects pays for fourteen panels on every page load to display
24
+ // one. `deferred` keeps the panel — the track's geometry, the tab role and the
25
+ // swipe all depend on it existing — and drops what is inside it.
26
+ //
27
+ // The space still announces itself as a tabpanel, so the switcher and keyboard
28
+ // navigation are unchanged; `data-space-deferred` tells the controller this one
29
+ // has nothing yet, and `spaceChange` is where a host fills it or navigates.
30
+ export const deferred = $props.deferred ?? false
31
+
19
32
  const themeStyle = resolveSidebarTheme(theme)
20
33
  export const scrollAreaClasses = themeStyle.scrollArea
21
34
 
@@ -32,7 +45,7 @@ export const actionClasses = [
32
45
  'flex w-full items-center gap-[8px]',
33
46
  'h-[30px] rounded-[8px] px-[6px]',
34
47
  'text-[13px] leading-[16px]',
35
- 'opacity-55 transition-[background-color,opacity] duration-150 ease-out',
48
+ 'opacity-55 stx-space-timed',
36
49
  'hover:bg-white/45 hover:opacity-100 dark:hover:bg-white/8',
37
50
  'cursor-default',
38
51
  ].join(' ')
@@ -40,7 +53,7 @@ export const actionClasses = [
40
53
  export const clearClasses = [
41
54
  'flex items-center gap-[4px] rounded-[6px] px-[5px] py-[2px]',
42
55
  'text-[11px] leading-[14px]',
43
- 'opacity-45 transition-opacity duration-150',
56
+ 'opacity-45 stx-space-timed',
44
57
  'hover:opacity-90',
45
58
  'cursor-default',
46
59
  ].join(' ')
@@ -64,6 +77,7 @@ function onAction(event) {
64
77
  data-space
65
78
  data-space-id="{{ id }}"
66
79
  data-space-active="{{ active ? 'true' : 'false' }}"
80
+ @if(deferred)data-space-deferred@endif
67
81
  @if(!active)inert@endif
68
82
  class="flex min-w-0 flex-1 flex-col"
69
83
  role="tabpanel"
@@ -71,7 +85,9 @@ function onAction(event) {
71
85
  @click="onAction($event)"
72
86
  >
73
87
  <div class="{{ scrollAreaClasses }} stx-sidebar-scroll">
74
- <SidebarPinned :items="pinned" />
88
+ @if(!deferred)
89
+ <SidebarPinned :items="pinned" />
90
+ @endif
75
91
 
76
92
  @if(label)
77
93
  <div class="{{ titleClasses }}">
@@ -82,6 +98,12 @@ function onAction(event) {
82
98
  </div>
83
99
  @endif
84
100
 
101
+ <!--
102
+ Rows, and the actions that belong with them, are what a deferred panel
103
+ drops. The title above stays: it is a single line, and it is what tells
104
+ you which space you are swiping into.
105
+ -->
106
+ @if(!deferred)
85
107
  @foreach(sections as section)
86
108
  <SidebarSection
87
109
  :id="section.id"
@@ -123,10 +145,22 @@ function onAction(event) {
123
145
  <span class="truncate">{{ action.label }}</span>
124
146
  </button>
125
147
  @endif
148
+ @endif
126
149
  </div>
127
150
  </section>
128
151
 
129
152
  <style scoped>
153
+ /* Anything that changes with the space shares one duration and one curve, so a
154
+ swipe is a single motion. See `--stx-space-settle` in SidebarSpaces. */
155
+ .stx-space-timed {
156
+ transition:
157
+ background-color var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
158
+ opacity var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
159
+ color var(--stx-space-settle, 420ms) var(--stx-space-ease, ease);
160
+ }
161
+ /* Hover is a separate intent and stays quick. */
162
+ .stx-space-timed:hover { transition-duration: 120ms; }
163
+
130
164
  /* The title icon rides the space accent. Published as a custom property by
131
165
  SidebarSpaces so it crossfades along with the panel instead of cutting. */
132
166
  .stx-space-title-icon {
@@ -1,8 +1,14 @@
1
1
  <script server>
2
- // The rail along the bottom of an Arc sidebar: one small icon per space, plus
3
- // an optional trailing "+". It is the discoverable counterpart to the swipe —
4
- // the gesture is what you use once you know the spaces exist, the rail is how
5
- // you find out.
2
+ // The indicator along the bottom of an Arc-style sidebar: one dot per space,
3
+ // plus an optional trailing "+".
4
+ //
5
+ // Dots rather than one icon per space, which is what Dia does and what this
6
+ // used to get wrong. An icon rail costs ~28px of width each, so it fits about
7
+ // eight spaces in a 280px sidebar and then overflows — and with every space
8
+ // showing the same default folder glyph, thirteen of them read as a broken
9
+ // progress bar rather than as a switcher. A dot is 6px, says only "which of
10
+ // how many", and leaves the identifying work to the space title and the tint,
11
+ // which is where it belongs.
6
12
  //
7
13
  // The buttons are markup only. `<SidebarSpaces>` owns the single click handler
8
14
  // and flips `data-space-current`, so the rail never holds its own copy of
@@ -10,89 +16,174 @@
10
16
  export const spaces = ($props.spaces || []).map(space => ({
11
17
  id: space.id,
12
18
  label: space.label || space.id,
13
- icon: space.icon || 'i-f7-circle',
14
19
  }))
15
20
 
16
21
  export const active = $props.active || (spaces[0] && spaces[0].id) || ''
17
22
  export const showAdd = $props.showAdd ?? false
18
23
  export const addLabel = $props.addLabel || 'New space'
24
+
25
+ // A single space has nothing to indicate. Dia hides the row entirely rather
26
+ // than showing one lonely dot, and so does this — unless "+" needs a home.
27
+ export const showDots = spaces.length > 1
19
28
  </script>
20
29
 
21
30
  <div
22
31
  data-space-switcher
23
- class="flex h-[38px] shrink-0 items-center gap-[1px] px-[8px] pb-[6px]"
32
+ class="stx-space-rail"
24
33
  role="tablist"
25
34
  aria-label="Spaces"
26
35
  aria-orientation="horizontal"
27
36
  >
28
- @foreach(spaces as space)
29
- <button
30
- type="button"
31
- data-space-target="{{ space.id }}"
32
- data-space-current="{{ space.id === active ? 'true' : 'false' }}"
33
- class="stx-space-tab"
34
- role="tab"
35
- aria-selected="{{ space.id === active ? 'true' : 'false' }}"
36
- aria-label="{{ space.label }}"
37
- title="{{ space.label }}"
38
- tabindex="{{ space.id === active ? '0' : '-1' }}"
39
- >
40
- <span class="{{ space.icon }} h-[15px] w-[15px]" aria-hidden="true"></span>
41
- </button>
42
- @endforeach
43
-
44
- <span class="flex-1"></span>
45
-
46
- @if(showAdd)
47
- <button
48
- type="button"
49
- data-space-add
50
- class="stx-space-tab"
51
- aria-label="{{ addLabel }}"
52
- title="{{ addLabel }}"
53
- >
54
- <span class="i-f7-plus h-[14px] w-[14px]" aria-hidden="true"></span>
55
- </button>
56
- @endif
37
+ <!--
38
+ A spacer of the same width as the trailing button, so the dots are centred
39
+ on the sidebar rather than on the space left over beside "+".
40
+ -->
41
+ <span class="stx-space-rail-gutter" aria-hidden="true"></span>
42
+
43
+ <div class="stx-space-dots">
44
+ @if(showDots)
45
+ @foreach(spaces as space)
46
+ <button
47
+ type="button"
48
+ data-space-target="{{ space.id }}"
49
+ data-space-current="{{ space.id === active ? 'true' : 'false' }}"
50
+ class="stx-space-dot"
51
+ role="tab"
52
+ aria-selected="{{ space.id === active ? 'true' : 'false' }}"
53
+ aria-label="{{ space.label }}"
54
+ title="{{ space.label }}"
55
+ tabindex="{{ space.id === active ? '0' : '-1' }}"
56
+ >
57
+ <!--
58
+ The dot is a child rather than the button itself: the button stays a
59
+ comfortable target while the mark stays small.
60
+ -->
61
+ <span class="stx-space-dot-mark" aria-hidden="true"></span>
62
+ </button>
63
+ @endforeach
64
+ @endif
65
+ </div>
66
+
67
+ <span class="stx-space-rail-gutter">
68
+ @if(showAdd)
69
+ <button
70
+ type="button"
71
+ data-space-add
72
+ class="stx-space-add"
73
+ aria-label="{{ addLabel }}"
74
+ title="{{ addLabel }}"
75
+ >
76
+ <span class="i-f7-plus h-[11px] w-[11px]" aria-hidden="true"></span>
77
+ </button>
78
+ @endif
79
+ </span>
57
80
  </div>
58
81
 
59
82
  <style scoped>
60
- /* Rail buttons are painted here rather than with utility classes because the
61
- current one has to pick up `--stx-space-accent`, which changes as you swipe.
62
- A class swap would have to be re-applied on every transition; an attribute
63
- rule keyed on `data-space-current` just follows along. */
64
- .stx-space-tab {
83
+ .stx-space-rail {
84
+ display: flex;
85
+ align-items: center;
86
+ flex-shrink: 0;
87
+ height: 34px;
88
+ padding: 0 8px 6px;
89
+ }
90
+
91
+ .stx-space-rail-gutter {
65
92
  display: grid;
66
93
  place-items: center;
67
- width: 28px;
68
- height: 26px;
69
- border-radius: 7px;
70
- color: currentColor;
71
- opacity: 0.4;
94
+ width: 22px;
95
+ flex-shrink: 0;
96
+ }
97
+
98
+ .stx-space-dots {
99
+ display: flex;
100
+ flex: 1;
101
+ align-items: center;
102
+ justify-content: center;
103
+ gap: 2px;
104
+ min-width: 0;
105
+ /* Many spaces scroll rather than squeeze: dots that shrink to fit stop
106
+ reading as dots, and a sidebar cannot get wider. The bar is hidden because
107
+ the dots are the affordance. */
108
+ overflow-x: auto;
109
+ scrollbar-width: none;
110
+ }
111
+ .stx-space-dots::-webkit-scrollbar { display: none; }
112
+
113
+ .stx-space-dot {
114
+ display: grid;
115
+ place-items: center;
116
+ width: 14px;
117
+ height: 18px;
118
+ flex-shrink: 0;
72
119
  background: transparent;
73
- transition: opacity 150ms ease, background-color 150ms ease, color 300ms ease;
120
+ border: 0;
121
+ padding: 0;
74
122
  cursor: default;
75
123
  }
76
- .stx-space-tab:hover {
77
- opacity: 0.75;
78
- background: rgba(255, 255, 255, 0.4);
124
+
125
+ .stx-space-dot-mark {
126
+ width: 6px;
127
+ height: 6px;
128
+ border-radius: 9999px;
129
+ background: currentColor;
130
+ opacity: 0.25;
131
+ /* One duration and one curve for everything that moves when the space
132
+ changes — see `--stx-space-settle` in SidebarSpaces. Independent timings
133
+ are what made a swipe read as several things happening at once. */
134
+ transition:
135
+ opacity var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
136
+ background-color var(--stx-space-settle, 420ms) var(--stx-space-ease, ease),
137
+ transform var(--stx-space-settle, 420ms) var(--stx-space-ease, ease);
138
+ }
139
+
140
+ /* Hover is not part of a space change, so it stays quick. */
141
+ .stx-space-dot:hover .stx-space-dot-mark {
142
+ opacity: 0.5;
143
+ transition-duration: 120ms;
79
144
  }
80
- .stx-space-tab[data-space-current="true"] {
145
+
146
+ .stx-space-dot[data-space-current="true"] .stx-space-dot-mark {
81
147
  opacity: 1;
82
- color: var(--stx-space-accent, currentColor);
83
- background: rgba(255, 255, 255, 0.55);
148
+ background: var(--stx-space-accent, currentColor);
149
+ /* Barely larger. Enough to find at a glance, not enough to jump. */
150
+ transform: scale(1.15);
151
+ }
152
+
153
+ .stx-space-dot:focus-visible {
154
+ outline: 2px solid var(--stx-space-accent, currentColor);
155
+ outline-offset: 2px;
156
+ border-radius: 9999px;
157
+ }
158
+
159
+ .stx-space-add {
160
+ display: grid;
161
+ place-items: center;
162
+ width: 20px;
163
+ height: 20px;
164
+ border-radius: 6px;
165
+ color: currentColor;
166
+ opacity: 0.35;
167
+ background: transparent;
168
+ border: 0;
169
+ padding: 0;
170
+ cursor: default;
171
+ transition: opacity 120ms ease, background-color 120ms ease;
84
172
  }
85
- .stx-space-tab:focus-visible {
173
+ .stx-space-add:hover {
174
+ opacity: 0.8;
175
+ background: rgba(255, 255, 255, 0.4);
176
+ }
177
+ .stx-space-add:focus-visible {
86
178
  outline: 2px solid var(--stx-space-accent, currentColor);
87
179
  outline-offset: 1px;
88
180
  }
89
181
 
90
182
  @media (prefers-color-scheme: dark) {
91
- .stx-space-tab:hover {
92
- background: rgba(255, 255, 255, 0.08);
93
- }
94
- .stx-space-tab[data-space-current="true"] {
95
- background: rgba(255, 255, 255, 0.12);
96
- }
183
+ .stx-space-add:hover { background: rgba(255, 255, 255, 0.1); }
184
+ }
185
+
186
+ @media (prefers-reduced-motion: reduce) {
187
+ .stx-space-dot-mark { transition: none; }
97
188
  }
98
189
  </style>
@@ -106,6 +106,20 @@ function tintTarget() {
106
106
  return root.closest('[data-stx-sidebar]') || root
107
107
  }
108
108
 
109
+ /**
110
+ * Publish the settle timing so CSS and the track animation cannot drift.
111
+ *
112
+ * `SETTLE_MS` drives the track's transform from JS; everything else that moves
113
+ * during a space change reads these properties. Written once at mount rather
114
+ * than duplicated as literals in four stylesheets, which is how they came to
115
+ * disagree — 420ms on one curve, 420ms on another, 300ms, and 150ms.
116
+ */
117
+ function publishTiming() {
118
+ const style = tintTarget().style
119
+ style.setProperty('--stx-space-settle', `${SETTLE_MS}ms`)
120
+ style.setProperty('--stx-space-ease', SETTLE_EASE)
121
+ }
122
+
109
123
  function applyTint(position) {
110
124
  const tint = tints[position]
111
125
  if (!tint) return
@@ -452,6 +466,10 @@ onMount(() => {
452
466
  tints = spaces.map(space => space.tint)
453
467
  if (ids.length === 0) return
454
468
 
469
+ // Before the first paint, so nothing animates on the default timing once and
470
+ // the shared one afterwards.
471
+ publishTiming()
472
+
455
473
  const startIndex = clampIndex(config.index || 0, ids.length)
456
474
  const saved = restored()
457
475
  const initial = saved === -1 ? startIndex : saved
@@ -514,6 +532,7 @@ defineExpose({
514
532
  :action="space.action"
515
533
  :clear="space.clear"
516
534
  :active="space.id === activeId"
535
+ :deferred="space.deferred === true"
517
536
  :theme="theme"
518
537
  />
519
538
  @endforeach
@@ -559,11 +578,19 @@ defineExpose({
559
578
  linear-gradient(180deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 32%),
560
579
  linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
561
580
  color: var(--stx-space-ink);
581
+ /* One duration and one curve for everything a space change moves.
582
+ This used to be four timings and three easings across the track, the
583
+ palette, the switcher and the space's own buttons — 420ms on one curve,
584
+ 420ms on another, 300ms, 150ms — so a swipe read as several things
585
+ happening near each other rather than as one motion. Published as custom
586
+ properties so the pieces cannot drift apart again. */
587
+ --stx-space-settle: 420ms;
588
+ --stx-space-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
562
589
  transition:
563
- --stx-space-from 420ms ease,
564
- --stx-space-to 420ms ease,
565
- --stx-space-ink 420ms ease,
566
- --stx-space-accent 420ms ease;
590
+ --stx-space-from var(--stx-space-settle) var(--stx-space-ease),
591
+ --stx-space-to var(--stx-space-settle) var(--stx-space-ease),
592
+ --stx-space-ink var(--stx-space-settle) var(--stx-space-ease),
593
+ --stx-space-accent var(--stx-space-settle) var(--stx-space-ease);
567
594
  }
568
595
 
569
596
  @media (prefers-color-scheme: dark) {
@@ -94,6 +94,20 @@ export interface SidebarSpaceData {
94
94
  tint?: string | SidebarSpaceTint
95
95
  /** Favorites grid pinned above the rows. */
96
96
  pinned?: SidebarPinnedItemData[]
97
+ /**
98
+ * Render this space's shell without its rows.
99
+ *
100
+ * Every space is server-rendered even though the Arc design shows one at a
101
+ * time, so the cost is linear in the number of spaces: fourteen projects
102
+ * means paying for fourteen panels on every page load to display one. A
103
+ * deferred space keeps its panel — the track geometry, the tab role and the
104
+ * swipe all depend on it existing — and its title, which is what identifies
105
+ * it mid-swipe, but drops the rows.
106
+ *
107
+ * The panel carries `data-space-deferred`, and `spaceChange` fires as usual,
108
+ * so a host can fill it in or navigate when the user arrives.
109
+ */
110
+ deferred?: boolean
97
111
  /** Row groups, identical in shape to a plain `<Sidebar>`'s sections. */
98
112
  sections?: SidebarSectionData[]
99
113
  /** Primary action row at the foot of the list. */
@@ -122,6 +136,8 @@ export interface NormalizedSidebarSpace {
122
136
  tint: SidebarSpaceTint
123
137
  /** `style` attribute value that publishes this space's palette. */
124
138
  style: string
139
+ /** See {@link SidebarSpaceData.deferred}. */
140
+ deferred: boolean
125
141
  }
126
142
 
127
143
  /**
@@ -228,6 +244,7 @@ export function normalizeSpace(space: SidebarSpaceData): NormalizedSidebarSpace
228
244
  clear: normalizeAction(space.clear, space.id, 'clear', 'i-f7-arrow-down'),
229
245
  tint,
230
246
  style: spaceTintVars(tint),
247
+ deferred: space.deferred === true,
231
248
  }
232
249
  }
233
250
 
@@ -1,98 +0,0 @@
1
- <script server>
2
- // The rail along the bottom of an Arc sidebar: one small icon per space, plus
3
- // an optional trailing "+". It is the discoverable counterpart to the swipe —
4
- // the gesture is what you use once you know the spaces exist, the rail is how
5
- // you find out.
6
- //
7
- // The buttons are markup only. `<SidebarSpaces>` owns the single click handler
8
- // and flips `data-space-current`, so the rail never holds its own copy of
9
- // which space is active — there is exactly one source of truth.
10
- export const spaces = ($props.spaces || []).map(space => ({
11
- id: space.id,
12
- label: space.label || space.id,
13
- icon: space.icon || 'i-f7-circle',
14
- }))
15
-
16
- export const active = $props.active || (spaces[0] && spaces[0].id) || ''
17
- export const showAdd = $props.showAdd ?? false
18
- export const addLabel = $props.addLabel || 'New space'
19
- </script>
20
-
21
- <div
22
- data-space-switcher
23
- class="flex h-[38px] shrink-0 items-center gap-[1px] px-[8px] pb-[6px]"
24
- role="tablist"
25
- aria-label="Spaces"
26
- aria-orientation="horizontal"
27
- >
28
- @foreach(spaces as space)
29
- <button
30
- type="button"
31
- data-space-target="{{ space.id }}"
32
- data-space-current="{{ space.id === active ? 'true' : 'false' }}"
33
- class="stx-space-tab"
34
- role="tab"
35
- aria-selected="{{ space.id === active ? 'true' : 'false' }}"
36
- aria-label="{{ space.label }}"
37
- title="{{ space.label }}"
38
- tabindex="{{ space.id === active ? '0' : '-1' }}"
39
- >
40
- <span class="{{ space.icon }} h-[15px] w-[15px]" aria-hidden="true"></span>
41
- </button>
42
- @endforeach
43
-
44
- <span class="flex-1"></span>
45
-
46
- @if(showAdd)
47
- <button
48
- type="button"
49
- data-space-add
50
- class="stx-space-tab"
51
- aria-label="{{ addLabel }}"
52
- title="{{ addLabel }}"
53
- >
54
- <span class="i-f7-plus h-[14px] w-[14px]" aria-hidden="true"></span>
55
- </button>
56
- @endif
57
- </div>
58
-
59
- <style scoped>
60
- /* Rail buttons are painted here rather than with utility classes because the
61
- current one has to pick up `--stx-space-accent`, which changes as you swipe.
62
- A class swap would have to be re-applied on every transition; an attribute
63
- rule keyed on `data-space-current` just follows along. */
64
- .stx-space-tab {
65
- display: grid;
66
- place-items: center;
67
- width: 28px;
68
- height: 26px;
69
- border-radius: 7px;
70
- color: currentColor;
71
- opacity: 0.4;
72
- background: transparent;
73
- transition: opacity 150ms ease, background-color 150ms ease, color 300ms ease;
74
- cursor: default;
75
- }
76
- .stx-space-tab:hover {
77
- opacity: 0.75;
78
- background: rgba(255, 255, 255, 0.4);
79
- }
80
- .stx-space-tab[data-space-current="true"] {
81
- opacity: 1;
82
- color: var(--stx-space-accent, currentColor);
83
- background: rgba(255, 255, 255, 0.55);
84
- }
85
- .stx-space-tab:focus-visible {
86
- outline: 2px solid var(--stx-space-accent, currentColor);
87
- outline-offset: 1px;
88
- }
89
-
90
- @media (prefers-color-scheme: dark) {
91
- .stx-space-tab:hover {
92
- background: rgba(255, 255, 255, 0.08);
93
- }
94
- .stx-space-tab[data-space-current="true"] {
95
- background: rgba(255, 255, 255, 0.12);
96
- }
97
- }
98
- </style>