@nuasite/cms 0.39.1 → 0.40.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.
Files changed (40) hide show
  1. package/dist/editor.js +14575 -13938
  2. package/package.json +1 -1
  3. package/src/build-processor.ts +1 -1
  4. package/src/collection-scanner.ts +49 -2
  5. package/src/dev-middleware.ts +1 -1
  6. package/src/editor/components/attribute-editor.tsx +0 -1
  7. package/src/editor/components/bg-image-overlay.tsx +7 -8
  8. package/src/editor/components/block-editor.tsx +12 -12
  9. package/src/editor/components/collections-browser.tsx +10 -10
  10. package/src/editor/components/create-page-modal.tsx +18 -18
  11. package/src/editor/components/delete-page-dialog.tsx +4 -3
  12. package/src/editor/components/field-utils.ts +54 -0
  13. package/src/editor/components/fields.tsx +254 -72
  14. package/src/editor/components/frontmatter-fields.tsx +135 -54
  15. package/src/editor/components/frontmatter-sidebar.tsx +55 -58
  16. package/src/editor/components/link-edit-popover.tsx +10 -5
  17. package/src/editor/components/markdown-editor-overlay.tsx +100 -39
  18. package/src/editor/components/markdown-inline-editor.tsx +58 -26
  19. package/src/editor/components/mdx-block-view.tsx +4 -4
  20. package/src/editor/components/mdx-component-picker.tsx +2 -2
  21. package/src/editor/components/media-library.tsx +19 -18
  22. package/src/editor/components/modal-shell.tsx +16 -3
  23. package/src/editor/components/prop-editor.tsx +15 -18
  24. package/src/editor/components/redirects-manager.tsx +42 -35
  25. package/src/editor/components/reference-picker.tsx +5 -4
  26. package/src/editor/components/seo-editor.tsx +36 -27
  27. package/src/editor/components/toolbar.tsx +50 -33
  28. package/src/editor/dom.ts +13 -2
  29. package/src/editor/editor.ts +7 -6
  30. package/src/editor/hooks/useBlockEditorHandlers.ts +7 -6
  31. package/src/editor/index.tsx +7 -6
  32. package/src/editor/signals.ts +44 -13
  33. package/src/editor/strings.ts +123 -0
  34. package/src/editor/styles.css +75 -2
  35. package/src/editor/types.ts +8 -0
  36. package/src/index.ts +6 -0
  37. package/src/source-finder/image-finder.ts +1 -1
  38. package/src/source-finder/search-index.ts +12 -4
  39. package/src/source-finder/snippet-utils.ts +4 -1
  40. package/src/types.ts +4 -0
@@ -1,5 +1,6 @@
1
1
  import type { ComponentChildren } from 'preact'
2
2
  import { useEffect, useState } from 'preact/hooks'
3
+ import { cn } from '../lib/cn'
3
4
  import { buildAstroUploadContext, getCollectionEntryOptions } from '../manifest'
4
5
  import { renameMarkdownPage } from '../markdown-api'
5
6
  import {
@@ -11,6 +12,7 @@ import {
11
12
  updateMarkdownFrontmatter,
12
13
  updateMarkdownPageMeta,
13
14
  } from '../signals'
15
+ import { STRINGS } from '../strings'
14
16
  import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
15
17
  import { ColorField, ComboBoxField, ImageField, MultiSelectField, NumberField, TextField, ToggleField } from './fields'
16
18
  import { groupFields } from './frontmatter-sidebar'
@@ -19,6 +21,37 @@ function isArrayOfObjects(value: unknown[]): value is Record<string, unknown>[]
19
21
  return value.length > 0 && typeof value[0] === 'object' && value[0] !== null
20
22
  }
21
23
 
24
+ /** Checkbox below a URL field that toggles opening the link in a new tab. */
25
+ function OpenInNewTabToggle({ field }: { field: FieldDefinition }) {
26
+ const fm = markdownEditorState.value.currentPage?.frontmatter ?? {}
27
+ const targetKey = `${field.name}OpenInNewTab`
28
+ const isChecked = fm[targetKey] === true
29
+ return (
30
+ <label class="flex items-center gap-2 mt-2 text-xs text-white/70 cursor-pointer w-fit" data-cms-ui>
31
+ <input
32
+ type="checkbox"
33
+ checked={isChecked}
34
+ onChange={(e) => updateMarkdownFrontmatter({ [targetKey]: (e.target as HTMLInputElement).checked })}
35
+ class="sr-only peer"
36
+ data-cms-ui
37
+ />
38
+ <span
39
+ class={cn(
40
+ 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
41
+ isChecked ? 'bg-cms-primary border-cms-primary' : 'border-white/30 bg-white/5',
42
+ )}
43
+ >
44
+ {isChecked && (
45
+ <svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
46
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
47
+ </svg>
48
+ )}
49
+ </span>
50
+ Open in new tab
51
+ </label>
52
+ )
53
+ }
54
+
22
55
  function FieldGroupHeader({ group, children }: { group: string | null; children: ComponentChildren }) {
23
56
  return (
24
57
  <>
@@ -66,9 +99,21 @@ export function FrontmatterField({
66
99
  type="checkbox"
67
100
  checked={value}
68
101
  onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
69
- class="w-4 h-4 rounded border-white/20 bg-white/10 text-cms-primary focus:ring-cms-primary focus:ring-offset-0 cursor-pointer"
102
+ class="sr-only peer"
70
103
  data-cms-ui
71
104
  />
105
+ <span
106
+ class={cn(
107
+ 'w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors',
108
+ value ? 'bg-cms-primary border-cms-primary' : 'border-white/30 bg-white/5',
109
+ )}
110
+ >
111
+ {value && (
112
+ <svg class="w-3 h-3 text-cms-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
114
+ </svg>
115
+ )}
116
+ </span>
72
117
  {label}
73
118
  </label>
74
119
  )
@@ -83,7 +128,7 @@ export function FrontmatterField({
83
128
  type="date"
84
129
  value={typeof value === 'string' ? value.split('T')[0] : ''}
85
130
  onChange={(e) => onChange((e.target as HTMLInputElement).value)}
86
- class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-cms-primary"
131
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
87
132
  data-cms-ui
88
133
  />
89
134
  </div>
@@ -119,7 +164,7 @@ export function FrontmatterField({
119
164
  onChange(arrayValue)
120
165
  }}
121
166
  placeholder={`Enter ${label.toLowerCase()} separated by commas`}
122
- class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-cms-primary"
167
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40"
123
168
  data-cms-ui
124
169
  />
125
170
  </div>
@@ -150,7 +195,7 @@ export function FrontmatterField({
150
195
  value={typeof value === 'string' ? value : ''}
151
196
  onChange={(e) => onChange((e.target as HTMLTextAreaElement).value)}
152
197
  rows={3}
153
- class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-cms-primary resize-none"
198
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40 resize-none"
154
199
  data-cms-ui
155
200
  />
156
201
  </div>
@@ -165,7 +210,7 @@ export function FrontmatterField({
165
210
  type="text"
166
211
  value={typeof value === 'string' ? value : String(value ?? '')}
167
212
  onChange={(e) => onChange((e.target as HTMLInputElement).value)}
168
- class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-cms-primary"
213
+ class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40"
169
214
  data-cms-ui
170
215
  />
171
216
  </div>
@@ -190,38 +235,53 @@ export function CreateModeFrontmatter({
190
235
  onSlugManualEdit,
191
236
  }: CreateModeFrontmatterProps) {
192
237
  const allFields = fields ?? collectionDefinition.fields
238
+ const urlFieldNames = new Set(allFields.filter((f) => f.type === 'url' || /link|href|url/i.test(f.name)).map((f) => f.name))
239
+ const isOpenInNewTabSibling = (name: string) => {
240
+ if (!name.endsWith('OpenInNewTab')) return false
241
+ const base = name.slice(0, -'OpenInNewTab'.length)
242
+ return urlFieldNames.has(base)
243
+ }
193
244
  // In create mode, skip complex fields (arrays, objects) — they can be edited after creation
194
- const displayFields = allFields.filter(f => f.type !== 'array' && f.type !== 'object')
245
+ // Draft is always rendered in the sidebar never inline in the header.
246
+ // `*OpenInNewTab` siblings are handled by the OpenInNewTabToggle next to the URL field.
247
+ const displayFields = allFields.filter(f => f.type !== 'array' && f.type !== 'object' && f.name !== 'draft' && !isOpenInNewTabSibling(f.name))
195
248
  const groups = groupFields(displayFields)
196
249
 
197
250
  return (
198
251
  <div class="space-y-4">
199
252
  {/* Slug field */}
200
253
  <div>
201
- <label class="block text-xs font-medium text-white/70 mb-1.5">
202
- URL Slug
254
+ <div class="flex items-center gap-1.5 mb-1.5">
255
+ <label class="block text-xs font-medium text-white/70">URL Slug</label>
256
+ <span class="relative group/tt inline-flex" data-cms-ui>
257
+ <svg class="w-3.5 h-3.5 text-white/40 hover:text-white/70 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
258
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
259
+ </svg>
260
+ <span class="absolute left-0 top-full mt-1 w-72 p-2 bg-black/90 text-white text-xs rounded-cms-sm opacity-0 invisible group-hover/tt:opacity-100 group-hover/tt:visible transition-all z-50 pointer-events-none whitespace-normal break-all">
261
+ Will be saved to: src/content/{collectionDefinition.name}/{page.slug || 'your-slug'}.{collectionDefinition.fileExtension}
262
+ </span>
263
+ </span>
264
+ </div>
265
+ <label class="flex items-center gap-1 px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm focus-within:border-white/40 focus-within:ring-1 focus-within:ring-white/10">
266
+ <span class="text-white/40 text-sm shrink-0">/</span>
267
+ <input
268
+ type="text"
269
+ value={page.slug}
270
+ onInput={(e) => {
271
+ onSlugManualEdit()
272
+ const slug = (e.target as HTMLInputElement).value
273
+ markdownEditorState.value = {
274
+ ...markdownEditorState.value,
275
+ currentPage: markdownEditorState.value.currentPage
276
+ ? { ...markdownEditorState.value.currentPage, slug }
277
+ : null,
278
+ }
279
+ }}
280
+ placeholder="url-friendly-slug"
281
+ class="flex-1 bg-transparent text-sm text-white placeholder-white/40 focus:outline-none"
282
+ data-cms-ui
283
+ />
203
284
  </label>
204
- <input
205
- type="text"
206
- value={page.slug}
207
- onInput={(e) => {
208
- onSlugManualEdit()
209
- const slug = (e.target as HTMLInputElement).value
210
- markdownEditorState.value = {
211
- ...markdownEditorState.value,
212
- currentPage: markdownEditorState.value.currentPage
213
- ? { ...markdownEditorState.value.currentPage, slug }
214
- : null,
215
- }
216
- }}
217
- placeholder="url-friendly-slug"
218
- class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
219
- data-cms-ui
220
- />
221
- <p class="mt-1 text-xs text-white/40">
222
- Will be saved to: src/content/{collectionDefinition.name}/
223
- {page.slug || 'your-slug'}.{collectionDefinition.fileExtension}
224
- </p>
225
285
  </div>
226
286
 
227
287
  {/* Schema fields */}
@@ -271,13 +331,13 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
271
331
  if (result.success && result.newSlug && result.newFilePath) {
272
332
  updateMarkdownPageMeta({ slug: result.newSlug, filePath: result.newFilePath })
273
333
  setLocalSlug(result.newSlug)
274
- showToast('Slug updated', 'success')
334
+ showToast(STRINGS.slug.updated, 'success')
275
335
  } else {
276
- showToast(result.error || 'Failed to rename', 'error')
336
+ showToast(result.error || STRINGS.slug.renameFailed, 'error')
277
337
  setLocalSlug(page.slug)
278
338
  }
279
339
  } catch {
280
- showToast('Failed to rename', 'error')
340
+ showToast(STRINGS.slug.renameFailed, 'error')
281
341
  setLocalSlug(page.slug)
282
342
  } finally {
283
343
  setIsRenaming(false)
@@ -289,7 +349,14 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
289
349
  <label class="block text-xs font-medium text-white/70 mb-1.5">
290
350
  URL Slug
291
351
  </label>
292
- <div class="flex gap-2">
352
+ <label
353
+ class={cn(
354
+ 'flex items-center gap-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus-within:border-white/40',
355
+ isDirty ? 'border-white/30' : 'border-white/20',
356
+ isRenaming && 'opacity-60',
357
+ )}
358
+ >
359
+ <span class="text-white/40 text-sm shrink-0">/</span>
293
360
  <input
294
361
  type="text"
295
362
  value={localSlug}
@@ -301,13 +368,11 @@ function SlugField({ page }: { page: MarkdownPageEntry }) {
301
368
  ;(e.target as HTMLInputElement).blur()
302
369
  }
303
370
  }}
304
- class={`flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:border-white/40 ${
305
- isDirty ? 'border-cms-primary' : 'border-white/20'
306
- }`}
371
+ class="flex-1 bg-transparent focus:outline-none"
307
372
  disabled={isRenaming}
308
373
  data-cms-ui
309
374
  />
310
- </div>
375
+ </label>
311
376
  </div>
312
377
  )
313
378
  }
@@ -323,14 +388,22 @@ export function EditModeFrontmatter({
323
388
  collectionDefinition,
324
389
  fields,
325
390
  }: EditModeFrontmatterProps) {
326
- const displayFields = fields ?? collectionDefinition?.fields ?? []
391
+ const allFields = fields ?? collectionDefinition?.fields ?? []
392
+ const urlFieldNames = new Set(allFields.filter((f) => f.type === 'url' || /link|href|url/i.test(f.name)).map((f) => f.name))
393
+ const isOpenInNewTabSibling = (name: string) => {
394
+ if (!name.endsWith('OpenInNewTab')) return false
395
+ const base = name.slice(0, -'OpenInNewTab'.length)
396
+ return urlFieldNames.has(base)
397
+ }
398
+ const displayFields = allFields.filter((f) => f.name !== 'draft' && !isOpenInNewTabSibling(f.name))
327
399
  // Collect schema field names for filtering extra keys
328
400
  const schemaFieldNames = new Set(
329
401
  collectionDefinition?.fields.map((f) => f.name) ?? [],
330
402
  )
331
- // Frontmatter keys not covered by the schema (user-added fields)
403
+ // Frontmatter keys not covered by the schema (user-added fields). Draft and the
404
+ // `*OpenInNewTab` siblings are rendered separately, so exclude them here.
332
405
  const extraKeys = Object.keys(page.frontmatter).filter(
333
- (key) => !schemaFieldNames.has(key),
406
+ (key) => !schemaFieldNames.has(key) && key !== 'draft' && !isOpenInNewTabSibling(key),
334
407
  )
335
408
  const groups = groupFields(displayFields)
336
409
 
@@ -409,19 +482,29 @@ export function SchemaFrontmatterField({
409
482
  switch (field.type) {
410
483
  case 'text':
411
484
  case 'url':
412
- case 'email':
485
+ case 'email': {
486
+ const isLinkLike = field.type === 'url'
487
+ || /link|href|url/i.test(field.name)
488
+ const linkTooltip = isLinkLike
489
+ ? 'Use https://... for external links, or /path for internal pages.'
490
+ : undefined
413
491
  return (
414
- <TextField
415
- label={label}
416
- value={(value as string) ?? ''}
417
- placeholder={hints?.placeholder ?? getPlaceholder(field)}
418
- maxLength={hints?.maxLength as number | undefined}
419
- minLength={hints?.minLength as number | undefined}
420
- onChange={(v) => onChange(v)}
421
- inputType={field.type === 'text' ? undefined : field.type}
422
- required={field.required}
423
- />
492
+ <>
493
+ <TextField
494
+ label={label}
495
+ value={(value as string) ?? ''}
496
+ placeholder={hints?.placeholder ?? getPlaceholder(field)}
497
+ maxLength={hints?.maxLength as number | undefined}
498
+ minLength={hints?.minLength as number | undefined}
499
+ onChange={(v) => onChange(v)}
500
+ inputType={field.type === 'text' ? undefined : field.type}
501
+ required={field.required}
502
+ tooltip={linkTooltip}
503
+ />
504
+ {field.type === 'url' && <OpenInNewTabToggle field={field} />}
505
+ </>
424
506
  )
507
+ }
425
508
 
426
509
  case 'image': {
427
510
  const astroContext = buildAstroUploadContext(field, collection, entrySlug)
@@ -429,14 +512,12 @@ export function SchemaFrontmatterField({
429
512
  <ImageField
430
513
  label={label}
431
514
  value={(value as string) ?? ''}
432
- placeholder={getPlaceholder(field)}
433
515
  onChange={(v) => onChange(v)}
434
516
  onBrowse={() => {
435
517
  openMediaLibraryWithCallback((url: string) => {
436
518
  onChange(url)
437
519
  }, astroContext)
438
520
  }}
439
- required={field.required}
440
521
  />
441
522
  )
442
523
  }
@@ -822,7 +903,7 @@ function ObjectFields({ label, value, onChange, schemaFields, extraKeys }: Objec
822
903
  }
823
904
  }}
824
905
  placeholder="New field name..."
825
- class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
906
+ class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40"
826
907
  data-cms-ui
827
908
  />
828
909
  <button
@@ -2,51 +2,10 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
2
2
  import { cn } from '../lib/cn'
3
3
  import { updateMarkdownFrontmatter } from '../signals'
4
4
  import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
5
+ import { groupFields, partitionFields } from './field-utils'
5
6
  import { formatFieldLabel, FrontmatterField, SchemaFrontmatterField } from './frontmatter-fields'
6
7
 
7
- // ============================================================================
8
- // Field Utilities
9
- // ============================================================================
10
-
11
- export function partitionFields(fields: FieldDefinition[]): { sidebar: FieldDefinition[]; header: FieldDefinition[] } {
12
- const sidebar: FieldDefinition[] = []
13
- const header: FieldDefinition[] = []
14
- for (const field of fields) {
15
- if (field.hidden) continue
16
- if (field.position === 'sidebar') {
17
- sidebar.push(field)
18
- } else {
19
- header.push(field)
20
- }
21
- }
22
- return { sidebar, header }
23
- }
24
-
25
- export interface FieldGroup {
26
- group: string | null
27
- fields: FieldDefinition[]
28
- }
29
-
30
- export function groupFields(fields: FieldDefinition[]): FieldGroup[] {
31
- const groups: FieldGroup[] = []
32
- const groupMap = new Map<string | null, FieldDefinition[]>()
33
- const order: (string | null)[] = []
34
-
35
- for (const field of fields) {
36
- const key = field.group ?? null
37
- if (!groupMap.has(key)) {
38
- groupMap.set(key, [])
39
- order.push(key)
40
- }
41
- groupMap.get(key)!.push(field)
42
- }
43
-
44
- for (const key of order) {
45
- groups.push({ group: key, fields: groupMap.get(key)! })
46
- }
47
-
48
- return groups
49
- }
8
+ export { groupFields, partitionFields } from './field-utils'
50
9
 
51
10
  // ============================================================================
52
11
  // Group Header
@@ -94,9 +53,11 @@ interface FrontmatterSidebarProps {
94
53
 
95
54
  export function FrontmatterSidebar({ fields, page, collectionDefinition }: FrontmatterSidebarProps) {
96
55
  const [state, setState] = useState(loadSidebarState)
56
+ const [isAnimating, setIsAnimating] = useState(false)
97
57
  const isResizing = useRef(false)
98
58
  const startX = useRef(0)
99
59
  const startWidth = useRef(0)
60
+ const animationTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
100
61
 
101
62
  const { width, collapsed } = state
102
63
 
@@ -108,8 +69,17 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
108
69
  })
109
70
  }, [])
110
71
 
72
+ const toggleCollapsed = useCallback(() => {
73
+ if (animationTimeout.current) clearTimeout(animationTimeout.current)
74
+ setIsAnimating(true)
75
+ updateState({ collapsed: !collapsed })
76
+ animationTimeout.current = setTimeout(() => setIsAnimating(false), 300)
77
+ }, [collapsed, updateState])
78
+
111
79
  const handleMouseDown = useCallback((e: MouseEvent) => {
112
80
  e.preventDefault()
81
+ if (animationTimeout.current) clearTimeout(animationTimeout.current)
82
+ setIsAnimating(false)
113
83
  isResizing.current = true
114
84
  startX.current = e.clientX
115
85
  startWidth.current = width
@@ -151,7 +121,11 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
151
121
 
152
122
  return (
153
123
  <div
154
- class={cn('relative shrink-0 border-l border-white/10 bg-white/5 flex', collapsed && 'w-8')}
124
+ class={cn(
125
+ 'relative shrink-0 border-l border-white/10 bg-white/5 flex overflow-hidden',
126
+ isAnimating && 'transition-[width] duration-300 ease-out',
127
+ collapsed && 'w-8',
128
+ )}
155
129
  style={collapsed ? undefined : { width: `${width}px` }}
156
130
  data-cms-ui
157
131
  >
@@ -166,27 +140,50 @@ export function FrontmatterSidebar({ fields, page, collectionDefinition }: Front
166
140
  {/* Collapse toggle */}
167
141
  <button
168
142
  type="button"
169
- onClick={() => updateState({ collapsed: !collapsed })}
170
- class="absolute top-2 left-0 -translate-x-1/2 z-20 w-5 h-5 rounded-full bg-cms-dark border border-white/20 flex items-center justify-center text-white/50 hover:text-white hover:border-white/40 transition-colors"
143
+ onClick={toggleCollapsed}
144
+ class="absolute top-2 left-1.5 -translate-x-1/2 z-20 w-5 h-5 rounded-cms-xs bg-white/10 hover:bg-white/20 border border-white/20 hover:border-white/40 flex items-center justify-center text-white/50 hover:text-white transition-colors"
171
145
  title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
172
146
  data-cms-ui
173
147
  >
174
- <svg
175
- class={cn('w-3 h-3 transition-transform', collapsed && 'rotate-180')}
176
- viewBox="0 0 24 24"
177
- fill="none"
178
- stroke="currentColor"
179
- stroke-width="2"
180
- stroke-linecap="round"
181
- stroke-linejoin="round"
182
- >
183
- <path d="m15 18-6-6 6-6" />
184
- </svg>
148
+ <span class="relative w-3 h-3 inline-block">
149
+ <svg
150
+ class={cn(
151
+ 'absolute inset-0 w-3 h-3 transition-opacity duration-200 ease-out',
152
+ collapsed ? 'opacity-0' : 'opacity-100',
153
+ )}
154
+ viewBox="0 0 24 24"
155
+ fill="none"
156
+ stroke="currentColor"
157
+ stroke-width="2"
158
+ stroke-linecap="round"
159
+ stroke-linejoin="round"
160
+ >
161
+ <path d="M3 5v14" />
162
+ <path d="M21 12H7" />
163
+ <path d="m15 18 6-6-6-6" />
164
+ </svg>
165
+ <svg
166
+ class={cn(
167
+ 'absolute inset-0 w-3 h-3 transition-opacity duration-200 ease-out',
168
+ collapsed ? 'opacity-100' : 'opacity-0',
169
+ )}
170
+ viewBox="0 0 24 24"
171
+ fill="none"
172
+ stroke="currentColor"
173
+ stroke-width="2"
174
+ stroke-linecap="round"
175
+ stroke-linejoin="round"
176
+ >
177
+ <path d="M3 19V5" />
178
+ <path d="m13 6-6 6 6 6" />
179
+ <path d="M7 12h14" />
180
+ </svg>
181
+ </span>
185
182
  </button>
186
183
 
187
184
  {/* Sidebar content */}
188
185
  {!collapsed && (
189
- <div class="flex-1 overflow-y-auto p-4 space-y-3 min-w-0">
186
+ <div class="flex-1 overflow-y-auto p-3 pt-10 space-y-3 min-w-0" style={{ scrollbarGutter: 'stable' }}>
190
187
  {groups.map((group, gi) => (
191
188
  <div key={gi} data-cms-ui>
192
189
  {group.group && <GroupHeader label={group.group} />}
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
2
+ import { cn } from '../lib/cn'
2
3
  import { HighlightMatch } from './fields'
3
4
  import { PrimaryButton } from './modal-shell'
4
5
 
@@ -131,7 +132,10 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
131
132
  >
132
133
  <form
133
134
  onSubmit={handleSubmit}
134
- class={`flex items-center gap-2 ${inline ? 'py-1.5' : 'px-4 py-2.5 bg-cms-dark border-b border-white/10'}`}
135
+ class={cn(
136
+ 'flex items-center gap-2',
137
+ inline ? 'py-1.5' : 'px-4 py-2 bg-cms-dark border-b border-white/10',
138
+ )}
135
139
  >
136
140
  <svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
137
141
  <path
@@ -152,7 +156,7 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
152
156
  onBlur={handleBlur}
153
157
  onKeyDown={handleKeyDown}
154
158
  autocomplete="off"
155
- class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-cms-primary/50 transition-colors"
159
+ class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-white/40 transition-colors"
156
160
  data-cms-ui
157
161
  />
158
162
  {showDropdown && (
@@ -169,11 +173,12 @@ export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, on
169
173
  e.preventDefault()
170
174
  selectOption(opt.value)
171
175
  }}
172
- class={`w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer ${
176
+ class={cn(
177
+ 'w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer',
173
178
  i === highlightedIndex
174
179
  ? 'bg-white/15 text-white'
175
- : 'text-white/70 hover:bg-white/10 hover:text-white'
176
- }`}
180
+ : 'text-white/70 hover:bg-white/10 hover:text-white',
181
+ )}
177
182
  data-cms-ui
178
183
  >
179
184
  <span class="block truncate font-medium">