@stacksjs/components 0.2.166 → 0.2.168

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
 
@@ -45,7 +45,7 @@ export const actionClasses = [
45
45
  'flex w-full items-center gap-[8px]',
46
46
  'h-[30px] rounded-[8px] px-[6px]',
47
47
  'text-[13px] leading-[16px]',
48
- 'opacity-55 transition-[background-color,opacity] duration-150 ease-out',
48
+ 'opacity-55 stx-space-timed',
49
49
  'hover:bg-white/45 hover:opacity-100 dark:hover:bg-white/8',
50
50
  'cursor-default',
51
51
  ].join(' ')
@@ -53,7 +53,7 @@ export const actionClasses = [
53
53
  export const clearClasses = [
54
54
  'flex items-center gap-[4px] rounded-[6px] px-[5px] py-[2px]',
55
55
  'text-[11px] leading-[14px]',
56
- 'opacity-45 transition-opacity duration-150',
56
+ 'opacity-45 stx-space-timed',
57
57
  'hover:opacity-90',
58
58
  'cursor-default',
59
59
  ].join(' ')
@@ -150,6 +150,17 @@ function onAction(event) {
150
150
  </section>
151
151
 
152
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
+
153
164
  /* The title icon rides the space accent. Published as a custom property by
154
165
  SidebarSpaces so it crossfades along with the panel instead of cutting. */
155
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
@@ -120,6 +134,32 @@ function applyTint(position) {
120
134
  style.setProperty('--stx-space-dark-accent', tint.dark.accent)
121
135
  }
122
136
 
137
+ /**
138
+ * Keep the active dot on screen.
139
+ *
140
+ * The dot row scrolls once there are more spaces than fit, and without this the
141
+ * active dot can sit outside the visible range — an indicator that indicates
142
+ * nothing. Measured on a sidebar with 22 spaces: the active one was the 20th,
143
+ * and none of the twelve you could see was lit.
144
+ *
145
+ * `block: 'nearest'` because this must never scroll the page vertically to
146
+ * bring a 6px dot into view.
147
+ */
148
+ function revealCurrentDot(tab) {
149
+ const row = tab.closest('[data-space-switcher] .stx-space-dots')
150
+ if (!row || row.scrollWidth <= row.clientWidth) return
151
+
152
+ const rowBox = row.getBoundingClientRect()
153
+ const dotBox = tab.getBoundingClientRect()
154
+ if (dotBox.left >= rowBox.left && dotBox.right <= rowBox.right) return
155
+
156
+ tab.scrollIntoView({
157
+ inline: 'center',
158
+ block: 'nearest',
159
+ behavior: animates() ? 'smooth' : 'auto',
160
+ })
161
+ }
162
+
123
163
  function markCurrent(position) {
124
164
  const id = ids[position]
125
165
 
@@ -128,6 +168,7 @@ function markCurrent(position) {
128
168
  tab.setAttribute('data-space-current', String(current))
129
169
  tab.setAttribute('aria-selected', String(current))
130
170
  tab.tabIndex = current ? 0 : -1
171
+ if (current) revealCurrentDot(tab)
131
172
  })
132
173
 
133
174
  root.querySelectorAll('[data-space]').forEach((panel) => {
@@ -452,6 +493,10 @@ onMount(() => {
452
493
  tints = spaces.map(space => space.tint)
453
494
  if (ids.length === 0) return
454
495
 
496
+ // Before the first paint, so nothing animates on the default timing once and
497
+ // the shared one afterwards.
498
+ publishTiming()
499
+
455
500
  const startIndex = clampIndex(config.index || 0, ids.length)
456
501
  const saved = restored()
457
502
  const initial = saved === -1 ? startIndex : saved
@@ -560,11 +605,19 @@ defineExpose({
560
605
  linear-gradient(180deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 32%),
561
606
  linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
562
607
  color: var(--stx-space-ink);
608
+ /* One duration and one curve for everything a space change moves.
609
+ This used to be four timings and three easings across the track, the
610
+ palette, the switcher and the space's own buttons — 420ms on one curve,
611
+ 420ms on another, 300ms, 150ms — so a swipe read as several things
612
+ happening near each other rather than as one motion. Published as custom
613
+ properties so the pieces cannot drift apart again. */
614
+ --stx-space-settle: 420ms;
615
+ --stx-space-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
563
616
  transition:
564
- --stx-space-from 420ms ease,
565
- --stx-space-to 420ms ease,
566
- --stx-space-ink 420ms ease,
567
- --stx-space-accent 420ms ease;
617
+ --stx-space-from var(--stx-space-settle) var(--stx-space-ease),
618
+ --stx-space-to var(--stx-space-settle) var(--stx-space-ease),
619
+ --stx-space-ink var(--stx-space-settle) var(--stx-space-ease),
620
+ --stx-space-accent var(--stx-space-settle) var(--stx-space-ease);
568
621
  }
569
622
 
570
623
  @media (prefers-color-scheme: dark) {
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // @bun
2
- // ../../../../../../../tmp/stx-components-build-dyAtys/stubs/stub-0.js
2
+ // ../../../../../../../tmp/stx-components-build-tmXNAI/stubs/stub-0.js
3
3
  var stub_0_default = { __stx: true, src: "./components/CodeBlock.stx" };
4
- // ../../../../../../../tmp/stx-components-build-dyAtys/stubs/stub-1.js
4
+ // ../../../../../../../tmp/stx-components-build-tmXNAI/stubs/stub-1.js
5
5
  var stub_1_default = { __stx: true, src: "./components/Footer.stx" };
6
- // ../../../../../../../tmp/stx-components-build-dyAtys/stubs/stub-2.js
6
+ // ../../../../../../../tmp/stx-components-build-tmXNAI/stubs/stub-2.js
7
7
  var stub_2_default = { __stx: true, src: "./components/Hero.stx" };
8
- // ../../../../../../../tmp/stx-components-build-dyAtys/stubs/stub-3.js
8
+ // ../../../../../../../tmp/stx-components-build-tmXNAI/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-t68ywe45.stx";
1147
+ var SidebarSpace_default = "./SidebarSpace-r062weq2.stx";
1148
1148
  // src/ui/sidebar/SidebarSpaces.stx
1149
- var SidebarSpaces_default = "./SidebarSpaces-w17e6ma4.stx";
1149
+ var SidebarSpaces_default = "./SidebarSpaces-f149wvf1.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",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/components",
3
3
  "type": "module",
4
- "version": "0.2.166",
4
+ "version": "0.2.168",
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.166",
68
- "bun-plugin-stx": "0.2.166",
67
+ "@stacksjs/stx": "0.2.168",
68
+ "bun-plugin-stx": "0.2.168",
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
 
@@ -45,7 +45,7 @@ export const actionClasses = [
45
45
  'flex w-full items-center gap-[8px]',
46
46
  'h-[30px] rounded-[8px] px-[6px]',
47
47
  'text-[13px] leading-[16px]',
48
- 'opacity-55 transition-[background-color,opacity] duration-150 ease-out',
48
+ 'opacity-55 stx-space-timed',
49
49
  'hover:bg-white/45 hover:opacity-100 dark:hover:bg-white/8',
50
50
  'cursor-default',
51
51
  ].join(' ')
@@ -53,7 +53,7 @@ export const actionClasses = [
53
53
  export const clearClasses = [
54
54
  'flex items-center gap-[4px] rounded-[6px] px-[5px] py-[2px]',
55
55
  'text-[11px] leading-[14px]',
56
- 'opacity-45 transition-opacity duration-150',
56
+ 'opacity-45 stx-space-timed',
57
57
  'hover:opacity-90',
58
58
  'cursor-default',
59
59
  ].join(' ')
@@ -150,6 +150,17 @@ function onAction(event) {
150
150
  </section>
151
151
 
152
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
+
153
164
  /* The title icon rides the space accent. Published as a custom property by
154
165
  SidebarSpaces so it crossfades along with the panel instead of cutting. */
155
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
@@ -120,6 +134,32 @@ function applyTint(position) {
120
134
  style.setProperty('--stx-space-dark-accent', tint.dark.accent)
121
135
  }
122
136
 
137
+ /**
138
+ * Keep the active dot on screen.
139
+ *
140
+ * The dot row scrolls once there are more spaces than fit, and without this the
141
+ * active dot can sit outside the visible range — an indicator that indicates
142
+ * nothing. Measured on a sidebar with 22 spaces: the active one was the 20th,
143
+ * and none of the twelve you could see was lit.
144
+ *
145
+ * `block: 'nearest'` because this must never scroll the page vertically to
146
+ * bring a 6px dot into view.
147
+ */
148
+ function revealCurrentDot(tab) {
149
+ const row = tab.closest('[data-space-switcher] .stx-space-dots')
150
+ if (!row || row.scrollWidth <= row.clientWidth) return
151
+
152
+ const rowBox = row.getBoundingClientRect()
153
+ const dotBox = tab.getBoundingClientRect()
154
+ if (dotBox.left >= rowBox.left && dotBox.right <= rowBox.right) return
155
+
156
+ tab.scrollIntoView({
157
+ inline: 'center',
158
+ block: 'nearest',
159
+ behavior: animates() ? 'smooth' : 'auto',
160
+ })
161
+ }
162
+
123
163
  function markCurrent(position) {
124
164
  const id = ids[position]
125
165
 
@@ -128,6 +168,7 @@ function markCurrent(position) {
128
168
  tab.setAttribute('data-space-current', String(current))
129
169
  tab.setAttribute('aria-selected', String(current))
130
170
  tab.tabIndex = current ? 0 : -1
171
+ if (current) revealCurrentDot(tab)
131
172
  })
132
173
 
133
174
  root.querySelectorAll('[data-space]').forEach((panel) => {
@@ -452,6 +493,10 @@ onMount(() => {
452
493
  tints = spaces.map(space => space.tint)
453
494
  if (ids.length === 0) return
454
495
 
496
+ // Before the first paint, so nothing animates on the default timing once and
497
+ // the shared one afterwards.
498
+ publishTiming()
499
+
455
500
  const startIndex = clampIndex(config.index || 0, ids.length)
456
501
  const saved = restored()
457
502
  const initial = saved === -1 ? startIndex : saved
@@ -560,11 +605,19 @@ defineExpose({
560
605
  linear-gradient(180deg, rgba(255, 255, 255, 0.34) 0%, rgba(255, 255, 255, 0) 32%),
561
606
  linear-gradient(180deg, var(--stx-space-from) 0%, var(--stx-space-to) 100%);
562
607
  color: var(--stx-space-ink);
608
+ /* One duration and one curve for everything a space change moves.
609
+ This used to be four timings and three easings across the track, the
610
+ palette, the switcher and the space's own buttons — 420ms on one curve,
611
+ 420ms on another, 300ms, 150ms — so a swipe read as several things
612
+ happening near each other rather than as one motion. Published as custom
613
+ properties so the pieces cannot drift apart again. */
614
+ --stx-space-settle: 420ms;
615
+ --stx-space-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
563
616
  transition:
564
- --stx-space-from 420ms ease,
565
- --stx-space-to 420ms ease,
566
- --stx-space-ink 420ms ease,
567
- --stx-space-accent 420ms ease;
617
+ --stx-space-from var(--stx-space-settle) var(--stx-space-ease),
618
+ --stx-space-to var(--stx-space-settle) var(--stx-space-ease),
619
+ --stx-space-ink var(--stx-space-settle) var(--stx-space-ease),
620
+ --stx-space-accent var(--stx-space-settle) var(--stx-space-ease);
568
621
  }
569
622
 
570
623
  @media (prefers-color-scheme: dark) {
@@ -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>