@fayz-ai/ui 0.6.6 → 0.7.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 (71) hide show
  1. package/dist/chunk-75EFDLST.js +274 -0
  2. package/dist/chunk-75EFDLST.js.map +1 -0
  3. package/dist/{chunk-YB5ZJKNG.cjs → chunk-7PTRAWHO.cjs} +263 -225
  4. package/dist/chunk-7PTRAWHO.cjs.map +1 -0
  5. package/dist/{chunk-LO5QZO7F.js → chunk-COC5DDFG.js} +125 -20
  6. package/dist/chunk-COC5DDFG.js.map +1 -0
  7. package/dist/{chunk-MYZT2OG7.js → chunk-FY7OZNKU.js} +24 -68
  8. package/dist/chunk-FY7OZNKU.js.map +1 -0
  9. package/dist/chunk-H6OULCHF.cjs +81 -0
  10. package/dist/chunk-H6OULCHF.cjs.map +1 -0
  11. package/dist/{chunk-DJHS2XRW.cjs → chunk-HG5RH3B3.cjs} +161 -54
  12. package/dist/chunk-HG5RH3B3.cjs.map +1 -0
  13. package/dist/{chunk-5QFB6AIQ.cjs → chunk-NWPMNPGR.cjs} +40 -40
  14. package/dist/{chunk-5QFB6AIQ.cjs.map → chunk-NWPMNPGR.cjs.map} +1 -1
  15. package/dist/{chunk-D6SXPF4F.cjs → chunk-ODY3GZ3Y.cjs} +41 -87
  16. package/dist/chunk-ODY3GZ3Y.cjs.map +1 -0
  17. package/dist/{chunk-22J2RTMN.js → chunk-OTUH4K4B.js} +196 -150
  18. package/dist/chunk-OTUH4K4B.js.map +1 -0
  19. package/dist/chunk-OW73RZOU.cjs +313 -0
  20. package/dist/chunk-OW73RZOU.cjs.map +1 -0
  21. package/dist/{chunk-XLBGB7IW.js → chunk-W5O2X277.js} +4 -4
  22. package/dist/{chunk-XLBGB7IW.js.map → chunk-W5O2X277.js.map} +1 -1
  23. package/dist/chunk-YRVE4FOD.js +57 -0
  24. package/dist/chunk-YRVE4FOD.js.map +1 -0
  25. package/dist/dashboard/index.cjs +27 -27
  26. package/dist/dashboard/index.js +3 -3
  27. package/dist/index.cjs +195 -175
  28. package/dist/index.d.ts +2 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -7
  31. package/dist/layout/ContentSplit.d.ts +32 -0
  32. package/dist/layout/ContentSplit.d.ts.map +1 -0
  33. package/dist/layout/Sidebar.d.ts.map +1 -1
  34. package/dist/layout/Topbar.d.ts.map +1 -1
  35. package/dist/layout/index.cjs +31 -23
  36. package/dist/layout/index.d.ts +1 -0
  37. package/dist/layout/index.d.ts.map +1 -1
  38. package/dist/layout/index.js +3 -3
  39. package/dist/primitives/index.cjs +126 -114
  40. package/dist/primitives/index.d.ts +2 -0
  41. package/dist/primitives/index.d.ts.map +1 -1
  42. package/dist/primitives/index.js +4 -4
  43. package/dist/primitives/markdown-editor.d.ts +23 -0
  44. package/dist/primitives/markdown-editor.d.ts.map +1 -0
  45. package/dist/primitives/markdown.d.ts +9 -0
  46. package/dist/primitives/markdown.d.ts.map +1 -0
  47. package/package.json +6 -3
  48. package/src/index.ts +9 -0
  49. package/src/layout/ContentSplit.tsx +153 -0
  50. package/src/layout/Sidebar.tsx +39 -14
  51. package/src/layout/Topbar.tsx +2 -1
  52. package/src/layout/index.ts +6 -0
  53. package/src/primitives/dropdown.tsx +4 -4
  54. package/src/primitives/index.ts +2 -0
  55. package/src/primitives/markdown-editor.tsx +75 -0
  56. package/src/primitives/markdown.tsx +169 -0
  57. package/src/primitives/select.tsx +1 -1
  58. package/dist/chunk-22J2RTMN.js.map +0 -1
  59. package/dist/chunk-D6SXPF4F.cjs.map +0 -1
  60. package/dist/chunk-DJHS2XRW.cjs.map +0 -1
  61. package/dist/chunk-DNYVEOAX.js +0 -11
  62. package/dist/chunk-DNYVEOAX.js.map +0 -1
  63. package/dist/chunk-E526FHZ4.js +0 -169
  64. package/dist/chunk-E526FHZ4.js.map +0 -1
  65. package/dist/chunk-LO5QZO7F.js.map +0 -1
  66. package/dist/chunk-MYZT2OG7.js.map +0 -1
  67. package/dist/chunk-VVVZO2LE.cjs +0 -13
  68. package/dist/chunk-VVVZO2LE.cjs.map +0 -1
  69. package/dist/chunk-W4XXXJRW.cjs +0 -196
  70. package/dist/chunk-W4XXXJRW.cjs.map +0 -1
  71. package/dist/chunk-YB5ZJKNG.cjs.map +0 -1
@@ -0,0 +1,153 @@
1
+ import * as React from 'react'
2
+ import { PanelRight, PanelRightClose, PanelRightOpen } from 'lucide-react'
3
+ import { cn } from '../utils/cn'
4
+ import { Button } from '../primitives/button'
5
+ import {
6
+ Sheet, SheetContent, SheetHeader, SheetBody, SheetTitle, SheetDescription,
7
+ } from '../primitives/sheet'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // ContentSplit — main content with a right-aligned companion panel, mobile
11
+ // first. Below `lg` the aside lives in a right Sheet (opened by a trigger the
12
+ // host places wherever fits — typically SubpageHeader actions, via
13
+ // <ContentSplitTrigger/>). From `lg` up it docks as a sticky right column.
14
+ //
15
+ // Pairs with SubpageHeader/ModulePage: the page keeps its own back-button
16
+ // header; ContentSplit only owns the columns below it. Reuse for any
17
+ // "document + operational panel" page (script + shooting checklist, form +
18
+ // help, detail + activity feed).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface ContentSplitContextValue {
22
+ open: boolean
23
+ setOpen: (open: boolean) => void
24
+ }
25
+
26
+ const ContentSplitContext = React.createContext<ContentSplitContextValue | null>(null)
27
+
28
+ export interface ContentSplitProps {
29
+ /** Main column (the document). */
30
+ children: React.ReactNode
31
+ /** Companion panel content (the checklist / help / activity). */
32
+ aside: React.ReactNode
33
+ /** Panel title — Sheet header on mobile, card header on desktop. */
34
+ asideTitle: React.ReactNode
35
+ /** Optional subtitle under the title. */
36
+ asideDescription?: React.ReactNode
37
+ /** Desktop column width (defaults to 20rem). */
38
+ asideWidth?: string
39
+ /** Controlled mobile-sheet state; uncontrolled when omitted. */
40
+ asideOpen?: boolean
41
+ onAsideOpenChange?: (open: boolean) => void
42
+ /** Desktop column can collapse into a slim reopen rail. */
43
+ collapsible?: boolean
44
+ /** Initial desktop collapsed state (e.g. expand only on the relevant day). */
45
+ defaultCollapsed?: boolean
46
+ className?: string
47
+ }
48
+
49
+ export function ContentSplit({
50
+ children,
51
+ aside,
52
+ asideTitle,
53
+ asideDescription,
54
+ asideWidth = '20rem',
55
+ asideOpen,
56
+ onAsideOpenChange,
57
+ collapsible = false,
58
+ defaultCollapsed = false,
59
+ className,
60
+ }: ContentSplitProps) {
61
+ const [internalOpen, setInternalOpen] = React.useState(false)
62
+ const open = asideOpen ?? internalOpen
63
+ const setOpen = onAsideOpenChange ?? setInternalOpen
64
+ const [collapsed, setCollapsed] = React.useState(collapsible && defaultCollapsed)
65
+
66
+ return (
67
+ <ContentSplitContext.Provider value={{ open, setOpen }}>
68
+ <div
69
+ className={cn('lg:grid lg:items-start lg:gap-6', className)}
70
+ style={{ gridTemplateColumns: `minmax(0, 1fr) ${collapsed ? 'auto' : asideWidth}` }}
71
+ >
72
+ <div className="min-w-0">{children}</div>
73
+
74
+ {/* Desktop: docked sticky column (or slim reopen rail when collapsed) */}
75
+ <aside className="hidden lg:block lg:sticky lg:top-4">
76
+ {collapsed ? (
77
+ <Button
78
+ variant="outline"
79
+ size="sm"
80
+ onClick={() => setCollapsed(false)}
81
+ className="h-9 w-9 p-0"
82
+ aria-label={typeof asideTitle === 'string' ? asideTitle : undefined}
83
+ title={typeof asideTitle === 'string' ? asideTitle : undefined}
84
+ >
85
+ <PanelRightOpen className="h-4 w-4" />
86
+ </Button>
87
+ ) : (
88
+ <div className="rounded-card border border-border bg-card">
89
+ <div className="flex items-start justify-between gap-2 border-b border-border px-4 py-3">
90
+ <div className="min-w-0">
91
+ <p className="text-sm font-semibold text-foreground">{asideTitle}</p>
92
+ {asideDescription && (
93
+ <p className="mt-0.5 text-xs text-muted-foreground">{asideDescription}</p>
94
+ )}
95
+ </div>
96
+ {collapsible && (
97
+ <button
98
+ type="button"
99
+ onClick={() => setCollapsed(true)}
100
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
101
+ aria-label="collapse"
102
+ >
103
+ <PanelRightClose className="h-4 w-4" />
104
+ </button>
105
+ )}
106
+ </div>
107
+ <div className="p-4">{aside}</div>
108
+ </div>
109
+ )}
110
+ </aside>
111
+ </div>
112
+
113
+ {/* Mobile: right sheet */}
114
+ <div className="lg:hidden">
115
+ <Sheet open={open} onOpenChange={setOpen}>
116
+ <SheetContent width="max-w-sm">
117
+ <SheetHeader>
118
+ <SheetTitle>{asideTitle}</SheetTitle>
119
+ {asideDescription && <SheetDescription>{asideDescription}</SheetDescription>}
120
+ </SheetHeader>
121
+ <SheetBody>{aside}</SheetBody>
122
+ </SheetContent>
123
+ </Sheet>
124
+ </div>
125
+ </ContentSplitContext.Provider>
126
+ )
127
+ }
128
+
129
+ export interface ContentSplitTriggerProps {
130
+ /** Accessible label; also the visible text unless `iconOnly`. */
131
+ label: React.ReactNode
132
+ iconOnly?: boolean
133
+ className?: string
134
+ }
135
+
136
+ /** Opens the mobile aside sheet. Renders nothing from `lg` up (the panel is
137
+ * already docked). Place inside SubpageHeader `actions` or any toolbar. */
138
+ export function ContentSplitTrigger({ label, iconOnly, className }: ContentSplitTriggerProps) {
139
+ const ctx = React.useContext(ContentSplitContext)
140
+ if (!ctx) return null
141
+ return (
142
+ <Button
143
+ variant="outline"
144
+ size="sm"
145
+ onClick={() => ctx.setOpen(true)}
146
+ className={cn('lg:hidden', className)}
147
+ aria-label={typeof label === 'string' ? label : undefined}
148
+ >
149
+ <PanelRight className={cn('h-4 w-4', !iconOnly && 'mr-1.5')} />
150
+ {!iconOnly && label}
151
+ </Button>
152
+ )
153
+ }
@@ -218,24 +218,45 @@ function NavItem({
218
218
  return content
219
219
  }
220
220
 
221
+ /** How specifically `route` matches `path` (-1 = no match; higher = more specific).
222
+ * An exact match beats a prefix match; a longer prefix beats a shorter one. */
223
+ function routeMatchScore(route: string, path: string): number {
224
+ if (path === route) return route.length + 1
225
+ if (route !== '/' && path.startsWith(route + '/')) return route.length
226
+ return -1
227
+ }
228
+
229
+ /** The single most-specific nav route that matches the current path (longest wins).
230
+ * Prevents a parent like `/courses` from staying active on `/courses/sales`. */
231
+ function resolveActiveRoute(navigation: NavigationItem[], path: string): string | null {
232
+ let best: string | null = null
233
+ let bestScore = 0
234
+ const consider = (route?: string) => {
235
+ if (!route) return
236
+ const score = routeMatchScore(route, path)
237
+ if (score > bestScore) { bestScore = score; best = route }
238
+ }
239
+ for (const item of navigation) {
240
+ consider(item.route)
241
+ for (const child of item.children ?? []) consider(child.route)
242
+ }
243
+ return best
244
+ }
245
+
221
246
  function NavGroup({
222
247
  item,
223
248
  collapsed,
224
- currentPath,
249
+ activeRoute,
225
250
  onNavigate,
226
251
  }: {
227
252
  item: NavigationItem
228
253
  collapsed: boolean
229
- currentPath: string
254
+ activeRoute: string | null
230
255
  onNavigate: (route: string) => void
231
256
  }) {
232
- const [open, setOpen] = React.useState(() =>
233
- item.children?.some((c) => currentPath === c.route || currentPath.startsWith(c.route + '/')) ?? false
234
- )
257
+ const isChildActive = item.children?.some((c) => c.route === activeRoute)
258
+ const [open, setOpen] = React.useState(() => isChildActive ?? false)
235
259
  const Icon = getIcon(item.icon)
236
- const isChildActive = item.children?.some(
237
- (c) => currentPath === c.route || currentPath.startsWith(c.route + '/')
238
- )
239
260
 
240
261
  if (collapsed) {
241
262
  return (
@@ -280,7 +301,7 @@ function NavGroup({
280
301
  <div className="ml-4 mt-0.5 space-y-0.5 border-l border-sidebar-border/50 pl-3">
281
302
  {item.children?.map((child) => {
282
303
  const ChildIcon = getIcon(child.icon)
283
- const isActive = currentPath === child.route || currentPath.startsWith(child.route + '/')
304
+ const isActive = child.route === activeRoute
284
305
  return (
285
306
  <button
286
307
  key={child.id}
@@ -322,6 +343,10 @@ export function Sidebar({
322
343
  const currentPath = currentPathProp
323
344
  const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
324
345
 
346
+ // Most-specific match wins, so sibling routes that share a prefix (e.g.
347
+ // /courses vs /courses/sales) don't both light up.
348
+ const activeRoute = resolveActiveRoute(navigation, currentPath)
349
+
325
350
  const mainItems = navigation.filter((item) => item.section === 'main')
326
351
  const secondaryItems = navigation.filter((item) => item.section === 'secondary')
327
352
  const settingsItems = navigation.filter((item) => item.section === 'settings')
@@ -379,13 +404,13 @@ export function Sidebar({
379
404
  <div className="space-y-1">
380
405
  {mainItems.map((item) =>
381
406
  item.children && item.children.length > 0 ? (
382
- <NavGroup key={item.id} item={item} collapsed={collapsed} currentPath={currentPath} onNavigate={handleNavigate} />
407
+ <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
383
408
  ) : (
384
409
  <NavItem
385
410
  key={item.id}
386
411
  item={item}
387
412
  collapsed={collapsed}
388
- isActive={currentPath === item.route || currentPath.startsWith(item.route + '/')}
413
+ isActive={item.route === activeRoute}
389
414
  onNavigate={handleNavigate}
390
415
  />
391
416
  )
@@ -397,13 +422,13 @@ export function Sidebar({
397
422
  <div className="mt-6 space-y-1">
398
423
  {secondaryItems.map((item) =>
399
424
  item.children && item.children.length > 0 ? (
400
- <NavGroup key={item.id} item={item} collapsed={collapsed} currentPath={currentPath} onNavigate={handleNavigate} />
425
+ <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
401
426
  ) : (
402
427
  <NavItem
403
428
  key={item.id}
404
429
  item={item}
405
430
  collapsed={collapsed}
406
- isActive={currentPath === item.route || currentPath.startsWith(item.route + '/')}
431
+ isActive={item.route === activeRoute}
407
432
  onNavigate={handleNavigate}
408
433
  />
409
434
  )
@@ -420,7 +445,7 @@ export function Sidebar({
420
445
  key={item.id}
421
446
  item={item}
422
447
  collapsed={collapsed}
423
- isActive={currentPath === item.route || currentPath.startsWith(item.route + '/')}
448
+ isActive={item.route === activeRoute}
424
449
  onNavigate={handleNavigate}
425
450
  />
426
451
  ))}
@@ -15,6 +15,7 @@ import {
15
15
  Package,
16
16
  Activity,
17
17
  BarChart3,
18
+ Clapperboard,
18
19
  FileText,
19
20
  Mail,
20
21
  DollarSign,
@@ -206,7 +207,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
206
207
  Contact, Building2, ChevronDown, Filter, Plus, Shield, List, ListChecks, FolderOpen, Dog, Cat, PawPrint, Heart, LayoutTemplate, LeafyGreen, Apple, Egg, Wheat,
207
208
  ArrowDownCircle, ArrowUpCircle, ArrowLeftRight, Plug, SlidersHorizontal, TableProperties, Landmark, Receipt, TrendingUp, CircleDollarSign, Clock, AlertTriangle, Sparkles, Wallet, Warehouse, Ruler, ArrowUpRight, ArrowDownRight, UserPlus, UserX, TreePalm,
208
209
  Boxes, Box, FileCheck2, PartyPopper, Radio, CalendarDays, BadgeDollarSign, Banknote, Layers, Music, Eye, ListMusic, Disc3, UsersRound, Mic,
209
- CalendarCheck2, CalendarX, ListPlus, Map, Ban, Clock3, Inbox, UserCheck, Star, Zap,
210
+ CalendarCheck2, CalendarX, ListPlus, Map, Ban, Clock3, Inbox, UserCheck, Star, Zap, Clapperboard,
210
211
  }
211
212
 
212
213
  function getIcon(name: string): LucideIcon {
@@ -31,3 +31,9 @@ export {
31
31
  useNavTransition,
32
32
  type NavTransition,
33
33
  } from './PageTransition'
34
+ export {
35
+ ContentSplit,
36
+ ContentSplitTrigger,
37
+ type ContentSplitProps,
38
+ type ContentSplitTriggerProps,
39
+ } from './ContentSplit'
@@ -25,7 +25,7 @@ const DropdownSubTrigger = React.forwardRef<
25
25
  <DropdownMenuPrimitive.SubTrigger
26
26
  ref={ref}
27
27
  className={cn(
28
- 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-muted data-[state=open]:bg-muted',
28
+ 'flex cursor-default select-none items-center rounded-button px-2 py-1.5 text-sm outline-none focus:bg-muted data-[state=open]:bg-muted',
29
29
  inset && 'pl-8',
30
30
  className
31
31
  )}
@@ -79,7 +79,7 @@ const DropdownItem = React.forwardRef<
79
79
  <DropdownMenuPrimitive.Item
80
80
  ref={ref}
81
81
  className={cn(
82
- 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
82
+ 'relative flex cursor-default select-none items-center rounded-button px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
83
83
  inset && 'pl-8',
84
84
  className
85
85
  )}
@@ -95,7 +95,7 @@ const DropdownCheckboxItem = React.forwardRef<
95
95
  <DropdownMenuPrimitive.CheckboxItem
96
96
  ref={ref}
97
97
  className={cn(
98
- 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
98
+ 'relative flex cursor-default select-none items-center rounded-button py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
99
99
  className
100
100
  )}
101
101
  checked={checked}
@@ -119,7 +119,7 @@ const DropdownRadioItem = React.forwardRef<
119
119
  <DropdownMenuPrimitive.RadioItem
120
120
  ref={ref}
121
121
  className={cn(
122
- 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
122
+ 'relative flex cursor-default select-none items-center rounded-button py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
123
123
  className
124
124
  )}
125
125
  {...props}
@@ -81,3 +81,5 @@ export { CurrencyInput } from './currency-input'
81
81
  export { ConfirmDialog } from './confirm-dialog'
82
82
  export { Popover, PopoverTrigger, PopoverContent } from './popover'
83
83
  export { SegmentedControl, type SegmentedControlProps, type SegmentedOption } from './segmented-control'
84
+ export { Markdown, renderMarkdown, type MarkdownProps } from './markdown'
85
+ export { MarkdownEditor, type MarkdownEditorProps } from './markdown-editor'
@@ -0,0 +1,75 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../utils/cn'
3
+ import { SegmentedControl } from './segmented-control'
4
+ import { Markdown } from './markdown'
5
+
6
+ export interface MarkdownEditorProps {
7
+ value: string
8
+ onChange: (value: string) => void
9
+ placeholder?: string
10
+ /** Minimum visible rows for the textarea. */
11
+ minRows?: number
12
+ defaultMode?: 'edit' | 'preview'
13
+ /** i18n injection — ui stays locale-agnostic. */
14
+ editLabel?: string
15
+ previewLabel?: string
16
+ /** Custom preview renderer — e.g. a social-post mockup instead of plain
17
+ * markdown. Defaults to the built-in Markdown renderer. */
18
+ renderPreview?: (value: string) => React.ReactNode
19
+ className?: string
20
+ }
21
+
22
+ /**
23
+ * Markdown editor with an edit/preview toggle. Plain textarea + the zero-dep
24
+ * Markdown renderer — deliberately not a block/WYSIWYG editor (that's a later
25
+ * milestone); this covers script/brief documents today.
26
+ */
27
+ export function MarkdownEditor({
28
+ value,
29
+ onChange,
30
+ placeholder,
31
+ minRows = 16,
32
+ defaultMode = 'preview',
33
+ editLabel = 'Edit',
34
+ previewLabel = 'Preview',
35
+ renderPreview,
36
+ className,
37
+ }: MarkdownEditorProps) {
38
+ const [mode, setMode] = React.useState<'edit' | 'preview'>(
39
+ defaultMode === 'preview' && value.trim() === '' ? 'edit' : defaultMode,
40
+ )
41
+ return (
42
+ <div className={cn('flex flex-col gap-2', className)}>
43
+ <div className="flex justify-end">
44
+ <SegmentedControl
45
+ aria-label="Markdown mode"
46
+ options={[
47
+ { value: 'edit', label: editLabel },
48
+ { value: 'preview', label: previewLabel },
49
+ ]}
50
+ value={mode}
51
+ onChange={(m) => setMode(m as 'edit' | 'preview')}
52
+ />
53
+ </div>
54
+ {mode === 'edit' ? (
55
+ <textarea
56
+ value={value}
57
+ onChange={(e) => onChange(e.target.value)}
58
+ placeholder={placeholder}
59
+ rows={Math.max(minRows, value.split('\n').length + 1)}
60
+ className={cn(
61
+ 'w-full resize-y rounded-md border border-border bg-card p-3',
62
+ 'font-mono text-[13px] leading-relaxed text-foreground',
63
+ 'placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring',
64
+ )}
65
+ />
66
+ ) : renderPreview ? (
67
+ renderPreview(value)
68
+ ) : (
69
+ <div className="rounded-md border border-border bg-card p-4">
70
+ <Markdown source={value} />
71
+ </div>
72
+ )}
73
+ </div>
74
+ )
75
+ }
@@ -0,0 +1,169 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../utils/cn'
3
+
4
+ /**
5
+ * Minimal markdown renderer — builds React elements directly (no HTML string,
6
+ * no dangerouslySetInnerHTML), so untrusted content is safe by construction.
7
+ * Supports the subset content docs actually use: h1–h3, ---, blockquote,
8
+ * ul/ol, paragraphs, and inline **bold** / *italic* / `code`.
9
+ */
10
+
11
+ const INLINE_TOKEN = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g
12
+
13
+ function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
14
+ return text.split(INLINE_TOKEN).map((part, i) => {
15
+ const key = `${keyPrefix}-${i}`
16
+ if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
17
+ return <strong key={key}>{part.slice(2, -2)}</strong>
18
+ }
19
+ if (part.startsWith('*') && part.endsWith('*') && part.length > 2) {
20
+ return <em key={key}>{part.slice(1, -1)}</em>
21
+ }
22
+ if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
23
+ return (
24
+ <code key={key} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]">
25
+ {part.slice(1, -1)}
26
+ </code>
27
+ )
28
+ }
29
+ return part === '' ? null : <React.Fragment key={key}>{part}</React.Fragment>
30
+ })
31
+ }
32
+
33
+ type Block =
34
+ | { type: 'heading'; level: 1 | 2 | 3; text: string }
35
+ | { type: 'hr' }
36
+ | { type: 'blockquote'; lines: string[] }
37
+ | { type: 'ul'; items: string[] }
38
+ | { type: 'ol'; items: string[] }
39
+ | { type: 'p'; lines: string[] }
40
+
41
+ function parseBlocks(source: string): Block[] {
42
+ const lines = source.replace(/\r\n/g, '\n').split('\n')
43
+ const blocks: Block[] = []
44
+ let i = 0
45
+ while (i < lines.length) {
46
+ const line = lines[i]
47
+ const trimmed = line.trim()
48
+ if (trimmed === '') { i++; continue }
49
+ const heading = /^(#{1,3})\s+(.*)$/.exec(trimmed)
50
+ if (heading) {
51
+ blocks.push({ type: 'heading', level: heading[1].length as 1 | 2 | 3, text: heading[2] })
52
+ i++
53
+ continue
54
+ }
55
+ if (/^(-{3,}|\*{3,})$/.test(trimmed)) {
56
+ blocks.push({ type: 'hr' })
57
+ i++
58
+ continue
59
+ }
60
+ if (trimmed.startsWith('>')) {
61
+ const quote: string[] = []
62
+ while (i < lines.length && lines[i].trim().startsWith('>')) {
63
+ quote.push(lines[i].trim().replace(/^>\s?/, ''))
64
+ i++
65
+ }
66
+ blocks.push({ type: 'blockquote', lines: quote })
67
+ continue
68
+ }
69
+ if (/^[-*]\s+/.test(trimmed)) {
70
+ const items: string[] = []
71
+ while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
72
+ items.push(lines[i].trim().replace(/^[-*]\s+/, ''))
73
+ i++
74
+ }
75
+ blocks.push({ type: 'ul', items })
76
+ continue
77
+ }
78
+ if (/^\d+[.)]\s+/.test(trimmed)) {
79
+ const items: string[] = []
80
+ while (i < lines.length && /^\d+[.)]\s+/.test(lines[i].trim())) {
81
+ items.push(lines[i].trim().replace(/^\d+[.)]\s+/, ''))
82
+ i++
83
+ }
84
+ blocks.push({ type: 'ol', items })
85
+ continue
86
+ }
87
+ const para: string[] = []
88
+ while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,3}\s|>|[-*]\s|\d+[.)]\s|-{3,}$|\*{3,}$)/.test(lines[i].trim())) {
89
+ para.push(lines[i].trim())
90
+ i++
91
+ }
92
+ blocks.push({ type: 'p', lines: para })
93
+ }
94
+ return blocks
95
+ }
96
+
97
+ const HEADING_CLASS: Record<1 | 2 | 3, string> = {
98
+ 1: 'text-xl font-semibold tracking-tight',
99
+ 2: 'text-base font-semibold tracking-tight',
100
+ 3: 'text-sm font-semibold',
101
+ }
102
+
103
+ export function renderMarkdown(source: string): React.ReactNode {
104
+ const blocks = parseBlocks(source)
105
+ return blocks.map((block, bi) => {
106
+ const key = `md-${bi}`
107
+ switch (block.type) {
108
+ case 'heading': {
109
+ const Tag = (`h${block.level}`) as 'h1' | 'h2' | 'h3'
110
+ return (
111
+ <Tag key={key} className={HEADING_CLASS[block.level]}>
112
+ {renderInline(block.text, key)}
113
+ </Tag>
114
+ )
115
+ }
116
+ case 'hr':
117
+ return <hr key={key} className="border-border" />
118
+ case 'blockquote':
119
+ return (
120
+ <blockquote key={key} className="border-l-2 border-border pl-3 text-muted-foreground">
121
+ {block.lines.map((l, li) => (
122
+ <p key={`${key}-${li}`}>{renderInline(l, `${key}-${li}`)}</p>
123
+ ))}
124
+ </blockquote>
125
+ )
126
+ case 'ul':
127
+ return (
128
+ <ul key={key} className="list-disc space-y-1 pl-5">
129
+ {block.items.map((item, li) => (
130
+ <li key={`${key}-${li}`}>{renderInline(item, `${key}-${li}`)}</li>
131
+ ))}
132
+ </ul>
133
+ )
134
+ case 'ol':
135
+ return (
136
+ <ol key={key} className="list-decimal space-y-1 pl-5">
137
+ {block.items.map((item, li) => (
138
+ <li key={`${key}-${li}`}>{renderInline(item, `${key}-${li}`)}</li>
139
+ ))}
140
+ </ol>
141
+ )
142
+ case 'p':
143
+ return (
144
+ <p key={key}>
145
+ {block.lines.map((l, li) => (
146
+ <React.Fragment key={`${key}-${li}`}>
147
+ {li > 0 && <br />}
148
+ {renderInline(l, `${key}-${li}`)}
149
+ </React.Fragment>
150
+ ))}
151
+ </p>
152
+ )
153
+ }
154
+ })
155
+ }
156
+
157
+ export interface MarkdownProps {
158
+ source: string
159
+ className?: string
160
+ }
161
+
162
+ /** Rendered markdown block — spacing tuned for script/brief documents. */
163
+ export function Markdown({ source, className }: MarkdownProps) {
164
+ return (
165
+ <div className={cn('space-y-3 text-sm leading-relaxed text-foreground', className)}>
166
+ {renderMarkdown(source)}
167
+ </div>
168
+ )
169
+ }
@@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
116
116
  <SelectPrimitive.Item
117
117
  ref={ref}
118
118
  className={cn(
119
- 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
119
+ 'relative flex w-full cursor-default select-none items-center rounded-button py-1.5 pl-8 pr-2 text-sm outline-none data-[highlighted]:bg-muted focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
120
120
  className
121
121
  )}
122
122
  {...props}