@stacksjs/components 0.2.119 → 0.2.121

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The shapes a sidebar's rows are described with.
3
+ *
4
+ * These live apart from `index.ts` because both the barrel and `spaces.ts`
5
+ * need them: a space carries sections exactly like a plain sidebar does. With
6
+ * the definitions in the barrel, the leaf had to import from it and the two
7
+ * modules formed a cycle — legal for types, but it drags the whole public
8
+ * surface into any program that touches one small module.
9
+ *
10
+ * `index.ts` re-exports everything here, so this split is invisible to callers.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ /**
16
+ * One navigation row.
17
+ *
18
+ * ```ts
19
+ * { id: 'icloud', label: 'iCloud', icon: 'i-f7-tray', iconColor: 'blue', count: 248, active: true }
20
+ * ```
21
+ */
22
+ export interface SidebarItemData {
23
+ id: string
24
+ label: string
25
+ /** Iconify utility class, e.g. `i-f7-tray`. F7 icons mirror SF Symbols. */
26
+ icon?: string
27
+ /** macOS system color name (`"blue"`, `"red"`, `"yellow"`, …) or any CSS color. */
28
+ iconColor?: string
29
+ /** Image URL rendered instead of an icon (album art, avatars). */
30
+ image?: string
31
+ href?: string
32
+ /** Right-aligned count — rendered as plain gray text like native macOS. */
33
+ count?: string | number
34
+ /** @deprecated Use `count`. */
35
+ badge?: string | number
36
+ active?: boolean
37
+ disabled?: boolean
38
+ /** Nested rows, indented and collapsible under this one. */
39
+ children?: SidebarItemData[]
40
+ /** Show a disclosure chevron even without children. */
41
+ expandable?: boolean
42
+ /** Initial disclosure state when the item has children. Defaults to true. */
43
+ expanded?: boolean
44
+ }
45
+
46
+ /** A titled group of rows (e.g. "Favorites"). Untitled when `label` is empty. */
47
+ export interface SidebarSectionData {
48
+ id: string
49
+ label?: string
50
+ items: SidebarItemData[]
51
+ /** Section headers collapse their group on click. Defaults to true. */
52
+ collapsible?: boolean
53
+ /** Initial collapse state. Defaults to false (expanded). */
54
+ collapsed?: boolean
55
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Sidebar spaces — the data side of Arc's swipeable scenes.
3
+ *
4
+ * A *space* is a whole sidebar at once: its own pinned tiles, its own folders
5
+ * and rows, its own bottom action, and — the part that carries the identity —
6
+ * its own color. Arc stacks them side by side and you swipe horizontally to
7
+ * move between them. `<SidebarSpaces>` renders that; this module owns
8
+ * everything about a space that is data rather than markup.
9
+ *
10
+ * ## Color is derived, never enumerated
11
+ *
12
+ * A space carries one seed color and every surface is mixed from it:
13
+ *
14
+ * from / to the panel's gradient stops
15
+ * ink the text color the whole space inherits
16
+ * accent the saturated color for the selected switcher icon
17
+ *
18
+ * The mixing happens in CSS via `color-mix(in oklab, …)` rather than in
19
+ * TypeScript, for three reasons. The browser interpolates in a perceptually
20
+ * even space, so a yellow space and a blue space land at the same apparent
21
+ * lightness instead of yellow washing out. `tint: 'blue'` (a macOS system
22
+ * color) and `tint: '#ff6b6b'` (a brand color) go through the identical path,
23
+ * so there is no second-class citizen. And because the output is a real
24
+ * `<color>`, the registered custom properties in `Sidebar.stx` can *animate*
25
+ * it — switching spaces crossfades the panel instead of cutting.
26
+ *
27
+ * ## Two palettes, one source
28
+ *
29
+ * Every tint resolves to a light and a dark set, and both are written to the
30
+ * pane as inline custom properties (`--stx-space-light-*`, `--stx-space-dark-*`).
31
+ * The scoped CSS picks between them under `prefers-color-scheme`. That split
32
+ * matters: it keeps the appearance switch in CSS, so a space renders correctly
33
+ * in dark mode on the *server*, before any client script runs.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import type { SidebarSectionData } from './rows'
39
+ import { macosColors, type MacosColor } from './themes'
40
+
41
+ /** The four surfaces a space paints, in one appearance. */
42
+ export interface SidebarSpaceTintColors {
43
+ /** Top stop of the panel gradient. */
44
+ from: string
45
+ /** Bottom stop of the panel gradient. */
46
+ to: string
47
+ /** Text color inherited by everything inside the space. */
48
+ ink: string
49
+ /** Saturated color for the active switcher icon and focus rings. */
50
+ accent: string
51
+ }
52
+
53
+ /** A space's palette in both appearances. */
54
+ export interface SidebarSpaceTint {
55
+ light: SidebarSpaceTintColors
56
+ dark: SidebarSpaceTintColors
57
+ }
58
+
59
+ /** A tile in a space's pinned grid — Arc's favorites row. */
60
+ export interface SidebarPinnedItemData {
61
+ id: string
62
+ /** Accessible name. Shown as a tooltip, never as visible text. */
63
+ label: string
64
+ /** Iconify utility class, e.g. `i-f7-house-fill`. */
65
+ icon?: string
66
+ /** Favicon or artwork URL, rendered instead of an icon. */
67
+ image?: string
68
+ /** macOS system color name or any CSS color, applied to `icon`. */
69
+ iconColor?: string
70
+ href?: string
71
+ }
72
+
73
+ /** A single labelled action row (Arc's "+ New Tab", Notes' "+ New Note"). */
74
+ export interface SidebarSpaceActionData {
75
+ /** Emitted back to the app. Defaults to the space id suffixed with the role. */
76
+ id?: string
77
+ label: string
78
+ /** Iconify utility class. Defaults to a plus for the primary action. */
79
+ icon?: string
80
+ }
81
+
82
+ /** One scene in a swipeable sidebar. */
83
+ export interface SidebarSpaceData {
84
+ id: string
85
+ /** Title shown above the rows, e.g. "Personal". */
86
+ label?: string
87
+ /** Iconify utility class — used for both the title and the switcher rail. */
88
+ icon?: string
89
+ /**
90
+ * Seed color. A macOS system color name (`'blue'`, `'green'`, …), any CSS
91
+ * color (`'#ff6b6b'`, `'oklch(70% 0.15 20)'`), or a fully specified
92
+ * {@link SidebarSpaceTint} when you want exact control.
93
+ */
94
+ tint?: string | SidebarSpaceTint
95
+ /** Favorites grid pinned above the rows. */
96
+ pinned?: SidebarPinnedItemData[]
97
+ /** Row groups, identical in shape to a plain `<Sidebar>`'s sections. */
98
+ sections?: SidebarSectionData[]
99
+ /** Primary action row at the foot of the list. */
100
+ action?: SidebarSpaceActionData
101
+ /** Small trailing action on the rule above `action` — Arc's "Clear". */
102
+ clear?: SidebarSpaceActionData
103
+ }
104
+
105
+ /** Payload of the `spaceChange` event. */
106
+ export interface SidebarSpaceChangeEvent {
107
+ id: string
108
+ index: number
109
+ /** What moved the sidebar — useful for telling a user gesture from a restore. */
110
+ source: 'swipe' | 'switcher' | 'keyboard' | 'native' | 'restore' | 'api'
111
+ }
112
+
113
+ /** A space with its palette resolved and its inline custom properties built. */
114
+ export interface NormalizedSidebarSpace {
115
+ id: string
116
+ label: string
117
+ icon: string
118
+ pinned: SidebarPinnedItemData[]
119
+ sections: SidebarSectionData[]
120
+ action: SidebarSpaceActionData | null
121
+ clear: SidebarSpaceActionData | null
122
+ tint: SidebarSpaceTint
123
+ /** `style` attribute value that publishes this space's palette. */
124
+ style: string
125
+ }
126
+
127
+ /**
128
+ * Neutral seed for spaces that declare no tint. Deliberately the macOS system
129
+ * gray rather than a literal gray, so an untinted space sits in the same
130
+ * perceptual family as the tinted ones instead of reading as "broken".
131
+ */
132
+ const NEUTRAL_SEED = macosColors.gray
133
+
134
+ function mix(color: string, percent: number, into: string): string {
135
+ return `color-mix(in oklab, ${color} ${percent}%, ${into})`
136
+ }
137
+
138
+ /**
139
+ * Build a full light/dark palette from one seed color.
140
+ *
141
+ * The percentages are the tuning surface of the whole feature, so they are
142
+ * worth stating plainly. In light appearance the panel stays *pale* — 16% and
143
+ * 34% of the seed — because Arc's spaces are washes, not fills; anything
144
+ * stronger and the white selection cards stop reading as raised. The ink is
145
+ * the seed pulled almost to black (22% seed) so text keeps a hint of the
146
+ * space's hue without losing contrast. In dark appearance the relationship
147
+ * inverts: the seed is mixed *into* near-black for the panel and *into* white
148
+ * for the ink and accent, which keeps a dark space recognizably the same color
149
+ * as its light counterpart rather than a different one.
150
+ */
151
+ export function deriveSpaceTint(seed: string): SidebarSpaceTint {
152
+ return {
153
+ light: {
154
+ from: mix(seed, 16, '#ffffff'),
155
+ to: mix(seed, 34, '#ffffff'),
156
+ ink: mix(seed, 22, '#17171b'),
157
+ accent: mix(seed, 88, '#2a2a30'),
158
+ },
159
+ dark: {
160
+ from: mix(seed, 26, '#101014'),
161
+ to: mix(seed, 14, '#08080b'),
162
+ ink: mix(seed, 12, '#f4f4f7'),
163
+ accent: mix(seed, 74, '#ffffff'),
164
+ },
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Resolve a space's `tint` prop to a palette.
170
+ *
171
+ * Accepts a macOS system color name, any CSS color, or a pre-built palette.
172
+ * Unset falls back to the neutral seed.
173
+ */
174
+ export function resolveSpaceTint(tint?: string | SidebarSpaceTint): SidebarSpaceTint {
175
+ if (!tint)
176
+ return deriveSpaceTint(NEUTRAL_SEED)
177
+ if (typeof tint !== 'string')
178
+ return tint
179
+ return deriveSpaceTint(macosColors[tint as MacosColor] || tint)
180
+ }
181
+
182
+ /**
183
+ * Render a palette as inline custom properties.
184
+ *
185
+ * Both appearances are published at once. `Sidebar.stx` maps them onto the
186
+ * four registered properties it actually paints with, choosing per
187
+ * `prefers-color-scheme`, so the correct appearance is already right in the
188
+ * server-rendered HTML.
189
+ */
190
+ export function spaceTintVars(tint: SidebarSpaceTint): string {
191
+ return [
192
+ `--stx-space-light-from: ${tint.light.from}`,
193
+ `--stx-space-light-to: ${tint.light.to}`,
194
+ `--stx-space-light-ink: ${tint.light.ink}`,
195
+ `--stx-space-light-accent: ${tint.light.accent}`,
196
+ `--stx-space-dark-from: ${tint.dark.from}`,
197
+ `--stx-space-dark-to: ${tint.dark.to}`,
198
+ `--stx-space-dark-ink: ${tint.dark.ink}`,
199
+ `--stx-space-dark-accent: ${tint.dark.accent}`,
200
+ ].join('; ')
201
+ }
202
+
203
+ function normalizeAction(
204
+ action: SidebarSpaceActionData | undefined,
205
+ spaceId: string,
206
+ role: string,
207
+ fallbackIcon: string,
208
+ ): SidebarSpaceActionData | null {
209
+ if (!action || !action.label)
210
+ return null
211
+ return {
212
+ id: action.id || `${spaceId}-${role}`,
213
+ label: action.label,
214
+ icon: action.icon || fallbackIcon,
215
+ }
216
+ }
217
+
218
+ /** Normalize one space so template bindings stay plain property reads. */
219
+ export function normalizeSpace(space: SidebarSpaceData): NormalizedSidebarSpace {
220
+ const tint = resolveSpaceTint(space.tint)
221
+ return {
222
+ id: space.id,
223
+ label: space.label || '',
224
+ icon: space.icon || '',
225
+ pinned: space.pinned || [],
226
+ sections: space.sections || [],
227
+ action: normalizeAction(space.action, space.id, 'action', 'i-f7-plus'),
228
+ clear: normalizeAction(space.clear, space.id, 'clear', 'i-f7-arrow-down'),
229
+ tint,
230
+ style: spaceTintVars(tint),
231
+ }
232
+ }
233
+
234
+ export function normalizeSpaces(spaces: SidebarSpaceData[]): NormalizedSidebarSpace[] {
235
+ return (spaces || []).map(normalizeSpace)
236
+ }
237
+
238
+ /**
239
+ * Index of `id` within `spaces`, or 0 when it is missing.
240
+ *
241
+ * Falling back to the first space rather than to -1 is deliberate: a stale
242
+ * persisted id or a typo should open the sidebar on *something*, never on a
243
+ * blank track scrolled past its own content.
244
+ */
245
+ export function spaceIndexOf(spaces: NormalizedSidebarSpace[], id?: string): number {
246
+ if (!id)
247
+ return 0
248
+ const index = spaces.findIndex(space => space.id === id)
249
+ return index === -1 ? 0 : index
250
+ }
251
+
252
+ /**
253
+ * The per-space palettes the client controller needs to repaint the pane.
254
+ *
255
+ * Only the colors travel — markup, sections and actions are already in the
256
+ * DOM, so re-serializing them would just bloat the attribute.
257
+ */
258
+ export function spaceTintPayload(spaces: NormalizedSidebarSpace[]): SidebarSpaceTint[] {
259
+ return spaces.map(space => space.tint)
260
+ }
@@ -23,6 +23,11 @@
23
23
  * when the window resigns key it falls back to a neutral fill with the label
24
24
  * left dark. Icons desaturate to gray in the same non-key state.
25
25
  *
26
+ * The other first-class theme is `arc`, a recreation of the Arc browser's
27
+ * sidebar. It inverts the macOS selection model — a raised white card on a
28
+ * warm tinted panel instead of an accent fill on gray — so it uses the
29
+ * `classes` selection model rather than `accent`. See its own block below.
30
+ *
26
31
  * Legacy themes (`workspace`, `desktop`, `solid`, `transparent`, `vibrancy`)
27
32
  * are preserved verbatim from the previous variant maps. `tahoe` now aliases
28
33
  * `macos` — it always meant "look like macOS", and now it actually does.
@@ -228,6 +233,108 @@ const desktop: SidebarTheme = {
228
233
  },
229
234
  }
230
235
 
236
+ /**
237
+ * `arc` — the Arc browser sidebar.
238
+ *
239
+ * Where macOS paints the selected row with the system accent, Arc raises it:
240
+ * the active row becomes a white card floating on a warm tinted panel, with a
241
+ * hairline border and a single-pixel drop shadow. That inversion — light card
242
+ * on tinted ground, rather than saturated fill on gray — is the whole look, so
243
+ * this theme uses the `classes` selection model and puts the card in
244
+ * `item.active` instead of the accent rules the `accent` model applies.
245
+ *
246
+ * Metrics taken from Arc 1.5x at @2x:
247
+ *
248
+ * row pitch 30px with 2px between rows — rows are separate cards, not the
249
+ * contiguous run AppKit uses, so consecutive selections read as distinct
250
+ * selection radius 8px · white fill · 1px hairline · shadow 0 1px 2px/8%
251
+ * label 13px, and the selected row goes to 500 (not 600 — Arc keeps the
252
+ * weight shift subtle because the card already carries the emphasis)
253
+ * section header 11px medium, sentence case, 55% muted
254
+ * icon 16px in an 18px slot · child indent 14px
255
+ * panel warm off-white (#f7f5f1), noticeably warmer than macOS's neutral
256
+ * #f2f2f4 — the warmth is what makes it read as Arc at a glance
257
+ *
258
+ * The panel tint is a soft top-down gradient rather than a flat fill, which is
259
+ * how Arc suggests the current space's color. Apps that want a per-space tint
260
+ * can override `--stx-sidebar-tint` on the pane; the gradient falls back to the
261
+ * warm default when it is unset.
262
+ */
263
+ const arc: SidebarTheme = {
264
+ selection: 'classes',
265
+ pane: [
266
+ 'bg-[#f7f5f1]/92 dark:bg-[#1a1a1c]/92',
267
+ 'backdrop-blur-[40px] backdrop-saturate-[160%]',
268
+ 'text-[#2c2a28] dark:text-[#ededf0]',
269
+ 'select-none',
270
+ ].join(' '),
271
+ layers: {
272
+ // Space tint. `--stx-sidebar-tint` lets an app color the panel per space
273
+ // the way Arc does; unset, it resolves to a warm neutral wash.
274
+ tint: [
275
+ 'bg-[linear-gradient(180deg,var(--stx-sidebar-tint,rgba(255,251,242,0.55))_0%,rgba(255,255,255,0)_38%)]',
276
+ 'dark:bg-[linear-gradient(180deg,var(--stx-sidebar-tint,rgba(255,255,255,0.05))_0%,rgba(255,255,255,0)_42%)]',
277
+ ].join(' '),
278
+ },
279
+ scrollArea: 'flex-1 overflow-y-auto overflow-x-hidden px-[8px] pb-[8px]',
280
+ // Text takes its color from the panel rather than declaring its own, and is
281
+ // muted with opacity instead. On the plain warm panel `currentColor` is the
282
+ // pane's own `#2c2a28`, so this renders identically to a hardcoded value —
283
+ // but inside a space it becomes `--stx-space-ink` and the whole list shifts
284
+ // hue with the panel on every swipe. Opacity is safe on these because each
285
+ // is a standalone span; the row itself keeps a solid color, since fading a
286
+ // row would take its white selection card down with it.
287
+ sectionHeader: [
288
+ 'group/section flex w-full items-center',
289
+ 'px-[8px] pt-[14px] pb-[4px]',
290
+ 'text-[11px] font-medium leading-[13px]',
291
+ 'opacity-55',
292
+ ].join(' '),
293
+ sectionChevron: [
294
+ 'i-f7-chevron-down h-[10px] w-[10px] ml-auto',
295
+ 'opacity-0 group-hover/section:opacity-100 transition-opacity duration-150',
296
+ 'transition-transform duration-200',
297
+ ].join(' '),
298
+ // 2px gutter: Arc rows are discrete cards, unlike AppKit's contiguous run.
299
+ sectionGroup: 'flex flex-col space-y-[2px]',
300
+ item: {
301
+ base: [
302
+ 'flex w-full items-center',
303
+ 'h-[30px] rounded-[8px] pl-[6px] pr-[8px]',
304
+ 'text-[13px] leading-[16px] font-normal',
305
+ 'text-current',
306
+ 'transition-[background-color,box-shadow,color] duration-150 ease-out',
307
+ 'cursor-default',
308
+ ].join(' '),
309
+ hover: 'hover:bg-white/55 dark:hover:bg-white/8',
310
+ // The signature: a raised white card rather than an accent fill. The label
311
+ // stays on the panel ink — Arc does not recolor a selected row, the card
312
+ // under it carries the emphasis.
313
+ active: [
314
+ 'bg-white dark:bg-white/14',
315
+ 'font-medium',
316
+ 'shadow-[0_1px_2px_rgba(0,0,0,0.08)] dark:shadow-[0_1px_2px_rgba(0,0,0,0.35)]',
317
+ 'ring-1 ring-black/5 dark:ring-white/8',
318
+ ].join(' '),
319
+ pressed: 'active:bg-white/80 dark:active:bg-white/18',
320
+ disabled: 'opacity-40 pointer-events-none',
321
+ disclosure: 'flex h-[16px] w-[16px] shrink-0 items-center justify-center',
322
+ chevron: [
323
+ 'i-f7-chevron-right h-[10px] w-[10px] opacity-45',
324
+ 'transition-transform duration-200 ease-out',
325
+ ].join(' '),
326
+ iconSlot: 'flex h-[18px] w-[18px] shrink-0 items-center justify-center mr-[8px]',
327
+ icon: 'h-[16px] w-[16px]',
328
+ image: 'h-[18px] w-[18px] rounded-[5px] object-cover shadow-sm',
329
+ label: 'flex-1 truncate text-left',
330
+ count: [
331
+ 'ml-[8px] shrink-0 tabular-nums',
332
+ 'text-[12px] leading-[16px] opacity-45',
333
+ ].join(' '),
334
+ indentPerLevel: 14,
335
+ },
336
+ }
337
+
231
338
  const solid: SidebarTheme = {
232
339
  ...macos,
233
340
  pane: 'bg-stone-100 dark:bg-neutral-900 text-black dark:text-white select-none',
@@ -252,6 +359,7 @@ export const sidebarThemes: Record<string, SidebarTheme> = {
252
359
  'tahoe': macos,
253
360
  'macos-tahoe': macos,
254
361
  'macos-latest': macos,
362
+ arc,
255
363
  workspace,
256
364
  desktop,
257
365
  solid,