@spunto/design-system 0.14.1 → 0.15.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,13 @@ import type { ReactNode } from "react"
4
4
  import { ChevronDownIcon, Settings2Icon } from "lucide-react"
5
5
 
6
6
  import { cn } from "../../utils"
7
- import type { ProjectFormSectionId, ProjectFormValue, ProjectSecretRef } from "./types"
7
+ import {
8
+ isInternalSectionId,
9
+ type ProjectFormCustomSection,
10
+ type ProjectFormSectionId,
11
+ type ProjectFormValue,
12
+ type ProjectSecretRef,
13
+ } from "./types"
8
14
 
9
15
  export interface AdvancedDisclosureProps {
10
16
  open: boolean
@@ -101,8 +107,8 @@ export function AdvancedDisclosure({
101
107
  * `lifecycle`, `3 secrets`. Pure, and exported, because a caller rendering its
102
108
  * own fold needs the same sentence.
103
109
  */
104
- export function advancedSummary(
105
- value: ProjectFormValue,
110
+ export function advancedSummary<TValue extends ProjectFormValue>(
111
+ value: TValue,
106
112
  sections: ProjectFormSectionId[],
107
113
  /**
108
114
  * Secrets already stored server-side. They count: a project whose only
@@ -110,10 +116,23 @@ export function advancedSummary(
110
116
  * keep the fold shut over them — exactly the "hides configuration you didn't
111
117
  * set yourself" failure this component exists to avoid.
112
118
  */
113
- savedSecrets: ProjectSecretRef[] = []
119
+ savedSecrets: ProjectSecretRef[] = [],
120
+ /**
121
+ * App-provided sections. Theirs count too, for the very same reason: a fold
122
+ * that shuts over a field it doesn't happen to know about hides it just as
123
+ * well as one it does.
124
+ */
125
+ customSections: ProjectFormCustomSection<TValue>[] = []
114
126
  ): string[] {
127
+ const custom = new Map(customSections.filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s]))
115
128
  const out: string[] = []
116
129
  for (const id of sections) {
130
+ const own = custom.get(id)
131
+ if (own) {
132
+ const label = own.summary?.(value)
133
+ if (label) out.push(label)
134
+ continue
135
+ }
117
136
  switch (id) {
118
137
  case "features":
119
138
  if (value.features.length) out.push(`${value.features.length} feature${value.features.length > 1 ? "s" : ""}`)
@@ -18,7 +18,10 @@
18
18
  // Controlled by a single `ProjectFormValue`, and nothing in here fetches:
19
19
  // catalogs come in as data, the extension search is a callback, the GitHub
20
20
  // combobox is a slot, `onSubmit` hands the value back. Which sections show is a
21
- // prop — creation and editing are one component with a different list.
21
+ // prop — creation and editing are one component with a different list, and an
22
+ // app adds a section of its own to that list (`ProjectFormCustomSection`) for a
23
+ // field the package doesn't model, rather than drawing a card beside the form
24
+ // and losing its numbering, its fold and its manifest row.
22
25
  //
23
26
  // Every brick is exported on its own, because a screen that needs only the repo
24
27
  // rows or only the manifest shouldn't have to take the form with it.
@@ -53,11 +56,15 @@ export type { ExtensionPickerProps } from "./extension-picker"
53
56
  export {
54
57
  CREATE_SECTIONS,
55
58
  EDIT_SECTIONS,
59
+ INTERNAL_SECTION_IDS,
56
60
  emptyProjectFormValue,
61
+ isInternalSectionId,
57
62
  toProjectFormValue,
58
63
  } from "./types"
59
64
  export type {
65
+ InternalSectionId,
60
66
  ProjectFeatureSelection,
67
+ ProjectFormCustomSection,
61
68
  ProjectFormSectionId,
62
69
  ProjectFormValue,
63
70
  ProjectRepo,
@@ -27,6 +27,9 @@ import { RepoList, type GithubConnection, type GithubRepoOption, type RepoFieldR
27
27
  import { SecretList } from "./secret-list"
28
28
  import {
29
29
  CREATE_SECTIONS,
30
+ isInternalSectionId,
31
+ type InternalSectionId,
32
+ type ProjectFormCustomSection,
30
33
  type ProjectFormSectionId,
31
34
  type ProjectFormValue,
32
35
  type ProjectSecretRef,
@@ -35,7 +38,7 @@ import type { DevcontainerFeatureEntry, DevcontainerImageEntry, VscodeExtensionE
35
38
 
36
39
  /** Chrome of each section: number, icon, accent, wording. */
37
40
  const SECTION_META: Record<
38
- ProjectFormSectionId,
41
+ InternalSectionId,
39
42
  { icon: ComponentType<{ className?: string }>; accent: SectionAccent; title: string; hint?: string }
40
43
  > = {
41
44
  identity: { icon: SquarePenIcon, accent: "build", title: "Identity", hint: "Name your project and what it's for" },
@@ -56,7 +59,7 @@ const SECTION_META: Record<
56
59
  * sections in front of someone creating their first project is how a form stops
57
60
  * being filled in at all.
58
61
  */
59
- const DEFAULT_ADVANCED: ProjectFormSectionId[] = [
62
+ const DEFAULT_ADVANCED: InternalSectionId[] = [
60
63
  "features",
61
64
  "extensions",
62
65
  "lifecycle",
@@ -66,13 +69,22 @@ const DEFAULT_ADVANCED: ProjectFormSectionId[] = [
66
69
  "secrets",
67
70
  ]
68
71
 
69
- export interface ProjectFormProps {
70
- value: ProjectFormValue
71
- onChange: (value: ProjectFormValue) => void
72
+ export interface ProjectFormProps<TValue extends ProjectFormValue = ProjectFormValue> {
73
+ /**
74
+ * The complete value. Generic: an app whose project has a field the package
75
+ * doesn't model declares an interface extending `ProjectFormValue` and every
76
+ * callback here — including a custom section's — speaks that type instead.
77
+ */
78
+ value: TValue
79
+ onChange: (value: TValue) => void
72
80
  /** Receives the current value. The app maps it to its own payload and calls its API. */
73
- onSubmit?: (value: ProjectFormValue) => void
81
+ onSubmit?: (value: TValue) => void
74
82
 
75
- /** Which sections to show, in order. Defaults to the seven creation ones. */
83
+ /**
84
+ * Which sections to show, in order. Defaults to the seven creation ones.
85
+ * A `customSections` id sits in here like any built-in — that's how an app's
86
+ * own card gets a place, a number and a fold.
87
+ */
76
88
  sections?: ProjectFormSectionId[]
77
89
  /**
78
90
  * Sections folded behind "Advanced options". Defaults to everything past
@@ -119,6 +131,19 @@ export interface ProjectFormProps {
119
131
  /** Extra content appended inside a section — the deploy key card, a warning… */
120
132
  extras?: Partial<Record<ProjectFormSectionId, ReactNode>>
121
133
 
134
+ /**
135
+ * Cards the app draws itself, for the fields the package doesn't model.
136
+ *
137
+ * Each one is a full section: put its id in `sections` to place it, in
138
+ * `advancedSections` to fold it, and it gets the same numbered card, the same
139
+ * summary line while the fold is closed, the same manifest row. Unlike
140
+ * `extras` — which appends *inside* an existing section — this is a section of
141
+ * its own, so it doesn't have to pretend to belong to one it has nothing to do
142
+ * with. An id that shadows a built-in is ignored: this extends the form, it
143
+ * doesn't override it.
144
+ */
145
+ customSections?: ProjectFormCustomSection<TValue>[]
146
+
122
147
  /** `false` drops the recap panel entirely and gives the form the full width. */
123
148
  manifest?: boolean
124
149
  submitLabel?: ReactNode
@@ -148,8 +173,15 @@ export interface ProjectFormProps {
148
173
  * for the app to send. Which sections are shown is a prop, so creation and
149
174
  * editing are the same component with a different list — not two screens that
150
175
  * drift apart.
176
+ *
177
+ * **And that list is open.** An app with a field the package doesn't model
178
+ * describes its own card in `customSections` and drops its id into `sections`:
179
+ * it then gets the numbering, the fold, the summary and the manifest row that a
180
+ * `FormSection` rendered next to the form can't have. Same for the value — the
181
+ * form is generic over it, so the app's extra fields are typed end to end
182
+ * instead of riding along behind a cast.
151
183
  */
152
- export function ProjectForm({
184
+ export function ProjectForm<TValue extends ProjectFormValue = ProjectFormValue>({
153
185
  value,
154
186
  onChange,
155
187
  onSubmit,
@@ -170,6 +202,7 @@ export function ProjectForm({
170
202
  onSearchExtensions,
171
203
  savedSecrets,
172
204
  extras,
205
+ customSections,
173
206
  manifest = true,
174
207
  submitLabel = "Save",
175
208
  submittingLabel = "Saving…",
@@ -179,14 +212,31 @@ export function ProjectForm({
179
212
  manifestFooter,
180
213
  manifestTitle,
181
214
  className,
182
- }: ProjectFormProps) {
183
- const patch = (next: Partial<ProjectFormValue>) => onChange({ ...value, ...next })
215
+ }: ProjectFormProps<TValue>) {
216
+ // The one and only write: a spread. Which is *why* a value carrying fields the
217
+ // package doesn't model survives a form it never taught about them — a
218
+ // contract now, not a lucky implementation detail (see `ProjectFormValue`).
219
+ // The cast lives here, once, so no app has to write one at every boundary:
220
+ // `{ ...TValue, ...Partial<TValue> }` is a TValue, TypeScript just can't say so
221
+ // about a spread of a type parameter.
222
+ const applyPatch = (next: Partial<ProjectFormValue> | Partial<TValue>) =>
223
+ onChange({ ...value, ...next } as TValue)
224
+ const patch: (next: Partial<ProjectFormValue>) => void = applyPatch
225
+ const patchCustom: (next: Partial<TValue>) => void = applyPatch
226
+
227
+ // An id in `sections` that matches neither a built-in nor a custom section is
228
+ // dropped up front rather than skipped at render: numbering counts positions,
229
+ // so leaving the hole in would shift every section after it.
230
+ const custom = new Map<string, ProjectFormCustomSection<TValue>>(
231
+ (customSections ?? []).filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s])
232
+ )
233
+ const shown = sections.filter((id) => isInternalSectionId(id) || custom.has(id))
184
234
 
185
- const rows = manifestRows(value, sections, savedSecrets)
235
+ const rows = manifestRows(value, shown, savedSecrets, customSections)
186
236
 
187
- const advanced = sections.filter((id) => advancedSections.includes(id))
188
- const primary = sections.filter((id) => !advancedSections.includes(id))
189
- const summary = advancedSummary(value, advanced, savedSecrets)
237
+ const advanced = shown.filter((id) => advancedSections.includes(id))
238
+ const primary = shown.filter((id) => !advancedSections.includes(id))
239
+ const summary = advancedSummary(value, advanced, savedSecrets, customSections)
190
240
  // Start open when there's already something in there — the initial value is
191
241
  // read once, on mount, so a later edit can't yank the fold open under the
192
242
  // user's cursor.
@@ -251,7 +301,12 @@ export function ProjectForm({
251
301
 
252
302
  /** One section, numbered by its position in the whole form (folded or not). */
253
303
  function section(id: ProjectFormSectionId, index: number) {
254
- const meta = SECTION_META[id]
304
+ const own = custom.get(id)
305
+ // A custom card is chrome-identical to a built-in one: same `FormSection`,
306
+ // same badge, same accent — only its body comes from the app.
307
+ const meta = own
308
+ ? { icon: own.icon, accent: own.accent, title: own.title, hint: own.hint }
309
+ : SECTION_META[id as InternalSectionId]
255
310
  return (
256
311
  <FormSection
257
312
  key={id}
@@ -265,13 +320,13 @@ export function ProjectForm({
265
320
  // you've already scrolled to it.
266
321
  style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
267
322
  >
268
- {renderSection(id)}
323
+ {own ? own.render(value, patchCustom) : renderSection(id as InternalSectionId)}
269
324
  {extras?.[id]}
270
325
  </FormSection>
271
326
  )
272
327
  }
273
328
 
274
- function renderSection(id: ProjectFormSectionId): ReactNode {
329
+ function renderSection(id: InternalSectionId): ReactNode {
275
330
  switch (id) {
276
331
  case "identity":
277
332
  return (
@@ -450,11 +505,17 @@ function parsePorts(raw: string): number[] {
450
505
  * actually shown get a line: a creation form that hides ports shouldn't recap
451
506
  * an empty "ports" row.
452
507
  */
453
- export function manifestRows(
454
- value: ProjectFormValue,
508
+ export function manifestRows<TValue extends ProjectFormValue>(
509
+ value: TValue,
455
510
  sections: ProjectFormSectionId[],
456
511
  /** Counted alongside the drafts — an existing secret is still a secret. */
457
- savedSecrets: ProjectSecretRef[] = []
512
+ savedSecrets: ProjectSecretRef[] = [],
513
+ /**
514
+ * App-provided sections. Their rows come after the built-in ones, in
515
+ * `sections` order — the built-in lines follow a fixed reading order rather
516
+ * than the form's, so there's no position to interleave them at.
517
+ */
518
+ customSections: ProjectFormCustomSection<TValue>[] = []
458
519
  ): ManifestRow[] {
459
520
  const shown = new Set(sections)
460
521
  const rows: ManifestRow[] = []
@@ -491,5 +552,13 @@ export function manifestRows(
491
552
  rows.push({ label: "secrets", value: `${n}`, done: n > 0 })
492
553
  }
493
554
 
555
+ if (customSections.length) {
556
+ const byId = new Map(customSections.filter((s) => !isInternalSectionId(s.id)).map((s) => [s.id, s]))
557
+ for (const id of sections) {
558
+ const row = byId.get(id)?.manifestRow?.(value)
559
+ if (row) rows.push(row)
560
+ }
561
+ }
562
+
494
563
  return rows
495
564
  }
@@ -8,6 +8,11 @@
8
8
  // payload on submit. A complete value is what makes the form controllable
9
9
  // without a single `?? ""` at every input.
10
10
 
11
+ import type { ComponentType, ReactNode } from "react"
12
+
13
+ import type { ManifestRow } from "./build-manifest"
14
+ import type { SectionAccent } from "./form-section"
15
+
11
16
  /** One repository to clone into the workspace. */
12
17
  export interface ProjectRepo {
13
18
  /** Client-side identity, stable across re-renders. Not the API's id. */
@@ -58,7 +63,25 @@ export interface ProjectSecretRef {
58
63
  name: string
59
64
  }
60
65
 
61
- /** Everything the form edits. Every field is present — see the note at the top. */
66
+ /**
67
+ * Everything the form edits. Every field is present — see the note at the top.
68
+ *
69
+ * **An app may carry more, and that is a supported contract, not a trick.** The
70
+ * form only ever patches at the spread (`onChange({ ...value, ...next })`), so
71
+ * any field it doesn't model travels through untouched. Declare an interface
72
+ * that extends this one and pass it to the form — `<ProjectForm<MyValue> …>`, or
73
+ * just let it infer from `value` — and every callback (`onChange`, `onSubmit`,
74
+ * and a custom section's `render` / `summary` / `manifestRow`) speaks *your*
75
+ * type, with no cast at the boundary:
76
+ *
77
+ * ```ts
78
+ * interface LiteFormValue extends ProjectFormValue { sharedVolumes: Volume[] }
79
+ * ```
80
+ *
81
+ * That's the whole extension story for the value, deliberately: a `custom`
82
+ * sub-bag would be a second place to put a field, and the fields apps actually
83
+ * add (a branch, a volume list) belong next to the others, not in a drawer.
84
+ */
62
85
  export interface ProjectFormValue {
63
86
  name: string
64
87
  description: string
@@ -77,7 +100,8 @@ export interface ProjectFormValue {
77
100
  dockerInDocker: boolean
78
101
  }
79
102
 
80
- export type ProjectFormSectionId =
103
+ /** The ten sections the package models and draws itself. */
104
+ export type InternalSectionId =
81
105
  | "identity"
82
106
  | "image"
83
107
  | "repositories"
@@ -89,6 +113,72 @@ export type ProjectFormSectionId =
89
113
  | "docker"
90
114
  | "secrets"
91
115
 
116
+ /**
117
+ * A section of the form: one of the ten above, or the id of a card the app
118
+ * brings itself through `customSections`.
119
+ *
120
+ * Open on purpose, and `(string & {})` rather than plain `string` so the ten
121
+ * known ids keep their autocompletion — a closed union meant an app with a field
122
+ * of its own had to either hang it under an unrelated section via `extras` or
123
+ * draw its own card outside the form, losing the numbering, the fold and the
124
+ * manifest along the way.
125
+ */
126
+ export type ProjectFormSectionId = InternalSectionId | (string & {})
127
+
128
+ /**
129
+ * A card the app draws itself, numbered and folded like the package's own.
130
+ *
131
+ * Same contract as an internal section, no more: `render` gets the value and a
132
+ * patch, and that's it. Nothing here fetches, nothing here holds state — this is
133
+ * a render callback that happens to be recognised by the form's chrome, not a
134
+ * plugin. What it buys over a hand-rolled `FormSection` rendered after the form
135
+ * is everything the chrome owns: its place in `sections` (inside the "Advanced
136
+ * options" fold if the app puts its id in `advancedSections`), its number in the
137
+ * shared sequence, its line in the fold's summary, and its row in the manifest.
138
+ *
139
+ * Generic over the value so an app that extends `ProjectFormValue` reads and
140
+ * patches its own fields without a cast (see the note on `ProjectFormValue`).
141
+ */
142
+ export interface ProjectFormCustomSection<TValue extends ProjectFormValue = ProjectFormValue> {
143
+ /** Used in `sections` / `advancedSections` / `extras`. Ignored if it shadows an internal id. */
144
+ id: string
145
+ title: string
146
+ hint?: string
147
+ icon?: ComponentType<{ className?: string }>
148
+ /** Defaults to `build`, like `FormSection` itself. */
149
+ accent?: SectionAccent
150
+ /** The card's body. `patch` merges into the value, exactly like an internal section. */
151
+ render: (value: TValue, patch: (next: Partial<TValue>) => void) => ReactNode
152
+ /**
153
+ * One short label — `2 volumes` — when this section holds something, `null`
154
+ * otherwise. Feeds the closed fold's summary, and with it the form's decision
155
+ * to start open. Skipping it means the fold can shut over a value the user
156
+ * typed, which is the failure the disclosure exists to prevent.
157
+ */
158
+ summary?: (value: TValue) => string | null
159
+ /** A line in the build manifest, or `null`. Appended after the built-in rows. */
160
+ manifestRow?: (value: TValue) => ManifestRow | null
161
+ }
162
+
163
+ /** Internal ids, as a runtime set — what tells a custom id from a built-in one. */
164
+ export const INTERNAL_SECTION_IDS: readonly InternalSectionId[] = [
165
+ "identity",
166
+ "image",
167
+ "repositories",
168
+ "features",
169
+ "extensions",
170
+ "lifecycle",
171
+ "ports",
172
+ "prewarm",
173
+ "docker",
174
+ "secrets",
175
+ ]
176
+
177
+ /** True for the ten sections the package draws itself. */
178
+ export function isInternalSectionId(id: ProjectFormSectionId): id is InternalSectionId {
179
+ return (INTERNAL_SECTION_IDS as readonly string[]).includes(id)
180
+ }
181
+
92
182
  /** Creation: the seven sections that matter before a project exists. */
93
183
  export const CREATE_SECTIONS: ProjectFormSectionId[] = [
94
184
  "identity",
@@ -24,6 +24,7 @@ export interface TagChipsProps {
24
24
  onRemove?: (tag: string, next: string[]) => void
25
25
  /** Longest accepted tag; anything past it is truncated on commit. */
26
26
  maxLength?: number
27
+ /** Posée sur le GROUPE (les chips et leur bouton « + »), pas sur chaque chip. */
27
28
  className?: string
28
29
  }
29
30
 
@@ -66,7 +67,11 @@ export function TagChips({ tags, onAdd, onRemove, maxLength = 32, className }: T
66
67
  if (!shown.length && !editable) return null
67
68
 
68
69
  return (
69
- <>
70
+ // Les chips ET le bouton « + » forment UNE unité de retour à la ligne. Sur
71
+ // une carte étroite (grille à trois colonnes, téléphone), l'en-tête wrappe —
72
+ // et laissé libre, c'est le « + » qui atterrit seul sur la ligne suivante
73
+ // sous une première ligne pleine. Groupé, il emmène les tags avec lui.
74
+ <span data-slot="tag-chips" className={cn("inline-flex flex-wrap items-center gap-2", className)}>
70
75
  {shown.map((tag) => {
71
76
  const c = tagColor(tag)
72
77
  return (
@@ -76,8 +81,7 @@ export function TagChips({ tags, onAdd, onRemove, maxLength = 32, className }: T
76
81
  "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium leading-none",
77
82
  c.bg,
78
83
  c.text,
79
- c.border,
80
- className
84
+ c.border
81
85
  )}
82
86
  >
83
87
  {tag}
@@ -134,6 +138,6 @@ export function TagChips({ tags, onAdd, onRemove, maxLength = 32, className }: T
134
138
  <PlusIcon className="h-2.5 w-2.5" />
135
139
  </button>
136
140
  ))}
137
- </>
141
+ </span>
138
142
  )
139
143
  }
@@ -178,27 +178,28 @@ export function WorkerCard({
178
178
  <TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
179
179
  </div>
180
180
 
181
- {/* Meta line. Each item carries its own leading separator instead of
182
- the separators being siblings: this row wraps onto two lines as
183
- soon as the card is narrow, and a sibling "·" then ends up
184
- stranded at the end of a line, pointing at nothing. Attached to
185
- the item, the worst case is a line that *starts* with "·", which
186
- reads as a continuation. */}
187
- <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
181
+ {/* Meta line, sans « · » entre les items : cette ligne wrappe dès que
182
+ la carte est étroite (grilles à deux ou trois colonnes,
183
+ téléphone), et un séparateur est un enfant flex comme un autre —
184
+ il wrappe comme un autre, donc la ligne du dessus se termine sur
185
+ un point suspendu dans le vide. Chaque item après l'auteur porte
186
+ sa propre icône, qui les sépare bien assez ; l'espacement fait le
187
+ reste. */}
188
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
188
189
  {creator && (
189
- <MetaItem first>
190
+ <MetaItem>
190
191
  <CreatorAvatar creator={creator} />
191
192
  <span className="text-muted-foreground">{creator.name}</span>
192
193
  </MetaItem>
193
194
  )}
194
195
  {nodeName && (
195
- <MetaItem first={!creator}>
196
+ <MetaItem>
196
197
  <ServerIcon className="h-3 w-3 shrink-0" />
197
198
  <span className="truncate">{nodeName}</span>
198
199
  </MetaItem>
199
200
  )}
200
201
  {worker.createdAt && (
201
- <MetaItem first={!creator && !nodeName} title={new Date(worker.createdAt).toLocaleString()}>
202
+ <MetaItem title={new Date(worker.createdAt).toLocaleString()}>
202
203
  <ClockIcon className="h-3 w-3 shrink-0" />
203
204
  {formatRelativeTime(worker.createdAt)}
204
205
  </MetaItem>
@@ -307,24 +308,9 @@ export function WorkerCard({
307
308
  * entirely: under ~26 rem the row is two lines deep anyway and the icons already tell
308
309
  * the items apart, so the dots are pure noise there.
309
310
  */
310
- function MetaItem({
311
- first = false,
312
- title,
313
- children,
314
- }: {
315
- first?: boolean
316
- title?: string
317
- children: ReactNode
318
- }) {
311
+ function MetaItem({ title, children }: { title?: string; children: ReactNode }) {
319
312
  return (
320
- <span
321
- title={title}
322
- className={cn(
323
- "flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground/60",
324
- !first &&
325
- "before:hidden before:text-muted-foreground/30 before:content-['·'] @min-[26rem]:before:mr-1 @min-[26rem]:before:inline"
326
- )}
327
- >
313
+ <span title={title} className="flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground/60">
328
314
  {children}
329
315
  </span>
330
316
  )